Recently Written · git

sbm-android

sbm bookmarks for Android: fuzzy search, share to add, sync with bm and the browser add-on

git clone https://github.com/equwal/sbm-android

Log | Files | Refs


app/src/main/java/com/equwal/sbm/Sync.kt (6549 bytes)

1 package com.equwal.sbm
2 
3 import android.content.Context
4 import android.os.Handler
5 import android.os.Looper
6 import java.io.IOException
7 import java.net.HttpURLConnection
8 import java.net.URL
9 import java.net.URLEncoder
10 import java.util.concurrent.Executors
11 
12 /**
13  * Sync with an sbm-sync server (https://github.com/equwal/sbm-sync). The app
14  * sends the file and the name of the version that the server gave last time.
15  * The server merges and sends back the file, which the app then writes.
16  */
17 object Sync {
18     const val DEFAULT_SERVER = "https://sbm.subread.space"
19     private const val PREFS = "sync"
20     private val worker by lazy { Executors.newSingleThreadExecutor() }
21     private val main by lazy { Handler(Looper.getMainLooper()) }
22 
23     /**
24      * A sync that did not work, with a message for the user. [urgent] means
25      * that the user must act: sign in again, or see the account.
26      */
27     class Failure(message: String, val signIn: Boolean = false, val urgent: Boolean = signIn) : IOException(message)
28 
29     private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
30 
31     fun signedIn(ctx: Context) = prefs(ctx).getString("token", null) != null
32     fun server(ctx: Context): String = prefs(ctx).getString("server", null) ?: DEFAULT_SERVER
33     fun email(ctx: Context): String = prefs(ctx).getString("email", null).orEmpty()
34 
35     /** Sign in, keep the token, then sync. [done] gets the result on the main thread. */
36     fun signIn(ctx: Context, server: String, email: String, password: String, done: (Result<Int>) -> Unit) {
37         val app = ctx.applicationContext
38         background(done) {
39             val site = address(server)
40             val form = "email=" + URLEncoder.encode(email.trim(), "UTF-8") +
41                 "&password=" + URLEncoder.encode(password, "UTF-8")
42             val r = post(app, "$site/api/login", form.toByteArray(), "application/x-www-form-urlencoded", null)
43             when (r.code) {
44                 200 -> {}
45                 401 -> throw Failure(app.getString(R.string.wrong_login))
46                 else -> throw Failure(r.text.trim())
47             }
48             prefs(app).edit().clear()
49                 .putString("server", site).putString("email", email.trim()).putString("token", r.text.trim())
50                 .apply()
51             syncNow(app)
52             count(app)
53         }
54     }
55 
56     fun signOut(ctx: Context) {
57         val app = ctx.applicationContext
58         val token = prefs(app).getString("token", null) ?: return
59         val site = server(app)
60         prefs(app).edit().remove("token").remove("version").remove("file").apply()
61         worker.execute {
62             try {
63                 post(app, "$site/api/logout", ByteArray(0), "text/plain", token)
64             } catch (e: IOException) {
65                 // The token stays valid on the server; nothing else is lost.
66             }
67         }
68     }
69 
70     /** Sync now. [done] gets, on the main thread, whether the file changed. */
71     fun run(ctx: Context, done: (Result<Boolean>) -> Unit) {
72         val app = ctx.applicationContext
73         background(done) { syncNow(app) }
74     }
75 
76     private fun count(ctx: Context) = Tsv.parse(Store.read(ctx)).size
77 
78     private fun syncNow(ctx: Context): Boolean {
79         val p = prefs(ctx)
80         val token = p.getString("token", null) ?: throw Failure(ctx.getString(R.string.sign_in_again), true)
81         val file = Store.file(ctx)?.toString() ?: throw Failure(ctx.getString(R.string.choose_first))
82         repeat(3) {
83             // The version belongs to the file that it came from.
84             val base = if (p.getString("file", null) == file) p.getString("version", null).orEmpty() else ""
85             val sent = Store.read(ctx)
86             val r = post(ctx, server(ctx) + "/api/sync?base=" + base, sent.toByteArray(Charsets.UTF_8),
87                 "text/plain; charset=utf-8", token)
88             when (r.code) {
89                 200 -> {}
90                 401 -> {
91                     p.edit().remove("token").apply()
92                     throw Failure(ctx.getString(R.string.sign_in_again), true)
93                 }
94                 402 -> throw Failure(ctx.getString(R.string.sync_paused), urgent = true)
95                 else -> throw Failure(r.text.trim())
96             }
97             val version = r.version ?: throw Failure(ctx.getString(R.string.no_version))
98             // A bookmark came in while the request ran: send the file again.
99             if (Store.read(ctx) != sent) return@repeat
100             val changed = r.text != sent
101             if (changed) Store.write(ctx, r.text)
102             p.edit().putString("file", file).putString("version", version).apply()
103             return changed
104         }
105         throw Failure(ctx.getString(R.string.file_busy))
106     }
107 
108     /** The address of a server as the user typed it, with https:// when it has no scheme. */
109     fun address(typed: String): String {
110         val t = typed.trim().trimEnd('/')
111         return if ("://" in t) t else "https://$t"
112     }
113 
114     private class Reply(val code: Int, val text: String, val version: String?)
115 
116     private fun post(ctx: Context, url: String, body: ByteArray, type: String, token: String?): Reply {
117         val c = try {
118             URL(url).openConnection() as HttpURLConnection
119         } catch (e: Exception) {
120             throw Failure(ctx.getString(R.string.bad_server, url))
121         }
122         try {
123             c.requestMethod = "POST"
124             c.connectTimeout = 15_000
125             c.readTimeout = 60_000
126             c.doOutput = true
127             c.setRequestProperty("Content-Type", type)
128             if (token != null) c.setRequestProperty("Authorization", "Bearer $token")
129             c.setFixedLengthStreamingMode(body.size)
130             c.outputStream.use { it.write(body) }
131             val code = c.responseCode
132             val text = (if (code < 400) c.inputStream else c.errorStream)
133                 ?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty()
134             return Reply(code, text, c.getHeaderField("Sbm-Version"))
135         } catch (e: Failure) {
136             throw e
137         } catch (e: IOException) {
138             throw Failure(ctx.getString(R.string.no_server, URL(url).host))
139         } finally {
140             c.disconnect()
141         }
142     }
143 
144     private fun <T> background(done: (Result<T>) -> Unit, work: () -> T) {
145         worker.execute {
146             val result = try {
147                 Result.success(work())
148             } catch (e: Exception) {
149                 Result.failure(e)
150             }
151             main.post { done(result) }
152         }
153     }
154 }