diff --git a/Makefile b/Makefile index c3ce071..da21d9a 100644 --- a/Makefile +++ b/Makefile @@ -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) $@ diff --git a/android/README.md b/android/README.md index b3ad361..c66668b 100644 --- a/android/README.md +++ b/android/README.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2a00756..c32abd1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c5124d7..4085a1a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,14 +8,15 @@ + android:theme="@style/AppTheme"> + android:exported="true"> diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt index 9421e45..0eb89cf 100644 --- a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt @@ -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?): 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") } } diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt index 7781a67..dde48b7 100644 --- a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt @@ -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) } } diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt index 7a6759c..3f59f3e 100644 --- a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt @@ -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) { diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt index 2fffa32..951aed0 100644 --- a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt @@ -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 diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to android/app/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/android/app/src/main/res/values-v29/themes.xml b/android/app/src/main/res/values-v29/themes.xml new file mode 100644 index 0000000..fb0a8f8 --- /dev/null +++ b/android/app/src/main/res/values-v29/themes.xml @@ -0,0 +1,5 @@ + + + +