// Berth Kotlin client 1.0.0 for Android · https://atberth.com/docs/sdk/ · MIT // Drop this file into an Android app (minSdk 24). Uses only HttpURLConnection and org.json, built into Android. // Calls block: run them off the main thread (Dispatchers.IO, an Executor, or a WorkManager job). // // val berth = Berth(context, "my-app", "bpk_...") // berth.auth.signIn("a@b.com", "password 1") // val rows = berth.from("notes").eq("done", "false").order("created_at", desc = true).limit(20).rows() // berth.from("notes").insert(JSONObject().put("title", "Hi")) // val sub = berth.from("notes").changes { type, record -> ... } // sub.close() to stop // val room = berth.channel("room:1", JSONObject().put("name", "Ana")) { kind, data -> ... }; room.send("typing", JSONObject()) package com.atberth import android.content.Context import org.json.JSONArray import org.json.JSONObject import java.io.BufferedReader import java.io.InputStreamReader import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder class BerthException(val status: Int, val code: String?, message: String) : Exception(message) class Berth(context: Context, app: String, val key: String, url: String = "https://api.atberth.com/v1") { private val base = "$url/apps/${enc(app)}" private val prefs = context.getSharedPreferences("berth.$app", Context.MODE_PRIVATE) internal var session: JSONObject? = prefs.getString("session", null)?.let { JSONObject(it) } set(v) { field = v; prefs.edit().apply { if (v == null) remove("session") else putString("session", v.toString()) }.apply() } val auth = Auth() private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") fun request(method: String, path: String, query: List> = emptyList(), body: Any? = null, contentType: String = "application/json", retried: Boolean = false): String { val qs = if (query.isEmpty()) "" else "?" + query.joinToString("&") { enc(it.first) + "=" + enc(it.second) } val c = URL("$base/$path$qs").openConnection() as HttpURLConnection c.requestMethod = method c.setRequestProperty("apikey", key) session?.optString("access_token")?.takeIf { it.isNotEmpty() }?.let { c.setRequestProperty("Authorization", "Bearer $it") } if (body != null) { c.doOutput = true c.setRequestProperty("Content-Type", contentType) c.outputStream.use { it.write(if (body is ByteArray) body else body.toString().toByteArray()) } } val status = c.responseCode val text = (if (status >= 400) c.errorStream else c.inputStream)?.bufferedReader()?.use { it.readText() } ?: "" if (status == 401 && !retried && session != null && auth.refresh()) return request(method, path, query, body, contentType, true) if (status !in 200..299) { val j = runCatching { JSONObject(text) }.getOrNull() throw BerthException(status, j?.optString("error"), j?.optString("message") ?: "HTTP $status") } return text } fun from(table: String) = Query(table) fun rpc(fn: String, args: JSONObject = JSONObject()): String = request("POST", "rpc/$fn", body = args) fun upload(bucket: String, key: String, bytes: ByteArray, contentType: String = "application/octet-stream") { request("PUT", "storage/objects/${enc(bucket)}/$key", body = bytes, contentType = contentType) } fun channel(name: String, presence: JSONObject? = null, onEvent: (String, JSONObject) -> Unit) = Channel(name, presence, onEvent) /** A Server-Sent Events subscription on its own thread. close() stops it. */ inner class Stream(path: String, query: List>, onEvent: (String, JSONObject) -> Unit) { @Volatile private var closed = false private var conn: HttpURLConnection? = null private val thread = Thread { while (!closed) { try { val q = query + listOfNotNull("apikey" to key, session?.optString("access_token")?.takeIf { it.isNotEmpty() }?.let { "access_token" to it }) val c = URL("$base/$path?" + q.joinToString("&") { enc(it.first) + "=" + enc(it.second) }).openConnection() as HttpURLConnection conn = c c.setRequestProperty("Accept", "text/event-stream") c.readTimeout = 60_000 if (c.responseCode == 401) { auth.refresh(); Thread.sleep(1000); continue } BufferedReader(InputStreamReader(c.inputStream)).use { r -> var event = "message"; val data = StringBuilder() while (!closed) { val line = r.readLine() ?: break when { line.isEmpty() -> { if (data.isNotEmpty()) runCatching { onEvent(event, JSONObject(data.toString())) }; event = "message"; data.clear() } line.startsWith("event:") -> event = line.substring(6).trim() line.startsWith("data:") -> data.append(line.substring(5).trim()) } } } } catch (e: Exception) { if (!closed) Thread.sleep(3000) } } }.apply { isDaemon = true; start() } fun close() { closed = true; runCatching { conn?.disconnect() } } } inner class Auth { val user: JSONObject? get() = session?.optJSONObject("user") private fun start(path: String, body: JSONObject): JSONObject? { session = JSONObject(request("POST", path, body = body)); return user } fun signUp(email: String, password: String) = start("auth/signup", JSONObject().put("email", email).put("password", password)) fun signIn(email: String, password: String) = start("auth/login", JSONObject().put("email", email).put("password", password)) fun sendCode(email: String) { request("POST", "auth/code", body = JSONObject().put("email", email)) } fun verify(email: String, code: String) = start("auth/verify", JSONObject().put("email", email).put("code", code)) /** Sign in with Google: pass the ID token from Credential Manager. */ fun signInWithGoogle(idToken: String) = start("auth/google", JSONObject().put("id_token", idToken)) fun refresh(): Boolean { val r = session?.optString("refresh_token")?.takeIf { it.isNotEmpty() } ?: return false return try { session = JSONObject(request("POST", "auth/refresh", body = JSONObject().put("refresh_token", r), retried = true)); true } catch (e: BerthException) { session = null; false } } fun signOut() { session?.optString("refresh_token")?.let { runCatching { request("POST", "auth/logout", body = JSONObject().put("refresh_token", it), retried = true) } } session = null } } inner class Query(private val table: String) { private val q = mutableListOf>() fun filter(col: String, op: String, value: String) = apply { q.add(col to "$op.$value") } fun eq(c: String, v: String) = filter(c, "eq", v) fun neq(c: String, v: String) = filter(c, "neq", v) fun gt(c: String, v: String) = filter(c, "gt", v) fun lt(c: String, v: String) = filter(c, "lt", v) fun order(col: String, desc: Boolean = false) = apply { q.add("order" to "$col.${if (desc) "desc" else "asc"}") } fun limit(n: Int) = apply { q.add("limit" to n.toString()) } fun rows(): JSONArray = JSONObject(request("GET", "tables/$table/rows", q)).getJSONArray("rows") fun insert(row: JSONObject): JSONObject = JSONObject(request("POST", "tables/$table/rows", body = row)) fun update(id: String, values: JSONObject): JSONObject = JSONObject(request("PATCH", "tables/$table/rows/${enc(id)}", body = values)) fun delete(id: String) { request("DELETE", "tables/$table/rows/${enc(id)}") } /** Live insert/update/delete events the signed-in user may see: onChange(type, eventJson). */ fun changes(onChange: (String, JSONObject) -> Unit) = Stream("tables/$table/stream", emptyList()) { kind, data -> if (kind == "insert" || kind == "update" || kind == "delete") onChange(kind, data) } } inner class Channel(private val name: String, presence: JSONObject?, onEvent: (String, JSONObject) -> Unit) { @Volatile var clientId: String? = null private val stream = Stream("channels/${enc(name)}/stream", listOfNotNull(presence?.let { "presence" to it.toString() })) { kind, data -> if (kind == "hello") clientId = data.optString("client_id") else onEvent(kind, data) } fun send(event: String, payload: JSONObject) { request("POST", "channels/${enc(name)}/broadcast", body = JSONObject().put("event", event).put("payload", payload).put("client_id", clientId)) } fun track(state: JSONObject) { clientId?.let { request("POST", "channels/${enc(name)}/presence", body = JSONObject().put("client_id", it).put("state", state)) } } fun close() = stream.close() } }