Collect Android browser sign-ins after the app was stopped

The Android app kept a pending browser sign-in in a thread inside the
login screen. Android often kills the process while the browser is in
front, so the approval reached the server but nothing collected it, and
the app came back signed out with nothing saved.

The pending device code is now kept in Prefs and collected whenever the
login screen resumes. The app passes return_url=keeporg://device-approved
when it starts the sign-in, and the server's approval page, after a
password or single sign-on, links back to it. Only the keeporg: scheme is
accepted. Against a server older than 0.9, which rejects the field, the
app retries without it.

Verified on an Android 15 emulator: approval collected after the process
was killed, and the fallback against the 0.8 server.

Server 0.9.0, Android 0.3.0 (versionCode 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 13:16:23 +01:00
co-authored by Claude Opus 5.5
parent ef6598fa15
commit 318bd68908
16 changed files with 352 additions and 49 deletions
+31 -5
View File
@@ -157,6 +157,26 @@ event stream open so colleagues' changes arrive without opening the app.
`Mirror.decide` is a transcription of the Go decision table. **Keep the two in
step**: a change to one belongs in the other in the same commit.
**Browser sign-in survives the app being stopped.** On a phone the browser
is a separate application, and Android may kill KeepOrg's process while it
is in front — SSO with a second factor is plenty of time for that. So the
pending device code is kept in `Prefs.pendingSignIn`, not in the activity,
and `LoginActivity` collects it on every `onResume`, polling at once. The
server's approval page links back with `keeporg://device-approved`, which
the device passes as `return_url` and the manifest routes to
`LoginActivity` (`singleTask`, so the waiting screen comes back rather than
a new one stacking on the browser). The link carries no data: it only
brings the screen forward, and the session still arrives by polling. A
session collected while the screen is in the background is saved at once
and the main screen opened on the next resume, because Android forbids
starting an activity from the background. Against a server older than 0.9,
which rejects `return_url`, `Api.deviceStart` retries without it and the
user returns by hand.
Before this, the code lived in a thread inside the activity: a killed
process lost it, the approval reached the server and nobody collected it,
and the app came back signed out with nothing saved.
### The KDBX codec (`internal/kdbx`)
A real KeePass 2 implementation, because merging cannot be done on
@@ -841,11 +861,17 @@ what an arm64 Linux build host needs.
the server would have to hold those too. They work fine on any key-less
strategy, where the server never opens the file.
- **No transformed-key cache.** Each merge pays the Argon2 cost three times.
- **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.
- **The Android app has run only on an emulator, and only to sign in.**
Browser sign-in was driven on an Android 15 emulator against a local
server: approval collected after the process was killed and the return
link followed, and the fallback against a 0.8 server. The file-picker
integration, the save-triggered sync and the foreground notification
still await a first real run.
- **Lint needs JDK 17.** AGP 8.7's lint cannot parse the version string of
newer JDKs (Homebrew's Gradle runs on JDK 26): run it as
`JAVA_HOME=$(/usr/libexec/java_home -v 17) make android-lint`. It reports
one warning, `OldTargetApi`, since API 36 and 37 SDKs exist; raising
`targetSdk` is its own change.
- **No key-file or Yubikey handling**, and none needed: those live in the
KeePass application.
- **Sessions are not listable or revocable individually** beyond
+10 -1
View File
@@ -23,11 +23,20 @@ https, and every form carries a per-session CSRF token.
| POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked |
| POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed |
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
| POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` |
| POST | `/api/v1/auth/device/start` | `{label, return_url}` | `{device_code, user_code, verification_url, interval, expires_in}`; `400` when `return_url` is not a `keeporg:` URL |
| POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` |
A device code is single use: the first successful poll consumes it.
`return_url` is optional (server 0.9 and later). When it is given, the page
that ends the approval, after a password or single sign-on, carries a
"Return to KeepOrg" link to it. Only the `keeporg:` scheme is accepted, so
the page cannot be made to send anybody elsewhere. It is for a client whose
browser is a separate application, which is to say a phone; the client still
learns of the approval by polling, and the link only brings the person back.
Servers before 0.9 reject the unknown field with `400`, so a client should
retry without it.
## Synchronization
| Method | Path | Notes |