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
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 := $(DIST)/$(APPID_ANDROID)_$(ANDROID_VERSION_CODE).apk
.PHONY: android
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
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)
@mkdir -p $(DIST)
cp $(APK_BUILT) $@
+42 -8
View File
@@ -2,7 +2,8 @@
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.
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
@@ -16,19 +17,52 @@ reaches the server.
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.
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
`internal/client/decide.go`. Keep the two in step.
`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:
Requirements: JDK 17 or newer, Gradle 8.9 or newer, and an Android SDK with
`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
make android-publish # upload to the private F-Droid repo
```
The Gradle wrapper is not committed, so the Makefile calls whichever `gradle`
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
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 {
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 {
// Keep the APK free of the Google dependency metadata blob so the
// build is reproducible for F-Droid.
+4 -3
View File
@@ -8,14 +8,15 @@
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@android:style/Theme.DeviceDefault.DayNight">
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -33,11 +33,12 @@ class KeepOrgProvider : DocumentsProvider() {
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 {
val c = MatrixCursor(projection ?: ROOT_PROJECTION)
@@ -46,7 +47,7 @@ class KeepOrgProvider : DocumentsProvider() {
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_TITLE, ctx().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)
@@ -60,7 +61,7 @@ class KeepOrgProvider : DocumentsProvider() {
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_DISPLAY_NAME, ctx().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)
@@ -84,7 +85,7 @@ class KeepOrgProvider : DocumentsProvider() {
?.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")
Syncer.request(ctx(), "picker listed documents")
return c
}
@@ -99,7 +100,7 @@ class KeepOrgProvider : DocumentsProvider() {
// 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")
Syncer.request(ctx(), "saved $documentId")
}
}
@@ -21,33 +21,33 @@ class LoginActivity : Activity() {
val prefs = Prefs(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)
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(
this, "password",
this, getString(R.string.hint_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)
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(Ui.label(this, "Username"))
root.addView(Ui.label(this, getString(R.string.label_username)))
root.addView(user)
root.addView(Ui.label(this, "Password"))
root.addView(Ui.label(this, getString(R.string.label_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(Ui.button(this, "Sign in") {
val url = server.text.toString().trim().trimEnd('/')
if (url.isEmpty()) {
message.text = "Enter the server address."
message.setText(R.string.msg_enter_server)
return@button
}
prefs.serverUrl = url
message.text = "Signing in..."
message.setText(R.string.msg_signing_in)
val api = Api(url, null)
Ui.background(this, {
api.login(user.text.toString().trim(), password.text.toString(),
@@ -56,10 +56,10 @@ class LoginActivity : Activity() {
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('/')
if (url.isEmpty()) {
message.text = "Enter the server address."
message.setText(R.string.msg_enter_server)
return@button
}
prefs.serverUrl = url
@@ -71,17 +71,17 @@ class LoginActivity : Activity() {
/** Runs the device flow: show a code, open a browser, wait for approval. */
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 ->
started.fold({ start ->
message.text = "Approve the code ${start.userCode} in the browser."
message.text = getString(R.string.msg_approve_code, start.userCode)
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."
message.setText(R.string.msg_approval_expired)
} else {
finishSignIn(session)
}
@@ -111,6 +111,6 @@ class LoginActivity : Activity() {
}
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)
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(Ui.body(this, getString(R.string.account_line, prefs.username, prefs.serverUrl)))
root.addView(Ui.label(this, getString(R.string.main_hint)))
status = Ui.body(this, getString(R.string.msg_loading))
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() }
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)
}
@@ -57,27 +51,31 @@ class MainActivity : Activity() {
}
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
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."
getString(R.string.msg_no_databases)
} 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"
val state = prefs.status(db.id).ifEmpty { getString(R.string.state_not_synced) }
getString(
R.string.db_entry, db.name, db.perm, db.mergeStrategy,
db.version.toString(), state,
)
}
}
}, { e ->
status.text = "Could not reach the server: ${e.message}"
status.text = getString(R.string.msg_server_unreachable, e.message ?: "")
})
}
}
private fun watchLabel() =
if (prefs.watchEnabled) "Stop watching for changes" else "Keep watching for changes"
private fun watchLabel() = getString(
if (prefs.watchEnabled) R.string.action_watch_stop else R.string.action_watch_start
)
private fun toggleWatch() {
if (prefs.watchEnabled) {
@@ -29,7 +29,7 @@ class SyncService : Service() {
stopSelf()
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")
if (!running) {
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="provider_root">KeepOrg databases</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>
@@ -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
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
```
@@ -298,16 +299,22 @@ What the suite actually covers:
server-wins, client-wins, manual, read-only restore, server-side rename,
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
- **One server node.** The JSON store is single-writer by design. Horizontal
scale means replacing `internal/store`.
- **No entry-level merge.** See the strategies section. The pluggable place
is the client, which holds the key.
- **The Android module has not been compiled in this environment** (no JDK or
Android SDK was available). The Gradle wrapper is deliberately not
committed; run `gradle wrapper --gradle-version 8.9` once, then
`make android`. Expect to fix small things on the first build.
- **The Android app has never run on a device.** It compiles clean, lint
reports no issues and the DEX carries every class the manifest names, but
no emulator or phone was available here. The first real run is where to
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
KeePass application.
- **Sessions are not listable or revocable individually** beyond