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:
co-authored by
Claude Opus 5.5
parent
ef6598fa15
commit
318bd68908
+3
-1
@@ -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
|
||||
|
||||
@@ -23,10 +23,26 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Exported for one link: the server's device-approval page offers
|
||||
keeporg://device-approved to bring the user back from the
|
||||
browser. Opening it only makes this screen collect the sign-in
|
||||
it already started; it carries no data. singleTask brings back
|
||||
the waiting instance instead of stacking a new one on the
|
||||
browser. -->
|
||||
<activity
|
||||
android:name=".LoginActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/sign_in" />
|
||||
android:exported="true"
|
||||
android:label="@string/sign_in"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="device-approved"
|
||||
android:scheme="keeporg" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".SyncService"
|
||||
|
||||
@@ -67,8 +67,21 @@ class Api(baseUrl: String, var token: String?) {
|
||||
return session(request("POST", "/api/v1/auth/login", body))
|
||||
}
|
||||
|
||||
fun deviceStart(label: String): DeviceStart {
|
||||
val o = request("POST", "/api/v1/auth/device/start", JSONObject().put("label", label))
|
||||
/**
|
||||
* Starts a browser sign-in. [returnUrl] is offered on the approval page
|
||||
* as a link back to this app; the server accepts only keeporg: URLs.
|
||||
* A server older than 0.9 rejects the field, so the request is retried
|
||||
* without it: the sign-in still completes, the user just switches back
|
||||
* by hand.
|
||||
*/
|
||||
fun deviceStart(label: String, returnUrl: String): DeviceStart {
|
||||
val body = JSONObject().put("label", label).put("return_url", returnUrl)
|
||||
val o = try {
|
||||
request("POST", "/api/v1/auth/device/start", body)
|
||||
} catch (e: ApiException) {
|
||||
if (e.status != 400 || e.message?.contains("return_url") != true) throw e
|
||||
request("POST", "/api/v1/auth/device/start", JSONObject().put("label", label))
|
||||
}
|
||||
return DeviceStart(
|
||||
o.getString("device_code"),
|
||||
o.getString("user_code"),
|
||||
|
||||
@@ -6,16 +6,30 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.widget.TextView
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Signs the device in, either with a password and the built-in second
|
||||
* factor, or by approving a code in a browser, which is also how single
|
||||
* sign-on works.
|
||||
*
|
||||
* A browser sign-in outlives this screen. The pending code is kept in
|
||||
* [Prefs], so it is collected whenever the screen comes back: when the
|
||||
* approval page's "Return to KeepOrg" link reopens it, when the user
|
||||
* switches back by hand, or when Android restarted the app meanwhile.
|
||||
*/
|
||||
class LoginActivity : Activity() {
|
||||
|
||||
private lateinit var message: TextView
|
||||
|
||||
/** Polls for the pending browser sign-in while this screen exists. */
|
||||
private var poller: Thread? = null
|
||||
|
||||
@Volatile
|
||||
private var destroyed = false
|
||||
|
||||
private var resumed = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val prefs = Prefs(this)
|
||||
@@ -69,43 +83,129 @@ class LoginActivity : Activity() {
|
||||
setContentView(root)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// The approval page's link back. onResume follows and collects it.
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
resumed = true
|
||||
if (Prefs(this).signedIn) {
|
||||
// Approved while this screen was in the background.
|
||||
openMain()
|
||||
return
|
||||
}
|
||||
collectPendingSignIn(now = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
resumed = false
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
destroyed = true
|
||||
poller?.interrupt()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Runs the device flow: show a code, open a browser, wait for approval. */
|
||||
private fun browserSignIn(api: Api) {
|
||||
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", 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user