Add OAuth device sign-in, sign-out/revoke, and at-rest credential encryption - #47
Conversation
The app can now pair with the gateway via SealGate's OAuth 2.0 device- authorization flow (RFC 8628, with PKCE) instead of only a pasted API key - the same flow the desktop daemon uses. Tapping "Sign in with SealGate" shows a user code, opens the dashboard's device-approval page, polls until approved, and stores the scoped `ewc_` credential plus the backend-issued device id. - DeviceAuthClient: Android-free device-flow client (PKCE, code request, token poll with slow_down/interval handling) so its logic is JVM-unit-testable. - GatewayUrls: derive the HTTPS API origin from the ws/wss gateway URL. - TunnelConfig/TunnelSettings/DeviceIdentityStore/TunnelService: carry the backend-issued device id so the tunnel's X-SealGate-Device-Id matches the id bound to the credential; persist the client installation id to rotate the same device row on re-auth. - MainActivity + layout + strings: "Sign in with SealGate" button and approval dialog; API key remains an advanced fallback. Manifest <queries> so the browser launch resolves on Android 11+. - Tests: PKCE format, gateway-URL derivation, poll-action decisions, device-id config behaviour. Backend side (mobile client profile) ships in edison-watch; see dev-docs/architecture/mobile-hardware-gateway-design.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
Address code-review findings on the device sign-in flow: - configFromInputs bound the stored device id to gateway-URL equality, so editing the gateway URL dropped the ewd_ id (and save() then deleted it), leaving the ewc_ credential unusable. The device id is bound to the credential, not the endpoint: reuse it whenever the field still holds the saved ewc_ token, regardless of URL. - DeviceIdentityStore wrote the OAuth-supplied ewd_ id into its local-UUID slot, so a later switch to API-key mode would present the backend's device id as its own. Only persist a locally minted id now; a supplied id is authoritative and already persisted by TunnelSettings. - MainActivity declares configChanges so a rotation during the (up to 10 min) browser-approval wait no longer cancels the poll and orphans the grant. - Trim PairingResult / DeviceTokenResponse to the fields the app consumes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
Persist the in-flight device grant so a sign-in that the OS interrupts while the user is approving in the browser resumes on next launch instead of being silently orphaned. Completes the review's #3: rotation was already covered by configChanges; this adds true process-death resilience without a ViewModel (whose scope would not survive the process anyway). - PendingSignInStore: private, short-lived store of the grant (device code, user code, verify URLs, PKCE verifier, absolute expiry), rebased on the time remaining at load and dropped once too little time is left. - MainActivity: extract the shared poll runner; save the grant when sign-in starts, clear it on success / terminal failure / explicit cancel, and keep it on lifecycle cancellation and process kill. onStart resumes a live grant (guarded, so the common foreground-return case is a no-op) without reopening the browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
Three hardening changes to the OAuth tunnel auth, all in the mobile client. Revoked/rejected credential no longer loops forever: TunnelClient now classifies terminal gateway refusals (1008 policy closes by their reason string, and 401/403 rejected upgrades) and stops the reconnect loop, publishing a new TunnelState.Unauthorized(reason). The UI turns that into a call to action - "Sign-in required", "Update required", or "Not enabled for your org" - and reveals the settings panel. Transient closes (network drops, server restart, "connection replaced") still reconnect with backoff. Sign out / revoke: a Sign out button (shown while a credential is stored) stops the tunnel, forgets the local credential, and best-effort revokes the installation via POST /api/v1/auth/device/revoke. Local sign-out always completes; a failed revoke only downgrades the confirmation toast. Credential encrypted at rest: SecretCipher wraps the tunnel credential and the in-flight PKCE verifier/device code with an AES-256-GCM key held in the AndroidKeyStore, so a prefs dump or a backup restored to another device cannot lift them. Legacy plaintext values are read transparently and re-encrypted on next save; a value that no longer decrypts reads as signed out, the correct outcome for an off-device credential. Pure decision helpers (close/failure classification, revoke status) are extracted and unit-tested; the Keystore and UI paths are exercised by CI's build + lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
There was a problem hiding this comment.
13 issues found across 19 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="README.md">
<violation number="1" location="README.md:112">
P2: For users upgrading with an existing API key, this statement is false until the settings are saved again: legacy plaintext is read unchanged and is not re-encrypted during load. Document the migration caveat so users do not assume the credential is already protected immediately after upgrading.</violation>
<violation number="2" location="README.md:117">
P3: Sign out does not always revoke an installation: API-key sign-out skips the revoke endpoint, and OAuth revocation is best-effort. Qualify this as a best-effort revoke for OAuth credentials so the fallback behavior and failure case are documented accurately.</violation>
</file>
<file name="app/src/main/java/ai/sealgate/stdiod/TunnelService.kt">
<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/TunnelService.kt:293">
P3: This duplicates the `TunnelStopReason` resource mapping already maintained in `MainActivity`, so changing a stop reason or its presentation can leave the notification and screen inconsistent. Move the mapping into one shared helper and use it from both classes.</violation>
</file>
<file name="app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt">
<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt:292">
P2: When the gateway field contains a malformed authority such as `wss://gateway:bad/path`, this check still accepts it. `Request.Builder().url(url)` then throws outside the guarded network call, so sign-in can crash instead of reporting an invalid gateway; validate the complete URL before returning it or move request construction inside the `try` blocks.</violation>
</file>
<file name="app/src/main/res/values/strings.xml">
<violation number="1" location="app/src/main/res/values/strings.xml:51">
P2: When the saved credential is an API key, sign-out only clears the local key and never revokes anything remotely, but this dialog promises dashboard revocation. Describe revocation as conditional and tell API-key users to connect again rather than sign in again.</violation>
<violation number="2" location="app/src/main/res/values/strings.xml:60">
P2: When the unauthorized reason is an unsupported protocol or a disabled organization, this accessibility description incorrectly blames an invalid credential and tells the user to sign in. Use wording that covers all terminal unauthorized reasons, or select the description from `state.reason` like the visible status does.</violation>
</file>
<file name="app/src/main/res/layout/dialog_device_sign_in.xml">
<violation number="1" location="app/src/main/res/layout/dialog_device_sign_in.xml:55">
P3: The indeterminate ProgressBar announces its own "loading" node alongside the adjacent "Waiting for approval…" text, so screen reader users hear redundant/unclear status. Mark the bar as decorative (importantForAccessibility=no) so only the labeled text is announced.</violation>
</file>
<file name="app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt">
<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt:65">
P2: When the remaining lifetime is no longer longer than the polling interval, `load` resumes a grant that cannot make its first poll before expiry. Reject records whose remaining time is less than or equal to the effective interval, rather than waiting for them to fail after resumption.</violation>
</file>
<file name="app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt">
<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt:25">
P2: When an `ewc_` token has no device ID, `isValid()` still allows connection, so `DeviceIdentityStore` substitutes a local UUID and the gateway rejects the credential. Require a nonblank device ID for `ewc_` credentials while retaining null for API-key mode.</violation>
</file>
<file name="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt">
<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt:388">
P2: This auth path accepts gateway strings that the tunnel cannot use and can crash on malformed authorities. Validate or normalize the gateway with the same strict rules used by `TunnelConfig` before requesting device authorization.</violation>
<violation number="2" location="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt:472">
P1: When approval completes while the browser still covers this activity, this call starts a foreground service from the background and can throw `ForegroundServiceStartNotAllowedException`. Defer the start until the activity is `STARTED`/resumed, then clear the pending grant after the start succeeds.</violation>
<violation number="3" location="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt:512">
P2: When sign-out cancels a job still inside `requestDeviceCode`, `signInButton` remains disabled because cancellation bypasses the only re-enable path. Re-enable it when cancelling the sign-in job or wrap the whole job in a cancellation-safe `finally` block.</violation>
</file>
<file name="app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt">
<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt:144">
P2: When `stop()` races with terminal close handling, `connectLoop()` can publish `Unauthorized` after `stop()` published `Disconnected`. Guard terminal publication against `stopped`, atomically with the stop state update, so explicit shutdown cannot leave a stale terminal status.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| binding.settingsPanel.visibility = View.GONE | ||
| updateSignOutVisibility() | ||
| Toast.makeText(this, R.string.sign_in_success, Toast.LENGTH_SHORT).show() | ||
| TunnelService.start(this, TunnelConfig(gatewayUrl, result.accessToken, result.deviceId)) |
There was a problem hiding this comment.
P1: When approval completes while the browser still covers this activity, this call starts a foreground service from the background and can throw ForegroundServiceStartNotAllowedException. Defer the start until the activity is STARTED/resumed, then clear the pending grant after the start succeeds.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/MainActivity.kt, line 472:
<comment>When approval completes while the browser still covers this activity, this call starts a foreground service from the background and can throw `ForegroundServiceStartNotAllowedException`. Defer the start until the activity is `STARTED`/resumed, then clear the pending grant after the start succeeds.</comment>
<file context>
@@ -315,6 +355,238 @@ class MainActivity : AppCompatActivity() {
+ binding.settingsPanel.visibility = View.GONE
+ updateSignOutVisibility()
+ Toast.makeText(this, R.string.sign_in_success, Toast.LENGTH_SHORT).show()
+ TunnelService.start(this, TunnelConfig(gatewayUrl, result.accessToken, result.deviceId))
+ PendingSignInStore.clear(this)
+ } catch (e: CancellationException) {
</file context>
| val afterScheme = trimmed.substringAfter("://", "") | ||
| // Authority ends at the first '/', '?' or '#'. | ||
| val authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#') | ||
| if (authority.isBlank() || authority.startsWith(":")) return null |
There was a problem hiding this comment.
P2: When the gateway field contains a malformed authority such as wss://gateway:bad/path, this check still accepts it. Request.Builder().url(url) then throws outside the guarded network call, so sign-in can crash instead of reporting an invalid gateway; validate the complete URL before returning it or move request construction inside the try blocks.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt, line 292:
<comment>When the gateway field contains a malformed authority such as `wss://gateway:bad/path`, this check still accepts it. `Request.Builder().url(url)` then throws outside the guarded network call, so sign-in can crash instead of reporting an invalid gateway; validate the complete URL before returning it or move request construction inside the `try` blocks.</comment>
<file context>
@@ -0,0 +1,317 @@
+ val afterScheme = trimmed.substringAfter("://", "")
+ // Authority ends at the first '/', '?' or '#'.
+ val authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#')
+ if (authority.isBlank() || authority.startsWith(":")) return null
+ return "$scheme://$authority"
+ }
</file context>
| <string name="tunnel_visual_connecting">Diagram: this phone is creating a secure connection to the SealGate gateway.</string> | ||
| <string name="tunnel_visual_connected">Diagram: this phone has a secure connection to the SealGate gateway.</string> | ||
| <string name="tunnel_visual_reconnecting">Diagram: the secure connection between this phone and the SealGate gateway was interrupted and is reconnecting automatically.</string> | ||
| <string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect.</string> |
There was a problem hiding this comment.
P2: When the unauthorized reason is an unsupported protocol or a disabled organization, this accessibility description incorrectly blames an invalid credential and tells the user to sign in. Use wording that covers all terminal unauthorized reasons, or select the description from state.reason like the visible status does.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/values/strings.xml, line 60:
<comment>When the unauthorized reason is an unsupported protocol or a disabled organization, this accessibility description incorrectly blames an invalid credential and tells the user to sign in. Use wording that covers all terminal unauthorized reasons, or select the description from `state.reason` like the visible status does.</comment>
<file context>
@@ -31,12 +31,33 @@
<string name="tunnel_visual_connecting">Diagram: this phone is creating a secure connection to the SealGate gateway.</string>
<string name="tunnel_visual_connected">Diagram: this phone has a secure connection to the SealGate gateway.</string>
<string name="tunnel_visual_reconnecting">Diagram: the secure connection between this phone and the SealGate gateway was interrupted and is reconnecting automatically.</string>
+ <string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect.</string>
<string name="tunnel_channel_name">Mobile Tunnel</string>
</file context>
| <string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect.</string> | |
| <string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway and needs attention before it can reconnect.</string> |
|
|
||
| <string name="action_sign_out">Sign out</string> | ||
| <string name="sign_out_dialog_title">Sign out?</string> | ||
| <string name="sign_out_dialog_message">This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect.</string> |
There was a problem hiding this comment.
P2: When the saved credential is an API key, sign-out only clears the local key and never revokes anything remotely, but this dialog promises dashboard revocation. Describe revocation as conditional and tell API-key users to connect again rather than sign in again.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/values/strings.xml, line 51:
<comment>When the saved credential is an API key, sign-out only clears the local key and never revokes anything remotely, but this dialog promises dashboard revocation. Describe revocation as conditional and tell API-key users to connect again rather than sign in again.</comment>
<file context>
@@ -31,12 +31,33 @@
+
+ <string name="action_sign_out">Sign out</string>
+ <string name="sign_out_dialog_title">Sign out?</string>
+ <string name="sign_out_dialog_message">This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect.</string>
+ <string name="sign_out_in_progress">Signing out…</string>
+ <string name="sign_out_done">Signed out.</string>
</file context>
| <string name="sign_out_dialog_message">This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect.</string> | |
| <string name="sign_out_dialog_message">This stops the tunnel and forgets this device\'s credential. OAuth credentials are also revoked in the dashboard when possible. You will need to connect again to reconnect.</string> |
| The credential (and the in-flight PKCE verifier of an interrupted sign-in) is | ||
| stored encrypted at rest with an AES-256-GCM key held in the AndroidKeyStore | ||
| (`SecretCipher`), so a prefs dump or a backup restored to another phone cannot |
There was a problem hiding this comment.
P2: For users upgrading with an existing API key, this statement is false until the settings are saved again: legacy plaintext is read unchanged and is not re-encrypted during load. Document the migration caveat so users do not assume the credential is already protected immediately after upgrading.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 112:
<comment>For users upgrading with an existing API key, this statement is false until the settings are saved again: legacy plaintext is read unchanged and is not re-encrypted during load. Document the migration caveat so users do not assume the credential is already protected immediately after upgrading.</comment>
<file context>
@@ -94,9 +94,29 @@ per-file limit, and 32 MiB total virtual filesystem limit.
+ dedicated `mobile` client id; the backend side lives in `edison-watch`
+ (`src/api/v1/routes/device_auth.py`, `dev-docs/architecture/mobile-hardware-gateway-design.md`).
+
+ The credential (and the in-flight PKCE verifier of an interrupted sign-in) is
+ stored encrypted at rest with an AES-256-GCM key held in the AndroidKeyStore
+ (`SecretCipher`), so a prefs dump or a backup restored to another phone cannot
</file context>
| The credential (and the in-flight PKCE verifier of an interrupted sign-in) is | |
| stored encrypted at rest with an AES-256-GCM key held in the AndroidKeyStore | |
| (`SecretCipher`), so a prefs dump or a backup restored to another phone cannot | |
| Newly saved credentials (and the in-flight PKCE verifier of an interrupted sign-in) are | |
| stored encrypted at rest with an AES-256-GCM key held in the AndroidKeyStore | |
| (`SecretCipher`). Existing plaintext credentials are re-encrypted the next time | |
| settings are saved, so a prefs dump or a backup restored to another phone cannot | |
| lift a newly saved credential. |
| if (signInJob?.isActive == true) return | ||
| val gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty() | ||
| binding.gatewayUrlLayout.error = null | ||
| val apiBase = GatewayUrls.apiBaseFromWs(gatewayUrl) |
There was a problem hiding this comment.
P2: This auth path accepts gateway strings that the tunnel cannot use and can crash on malformed authorities. Validate or normalize the gateway with the same strict rules used by TunnelConfig before requesting device authorization.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/MainActivity.kt, line 388:
<comment>This auth path accepts gateway strings that the tunnel cannot use and can crash on malformed authorities. Validate or normalize the gateway with the same strict rules used by `TunnelConfig` before requesting device authorization.</comment>
<file context>
@@ -315,6 +355,238 @@ class MainActivity : AppCompatActivity() {
+ if (signInJob?.isActive == true) return
+ val gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty()
+ binding.gatewayUrlLayout.error = null
+ val apiBase = GatewayUrls.apiBaseFromWs(gatewayUrl)
+ if (apiBase == null) {
+ binding.gatewayUrlLayout.error = getString(R.string.error_gateway_url)
</file context>
| _state.value = TunnelState.Connecting | ||
| val sessionSawHello = runOneConnection() | ||
| val outcome = runOneConnection() | ||
| if (outcome.terminalReason != null) { |
There was a problem hiding this comment.
P2: When stop() races with terminal close handling, connectLoop() can publish Unauthorized after stop() published Disconnected. Guard terminal publication against stopped, atomically with the stop state update, so explicit shutdown cannot leave a stale terminal status.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt, line 144:
<comment>When `stop()` races with terminal close handling, `connectLoop()` can publish `Unauthorized` after `stop()` published `Disconnected`. Guard terminal publication against `stopped`, atomically with the stop state update, so explicit shutdown cannot leave a stale terminal status.</comment>
<file context>
@@ -121,11 +140,19 @@ class TunnelClient(
_state.value = TunnelState.Connecting
- val sessionSawHello = runOneConnection()
+ val outcome = runOneConnection()
+ if (outcome.terminalReason != null) {
+ // The gateway rejected the credential for good. Stop reconnecting
+ // (retrying the same credential would just loop every backoff) and
</file context>
| (`SecretCipher`), so a prefs dump or a backup restored to another phone cannot | ||
| lift it. To disconnect, open settings and tap **Sign out**: the app stops the | ||
| tunnel, forgets the local credential, and revokes the installation in the | ||
| dashboard (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the |
There was a problem hiding this comment.
P3: Sign out does not always revoke an installation: API-key sign-out skips the revoke endpoint, and OAuth revocation is best-effort. Qualify this as a best-effort revoke for OAuth credentials so the fallback behavior and failure case are documented accurately.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 117:
<comment>Sign out does not always revoke an installation: API-key sign-out skips the revoke endpoint, and OAuth revocation is best-effort. Qualify this as a best-effort revoke for OAuth credentials so the fallback behavior and failure case are documented accurately.</comment>
<file context>
@@ -94,9 +94,29 @@ per-file limit, and 32 MiB total virtual filesystem limit.
+ (`SecretCipher`), so a prefs dump or a backup restored to another phone cannot
+ lift it. To disconnect, open settings and tap **Sign out**: the app stops the
+ tunnel, forgets the local credential, and revokes the installation in the
+ dashboard (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the
+ credential itself, the tunnel stops reconnecting and the app asks you to sign
+ in again instead of looping.
</file context>
| dashboard (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the | |
| + dashboard for OAuth credentials on a best-effort basis (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the |
| ) | ||
| } | ||
|
|
||
| private fun stopReasonStatus(reason: TunnelStopReason): Int = |
There was a problem hiding this comment.
P3: This duplicates the TunnelStopReason resource mapping already maintained in MainActivity, so changing a stop reason or its presentation can leave the notification and screen inconsistent. Move the mapping into one shared helper and use it from both classes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/TunnelService.kt, line 293:
<comment>This duplicates the `TunnelStopReason` resource mapping already maintained in `MainActivity`, so changing a stop reason or its presentation can leave the notification and screen inconsistent. Move the mapping into one shared helper and use it from both classes.</comment>
<file context>
@@ -281,6 +284,17 @@ class TunnelService : LifecycleService() {
+ )
+ }
+
+ private fun stopReasonStatus(reason: TunnelStopReason): Int =
+ when (reason) {
+ TunnelStopReason.CREDENTIAL_REJECTED -> R.string.tunnel_state_sign_in_required
</file context>
| android:gravity="center_vertical" | ||
| android:orientation="horizontal"> | ||
|
|
||
| <ProgressBar |
There was a problem hiding this comment.
P3: The indeterminate ProgressBar announces its own "loading" node alongside the adjacent "Waiting for approval…" text, so screen reader users hear redundant/unclear status. Mark the bar as decorative (importantForAccessibility=no) so only the labeled text is announced.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/layout/dialog_device_sign_in.xml, line 55:
<comment>The indeterminate ProgressBar announces its own "loading" node alongside the adjacent "Waiting for approval…" text, so screen reader users hear redundant/unclear status. Mark the bar as decorative (importantForAccessibility=no) so only the labeled text is announced.</comment>
<file context>
@@ -0,0 +1,68 @@
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+ <ProgressBar
+ android:layout_width="18dp"
+ android:layout_height="18dp"
</file context>
Summary
Adds an OAuth 2.0 device-authorization sign-in flow to the Android tunnel client (replacing manual API-key entry as the primary path) and hardens the stored credential. The backend side lives in
Edison-Watch/edison-watch.Changes
mobileclient id, shows a short code, and receives a scopedewc_tunnel credential bound to a backend-issued device id. Pasting an API key still works as an alternative. Sign-in survives process death mid-flow (the in-flight PKCE verifier / device code is persisted and resumed).1008policy closes by reason, and401/403on the upgrade) into a newTunnelState.Unauthorized(reason)and stops the reconnect loop instead of retrying forever, surfacing "Sign-in required" / "Update required" / "Not enabled for your org" and revealing the settings panel. Transient closes still reconnect with backoff.POST /api/v1/auth/device/revoke; local sign-out always completes even if the revoke call fails.SecretCipher(AES-256-GCM key in the AndroidKeyStore), so a prefs dump or a backup restored to another phone cannot lift it. Legacy plaintext reads transparently and re-encrypts on next save.Testing
./gradlew assembleDebug)./gradlew testDebugUnitTest)./gradlew lintDebug)Note: the cloud sandbox has no Android SDK (
dl.google.comis blocked), so the CI job "Build & unit test" is the sole build/lint/test gate; these boxes track its result on this PR. New unit tests cover the WS close/failure classification and the revoke status handling; the previous head passed all three CI steps.Related Issues
N/A
🤖 Generated with Claude Code
https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
Generated by Claude Code
Summary by cubic
Adds OAuth 2.0 device sign-in to the Android tunnel client, so users approve the phone from the SealGate dashboard instead of pasting an API key. Also stops reconnecting forever when the gateway revokes a credential, adds sign-out, and encrypts stored credentials at rest.
Sign-in and sign-out
ewc_credential bound to a backend-issued device id; pasting an API key still works.POST /api/v1/auth/device/revoke; local sign-out always completes.mobileclient profile ships separately inEdison-Watch/edison-watch.Credential handling
Written for commit 0493ab9. Summary will update on new commits.