Make the Android app build, and fix what compiling it exposed

Why: the module was written but never compiled, so nothing about it was
proved. Building it found a name clash and a theme that would have crashed
every device below Android 10.

What:
- KeepOrgProvider.requireContext -> ctx(): ContentProvider gained
  requireContext() in API 30, and Kotlin refused the silent hiding.
- The application theme was @android:style/Theme.DeviceDefault.DayNight,
  which is API 29 while minSdk is 26. Replaced with @style/AppTheme, plain
  DeviceDefault in values/ and DayNight in values-v29/.
- Backup exclusions: data_extraction_rules.xml and backup_rules.xml exclude
  every domain for both cloud backup and device transfer. The session token
  authenticates as the user and the mirrored files are the organization's
  password databases; neither belongs in a backup.
- User-facing strings moved from code into strings.xml, so the app is
  translatable. Layouts stay in code, as before.
- mipmap-anydpi-v26 -> mipmap-anydpi (the qualifier is redundant at minSdk
  26), and the redundant activity label is gone.
- Lint is configured to write a text report, and `make android-lint` runs it;
  `make android` now fails with a clear message when ANDROID_HOME has no SDK.

Proved: the unsigned release APK builds (656 KB), lint reports "No issues
found", and the packaged DEX contains all 16 classes including every manifest
component. Toolchain used: JDK 21, Gradle 8.10.2, SDK platform 35 and
build-tools 35.0.0; on this arm64 host Google's x86-64 aapt2 runs under
qemu-user-static with amd64 multiarch libraries, which android/README.md now
documents. The app has still not run on a device: there is none here.
This commit is contained in:
2026-10-29 16:55:26 +01:00
parent 0e83acb9b1
commit a58fa6158d
15 changed files with 192 additions and 58 deletions
+11 -1
View File
@@ -135,14 +135,24 @@ $(ZIP): $(BUILD)/windows-amd64/keeporg.exe packaging/windows/README.txt
# -------------------------------------------------------------------- android # -------------------------------------------------------------------- android
GRADLE ?= $(shell test -x android/gradlew && echo ./gradlew || echo gradle) GRADLE ?= $(shell test -x android/gradlew && echo ./gradlew || echo gradle)
# Gradle 8.9+ and JDK 17+ are required by the Android Gradle plugin.
ANDROID_HOME ?= $(HOME)/android-sdk
APK_BUILT := android/app/build/outputs/apk/release/app-release-unsigned.apk APK_BUILT := android/app/build/outputs/apk/release/app-release-unsigned.apk
APK := $(DIST)/$(APPID_ANDROID)_$(ANDROID_VERSION_CODE).apk APK := $(DIST)/$(APPID_ANDROID)_$(ANDROID_VERSION_CODE).apk
.PHONY: android .PHONY: android
android: $(APK) ## build the unsigned release APK under its publishing name android: $(APK) ## build the unsigned release APK under its publishing name
.PHONY: android-lint
android-lint: ## run Android lint; any finding fails the build
cd android && ANDROID_HOME="$(ANDROID_HOME)" $(GRADLE) --no-daemon :app:lintRelease
@cat android/app/build/reports/lint-results.txt
$(APK): $(shell find android/app/src -type f 2>/dev/null) android/app/build.gradle.kts $(APK): $(shell find android/app/src -type f 2>/dev/null) android/app/build.gradle.kts
cd android && $(GRADLE) --no-daemon assembleRelease \ @test -d "$(ANDROID_HOME)/platforms" || { \
printf 'no Android SDK at %s\nset ANDROID_HOME, or see android/README.md\n' "$(ANDROID_HOME)" >&2; \
exit 1; }
cd android && ANDROID_HOME="$(ANDROID_HOME)" $(GRADLE) --no-daemon assembleRelease \
-PversionName=$(VERSION_ANDROID) -PversionCode=$(ANDROID_VERSION_CODE) -PversionName=$(VERSION_ANDROID) -PversionCode=$(ANDROID_VERSION_CODE)
@mkdir -p $(DIST) @mkdir -p $(DIST)
cp $(APK_BUILT) $@ cp $(APK_BUILT) $@
+42 -8
View File
@@ -2,7 +2,8 @@
A KDBX synchronization adapter with no third-party dependencies: the server A KDBX synchronization adapter with no third-party dependencies: the server
is reached with `java.net.HttpURLConnection`, JSON is parsed with the is reached with `java.net.HttpURLConnection`, JSON is parsed with the
platform's `org.json`, and the two screens are built in code. platform's `org.json`, and the two screens are built in code. The release APK
is about 650 KB.
## How a KeePass application sees it ## How a KeePass application sees it
@@ -16,19 +17,52 @@ reaches the server.
open, so a change pushed by a colleague arrives without opening the app. The 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. user turns it on from the main screen; it is not started at boot.
Nothing the app holds may leave the device in a backup or a device transfer:
`res/xml/data_extraction_rules.xml` and `res/xml/backup_rules.xml` exclude
every domain. The session token authenticates as the user, and the mirrored
files are the organization's password databases.
The merge policy in `Mirror.decide` is the same table as the desktop client's The merge policy in `Mirror.decide` is the same table as the desktop client's
`internal/client/decide.go`. Keep the two in step. `internal/client/decide.go`. **Keep the two in step.**
## Building ## Building
The Gradle wrapper is not committed. Generate it once with a local Gradle Requirements: JDK 17 or newer, Gradle 8.9 or newer, and an Android SDK with
8.9 or newer, or let the Makefile call Gradle directly: `platforms;android-35` and `build-tools;35.0.0`.
```sh
export ANDROID_HOME=$HOME/android-sdk
make android # unsigned release APK, named for the F-Droid repository
make android-lint # lint; the project is kept at zero findings
make android-publish
``` ```
gradle wrapper --gradle-version 8.9 # once, optional
make android # unsigned release APK The Gradle wrapper is not committed, so the Makefile calls whichever `gradle`
make android-publish # upload to the private F-Droid repo is on `PATH`. Generate a wrapper if you prefer one:
``` `gradle wrapper --gradle-version 8.10.2`.
The release build is deliberately unsigned: the private F-Droid repository The release build is deliberately unsigned: the private F-Droid repository
signs what it publishes. signs what it publishes.
### Building on an arm64 Linux host
Google ships `aapt2` for linux-x86_64 only, so an aarch64 build host needs to
be able to run it. Debian's own arm64 `aapt2` is too old to read the API 35
`android.jar`, so run the official one through QEMU instead:
```sh
sudo dpkg --add-architecture amd64
sudo apt update
sudo apt install qemu-user-static libc6:amd64 libstdc++6:amd64 zlib1g:amd64
```
After that `aapt2` runs transparently and the build needs no further flags.
Everything else in the Android toolchain (R8, the resource merger, zipflinger,
apksig) is Java and architecture-independent.
## What is verified and what is not
The APK builds clean and lint reports no issues, and the packaged DEX contains
every class the manifest names. The app has **not** been exercised on a device
or emulator: there was none available. The first real run is where to confirm
the picker integration, the save-triggered sync and the notification.
+5
View File
@@ -34,6 +34,11 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
lint {
// A lint finding is a defect until it is fixed or recorded here.
textReport = true
textOutput = file("build/reports/lint-results.txt")
}
dependenciesInfo { dependenciesInfo {
// Keep the APK free of the Google dependency metadata blob so the // Keep the APK free of the Google dependency metadata blob so the
// build is reproducible for F-Droid. // build is reproducible for F-Droid.
+4 -3
View File
@@ -8,14 +8,15 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@android:style/Theme.DeviceDefault.DayNight"> android:theme="@style/AppTheme">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true">
android:label="@string/app_name">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -33,11 +33,12 @@ class KeepOrgProvider : DocumentsProvider() {
closeThread.quitSafely() closeThread.quitSafely()
} }
private fun prefs() = Prefs(requireContext()) private fun prefs() = Prefs(ctx())
private fun mirrorDir(): File = File(requireContext().filesDir, "databases").apply { mkdirs() } private fun mirrorDir(): File = File(ctx().filesDir, "databases").apply { mkdirs() }
private fun requireContext() = context ?: throw FileNotFoundException("no context") /** Named ctx, not requireContext, which ContentProvider only has from API 30. */
private fun ctx() = context ?: throw FileNotFoundException("no context")
override fun queryRoots(projection: Array<out String>?): Cursor { override fun queryRoots(projection: Array<out String>?): Cursor {
val c = MatrixCursor(projection ?: ROOT_PROJECTION) val c = MatrixCursor(projection ?: ROOT_PROJECTION)
@@ -46,7 +47,7 @@ class KeepOrgProvider : DocumentsProvider() {
c.newRow().apply { c.newRow().apply {
add(Root.COLUMN_ROOT_ID, ROOT_ID) add(Root.COLUMN_ROOT_ID, ROOT_ID)
add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC) add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC)
add(Root.COLUMN_TITLE, requireContext().getString(R.string.app_name)) add(Root.COLUMN_TITLE, ctx().getString(R.string.app_name))
add(Root.COLUMN_SUMMARY, p.username.ifEmpty { p.serverUrl }) 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_FLAGS, Root.FLAG_SUPPORTS_IS_CHILD or Root.FLAG_LOCAL_ONLY)
add(Root.COLUMN_MIME_TYPES, KDBX_MIME) add(Root.COLUMN_MIME_TYPES, KDBX_MIME)
@@ -60,7 +61,7 @@ class KeepOrgProvider : DocumentsProvider() {
if (documentId == ROOT_DOC) { if (documentId == ROOT_DOC) {
c.newRow().apply { c.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, ROOT_DOC) add(Document.COLUMN_DOCUMENT_ID, ROOT_DOC)
add(Document.COLUMN_DISPLAY_NAME, requireContext().getString(R.string.provider_root)) add(Document.COLUMN_DISPLAY_NAME, ctx().getString(R.string.provider_root))
add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE) add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE)
add(Document.COLUMN_SIZE, null) add(Document.COLUMN_SIZE, null)
@@ -84,7 +85,7 @@ class KeepOrgProvider : DocumentsProvider() {
?.sortedBy { it.name } ?.sortedBy { it.name }
?.forEach { addFileRow(c, it) } ?.forEach { addFileRow(c, it) }
// Ask for a fresh pass so a picker that is open shows current files. // Ask for a fresh pass so a picker that is open shows current files.
Syncer.request(requireContext(), "picker listed documents") Syncer.request(ctx(), "picker listed documents")
return c return c
} }
@@ -99,7 +100,7 @@ class KeepOrgProvider : DocumentsProvider() {
// The application is about to save: reconcile once it has closed the // The application is about to save: reconcile once it has closed the
// file, which is the moment the new bytes are complete on disk. // file, which is the moment the new bytes are complete on disk.
return ParcelFileDescriptor.open(file, access, closeHandler) { return ParcelFileDescriptor.open(file, access, closeHandler) {
Syncer.request(requireContext(), "saved $documentId") Syncer.request(ctx(), "saved $documentId")
} }
} }
@@ -21,33 +21,33 @@ class LoginActivity : Activity() {
val prefs = Prefs(this) val prefs = Prefs(this)
val root = Ui.column(this) val root = Ui.column(this)
val server = Ui.field(this, "https://keeporg.example.com", InputType.TYPE_TEXT_VARIATION_URI) val server = Ui.field(this, getString(R.string.hint_server), InputType.TYPE_TEXT_VARIATION_URI)
server.setText(prefs.serverUrl) server.setText(prefs.serverUrl)
val user = Ui.field(this, "username", InputType.TYPE_CLASS_TEXT) val user = Ui.field(this, getString(R.string.hint_username), InputType.TYPE_CLASS_TEXT)
val password = Ui.field( val password = Ui.field(
this, "password", this, getString(R.string.hint_password),
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_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) val totp = Ui.field(this, getString(R.string.hint_totp), InputType.TYPE_CLASS_NUMBER)
message = Ui.body(this) message = Ui.body(this)
root.addView(Ui.heading(this, getString(R.string.sign_in))) root.addView(Ui.heading(this, getString(R.string.sign_in)))
root.addView(Ui.label(this, "Server")) root.addView(Ui.label(this, getString(R.string.label_server)))
root.addView(server) root.addView(server)
root.addView(Ui.label(this, "Username")) root.addView(Ui.label(this, getString(R.string.label_username)))
root.addView(user) root.addView(user)
root.addView(Ui.label(this, "Password")) root.addView(Ui.label(this, getString(R.string.label_password)))
root.addView(password) root.addView(password)
root.addView(Ui.label(this, "Second factor")) root.addView(Ui.label(this, getString(R.string.label_second_factor)))
root.addView(totp) root.addView(totp)
root.addView(Ui.button(this, "Sign in") { root.addView(Ui.button(this, "Sign in") {
val url = server.text.toString().trim().trimEnd('/') val url = server.text.toString().trim().trimEnd('/')
if (url.isEmpty()) { if (url.isEmpty()) {
message.text = "Enter the server address." message.setText(R.string.msg_enter_server)
return@button return@button
} }
prefs.serverUrl = url prefs.serverUrl = url
message.text = "Signing in..." message.setText(R.string.msg_signing_in)
val api = Api(url, null) val api = Api(url, null)
Ui.background(this, { Ui.background(this, {
api.login(user.text.toString().trim(), password.text.toString(), api.login(user.text.toString().trim(), password.text.toString(),
@@ -56,10 +56,10 @@ class LoginActivity : Activity() {
result.fold({ finishSignIn(it) }, { failed(it) }) result.fold({ finishSignIn(it) }, { failed(it) })
} }
}) })
root.addView(Ui.button(this, "Sign in with a browser or SSO") { root.addView(Ui.button(this, getString(R.string.action_sign_in_browser)) {
val url = server.text.toString().trim().trimEnd('/') val url = server.text.toString().trim().trimEnd('/')
if (url.isEmpty()) { if (url.isEmpty()) {
message.text = "Enter the server address." message.setText(R.string.msg_enter_server)
return@button return@button
} }
prefs.serverUrl = url prefs.serverUrl = url
@@ -71,17 +71,17 @@ class LoginActivity : Activity() {
/** Runs the device flow: show a code, open a browser, wait for approval. */ /** Runs the device flow: show a code, open a browser, wait for approval. */
private fun browserSignIn(api: Api) { private fun browserSignIn(api: Api) {
message.text = "Opening a browser..." message.setText(R.string.msg_opening_browser)
Ui.background(this, { api.deviceStart(android.os.Build.MODEL ?: "android") }) { started -> Ui.background(this, { api.deviceStart(android.os.Build.MODEL ?: "android") }) { started ->
started.fold({ start -> started.fold({ start ->
message.text = "Approve the code ${start.userCode} in the browser." message.text = getString(R.string.msg_approve_code, start.userCode)
runCatching { runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl)))
} }
Ui.background(this, { poll(api, start) }) { polled -> Ui.background(this, { poll(api, start) }) { polled ->
polled.fold({ session -> polled.fold({ session ->
if (session == null) { if (session == null) {
message.text = "The approval window expired. Try again." message.setText(R.string.msg_approval_expired)
} else { } else {
finishSignIn(session) finishSignIn(session)
} }
@@ -111,6 +111,6 @@ class LoginActivity : Activity() {
} }
private fun failed(e: Throwable) { private fun failed(e: Throwable) {
message.text = e.message ?: "Sign-in failed." message.text = e.message ?: getString(R.string.msg_sign_in_failed)
} }
} }
@@ -30,21 +30,15 @@ class MainActivity : Activity() {
val root = Ui.column(this) val root = Ui.column(this)
root.addView(Ui.heading(this, getString(R.string.app_name))) root.addView(Ui.heading(this, getString(R.string.app_name)))
root.addView(Ui.body(this, "${prefs.username} at ${prefs.serverUrl}")) root.addView(Ui.body(this, getString(R.string.account_line, prefs.username, prefs.serverUrl)))
root.addView( root.addView(Ui.label(this, getString(R.string.main_hint)))
Ui.label( status = Ui.body(this, getString(R.string.msg_loading))
this,
"Open these databases from any KeePass application: pick " +
"\"KeepOrg\" in its file picker."
)
)
status = Ui.body(this, "Loading...")
root.addView(status) root.addView(status)
root.addView(Ui.button(this, "Sync now") { refresh(force = true) }) root.addView(Ui.button(this, getString(R.string.action_sync_now)) { refresh(force = true) })
watchButton = Ui.button(this, watchLabel()) { toggleWatch() } watchButton = Ui.button(this, watchLabel()) { toggleWatch() }
root.addView(watchButton) root.addView(watchButton)
root.addView(Ui.button(this, "Sign out") { signOut() }) root.addView(Ui.button(this, getString(R.string.action_sign_out)) { signOut() })
setContentView(root) setContentView(root)
} }
@@ -57,27 +51,31 @@ class MainActivity : Activity() {
} }
private fun refresh(force: Boolean) { private fun refresh(force: Boolean) {
status.text = if (force) "Synchronizing..." else status.text if (force) status.setText(R.string.msg_synchronizing)
val api = Syncer.apiOf(this) ?: return val api = Syncer.apiOf(this) ?: return
Syncer.request(this, if (force) "user asked" else "screen opened") { } Syncer.request(this, if (force) "user asked" else "screen opened") { }
Ui.background(this, { api.databases() }) { result -> Ui.background(this, { api.databases() }) { result ->
result.fold({ dbs -> result.fold({ dbs ->
status.text = if (dbs.isEmpty()) { status.text = if (dbs.isEmpty()) {
"No databases are assigned to this account yet." getString(R.string.msg_no_databases)
} else { } else {
dbs.joinToString("\n\n") { db -> dbs.joinToString("\n\n") { db ->
val state = prefs.status(db.id).ifEmpty { "not synced yet" } val state = prefs.status(db.id).ifEmpty { getString(R.string.state_not_synced) }
"${db.name}\n ${db.perm}, ${db.mergeStrategy}, v${db.version}\n $state" getString(
R.string.db_entry, db.name, db.perm, db.mergeStrategy,
db.version.toString(), state,
)
} }
} }
}, { e -> }, { e ->
status.text = "Could not reach the server: ${e.message}" status.text = getString(R.string.msg_server_unreachable, e.message ?: "")
}) })
} }
} }
private fun watchLabel() = private fun watchLabel() = getString(
if (prefs.watchEnabled) "Stop watching for changes" else "Keep watching for changes" if (prefs.watchEnabled) R.string.action_watch_stop else R.string.action_watch_start
)
private fun toggleWatch() { private fun toggleWatch() {
if (prefs.watchEnabled) { if (prefs.watchEnabled) {
@@ -29,7 +29,7 @@ class SyncService : Service() {
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
startForeground(NOTIFICATION_ID, notification("Watching for changes")) startForeground(NOTIFICATION_ID, notification(getString(R.string.notif_watching)))
Syncer.request(this, "service started") Syncer.request(this, "service started")
if (!running) { if (!running) {
running = true running = true
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- From API 29 the platform theme follows the system light/dark setting. -->
<style name="AppTheme" parent="@android:style/Theme.DeviceDefault.DayNight" />
</resources>
@@ -4,4 +4,36 @@
<string name="sign_in">Sign in</string> <string name="sign_in">Sign in</string>
<string name="provider_root">KeepOrg databases</string> <string name="provider_root">KeepOrg databases</string>
<string name="sync_channel">Synchronization</string> <string name="sync_channel">Synchronization</string>
<string name="notif_watching">Watching for changes</string>
<!-- Sign-in screen -->
<string name="label_server">Server</string>
<string name="label_username">Username</string>
<string name="label_password">Password</string>
<string name="label_second_factor">Second factor</string>
<string name="hint_server">https://keeporg.example.com</string>
<string name="hint_username">username</string>
<string name="hint_password">password</string>
<string name="hint_totp">one-time code (if enabled)</string>
<string name="action_sign_in_browser">Sign in with a browser or SSO</string>
<string name="msg_enter_server">Enter the server address.</string>
<string name="msg_signing_in">Signing in…</string>
<string name="msg_opening_browser">Opening a browser…</string>
<string name="msg_approve_code">Approve the code %1$s in the browser.</string>
<string name="msg_approval_expired">The approval window expired. Try again.</string>
<string name="msg_sign_in_failed">Sign-in failed.</string>
<!-- Main screen -->
<string name="account_line">%1$s at %2$s</string>
<string name="main_hint">Open these databases from any KeePass application: pick \"KeepOrg\" in its file picker.</string>
<string name="action_sync_now">Sync now</string>
<string name="action_watch_start">Keep watching for changes</string>
<string name="action_watch_stop">Stop watching for changes</string>
<string name="action_sign_out">Sign out</string>
<string name="msg_loading">Loading…</string>
<string name="msg_synchronizing">Synchronizing…</string>
<string name="msg_no_databases">No databases are assigned to this account yet.</string>
<string name="msg_server_unreachable">Could not reach the server: %1$s</string>
<string name="state_not_synced">not synced yet</string>
<string name="db_entry">%1$s\n %2$s, %3$s, v%4$s\n %5$s</string>
</resources> </resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base theme for API 26 to 28, which have no DayNight variant. -->
<style name="AppTheme" parent="@android:style/Theme.DeviceDefault" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The pre-Android-12 counterpart of data_extraction_rules.xml. Backup is
already off through android:allowBackup, and this makes the intent explicit
for every release the app supports.
-->
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</full-backup-content>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Nothing this app holds may leave the device in a backup or a device
transfer: the session token authenticates as the user, and the mirrored
files are the organization's password databases. A new device signs in
again, which is one invite or one password away.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
+11 -4
View File
@@ -272,6 +272,7 @@ manual, never automatic:
```sh ```sh
make android # unsigned APK, named cc.tiredsysadmin.android.keeporg_<code>.apk make android # unsigned APK, named cc.tiredsysadmin.android.keeporg_<code>.apk
make android-lint # kept at zero findings
make android-publish # scp to the private F-Droid unsigned and metadata directories make android-publish # scp to the private F-Droid unsigned and metadata directories
``` ```
@@ -298,16 +299,22 @@ What the suite actually covers:
server-wins, client-wins, manual, read-only restore, server-side rename, server-wins, client-wins, manual, read-only restore, server-side rename,
and an update arriving over the event stream. and an update arriving over the event stream.
The Android module is checked by `make android-lint`, which is kept at zero
findings, and `make android` builds the unsigned release APK. See
[../android/README.md](../android/README.md) for its requirements, including
what an arm64 Linux build host needs.
## Known limits and the next things to do ## Known limits and the next things to do
- **One server node.** The JSON store is single-writer by design. Horizontal - **One server node.** The JSON store is single-writer by design. Horizontal
scale means replacing `internal/store`. scale means replacing `internal/store`.
- **No entry-level merge.** See the strategies section. The pluggable place - **No entry-level merge.** See the strategies section. The pluggable place
is the client, which holds the key. is the client, which holds the key.
- **The Android module has not been compiled in this environment** (no JDK or - **The Android app has never run on a device.** It compiles clean, lint
Android SDK was available). The Gradle wrapper is deliberately not reports no issues and the DEX carries every class the manifest names, but
committed; run `gradle wrapper --gradle-version 8.9` once, then no emulator or phone was available here. The first real run is where to
`make android`. Expect to fix small things on the first build. confirm the file-picker integration, the save-triggered sync and the
foreground notification.
- **No key-file or Yubikey handling**, and none needed: those live in the - **No key-file or Yubikey handling**, and none needed: those live in the
KeePass application. KeePass application.
- **Sessions are not listable or revocable individually** beyond - **Sessions are not listable or revocable individually** beyond