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 }