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:
@@ -0,0 +1,34 @@
|
||||
# KeepOrg for Android
|
||||
|
||||
A KDBX synchronization adapter with no third-party dependencies: the server
|
||||
is reached with `java.net.HttpURLConnection`, JSON is parsed with the
|
||||
platform's `org.json`, and the two screens are built in code.
|
||||
|
||||
## How a KeePass application sees it
|
||||
|
||||
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored databases appear
|
||||
in every file picker under "KeepOrg". KeePassDX and Keepass2Android open them
|
||||
like any other document. When the application closes a file it had opened for
|
||||
writing, the provider triggers a synchronization pass, which is how a save
|
||||
reaches the server.
|
||||
|
||||
`SyncService` is a foreground service that holds the server's event stream
|
||||
open, so a change pushed by a colleague arrives without opening the app. The
|
||||
user turns it on from the main screen; it is not started at boot.
|
||||
|
||||
The merge policy in `Mirror.decide` is the same table as the desktop client's
|
||||
`internal/client/decide.go`. Keep the two in step.
|
||||
|
||||
## Building
|
||||
|
||||
The Gradle wrapper is not committed. Generate it once with a local Gradle
|
||||
8.9 or newer, or let the Makefile call Gradle directly:
|
||||
|
||||
```
|
||||
gradle wrapper --gradle-version 8.9 # once, optional
|
||||
make android # unsigned release APK
|
||||
make android-publish # upload to the private F-Droid repo
|
||||
```
|
||||
|
||||
The release build is deliberately unsigned: the private F-Droid repository
|
||||
signs what it publishes.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
// Root build file. The application has no third-party dependencies: it talks
|
||||
// to the server with java.net.HttpURLConnection and draws its two screens in
|
||||
// code, which keeps the APK small and an F-Droid build reproducible.
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2g
|
||||
android.useAndroidX=false
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,44 @@
|
||||
# F-Droid metadata for the private repository.
|
||||
# Publish with: make android-publish (copies this file to the metadata
|
||||
# directory and the unsigned APK to the unsigned directory).
|
||||
Categories:
|
||||
- Security
|
||||
License: AGPL-3.0-or-later
|
||||
AuthorName: tiredsysadmin
|
||||
SourceCode: https://garage.rcmd.space/keeporg
|
||||
IssueTracker: https://garage.rcmd.space/keeporg/issues
|
||||
|
||||
Name: KeepOrg
|
||||
Summary: Collaborative sync adapter for KeePass databases
|
||||
Description: |-
|
||||
KeepOrg mirrors the KDBX databases a KeepOrg server has assigned to you and
|
||||
publishes them to every file picker on the device, so KeePassDX or
|
||||
Keepass2Android opens them as ordinary files.
|
||||
|
||||
Each save is pushed to the server, and changes other people push are applied
|
||||
here. When two people save at once, the database's merge strategy decides
|
||||
the outcome; the losing copy is always kept beside the file so nothing is
|
||||
lost silently.
|
||||
|
||||
The app never opens a database and never sees a master password. It moves
|
||||
encrypted bytes and nothing else.
|
||||
|
||||
Sign in with a password and the server's built-in time-based second factor,
|
||||
or approve the device in a browser, which is also how single sign-on works.
|
||||
|
||||
RepoType: git
|
||||
Repo: https://garage.rcmd.space/keeporg.git
|
||||
|
||||
Builds:
|
||||
- versionName: 0.1.0
|
||||
versionCode: 1
|
||||
commit: v0.1.0-android
|
||||
subdir: android/app
|
||||
gradle:
|
||||
- yes
|
||||
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
|
||||
|
||||
AutoUpdateMode: None
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 0.1.0
|
||||
CurrentVersionCode: 1
|
||||
@@ -0,0 +1,15 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "KeepOrg"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user