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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user