Add Android client, packaging, Makefile entrypoint and handover docs

Why: the set has to be installable and publishable, not just buildable, and
the next person needs the reasoning, not only the code.

What:
- android/: Kotlin client with no third-party dependencies. A
  DocumentsProvider publishes the mirrored databases to every file picker, so
  KeePassDX opens them as ordinary documents; closing a file it wrote
  triggers a sync. A foreground service holds the event stream open.
  Mirror.decide transcribes the Go merge table, so both platforms resolve a
  conflict identically.
- Makefile as the single entrypoint, with file targets so only what changed
  is rebuilt: host and cross builds, both .deb packages, the macOS DMG with
  its /Applications symlink, the Windows zip, the APK under its F-Droid
  publishing name, and manual-only android-publish.
- versions.mk holds one version per platform plus the Android versionCode;
  `make bump` and `make changelog` move them. Debian changelog is tracked.
- systemd units (hardened system unit for the server, user unit for the
  agent), macOS bundle and launch agent, Windows Task Scheduler job.
- docs/HANDOVER.md on architecture and every decision that shaped it,
  docs/PROTOCOL.md for the API, README for the quick start.

Proved: make check and make test-race both pass; built both .deb packages and
inspected their contents, built the Windows zip, cross-compiled all five
desktop targets, and exercised make bump. The Android module is written but
not compiled: no JDK or Android SDK in this environment, which HANDOVER.md
states plainly.
This commit is contained in:
2026-10-29 16:26:33 +01:00
parent 4d0d53ff04
commit 0e83acb9b1
40 changed files with 2563 additions and 24 deletions
+43
View File
@@ -0,0 +1,43 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
// Version comes from versions.mk through the Makefile:
// ./gradlew assembleRelease -PversionName=0.1.0 -PversionCode=1
val appVersionName = (project.findProperty("versionName") as String?) ?: "0.0.0"
val appVersionCode = ((project.findProperty("versionCode") as String?) ?: "1").toInt()
android {
namespace = "cc.tiredsysadmin.android.keeporg"
compileSdk = 35
defaultConfig {
applicationId = "cc.tiredsysadmin.android.keeporg"
minSdk = 26
targetSdk = 35
versionCode = appVersionCode
versionName = appVersionName
}
buildTypes {
release {
isMinifyEnabled = false
// Unsigned on purpose: the private F-Droid repository signs it.
signingConfig = null
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
dependenciesInfo {
// Keep the APK free of the Google dependency metadata blob so the
// build is reproducible for F-Droid.
includeInApk = false
includeInBundle = false
}
}
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@android:style/Theme.DeviceDefault.DayNight">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".LoginActivity"
android:exported="false"
android:label="@string/sign_in" />
<service
android:name=".SyncService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- The databases appear in every file picker as a storage provider,
so KeePassDX or Keepass2Android opens them like any other file. -->
<provider
android:name=".KeepOrgProvider"
android:authorities="cc.tiredsysadmin.android.keeporg.documents"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
@@ -0,0 +1,244 @@
package cc.tiredsysadmin.android.keeporg
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** A structured failure from the server. */
class ApiException(
val status: Int,
val code: String,
message: String,
val currentVersion: Long = 0,
val mergeStrategy: String = "",
) : IOException(message) {
val isConflict: Boolean get() = code == "conflict" || code == "force_not_allowed"
}
data class Db(
val id: String,
val name: String,
val perm: String,
val mergeStrategy: String,
val version: Long,
val sha256: String,
val updatedBy: String,
) {
val canWrite: Boolean get() = perm == "write" || perm == "admin"
}
data class SessionInfo(val token: String, val username: String, val totpEnabled: Boolean)
data class DeviceStart(
val deviceCode: String,
val userCode: String,
val verificationUrl: String,
val interval: Int,
val expiresIn: Int,
)
data class PushResult(val version: Long, val sha256: String, val unchanged: Boolean)
/**
* The KeepOrg HTTP API over the platform's own HTTP client. The server
* contract is small enough that a dependency would cost more than it saves.
*/
class Api(baseUrl: String, var token: String?) {
private val base: String = baseUrl.trimEnd('/')
val serverUrl: String get() = base
fun login(user: String, password: String, totp: String, label: String): SessionInfo {
val body = JSONObject()
.put("username", user).put("password", password)
.put("totp", totp).put("label", label)
return session(request("POST", "/api/v1/auth/login", body))
}
fun deviceStart(label: String): DeviceStart {
val o = request("POST", "/api/v1/auth/device/start", JSONObject().put("label", label))
return DeviceStart(
o.getString("device_code"),
o.getString("user_code"),
o.getString("verification_url"),
o.optInt("interval", 2),
o.optInt("expires_in", 600),
)
}
/** Returns null while the browser has not approved the login yet. */
fun devicePoll(deviceCode: String): SessionInfo? {
return try {
session(request("POST", "/api/v1/auth/device/poll",
JSONObject().put("device_code", deviceCode)))
} catch (e: ApiException) {
if (e.status == 202 || e.code == "pending") null else throw e
}
}
fun logout() {
try {
request("POST", "/api/v1/auth/logout", null)
} catch (_: IOException) {
// A local sign-out must succeed even when the server is away.
}
}
fun databases(): List<Db> {
val arr = request("GET", "/api/v1/databases", null).getJSONArray("databases")
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
Db(
id = o.getString("id"),
name = o.getString("name"),
perm = o.optString("perm"),
mergeStrategy = o.optString("merge_strategy"),
version = o.optLong("version"),
sha256 = o.optString("sha256"),
updatedBy = o.optString("updated_by"),
)
}
}
/** Downloads the current bytes and version; version 0 means nothing stored. */
fun pull(dbId: String): Pair<ByteArray, Long> {
val c = open("GET", "/api/v1/databases/${enc(dbId)}/blob")
try {
val status = c.responseCode
if (status == 204) return Pair(ByteArray(0), 0L)
if (status != 200) throw errorOf(c, status)
val data = c.inputStream.use { it.readAllBytesCompat() }
val version = c.getHeaderField("ETag")?.trim('"')?.toLongOrNull() ?: 0L
return Pair(data, version)
} finally {
c.disconnect()
}
}
fun push(dbId: String, baseVersion: Long, data: ByteArray, force: Boolean): PushResult {
val c = open("PUT", "/api/v1/databases/${enc(dbId)}/blob")
try {
c.setRequestProperty("If-Match", if (force) "*" else baseVersion.toString())
c.setRequestProperty("Content-Type", "application/octet-stream")
c.doOutput = true
c.setFixedLengthStreamingMode(data.size)
c.outputStream.use { it.write(data) }
val status = c.responseCode
if (status != 200) throw errorOf(c, status)
val o = JSONObject(c.inputStream.use { it.readAllBytesCompat() }.decodeToString())
return PushResult(o.getLong("version"), o.optString("sha256"), o.optBoolean("unchanged"))
} finally {
c.disconnect()
}
}
/**
* Reads the server's event stream until it ends or the thread is
* interrupted. Events only ever trigger a full reconcile, so a dropped
* stream costs a delay, never data.
*/
fun events(onEvent: (JSONObject) -> Unit) {
val url = URL("$base/api/v1/events?access_token=" + enc(token ?: ""))
val c = url.openConnection() as HttpURLConnection
c.requestMethod = "GET"
c.setRequestProperty("Accept", "text/event-stream")
c.connectTimeout = 15_000
// The server sends a keepalive comment every 25 seconds.
c.readTimeout = 70_000
try {
if (c.responseCode != 200) throw errorOf(c, c.responseCode)
c.inputStream.bufferedReader().use { r ->
var data = StringBuilder()
while (true) {
val line = r.readLine() ?: return
when {
line.isEmpty() -> {
if (data.isNotEmpty()) {
runCatching { onEvent(JSONObject(data.toString())) }
data = StringBuilder()
}
}
line.startsWith(":") -> Unit
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
}
}
}
} finally {
c.disconnect()
}
}
// ---- plumbing ----
private fun session(o: JSONObject): SessionInfo {
val user = o.optJSONObject("user")
return SessionInfo(
o.getString("token"),
user?.optString("username") ?: "",
user?.optBoolean("totp_enabled") ?: false,
)
}
private fun open(method: String, path: String): HttpURLConnection {
val c = URL(base + path).openConnection() as HttpURLConnection
c.requestMethod = method
c.connectTimeout = 15_000
c.readTimeout = 60_000
c.setRequestProperty("Accept", "application/json")
token?.takeIf { it.isNotEmpty() }?.let { c.setRequestProperty("Authorization", "Bearer $it") }
return c
}
private fun request(method: String, path: String, body: JSONObject?): JSONObject {
val c = open(method, path)
try {
if (body != null) {
c.setRequestProperty("Content-Type", "application/json")
c.doOutput = true
c.outputStream.use { it.write(body.toString().toByteArray()) }
}
val status = c.responseCode
if (status < 200 || status > 299) throw errorOf(c, status)
if (status == 202) throw ApiException(202, "pending", "pending")
val text = c.inputStream.use { it.readAllBytesCompat() }.decodeToString()
return if (text.isBlank()) JSONObject() else JSONObject(text)
} finally {
c.disconnect()
}
}
private fun errorOf(c: HttpURLConnection, status: Int): ApiException {
val text = try {
(c.errorStream ?: c.inputStream)?.use { it.readAllBytesCompat() }?.decodeToString() ?: ""
} catch (_: IOException) {
""
}
val o = runCatching { JSONObject(text) }.getOrNull()
return ApiException(
status = status,
code = o?.optString("code") ?: "",
message = o?.optString("error")?.takeIf { it.isNotEmpty() } ?: "server returned $status",
currentVersion = o?.optLong("current_version") ?: 0L,
mergeStrategy = o?.optString("merge_strategy") ?: "",
)
}
private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8")
}
/** readAllBytes() needs API 33; this works on every supported release. */
internal fun InputStream.readAllBytesCompat(): ByteArray {
val out = ByteArrayOutputStream()
val buf = ByteArray(16 * 1024)
while (true) {
val n = read(buf)
if (n < 0) break
out.write(buf, 0, n)
}
return out.toByteArray()
}
@@ -0,0 +1,182 @@
package cc.tiredsysadmin.android.keeporg
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import java.io.File
import java.io.FileNotFoundException
/**
* Publishes the mirrored databases as a storage provider, so any KeePass
* application on the device opens them through the normal file picker and
* saves back into them. A save closes the descriptor, which is where a
* synchronization pass is triggered.
*/
class KeepOrgProvider : DocumentsProvider() {
private lateinit var closeThread: HandlerThread
private lateinit var closeHandler: Handler
override fun onCreate(): Boolean {
closeThread = HandlerThread("keeporg-doc-close").apply { start() }
closeHandler = Handler(closeThread.looper)
return true
}
override fun shutdown() {
closeThread.quitSafely()
}
private fun prefs() = Prefs(requireContext())
private fun mirrorDir(): File = File(requireContext().filesDir, "databases").apply { mkdirs() }
private fun requireContext() = context ?: throw FileNotFoundException("no context")
override fun queryRoots(projection: Array<out String>?): Cursor {
val c = MatrixCursor(projection ?: ROOT_PROJECTION)
val p = prefs()
if (!p.signedIn) return c // signed out: the provider shows no root at all
c.newRow().apply {
add(Root.COLUMN_ROOT_ID, ROOT_ID)
add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC)
add(Root.COLUMN_TITLE, requireContext().getString(R.string.app_name))
add(Root.COLUMN_SUMMARY, p.username.ifEmpty { p.serverUrl })
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_IS_CHILD or Root.FLAG_LOCAL_ONLY)
add(Root.COLUMN_MIME_TYPES, KDBX_MIME)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
}
return c
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
val c = MatrixCursor(projection ?: DOC_PROJECTION)
if (documentId == ROOT_DOC) {
c.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, ROOT_DOC)
add(Document.COLUMN_DISPLAY_NAME, requireContext().getString(R.string.provider_root))
add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE)
add(Document.COLUMN_SIZE, null)
add(Document.COLUMN_LAST_MODIFIED, mirrorDir().lastModified())
}
return c
}
addFileRow(c, fileOf(documentId))
return c
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?,
): Cursor {
if (parentDocumentId != ROOT_DOC) throw FileNotFoundException("not a directory")
val c = MatrixCursor(projection ?: DOC_PROJECTION)
mirrorDir().listFiles()
?.filter { it.isFile && it.name.endsWith(".kdbx") && !it.name.endsWith(".keeporg-tmp") }
?.sortedBy { it.name }
?.forEach { addFileRow(c, it) }
// Ask for a fresh pass so a picker that is open shows current files.
Syncer.request(requireContext(), "picker listed documents")
return c
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?,
): ParcelFileDescriptor {
val file = fileOf(documentId)
val access = ParcelFileDescriptor.parseMode(mode)
if (!mode.contains("w")) return ParcelFileDescriptor.open(file, access)
// The application is about to save: reconcile once it has closed the
// file, which is the moment the new bytes are complete on disk.
return ParcelFileDescriptor.open(file, access, closeHandler) {
Syncer.request(requireContext(), "saved $documentId")
}
}
/**
* Creating a document is allowed only under the name of a database the
* server has assigned to this device but that has no content yet. That
* is how a brand new shared database gets its first bytes from a KeePass
* application, without the picker inventing files the server knows
* nothing about.
*/
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String {
if (parentDocumentId != ROOT_DOC) throw FileNotFoundException("not a directory")
val wanted = if (displayName.endsWith(".kdbx")) displayName else "$displayName.kdbx"
val p = prefs()
val known = p.trackedIds.any { p.state(it).file == wanted }
if (!known) {
throw FileNotFoundException(
"no database named ${wanted.removeSuffix(".kdbx")} is assigned to you; " +
"create it on the server first"
)
}
val file = File(mirrorDir(), wanted)
if (!file.exists() && !file.createNewFile()) {
throw FileNotFoundException("could not create $wanted")
}
return wanted
}
/** Only the conflict copies this app wrote may be deleted from a picker. */
override fun deleteDocument(documentId: String) {
if (!isDisposable(documentId)) {
throw UnsupportedOperationException("a synced database cannot be deleted here")
}
if (!fileOf(documentId).delete()) throw FileNotFoundException(documentId)
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
parentDocumentId == ROOT_DOC && documentId != ROOT_DOC
private fun addFileRow(c: MatrixCursor, file: File) {
var flags = Document.FLAG_SUPPORTS_WRITE
if (isDisposable(file.name)) flags = flags or Document.FLAG_SUPPORTS_DELETE
c.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, file.name)
add(Document.COLUMN_DISPLAY_NAME, file.name)
add(Document.COLUMN_MIME_TYPE, KDBX_MIME)
add(Document.COLUMN_SIZE, file.length())
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
add(Document.COLUMN_FLAGS, flags)
}
}
private fun fileOf(documentId: String): File {
if (documentId.contains('/') || documentId.contains("..")) {
throw FileNotFoundException("bad document id")
}
val f = File(mirrorDir(), documentId)
if (!f.isFile) throw FileNotFoundException(documentId)
return f
}
private fun isDisposable(name: String) =
name.contains(".conflict-") || name.contains(".server-v")
companion object {
const val AUTHORITY = "cc.tiredsysadmin.android.keeporg.documents"
const val ROOT_ID = "keeporg"
const val ROOT_DOC = "root"
const val KDBX_MIME = "application/x-keepass2"
private val ROOT_PROJECTION = arrayOf(
Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY, Root.COLUMN_FLAGS, Root.COLUMN_MIME_TYPES, Root.COLUMN_ICON,
)
private val DOC_PROJECTION = arrayOf(
Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE,
Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS,
)
}
}
@@ -0,0 +1,116 @@
package cc.tiredsysadmin.android.keeporg
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.InputType
import android.widget.TextView
/**
* Signs the device in, either with a password and the built-in second
* factor, or by approving a code in a browser, which is also how single
* sign-on works.
*/
class LoginActivity : Activity() {
private lateinit var message: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = Prefs(this)
val root = Ui.column(this)
val server = Ui.field(this, "https://keeporg.example.com", InputType.TYPE_TEXT_VARIATION_URI)
server.setText(prefs.serverUrl)
val user = Ui.field(this, "username", InputType.TYPE_CLASS_TEXT)
val password = Ui.field(
this, "password",
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
)
val totp = Ui.field(this, "one-time code (if enabled)", InputType.TYPE_CLASS_NUMBER)
message = Ui.body(this)
root.addView(Ui.heading(this, getString(R.string.sign_in)))
root.addView(Ui.label(this, "Server"))
root.addView(server)
root.addView(Ui.label(this, "Username"))
root.addView(user)
root.addView(Ui.label(this, "Password"))
root.addView(password)
root.addView(Ui.label(this, "Second factor"))
root.addView(totp)
root.addView(Ui.button(this, "Sign in") {
val url = server.text.toString().trim().trimEnd('/')
if (url.isEmpty()) {
message.text = "Enter the server address."
return@button
}
prefs.serverUrl = url
message.text = "Signing in..."
val api = Api(url, null)
Ui.background(this, {
api.login(user.text.toString().trim(), password.text.toString(),
totp.text.toString().trim(), android.os.Build.MODEL ?: "android")
}) { result ->
result.fold({ finishSignIn(it) }, { failed(it) })
}
})
root.addView(Ui.button(this, "Sign in with a browser or SSO") {
val url = server.text.toString().trim().trimEnd('/')
if (url.isEmpty()) {
message.text = "Enter the server address."
return@button
}
prefs.serverUrl = url
browserSignIn(Api(url, null))
})
root.addView(message)
setContentView(root)
}
/** Runs the device flow: show a code, open a browser, wait for approval. */
private fun browserSignIn(api: Api) {
message.text = "Opening a browser..."
Ui.background(this, { api.deviceStart(android.os.Build.MODEL ?: "android") }) { started ->
started.fold({ start ->
message.text = "Approve the code ${start.userCode} in the browser."
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl)))
}
Ui.background(this, { poll(api, start) }) { polled ->
polled.fold({ session ->
if (session == null) {
message.text = "The approval window expired. Try again."
} else {
finishSignIn(session)
}
}, { failed(it) })
}
}, { failed(it) })
}
}
private fun poll(api: Api, start: DeviceStart): SessionInfo? {
val deadline = System.currentTimeMillis() + start.expiresIn * 1000L
val interval = (start.interval.coerceAtLeast(1)) * 1000L
while (System.currentTimeMillis() < deadline) {
Thread.sleep(interval)
api.devicePoll(start.deviceCode)?.let { return it }
}
return null
}
private fun finishSignIn(session: SessionInfo) {
val prefs = Prefs(this)
prefs.token = session.token
prefs.username = session.username
Syncer.request(this, "signed in")
startActivity(Intent(this, MainActivity::class.java))
finish()
}
private fun failed(e: Throwable) {
message.text = e.message ?: "Sign-in failed."
}
}
@@ -0,0 +1,121 @@
package cc.tiredsysadmin.android.keeporg
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
/**
* One screen: which databases are mirrored, what state each is in, and the
* switch that keeps the device listening for other people's changes.
*/
class MainActivity : Activity() {
private lateinit var prefs: Prefs
private lateinit var status: TextView
private lateinit var watchButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = Prefs(this)
if (!prefs.signedIn) {
startActivity(Intent(this, LoginActivity::class.java))
finish()
return
}
val root = Ui.column(this)
root.addView(Ui.heading(this, getString(R.string.app_name)))
root.addView(Ui.body(this, "${prefs.username} at ${prefs.serverUrl}"))
root.addView(
Ui.label(
this,
"Open these databases from any KeePass application: pick " +
"\"KeepOrg\" in its file picker."
)
)
status = Ui.body(this, "Loading...")
root.addView(status)
root.addView(Ui.button(this, "Sync now") { refresh(force = true) })
watchButton = Ui.button(this, watchLabel()) { toggleWatch() }
root.addView(watchButton)
root.addView(Ui.button(this, "Sign out") { signOut() })
setContentView(root)
}
override fun onResume() {
super.onResume()
if (!prefs.signedIn) return
// Restart the watcher if the system stopped it while we were away.
if (prefs.watchEnabled) SyncService.start(this)
refresh(force = false)
}
private fun refresh(force: Boolean) {
status.text = if (force) "Synchronizing..." else status.text
val api = Syncer.apiOf(this) ?: return
Syncer.request(this, if (force) "user asked" else "screen opened") { }
Ui.background(this, { api.databases() }) { result ->
result.fold({ dbs ->
status.text = if (dbs.isEmpty()) {
"No databases are assigned to this account yet."
} else {
dbs.joinToString("\n\n") { db ->
val state = prefs.status(db.id).ifEmpty { "not synced yet" }
"${db.name}\n ${db.perm}, ${db.mergeStrategy}, v${db.version}\n $state"
}
}
}, { e ->
status.text = "Could not reach the server: ${e.message}"
})
}
}
private fun watchLabel() =
if (prefs.watchEnabled) "Stop watching for changes" else "Keep watching for changes"
private fun toggleWatch() {
if (prefs.watchEnabled) {
prefs.watchEnabled = false
SyncService.stop(this)
} else {
if (Build.VERSION.SDK_INT >= 33 &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
return
}
prefs.watchEnabled = true
SyncService.start(this)
}
watchButton.text = watchLabel()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) {
prefs.watchEnabled = true
SyncService.start(this)
watchButton.text = watchLabel()
}
}
private fun signOut() {
val api = Syncer.apiOf(this)
SyncService.stop(this)
prefs.signOut()
Ui.background(this, { api?.logout() }) {
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
}
}
@@ -0,0 +1,244 @@
package cc.tiredsysadmin.android.keeporg
import android.content.Context
import android.provider.DocumentsContract
import android.util.Log
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Keeps a directory of real .kdbx files in step with the server. The files
* are published to every file picker by [KeepOrgProvider], so KeePassDX or
* Keepass2Android opens them as ordinary documents.
*
* The decision table below is the same policy the desktop client applies, so
* both platforms behave identically in a conflict.
*/
class Mirror(private val context: Context, private val api: Api, private val prefs: Prefs) {
enum class Action { NOTHING, PULL, PUSH, FORCE_PUSH, SERVER_WINS, RESTORE, MANUAL }
/** The mirror directory, private to the app. */
val dir: File = File(context.filesDir, "databases").apply { mkdirs() }
@Synchronized
fun syncAll(): Result<Unit> {
val dbs = try {
api.databases()
} catch (e: IOException) {
return Result.failure(e)
}
var firstError: Throwable? = null
for (db in dbs) {
try {
reconcile(db)
} catch (e: IOException) {
prefs.putStatus(db.id, "error: ${e.message}")
Log.w(TAG, "reconcile ${db.name}", e)
if (firstError == null) firstError = e
}
}
val seen = dbs.map { it.id }.toSet()
for (gone in prefs.trackedIds - seen) {
// The file itself stays: deleting someone's password database is
// not this app's decision.
prefs.forgetState(gone)
}
prefs.trackedIds = seen
notifyPicker()
return if (firstError == null) Result.success(Unit) else Result.failure(firstError)
}
/** Decides and applies the one action this database needs now. */
private fun reconcile(db: Db) {
val st = trackedState(db)
val file = File(dir, st.file)
val localExists = file.isFile
val local = if (localExists) file.readBytes() else ByteArray(0)
val localSha = if (localExists) digest(local) else ""
val action = decide(
localExists = localExists,
localSha = localSha,
syncedSha = st.sha256,
syncedVersion = st.version,
remoteSha = db.sha256,
remoteVersion = db.version,
canWrite = db.canWrite,
strategy = db.mergeStrategy,
conflictSeen = st.conflictMarker == conflictMarker(db.version, localSha),
)
if (action != Action.MANUAL && st.conflictMarker.isNotEmpty()) st.conflictMarker = ""
when (action) {
Action.NOTHING -> if (prefs.status(db.id).isEmpty()) prefs.putStatus(db.id, "in sync")
Action.PULL -> pull(db, st, file)
Action.PUSH -> push(db, st, file, local, force = false)
Action.FORCE_PUSH -> push(db, st, file, local, force = true)
Action.SERVER_WINS, Action.RESTORE -> {
val kept = keepConflictCopy(file, local)
pull(db, st, file)
val why = if (action == Action.RESTORE)
"read-only access: your save was kept as ${kept.name}"
else
"the server copy was newer: your save was kept as ${kept.name}"
prefs.putStatus(db.id, why)
}
Action.MANUAL -> {
val (remote, version) = api.pull(db.id)
val side = File(dir, st.file.removeSuffix(".kdbx") + ".server-v$version.kdbx")
writeAtomic(side, remote)
st.conflictMarker = conflictMarker(db.version, localSha)
prefs.putStatus(db.id, "both copies changed: the server copy is in ${side.name}")
}
}
prefs.putState(db.id, st)
}
private fun pull(db: Db, st: Prefs.FileState, file: File) {
val (data, version) = api.pull(db.id)
if (version == 0L || data.isEmpty()) return
writeAtomic(file, data)
st.version = version
st.sha256 = digest(data)
prefs.putStatus(db.id, "pulled version $version")
}
private fun push(db: Db, st: Prefs.FileState, file: File, data: ByteArray, force: Boolean) {
try {
val res = api.push(db.id, st.version, data, force)
st.version = res.version
st.sha256 = res.sha256
prefs.putStatus(db.id, if (res.unchanged) "in sync" else "pushed version ${res.version}")
} catch (e: ApiException) {
when {
e.isConflict -> {
val strategy = e.mergeStrategy.ifEmpty { db.mergeStrategy }
if (strategy == "client-wins" && !force) {
push(db, st, file, data, force = true)
return
}
val kept = keepConflictCopy(file, data)
pull(db, st, file)
prefs.putStatus(db.id, "the server copy was newer: your save was kept as ${kept.name}")
}
e.status == 403 -> {
val kept = keepConflictCopy(file, data)
pull(db, st, file)
prefs.putStatus(db.id, "read-only access: your save was kept as ${kept.name}")
}
else -> throw e
}
}
}
/** Returns the state for a database, following a rename made on the server. */
private fun trackedState(db: Db): Prefs.FileState {
val st = prefs.state(db.id)
if (st.file.isEmpty()) {
st.name = db.name
st.file = fileName(db)
return st
}
if (st.name != db.name) {
val want = fileName(db)
val old = File(dir, st.file)
if (old.isFile && want != st.file) {
if (!old.renameTo(File(dir, want))) {
Log.w(TAG, "could not rename ${st.file} to $want")
return st
}
}
st.name = db.name
st.file = want
}
return st
}
private fun fileName(db: Db): String {
val base = sanitize(db.name).ifEmpty { "database" }
val name = "$base.kdbx"
val taken = prefs.trackedIds.any { it != db.id && prefs.state(it).file == name }
return if (taken) "$base-${db.id.take(6)}.kdbx" else name
}
private fun keepConflictCopy(file: File, data: ByteArray): File {
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val copy = File(dir, file.name.removeSuffix(".kdbx") + ".conflict-$stamp.kdbx")
if (data.isNotEmpty()) writeAtomic(copy, data)
return copy
}
/** Tells every open file picker that the listing changed. */
fun notifyPicker() {
val uri = DocumentsContract.buildChildDocumentsUri(KeepOrgProvider.AUTHORITY, KeepOrgProvider.ROOT_DOC)
runCatching { context.contentResolver.notifyChange(uri, null) }
}
companion object {
private const val TAG = "KeepOrgMirror"
/**
* The whole client-side synchronization policy, kept pure so it can
* be read in one place and matched against the desktop client.
*/
fun decide(
localExists: Boolean,
localSha: String,
syncedSha: String,
syncedVersion: Long,
remoteSha: String,
remoteVersion: Long,
canWrite: Boolean,
strategy: String,
conflictSeen: Boolean,
): Action {
val localChanged = localExists && localSha != syncedSha
val remoteChanged = remoteVersion != syncedVersion || remoteSha != syncedSha
return when {
!localExists -> if (remoteVersion > 0) Action.PULL else Action.NOTHING
localChanged && !remoteChanged -> if (canWrite) Action.PUSH else Action.RESTORE
!localChanged && remoteChanged -> Action.PULL
localChanged && remoteChanged -> when {
!canWrite -> Action.RESTORE
strategy == "client-wins" -> Action.FORCE_PUSH
strategy == "manual" -> if (conflictSeen) Action.NOTHING else Action.MANUAL
else -> Action.SERVER_WINS
}
else -> Action.NOTHING
}
}
fun digest(data: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(data)
.joinToString("") { "%02x".format(it) }
fun conflictMarker(remoteVersion: Long, localSha: String): String =
if (localSha.isEmpty()) "" else "$remoteVersion/$localSha"
/** Replaces a file in one step, so a reader never sees half a database. */
fun writeAtomic(target: File, data: ByteArray) {
val tmp = File(target.parentFile, target.name + ".keeporg-tmp")
tmp.outputStream().use { it.write(data); it.fd.sync() }
if (!tmp.renameTo(target)) {
tmp.delete()
throw IOException("could not replace ${target.name}")
}
}
fun sanitize(name: String): String = buildString {
for (ch in name.trim()) {
append(if (ch.isLetterOrDigit() || ch in "-_. ") ch else '-')
}
}.removeSuffix(".kdbx").trim(' ', '.', '-')
}
}
@@ -0,0 +1,77 @@
package cc.tiredsysadmin.android.keeporg
import android.content.Context
import org.json.JSONObject
/**
* Everything the app remembers: where the server is, this device's session
* token, and what this device last agreed with the server about each
* database. Master passwords are never stored, because the app never opens a
* database; it only moves bytes for the KeePass application that does.
*/
class Prefs(context: Context) {
private val sp = context.getSharedPreferences("keeporg", Context.MODE_PRIVATE)
var serverUrl: String
get() = sp.getString("server_url", "") ?: ""
set(v) = sp.edit().putString("server_url", v).apply()
var token: String
get() = sp.getString("token", "") ?: ""
set(v) = sp.edit().putString("token", v).apply()
var username: String
get() = sp.getString("username", "") ?: ""
set(v) = sp.edit().putString("username", v).apply()
val signedIn: Boolean get() = serverUrl.isNotEmpty() && token.isNotEmpty()
/** Whether the user asked this device to keep listening for changes. */
var watchEnabled: Boolean
get() = sp.getBoolean("watch_enabled", false)
set(v) = sp.edit().putBoolean("watch_enabled", v).apply()
fun signOut() = sp.edit().clear().apply()
/** What this device last synced for one database. */
data class FileState(
var name: String = "",
var file: String = "",
var version: Long = 0,
var sha256: String = "",
var conflictMarker: String = "",
)
fun state(dbId: String): FileState {
val raw = sp.getString("state_$dbId", null) ?: return FileState()
val o = runCatching { JSONObject(raw) }.getOrNull() ?: return FileState()
return FileState(
o.optString("name"),
o.optString("file"),
o.optLong("version"),
o.optString("sha256"),
o.optString("conflict_marker"),
)
}
fun putState(dbId: String, st: FileState) {
val o = JSONObject()
.put("name", st.name).put("file", st.file)
.put("version", st.version).put("sha256", st.sha256)
.put("conflict_marker", st.conflictMarker)
sp.edit().putString("state_$dbId", o.toString()).apply()
}
fun forgetState(dbId: String) = sp.edit().remove("state_$dbId").apply()
/** Known database ids, so files of unassigned databases stop being tracked. */
var trackedIds: Set<String>
get() = sp.getStringSet("tracked", emptySet()) ?: emptySet()
set(v) = sp.edit().putStringSet("tracked", v).apply()
/** The last status line shown on the main screen, per database. */
fun putStatus(dbId: String, text: String) = sp.edit().putString("status_$dbId", text).apply()
fun status(dbId: String): String = sp.getString("status_$dbId", "") ?: ""
}
@@ -0,0 +1,111 @@
package cc.tiredsysadmin.android.keeporg
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Log
import java.io.IOException
/**
* Holds the server's event stream open so a change made by a colleague
* reaches this device without the user opening the app. A notification is
* required for that, and it doubles as the switch the user can see.
*/
class SyncService : Service() {
@Volatile
private var running = false
private var worker: Thread? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!Prefs(this).signedIn) {
stopSelf()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, notification("Watching for changes"))
Syncer.request(this, "service started")
if (!running) {
running = true
worker = Thread({ listen() }, "keeporg-events").apply { isDaemon = true; start() }
}
return START_STICKY
}
override fun onDestroy() {
running = false
worker?.interrupt()
super.onDestroy()
}
/** Reconnects to the event stream for as long as the service lives. */
private fun listen() {
var backoff = 1000L
while (running) {
val api = Syncer.apiOf(this)
if (api == null) {
stopSelf()
return
}
try {
api.events { ev ->
backoff = 1000L
Log.d(TAG, "event ${ev.optString("type")} ${ev.optString("name")}")
Syncer.request(this, "server event")
}
} catch (e: IOException) {
if (!running) return
Log.d(TAG, "event stream: ${e.message}")
} catch (e: InterruptedException) {
return
}
// A dropped stream only delays a reconcile, so retry gently and
// reconcile once on the way past in case something was missed.
Syncer.request(this, "stream reconnect")
try {
Thread.sleep(backoff)
} catch (e: InterruptedException) {
return
}
if (backoff < 60_000L) backoff *= 2
}
}
private fun notification(text: String): Notification {
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel(CHANNEL, getString(R.string.sync_channel), NotificationManager.IMPORTANCE_LOW)
)
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return Notification.Builder(this, CHANNEL)
.setContentTitle(getString(R.string.app_name))
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentIntent(open)
.setOngoing(true)
.build()
}
companion object {
private const val TAG = "KeepOrgService"
private const val CHANNEL = "sync"
private const val NOTIFICATION_ID = 1
fun start(context: Context) {
context.startForegroundService(Intent(context, SyncService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, SyncService::class.java))
}
}
}
@@ -0,0 +1,48 @@
package cc.tiredsysadmin.android.keeporg
import android.content.Context
import android.util.Log
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* The one place a synchronization pass is run. Work is serialized on a single
* thread, and a request that arrives while a pass is already queued is
* folded into it, so a burst of saves costs one reconcile.
*/
object Syncer {
private const val TAG = "KeepOrgSyncer"
private val pool = Executors.newSingleThreadExecutor()
private val queued = AtomicBoolean(false)
@Volatile
var lastError: String? = null
private set
/** Builds an API client from the stored session, or null when signed out. */
fun apiOf(context: Context): Api? {
val prefs = Prefs(context)
if (!prefs.signedIn) return null
return Api(prefs.serverUrl, prefs.token)
}
fun mirrorOf(context: Context): Mirror? {
val api = apiOf(context) ?: return null
return Mirror(context.applicationContext, api, Prefs(context))
}
/** Queues a pass. [done] runs on the worker thread when it finishes. */
fun request(context: Context, reason: String, done: ((Result<Unit>) -> Unit)? = null) {
val app = context.applicationContext
if (done == null && !queued.compareAndSet(false, true)) return
pool.execute {
if (done == null) queued.set(false)
Log.d(TAG, "sync: $reason")
val mirror = mirrorOf(app)
val result = mirror?.syncAll() ?: Result.failure(IllegalStateException("signed out"))
lastError = result.exceptionOrNull()?.message
done?.invoke(result)
}
}
}
@@ -0,0 +1,71 @@
package cc.tiredsysadmin.android.keeporg
import android.app.Activity
import android.content.Context
import android.graphics.Typeface
import android.util.TypedValue
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
/**
* The two screens are built in code. The app has no third-party UI library
* and needs no layout files, which keeps the APK and the build minimal.
*/
internal object Ui {
fun dp(context: Context, value: Int): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics
).toInt()
fun column(context: Context): LinearLayout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val pad = dp(context, 20)
setPadding(pad, pad, pad, pad)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
}
fun heading(context: Context, text: String): TextView = TextView(context).apply {
this.text = text
textSize = 20f
setTypeface(typeface, Typeface.BOLD)
setPadding(0, 0, 0, dp(context, 12))
}
fun label(context: Context, text: String): TextView = TextView(context).apply {
this.text = text
textSize = 13f
alpha = 0.7f
setPadding(0, dp(context, 12), 0, dp(context, 2))
}
fun body(context: Context, text: String = ""): TextView = TextView(context).apply {
this.text = text
textSize = 14f
setTypeface(Typeface.MONOSPACE)
setPadding(0, dp(context, 8), 0, dp(context, 8))
}
fun field(context: Context, hint: String, inputType: Int): EditText = EditText(context).apply {
this.hint = hint
this.inputType = inputType
}
fun button(context: Context, text: String, onClick: () -> Unit): Button = Button(context).apply {
this.text = text
setOnClickListener { onClick() }
}
/** Runs work off the main thread and delivers the result back on it. */
fun <T> background(activity: Activity, work: () -> T, then: (Result<T>) -> Unit) {
Thread {
val result = runCatching(work)
activity.runOnUiThread { if (!activity.isFinishing) then(result) }
}.start()
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A key over a pair of sync arrows, drawn as a single vector path set. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30a12,12 0 1,0 -11.3,16h4.3v6h6v6h6v-9.5a12,12 0 0,0 -5,-18.5zM54,36a4,4 0 1,1 0,8a4,4 0 0,1 0,-8z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M34,66h34l-6,-6h8l10,10l-10,10h-8l6,-6h-34z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M74,86h-34l6,6h-8l-10,-10l10,-10h8l-6,6h34z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/icon_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_background">#2D6A4F</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">KeepOrg</string>
<string name="sign_in">Sign in</string>
<string name="provider_root">KeepOrg databases</string>
<string name="sync_channel">Synchronization</string>
</resources>