diff --git a/android/README.md b/android/README.md
index c66668b..2096dad 100644
--- a/android/README.md
+++ b/android/README.md
@@ -27,7 +27,9 @@ The merge policy in `Mirror.decide` is the same table as the desktop client's
## Building
-Requirements: JDK 17 or newer, Gradle 8.9 or newer, and an Android SDK with
+Requirements: JDK 17 (lint in AGP 8.7 fails on newer JDKs; set `JAVA_HOME`
+to a 17 when Gradle defaults to something else), Gradle 8.9 or newer, and an
+Android SDK with
`platforms;android-35` and `build-tools;35.0.0`.
```sh
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4085a1a..f35a041 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -23,10 +23,26 @@
+
+ android:exported="true"
+ android:label="@string/sign_in"
+ android:launchMode="singleTask">
+
+
+
+
+
+
+
+ Ui.background(this, {
+ api.deviceStart(android.os.Build.MODEL ?: "android", RETURN_URL)
+ }) { started ->
started.fold({ start ->
- message.text = getString(R.string.msg_approve_code, start.userCode)
+ Prefs(this).pendingSignIn = Prefs.PendingSignIn(
+ start.deviceCode, start.userCode, start.interval,
+ System.currentTimeMillis() + start.expiresIn * 1000L,
+ )
+ collectPendingSignIn(now = false)
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl)))
}
- Ui.background(this, { poll(api, start) }) { polled ->
- polled.fold({ session ->
- if (session == null) {
- message.setText(R.string.msg_approval_expired)
- } else {
- finishSignIn(session)
- }
- }, { failed(it) })
- }
}, { failed(it) })
}
}
- private fun poll(api: Api, start: DeviceStart): SessionInfo? {
- val deadline = System.currentTimeMillis() + start.expiresIn * 1000L
- val interval = (start.interval.coerceAtLeast(1)) * 1000L
- while (System.currentTimeMillis() < deadline) {
- Thread.sleep(interval)
- api.devicePoll(start.deviceCode)?.let { return it }
+ /**
+ * Polls for the pending browser sign-in until it is approved, refused or
+ * expired. It keeps running while the browser is in front, and with
+ * [now] polls at once instead of after the first interval, which is what
+ * returning to the app wants.
+ */
+ private fun collectPendingSignIn(now: Boolean) {
+ val prefs = Prefs(applicationContext)
+ val pending = prefs.pendingSignIn ?: return
+ if (pending.expired) {
+ prefs.pendingSignIn = null
+ message.setText(R.string.msg_approval_expired)
+ return
}
- return null
+ message.text = getString(R.string.msg_approve_code, pending.userCode)
+ poller?.let { running ->
+ if (running.isAlive) {
+ if (now) running.interrupt() // cut the current wait short
+ return
+ }
+ }
+ val api = Api(prefs.serverUrl, null)
+ val interval = pending.intervalSeconds.coerceAtLeast(1) * 1000L
+ poller = Thread {
+ var wait = !now
+ while (!destroyed && !pending.expired) {
+ if (wait) {
+ try {
+ Thread.sleep(interval)
+ } catch (_: InterruptedException) {
+ if (destroyed) return@Thread
+ }
+ }
+ wait = true
+ val session: SessionInfo? = try {
+ api.devicePoll(pending.deviceCode)
+ } catch (e: ApiException) {
+ // Expired, already used, or refused: this code is spent.
+ // A screen that went away mid-request may have spent it
+ // on our behalf, in which case we are signed in.
+ prefs.pendingSignIn = null
+ runOnUiThread {
+ if (destroyed) return@runOnUiThread
+ if (prefs.signedIn) openMain() else failed(e)
+ }
+ return@Thread
+ } catch (_: IOException) {
+ continue // the network may be down while the browser is busy
+ }
+ if (session == null) continue
+ // Saved here, not on the UI thread, so an approval collected
+ // as the screen goes away is still kept.
+ prefs.pendingSignIn = null
+ saveSession(prefs, session)
+ runOnUiThread { if (!destroyed && resumed) openMain() }
+ return@Thread
+ }
+ if (pending.expired) {
+ prefs.pendingSignIn = null
+ runOnUiThread { if (!destroyed) message.setText(R.string.msg_approval_expired) }
+ }
+ }.apply { start() }
}
private fun finishSignIn(session: SessionInfo) {
- val prefs = Prefs(this)
+ saveSession(Prefs(applicationContext), session)
+ openMain()
+ }
+
+ private fun saveSession(prefs: Prefs, session: SessionInfo) {
prefs.token = session.token
prefs.username = session.username
- Syncer.request(this, "signed in")
+ Syncer.request(applicationContext, "signed in")
+ }
+
+ private fun openMain() {
startActivity(Intent(this, MainActivity::class.java))
finish()
}
@@ -113,4 +213,9 @@ class LoginActivity : Activity() {
private fun failed(e: Throwable) {
message.text = e.message ?: getString(R.string.msg_sign_in_failed)
}
+
+ companion object {
+ /** Where the approval page links back to; see the manifest. */
+ const val RETURN_URL = "keeporg://device-approved"
+ }
}
diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt
index f07655e..a9b1f03 100644
--- a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt
+++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt
@@ -27,6 +27,41 @@ class Prefs(context: Context) {
val signedIn: Boolean get() = serverUrl.isNotEmpty() && token.isNotEmpty()
+ /** A browser sign-in waiting for approval. */
+ data class PendingSignIn(
+ val deviceCode: String,
+ val userCode: String,
+ val intervalSeconds: Int,
+ val expiresAtMillis: Long,
+ ) {
+ val expired: Boolean get() = System.currentTimeMillis() >= expiresAtMillis
+ }
+
+ /**
+ * The browser sign-in in progress. It lives here rather than in the
+ * activity because Android may stop the app while the browser is in
+ * front, and the approval must still be collected when the user returns.
+ */
+ var pendingSignIn: PendingSignIn?
+ get() {
+ val raw = sp.getString("pending_sign_in", null) ?: return null
+ val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null
+ return PendingSignIn(
+ o.optString("device_code"), o.optString("user_code"),
+ o.optInt("interval", 2), o.optLong("expires_at"),
+ )
+ }
+ set(v) {
+ if (v == null) {
+ sp.edit().remove("pending_sign_in").apply()
+ return
+ }
+ val o = JSONObject()
+ .put("device_code", v.deviceCode).put("user_code", v.userCode)
+ .put("interval", v.intervalSeconds).put("expires_at", v.expiresAtMillis)
+ sp.edit().putString("pending_sign_in", o.toString()).apply()
+ }
+
/** Whether the user asked this device to keep listening for changes. */
var watchEnabled: Boolean
get() = sp.getBoolean("watch_enabled", false)
diff --git a/android/metadata/cc.tiredsysadmin.android.keeporg.yml b/android/metadata/cc.tiredsysadmin.android.keeporg.yml
index 052f1c7..ce82d71 100644
--- a/android/metadata/cc.tiredsysadmin.android.keeporg.yml
+++ b/android/metadata/cc.tiredsysadmin.android.keeporg.yml
@@ -49,7 +49,15 @@ Builds:
- yes
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
+ - versionName: 0.3.0
+ versionCode: 3
+ commit: v0.3.0-android
+ subdir: android/app
+ gradle:
+ - yes
+ output: android/app/build/outputs/apk/release/app-release-unsigned.apk
+
AutoUpdateMode: None
UpdateCheckMode: Tags
-CurrentVersion: 0.2.0
-CurrentVersionCode: 2
+CurrentVersion: 0.3.0
+CurrentVersionCode: 3
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index ba52b71..83e7ef8 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -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
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 9e29971..dc03e85 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -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 |
diff --git a/internal/server/auth.go b/internal/server/auth.go
index 268c4f7..f5b4961 100644
--- a/internal/server/auth.go
+++ b/internal/server/auth.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
+ "net/url"
"strings"
"time"
@@ -187,15 +188,22 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *st
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
var req struct {
- Label string `json:"label"`
+ Label string `json:"label"`
+ ReturnURL string `json:"return_url"`
}
if r.ContentLength > 0 && !readJSON(w, r, &req) {
return
}
+ if req.ReturnURL != "" && !validReturnURL(req.ReturnURL) {
+ fail(w, http.StatusBadRequest, "bad_request",
+ "return_url must use the "+returnScheme+": scheme")
+ return
+ }
d := &store.DeviceAuth{
DeviceCode: secret.Token(),
UserCode: secret.UserCode(),
Label: req.Label,
+ ReturnURL: req.ReturnURL,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(deviceTTL),
}
@@ -276,11 +284,13 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
return
}
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
+ var returnURL string
err := s.st.Update(func(st *store.State) error {
d := st.DeviceByUserCode(code)
if d == nil || time.Now().After(d.ExpiresAt) {
return errors.New("unknown or expired code")
}
+ returnURL = d.ReturnURL
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
if err != nil {
return err
@@ -298,10 +308,20 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
case err != nil:
s.renderDevice(w, code, err.Error())
default:
- s.renderDone(w, "Device approved", "You can close this page and return to the application.")
+ s.renderApproved(w, returnURL)
}
}
+// returnScheme is the only scheme a device may ask to be sent back to. It
+// names KeepOrg's own clients, so the approval page cannot be turned into a
+// redirect to anywhere else.
+const returnScheme = "keeporg"
+
+func validReturnURL(raw string) bool {
+ u, err := url.Parse(raw)
+ return err == nil && u.Scheme == returnScheme && u.User == nil
+}
+
// changePassword replaces a user's password after proving the current one.
// It is the one place a password changes, so the rules live here: the
// current password is required even though the caller already holds a
diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go
index 766bf7e..6f470db 100644
--- a/internal/server/integration_test.go
+++ b/internal/server/integration_test.go
@@ -452,3 +452,39 @@ func TestNonAdminCannotAdminister(t *testing.T) {
t.Fatalf("anonymous list: %d", status)
}
}
+
+func TestDeviceApprovalLinksBackToTheApp(t *testing.T) {
+ h := newHarness(t)
+ if status, body := h.call("POST", "/api/v1/auth/device/start", "",
+ map[string]string{"label": "phone", "return_url": "https://evil.example/"}); status != http.StatusBadRequest {
+ t.Fatalf("foreign return_url accepted: %d %v", status, body)
+ }
+ status, start := h.call("POST", "/api/v1/auth/device/start", "",
+ map[string]string{"label": "phone", "return_url": "keeporg://device-approved"})
+ if status != http.StatusOK {
+ t.Fatalf("device start: %d %v", status, start)
+ }
+ resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{
+ "code": {start["user_code"].(string)}, "username": {"admin"}, "password": {adminPassword}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ page, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if !strings.Contains(string(page), `href="keeporg://device-approved"`) {
+ t.Fatalf("approval page has no link back to the app: %s", page)
+ }
+
+ // A client that asked for nothing gets no link.
+ _, start = h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
+ resp, err = h.http.Client().PostForm(h.http.URL+"/device", url.Values{
+ "code": {start["user_code"].(string)}, "username": {"admin"}, "password": {adminPassword}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ page, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if strings.Contains(string(page), "Return to KeepOrg") {
+ t.Fatalf("link shown without a return_url: %s", page)
+ }
+}
diff --git a/internal/server/oidc.go b/internal/server/oidc.go
index 364512c..e473914 100644
--- a/internal/server/oidc.go
+++ b/internal/server/oidc.go
@@ -232,7 +232,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
return
}
- var webToken string
+ var webToken, returnURL string
if err := s.st.Update(func(st *store.State) error {
var dev *store.DeviceAuth
for _, d := range st.Devices {
@@ -257,6 +257,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
return nil
}
dev.SessionToken = token
+ returnURL = dev.ReturnURL
return nil
}); err != nil {
s.renderDone(w, "Sign-in failed", err.Error())
@@ -267,7 +268,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/web/vaults", http.StatusFound)
return
}
- s.renderDone(w, "Device approved", "You can close this page and return to the application.")
+ s.renderApproved(w, returnURL)
}
// linkOIDCUser finds the account behind an identity, provisioning one when
diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go
index ffff452..a0a12e5 100644
--- a/internal/server/oidc_test.go
+++ b/internal/server/oidc_test.go
@@ -140,7 +140,8 @@ func TestOIDCApprovesAWaitingClient(t *testing.T) {
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
- status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
+ status, start := h.call("POST", "/api/v1/auth/device/start", "",
+ map[string]string{"label": "phone", "return_url": "keeporg://device-approved"})
if status != http.StatusOK {
t.Fatalf("device start: %d %v", status, start)
}
@@ -151,6 +152,10 @@ func TestOIDCApprovesAWaitingClient(t *testing.T) {
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
}
+ // A phone is handed back to its app from the provider's redirect too.
+ if !strings.Contains(page, `href="keeporg://device-approved"`) {
+ t.Fatalf("no link back to the app after single sign-on:\n%s", page)
+ }
// A device approval must not also hand the browser a session cookie.
if b.cookie() != "" {
t.Fatal("approving a device set a web session cookie")
diff --git a/internal/server/pages.go b/internal/server/pages.go
index be88a00..b82cdab 100644
--- a/internal/server/pages.go
+++ b/internal/server/pages.go
@@ -31,8 +31,9 @@ p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem}
label{display:block;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7;margin:.9rem 0 .25rem}
input{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:transparent;
border:1px solid var(--line);border-radius:8px}
-button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff;
+button,a.button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff;
background:var(--accent);border:0;border-radius:8px;cursor:pointer}
+a.button{display:block;box-sizing:border-box;text-align:center;text-decoration:none}
.err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem}
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
code{font-size:1.1rem;letter-spacing:.08em}
@@ -250,7 +251,8 @@ to ask for.
{{end}}`))
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
-{{define "body"}}{{.Title}}
{{.Message}}
{{end}}`))
+{{define "body"}}{{.Title}}
{{.Message}}
+{{if .Return}}Return to KeepOrg
{{end}}{{end}}`))
func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) {
render(w, deviceTmpl, map[string]any{
@@ -290,6 +292,18 @@ func (s *Server) renderDone(w http.ResponseWriter, title, msg string) {
render(w, doneTmpl, map[string]any{"Title": title, "Message": msg})
}
+// renderApproved ends a device approval. A client that gave a return URL
+// gets a link back to itself; html/template would neutralise its custom
+// scheme, which is why it is marked safe here, after validReturnURL.
+func (s *Server) renderApproved(w http.ResponseWriter, returnURL string) {
+ d := map[string]any{"Title": "Device approved",
+ "Message": "You can close this page and return to the application."}
+ if returnURL != "" && validReturnURL(returnURL) {
+ d["Return"] = template.URL(returnURL)
+ }
+ render(w, doneTmpl, d)
+}
+
func render(w http.ResponseWriter, t *template.Template, data map[string]any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Referrer-Policy", "no-referrer")
diff --git a/internal/store/model.go b/internal/store/model.go
index 982b41c..00e4a67 100644
--- a/internal/store/model.go
+++ b/internal/store/model.go
@@ -161,12 +161,16 @@ type DeviceAuth struct {
UserCode string `json:"user_code"`
// Web marks a sign-in that ends in a cookie rather than in a token a
// waiting client polls for.
- Web bool `json:"web,omitempty"`
- Label string `json:"label,omitempty"`
- SessionToken string `json:"session_token,omitempty"`
- OIDCState string `json:"oidc_state,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- ExpiresAt time.Time `json:"expires_at"`
+ Web bool `json:"web,omitempty"`
+ Label string `json:"label,omitempty"`
+ SessionToken string `json:"session_token,omitempty"`
+ OIDCState string `json:"oidc_state,omitempty"`
+ // ReturnURL is where the approval page sends the person back to: the
+ // client's own URL scheme, for a phone whose browser would otherwise be
+ // a dead end. Empty for clients that need no hand-back.
+ ReturnURL string `json:"return_url,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
}
// State is the whole metadata set, serialized as one JSON document.
diff --git a/packaging/deb/server/changelog b/packaging/deb/server/changelog
index a1182f8..91a2d06 100644
--- a/packaging/deb/server/changelog
+++ b/packaging/deb/server/changelog
@@ -1,3 +1,12 @@
+keeporgd (0.9.0) unstable; urgency=medium
+
+ * A client starting a browser sign-in may pass return_url, a keeporg:
+ URL, and the approval page then links back to it, after a password or
+ single sign-on alike. The Android app uses it to come back from the
+ browser.
+
+ -- Azer Abdullaev Mon, 02 Nov 2026 13:11:01 +0100
+
keeporgd (0.8.0) unstable; urgency=medium
* The public base URL can be set with the KEEPORG_EXTERNAL_URL
diff --git a/versions.mk b/versions.mk
index 6cbd18b..d61876e 100644
--- a/versions.mk
+++ b/versions.mk
@@ -1,13 +1,13 @@
# Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM= PART=
-VERSION_SERVER := 0.8.0
+VERSION_SERVER := 0.9.0
VERSION_LINUX := 0.5.0
VERSION_MACOS := 0.5.0
VERSION_WINDOWS := 0.5.0
VERSION_BSD := 0.5.0
-VERSION_ANDROID := 0.2.0
+VERSION_ANDROID := 0.3.0
# Monotonic Android versionCode, never decreases.
-ANDROID_VERSION_CODE := 2
+ANDROID_VERSION_CODE := 3
# Reverse-DNS application identifiers.
APPID_ANDROID := cc.tiredsysadmin.android.keeporg