Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de8f5558c2 | ||
|
|
adcf4ded79 | ||
|
|
05d6ee4d7c | ||
|
|
41cbafddba | ||
|
|
8632ced503 | ||
|
|
ae18bbee24 | ||
|
|
55a838cb78 | ||
|
|
d367cd3a24 | ||
|
|
5bd0b2acaf | ||
|
|
6f0948ca01 | ||
|
|
541a7c078d | ||
|
|
8865a31013 | ||
|
|
a199c35377 | ||
|
|
6a495b6e06 | ||
|
|
105da550e7 | ||
|
|
febc26fe35 | ||
|
|
28a906215d | ||
|
|
1617f75f1a | ||
|
|
c519502551 | ||
|
|
dafa6f3a18 | ||
|
|
246d9f1010 | ||
|
|
e9f32673be |
@@ -3,23 +3,14 @@ name: Android On-Demand
|
||||
run-name: Android ${{ inputs.preset }} · ${{ inputs.head_sha }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
head_sha:
|
||||
description: Exact pushed commit SHA to verify
|
||||
required: true
|
||||
type: string
|
||||
preset:
|
||||
description: Android verification lane
|
||||
required: true
|
||||
default: focused
|
||||
type: choice
|
||||
options:
|
||||
- focused
|
||||
- lint
|
||||
- assemble-debug
|
||||
- release-smoke
|
||||
- all-final
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,11 +29,19 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_SHA: ${{ inputs.head_sha }}
|
||||
REQUESTED_PRESET: ${{ inputs.preset }}
|
||||
run: |
|
||||
if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "head_sha must be a full lowercase 40-character commit SHA" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$REQUESTED_PRESET" in
|
||||
focused|lint|assemble-debug|release-smoke|all-final) ;;
|
||||
*)
|
||||
echo "unsupported Android preset: $REQUESTED_PRESET" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Checkout exact commit
|
||||
uses: actions/checkout@v7
|
||||
|
||||
@@ -20,13 +20,25 @@ on:
|
||||
description: "Exact candidate commit to check"
|
||||
required: true
|
||||
type: string
|
||||
android_preset:
|
||||
description: "Optional Android-only compute lane"
|
||||
required: false
|
||||
default: auto
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- focused
|
||||
- lint
|
||||
- assemble-debug
|
||||
- release-smoke
|
||||
- all-final
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ci-required-${{ github.ref }}
|
||||
group: ci-required-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.head_sha, inputs.android_preset) || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -113,33 +125,41 @@ jobs:
|
||||
|
||||
android:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
if: needs.changes.outputs.android == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-android.yml
|
||||
|
||||
android_on_demand:
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.android_preset != 'auto'
|
||||
uses: ./.github/workflows/android-on-demand.yml
|
||||
with:
|
||||
head_sha: ${{ inputs.head_sha }}
|
||||
preset: ${{ inputs.android_preset }}
|
||||
|
||||
desktop:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.desktop == 'true'
|
||||
if: needs.changes.outputs.desktop == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-desktop.yml
|
||||
|
||||
plugin:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.plugin == 'true'
|
||||
if: needs.changes.outputs.plugin == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-plugin.yml
|
||||
|
||||
dashboard:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dashboard == 'true'
|
||||
if: needs.changes.outputs.dashboard == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-dashboard.yml
|
||||
|
||||
contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.contract == 'true'
|
||||
if: needs.changes.outputs.contract == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
uses: ./.github/workflows/ci-contract.yml
|
||||
|
||||
docs:
|
||||
name: Build public docs
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs == 'true'
|
||||
if: needs.changes.outputs.docs == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -161,11 +181,12 @@ jobs:
|
||||
guard:
|
||||
name: Required checks
|
||||
if: always()
|
||||
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
|
||||
needs: [changes, android, android_on_demand, desktop, plugin, dashboard, contract, docs]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
ANDROID_RESULT: ${{ needs.android.result }}
|
||||
ANDROID_ON_DEMAND_RESULT: ${{ needs.android_on_demand.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
PLUGIN_RESULT: ${{ needs.plugin.result }}
|
||||
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
|
||||
@@ -176,7 +197,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
failed=0
|
||||
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
for check in CHANGES ANDROID ANDROID_ON_DEMAND DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
result_var="${check}_RESULT"
|
||||
result="${!result_var}"
|
||||
echo "$check: $result"
|
||||
|
||||
@@ -15,6 +15,7 @@ contract here and in `RELEASE.md`.
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- Gateway/session/reconnect testing → **[docs/gateway-contract-testing.md](docs/gateway-contract-testing.md)**
|
||||
- Android local/cloud verification → **[docs/android-build-lane.md](docs/android-build-lane.md)**
|
||||
- Android emulator lanes → **[docs/android-emulator-testing.md](docs/android-emulator-testing.md)** — suggest the smallest relevant API 36 lanes; never run the full matrix automatically
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android Supervised Mode uses app-specific parent access.** Parents choose a six-digit PIN or password, receive a shareable six-word recovery phrase, and can remove the credential without losing their supervised profile, capability, appearance, visibility, session, or relock settings. Android device credentials and biometrics no longer grant parent access.
|
||||
- **Android What's New now provides a readable, complete release record.** One overall title and summary lead into selected highlights, every remaining user-visible addition, improvement, and fix, and relevant compatibility boundaries. Toast counts and previews are derived from that same inventory, so View all no longer promises details the expanded dialog and history cannot show.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android keeps completed chat text visible when Dashboard sign-in expires.** Generic and reason-coded history `401` responses settle the local turn, preserve its transcript, and surface the existing sign-in recovery without reading another profile's API history.
|
||||
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
|
||||
- **Supervised Gateway setup stays parent-owned.** Add Gateway is single-flight and checks live parent authority before allocating a draft, relock/back cancels the exact pending setup, and the locked Chat footer no longer attempts protected navigation.
|
||||
- **Generated images stay visible and use their intended Chat animation.** Completed image media survives a marker-lagging history refresh, and both the built-in `image_generate` tool and profile tools ending in `_create_image` use the image-generation presentation.
|
||||
|
||||
+5
-4
@@ -270,9 +270,10 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android cloud verification (preferred for pushed work):** dispatch
|
||||
`.github/workflows/android-on-demand.yml` against the exact pushed SHA with
|
||||
`focused`, `lint`, `assemble-debug`, `release-smoke`, or `all-final`. Check for
|
||||
- **Android cloud verification (preferred for pushed work):** dispatch the
|
||||
registered `Required checks` workflow with an exact base/head SHA pair and
|
||||
`android_preset` set to `focused`, `lint`, `assemble-debug`, `release-smoke`,
|
||||
or `all-final`. It calls the reusable Android workflow from `dev`. Check for
|
||||
an existing run before dispatching the same SHA/preset again. The four
|
||||
`all-final` compute jobs use isolated runners and may execute concurrently.
|
||||
- **Full local Android gate (optional):** `scripts\dev.bat prepush` on Windows
|
||||
@@ -287,7 +288,7 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
device lane is scheduled automatically.
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched. `android-on-demand.yml` is the trusted manual compute lane for an exact pushed commit; it does not replace required PR checks.
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched. The registered `ci-required.yml` dispatcher calls `android-on-demand.yml` as the trusted manual compute lane for an exact pushed commit; it does not replace required PR checks.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
@@ -6,6 +6,19 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Consider hosted Android emulator execution
|
||||
|
||||
The local API 36 Gradle Managed Device lanes are intentionally on demand and
|
||||
individually selected. The current Android On-Demand workflow covers hosted
|
||||
source, unit, lint, and build verification only; it does not run emulators. If
|
||||
local emulator capacity becomes a recurring constraint, evaluate a separately
|
||||
approved hosted-emulator design with explicit cost, concurrency, artifact
|
||||
retention, and trigger policy. Do not schedule the full form-factor matrix or
|
||||
add a device farm until that policy is approved; keep live-server mutation tests
|
||||
outside any automatic matrix.
|
||||
|
||||
---
|
||||
|
||||
## Upstream a public Dashboard plugin WebSocket admission seam
|
||||
|
||||
The same-origin Relay ingress follows current upstream's bundled Dashboard
|
||||
|
||||
@@ -249,6 +249,58 @@ android {
|
||||
it.systemProperty("roborazzi.test.record", "true")
|
||||
it.maxHeapSize = "2g"
|
||||
}
|
||||
|
||||
// On-demand only. Keep each form factor as an individually selected
|
||||
// Gradle-managed device; there is deliberately no aggregate matrix
|
||||
// task or scheduled emulator job. See docs/android-emulator-testing.md.
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("compactPhoneApi36") {
|
||||
device = "Pixel 2"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("standardPhoneApi36") {
|
||||
device = "Pixel 6"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("largePhoneApi36") {
|
||||
device = "Pixel 7 Pro"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("foldableApi36") {
|
||||
device = "Pixel Fold"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("tabletApi36") {
|
||||
device = "Pixel Tablet"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
}
|
||||
create("futureApi37Ps16k") {
|
||||
device = "Pixel 7 Pro"
|
||||
apiLevel = 37
|
||||
systemImageSource = "google_apis_playstore"
|
||||
require64Bit = true
|
||||
testedAbi = "x86_64"
|
||||
pageAlignment =
|
||||
com.android.build.api.dsl.ManagedVirtualDevice.PageAlignment.FORCE_16KB_PAGES
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +442,12 @@ dependencies {
|
||||
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
|
||||
testImplementation(libs.konsist)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
// Compose UI Test still declares Espresso 3.5.0 transitively. API 37
|
||||
// removed the reflected InputManager.getInstance() seam; Espresso 3.7.0
|
||||
// uses Context.getSystemService and is the current stable AndroidX line.
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
// On-device vanilla-Gateway contract tests exercise the production
|
||||
// Dashboard ticket + WebSocket stack over real loopback sockets.
|
||||
androidTestImplementation(libs.okhttp.mockwebserver)
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
|
||||
+201
-7
@@ -4,6 +4,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -17,6 +18,11 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
@@ -27,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -44,6 +51,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -74,10 +82,11 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
|
||||
@Volatile
|
||||
private var persistedHistory: List<MessageItem> = emptyList()
|
||||
private val historySignInRequired = MutableStateFlow(false)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fixture = AndroidGatewayContractFixture()
|
||||
fixture = AndroidGatewayContractFixture().also { it.profileName = PROFILE_NAME }
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
@@ -97,6 +106,7 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
handler,
|
||||
)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setSessionProfileNameProvider { PROFILE_NAME }
|
||||
it.setProfileMessageLoader { Result.success(persistedHistory) }
|
||||
it.updateGatewayClient(gatewayClient)
|
||||
it.setChatVisible(true)
|
||||
@@ -105,6 +115,7 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
compose.setContent {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
|
||||
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
|
||||
MaterialTheme {
|
||||
Column(Modifier.testTag("contract-transcript")) {
|
||||
Text(
|
||||
@@ -117,6 +128,14 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
modifier = Modifier.testTag("message-${message.id}"),
|
||||
)
|
||||
}
|
||||
if (signInRequired) {
|
||||
Button(
|
||||
onClick = {},
|
||||
modifier = Modifier.testTag("dashboard-sign-in-recovery"),
|
||||
) {
|
||||
Text("SIGN IN")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,9 +271,6 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
"session.interrupt",
|
||||
"prompt.submit",
|
||||
)
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val baselineActiveList = fixture.rpcCount("session.active_list")
|
||||
fixture.activeSessionStatus = "working"
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
@@ -269,6 +285,17 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
)
|
||||
viewModel.setChatTurnCheckpointStore(null)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
assertTrue(runBlocking { gatewayClient.observeAwait() })
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
viewModel.updateSessionActivityDirectory(listOf(PROFILE_NAME to STORED_SESSION_ID))
|
||||
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val baselineActiveList = fixture.rpcCount("session.active_list")
|
||||
fixture.activeSessionStatus = "working"
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
@@ -293,6 +320,141 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalCompletion_genericHistory401RetainsTranscriptAndRequiresProfileSignIn() {
|
||||
bindDashboardHistoryFailure(
|
||||
body = "Unauthorized",
|
||||
profileName = PROFILE_NAME,
|
||||
)
|
||||
|
||||
viewModel.sendMessage("Keep this local transcript")
|
||||
fixture.awaitRpc("prompt.submit")
|
||||
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", LOCAL_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", LOCAL_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
|
||||
compose.waitUntil(15_000) {
|
||||
historySignInRequired.value &&
|
||||
!handler.isStreaming.value &&
|
||||
handler.messages.value.any { it.content == LOCAL_COMPLETION }
|
||||
}
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
|
||||
compose.onNodeWithTag("dashboard-sign-in-recovery").assertIsDisplayed()
|
||||
assertFalse(viewModel.isLoadingHistory.value)
|
||||
assertTrue(handler.messages.value.any { it.content == "Keep this local transcript" })
|
||||
assertTrue(handler.messages.value.any { it.content == LOCAL_COMPLETION })
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
assertExactProfileHistoryOnly(PROFILE_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredCompletion_sessionExpiredHistoryRetainsSettledTranscript() {
|
||||
bindDashboardHistoryFailure(
|
||||
body = """{"reason":"session_expired"}""",
|
||||
profileName = PROFILE_NAME,
|
||||
)
|
||||
val now = System.currentTimeMillis()
|
||||
val contextKey = AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME)
|
||||
viewModel.setChatTurnCheckpointStore(
|
||||
MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = contextKey,
|
||||
profileKey = PROFILE_NAME,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = LIVE_SESSION_ID,
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("recovered-user", "Resume this turn", now - 2_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "recovered-assistant",
|
||||
content = "Recovered partial",
|
||||
timestamp = now - 1_900L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = now - 2_000L,
|
||||
updatedAt = now,
|
||||
),
|
||||
),
|
||||
)
|
||||
fixture.recoveryRunning = true
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
|
||||
fixture.awaitRpc("session.activate")
|
||||
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", RECOVERED_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", RECOVERED_COMPLETION) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
|
||||
compose.waitUntil(15_000) {
|
||||
historySignInRequired.value && !handler.isStreaming.value
|
||||
}
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
|
||||
compose.onNodeWithTag("dashboard-sign-in-recovery").assertIsDisplayed()
|
||||
assertFalse(viewModel.isLoadingHistory.value)
|
||||
assertTrue(
|
||||
"recovered completion was not retained: ${handler.messages.value}",
|
||||
handler.messages.value.any { it.content.contains(RECOVERED_COMPLETION.trim()) },
|
||||
)
|
||||
assertFalse(handler.messages.value.any { it.isStreaming || it.isThinkingStreaming })
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
assertExactProfileHistoryOnly(PROFILE_NAME)
|
||||
}
|
||||
|
||||
private fun bindDashboardHistoryFailure(body: String, profileName: String) {
|
||||
fixture.profileName = profileName
|
||||
fixture.historyFailureBody = body
|
||||
val dashboard = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
)
|
||||
viewModel.setProfileMessageLoaderWithMode { profile, sessionId, mode ->
|
||||
dashboard.getSessionMessages(sessionId, profile, mode)
|
||||
}
|
||||
viewModel.setDashboardSignInRequiredHandler {
|
||||
historySignInRequired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertExactProfileHistoryOnly(profileName: String) {
|
||||
val historyRequests = fixture.historyRequestPaths()
|
||||
assertTrue("no Dashboard history request was observed", historyRequests.isNotEmpty())
|
||||
assertTrue(
|
||||
"history escaped the exact profile: $historyRequests",
|
||||
historyRequests.all { it.contains("profile=$profileName") },
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STORED_SESSION_ID = "20260821_120000_fixture"
|
||||
const val LIVE_SESSION_ID = "fixture-live-1"
|
||||
@@ -301,6 +463,23 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
const val PARTIAL_ANSWER = "Partial foreground answer"
|
||||
const val AUTHORITATIVE_ANSWER = "Foreground task finished."
|
||||
const val FOREIGN_ANSWER = "Wrong session content"
|
||||
const val PROFILE_NAME = "research"
|
||||
const val LOCAL_COMPLETION = "Completed before Dashboard auth expired."
|
||||
const val RECOVERED_COMPLETION = " and then recovered to completion."
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryCheckpointStore(
|
||||
private var checkpoint: ChatTurnCheckpoint?,
|
||||
) : ChatTurnCheckpointStore {
|
||||
override suspend fun read(): ChatTurnCheckpoint? = checkpoint
|
||||
|
||||
override suspend fun write(checkpoint: ChatTurnCheckpoint) {
|
||||
this.checkpoint = checkpoint
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
checkpoint = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +499,12 @@ internal class AndroidGatewayContractFixture {
|
||||
@Volatile
|
||||
var activeSessionStatus: String? = null
|
||||
|
||||
@Volatile
|
||||
var historyFailureBody: String? = null
|
||||
|
||||
@Volatile
|
||||
var profileName: String = "default"
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
sockets.add(webSocket)
|
||||
@@ -377,6 +562,11 @@ internal class AndroidGatewayContractFixture {
|
||||
"""{"ticket":"device-${ticketCount.incrementAndGet()}","ttl_seconds":30}""",
|
||||
)
|
||||
path.startsWith("/api/ws") -> MockResponse().withWebSocketUpgrade(listener)
|
||||
path.startsWith("/api/sessions/") && path.contains("/messages") &&
|
||||
historyFailureBody != null -> MockResponse()
|
||||
.setResponseCode(401)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(historyFailureBody.orEmpty())
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
@@ -388,7 +578,7 @@ internal class AndroidGatewayContractFixture {
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
put("info", buildJsonObject { put("profile_name", "default") })
|
||||
put("info", buildJsonObject { put("profile_name", profileName) })
|
||||
}
|
||||
|
||||
fun event(type: String, payload: JsonObject?, sessionId: String?): String =
|
||||
@@ -406,7 +596,7 @@ internal class AndroidGatewayContractFixture {
|
||||
sockets.poll(5, TimeUnit.SECONDS) ?: error("Gateway WebSocket did not open")
|
||||
|
||||
fun awaitRpc(method: String): JsonObject {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
|
||||
while (System.nanoTime() < deadline) {
|
||||
rpcLog.firstOrNull { it.first == method }?.let { return it.second }
|
||||
Thread.sleep(20)
|
||||
@@ -415,7 +605,7 @@ internal class AndroidGatewayContractFixture {
|
||||
}
|
||||
|
||||
fun awaitRpcCount(method: String, count: Int) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (rpcCount(method) >= count) return
|
||||
Thread.sleep(20)
|
||||
@@ -425,6 +615,10 @@ internal class AndroidGatewayContractFixture {
|
||||
|
||||
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
|
||||
|
||||
fun historyRequestPaths(): List<String> = requestPaths.filter {
|
||||
it.startsWith("/api/sessions/") && it.contains("/messages")
|
||||
}
|
||||
|
||||
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
|
||||
|
||||
fun shutdown() {
|
||||
|
||||
@@ -81,6 +81,24 @@ class SupervisedModeStore private constructor(
|
||||
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
|
||||
}
|
||||
|
||||
/** Disable every policy while preserving its configured controls and remove the parent credential atomically. */
|
||||
internal suspend fun disableAllAndRemoveCredential(
|
||||
parentCredentialKey: Preferences.Key<String>,
|
||||
) {
|
||||
dataStore.edit { preferences ->
|
||||
val decoded = decode(preferences[KEY_POLICIES])
|
||||
if (decoded.corrupt || decoded.policies.isEmpty()) {
|
||||
preferences.remove(KEY_POLICIES)
|
||||
} else {
|
||||
val disabled = decoded.policies.mapValues { (_, policy) ->
|
||||
policy.copy(enabled = false).normalized()
|
||||
}
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, disabled)
|
||||
}
|
||||
preferences.remove(parentCredentialKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(raw: String?): DecodeResult {
|
||||
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
|
||||
return try {
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Availability of the app-specific parent credential. */
|
||||
enum class SupervisedParentAuthStatus {
|
||||
Missing,
|
||||
Configured,
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
/** Input method selected for the app-specific parent credential. */
|
||||
@Serializable
|
||||
enum class SupervisedParentCredentialType {
|
||||
Legacy,
|
||||
Pin,
|
||||
Password,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private enum class SupervisedRecoveryFormat {
|
||||
LegacyCode,
|
||||
WordPhrase,
|
||||
}
|
||||
|
||||
/** Result of a parent-secret or recovery-phrase verification attempt. */
|
||||
sealed interface SupervisedParentAuthResult {
|
||||
data object Success : SupervisedParentAuthResult
|
||||
data class Invalid(val attemptsBeforeDelay: Int) : SupervisedParentAuthResult
|
||||
data class Throttled(val retryAfterMillis: Long) : SupervisedParentAuthResult
|
||||
data object Missing : SupervisedParentAuthResult
|
||||
data object Corrupt : SupervisedParentAuthResult
|
||||
}
|
||||
|
||||
/** Successful enrollment returns a recovery phrase which is shown once and never persisted. */
|
||||
data class SupervisedParentEnrollment(val recoveryPhrase: String)
|
||||
|
||||
/** Validation result for a new parent PIN or password. */
|
||||
data class SupervisedParentSecretValidation(
|
||||
val valid: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/** Narrow authentication surface consumed by Compose dialogs and test fakes. */
|
||||
interface SupervisedParentAuthenticator {
|
||||
val credentialTypeFlow: Flow<SupervisedParentCredentialType?>
|
||||
suspend fun enroll(
|
||||
newSecret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): Result<SupervisedParentEnrollment>
|
||||
suspend fun verify(secret: CharArray): SupervisedParentAuthResult
|
||||
suspend fun change(
|
||||
currentSecret: CharArray,
|
||||
newSecret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): Result<SupervisedParentEnrollment>
|
||||
suspend fun resetWithRecoveryPhrase(
|
||||
recoveryPhrase: CharArray,
|
||||
newSecret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): Result<SupervisedParentEnrollment>
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific parent authentication for Supervised Mode.
|
||||
*
|
||||
* This store deliberately does not delegate to Android's device credential: a
|
||||
* child can legitimately own the PIN or biometrics on their Android profile.
|
||||
* Only salted PBKDF2 verifiers and bounded failure state are stored. The parent
|
||||
* secret and recovery phrase are never persisted.
|
||||
*/
|
||||
class SupervisedParentAuthStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val iterations: Int,
|
||||
private val minimumAcceptedIterations: Int,
|
||||
private val random: SecureRandom,
|
||||
private val nowMillis: () -> Long,
|
||||
) : SupervisedParentAuthenticator {
|
||||
constructor(context: Context) : this(
|
||||
dataStore = context.applicationContext.relayDataStore,
|
||||
iterations = DEFAULT_PBKDF2_ITERATIONS,
|
||||
minimumAcceptedIterations = MIN_ACCEPTED_ITERATIONS,
|
||||
random = SecureRandom(),
|
||||
nowMillis = System::currentTimeMillis,
|
||||
)
|
||||
|
||||
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = false }
|
||||
val statusFlow: Flow<SupervisedParentAuthStatus> = dataStore.data.map { preferences ->
|
||||
decode(preferences[KEY_RECORD]).status
|
||||
}
|
||||
override val credentialTypeFlow: Flow<SupervisedParentCredentialType?> = dataStore.data.map { preferences ->
|
||||
decode(preferences[KEY_RECORD]).record?.credentialType
|
||||
}
|
||||
|
||||
override suspend fun enroll(
|
||||
newSecret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): Result<SupervisedParentEnrollment> = processMutex.withLock {
|
||||
val validation = validateNewSecret(newSecret, credentialType)
|
||||
if (!validation.valid) {
|
||||
return Result.failure(IllegalArgumentException(validation.message))
|
||||
}
|
||||
if (decode(dataStore.data.first()[KEY_RECORD]).status != SupervisedParentAuthStatus.Missing) {
|
||||
return Result.failure(IllegalStateException("Parent access is already configured or unavailable."))
|
||||
}
|
||||
runCatching { enrollLocked(newSecret, credentialType) }
|
||||
}
|
||||
|
||||
override suspend fun verify(secret: CharArray): SupervisedParentAuthResult = processMutex.withLock {
|
||||
verifyLocked(secret, AuthTarget.ParentSecret)
|
||||
}
|
||||
|
||||
override suspend fun change(
|
||||
currentSecret: CharArray,
|
||||
newSecret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): Result<SupervisedParentEnrollment> = processMutex.withLock {
|
||||
val validation = validateNewSecret(newSecret, credentialType)
|
||||
if (!validation.valid) {
|
||||
return Result.failure(IllegalArgumentException(validation.message))
|
||||
}
|
||||
when (val verified = verifyLocked(currentSecret, AuthTarget.ParentSecret)) {
|
||||
SupervisedParentAuthResult.Success -> runCatching { enrollLocked(newSecret, credentialType) }
|
||||
else -> Result.failure(ParentAuthenticationException(verified))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resetWithRecoveryPhrase(
|
||||
recoveryPhrase: CharArray,
|
||||
newSecret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): Result<SupervisedParentEnrollment> = processMutex.withLock {
|
||||
val validation = validateNewSecret(newSecret, credentialType)
|
||||
if (!validation.valid) {
|
||||
return Result.failure(IllegalArgumentException(validation.message))
|
||||
}
|
||||
val record = decode(dataStore.data.first()[KEY_RECORD]).record
|
||||
?: return Result.failure(ParentAuthenticationException(SupervisedParentAuthResult.Missing))
|
||||
val normalizedRecovery = normalizeRecoveryPhrase(recoveryPhrase, record.recoveryFormat)
|
||||
try {
|
||||
when (val verified = verifyLocked(normalizedRecovery, AuthTarget.RecoveryCode)) {
|
||||
SupervisedParentAuthResult.Success -> runCatching { enrollLocked(newSecret, credentialType) }
|
||||
else -> Result.failure(ParentAuthenticationException(verified))
|
||||
}
|
||||
} finally {
|
||||
normalizedRecovery.fill('\u0000')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated escape hatch used by the parent controls.
|
||||
*
|
||||
* The app-global credential cannot be removed while leaving any supervised
|
||||
* policy enabled. Every policy is disabled, but its configuration is retained,
|
||||
* in the same transaction that removes the credential.
|
||||
*/
|
||||
suspend fun clearCredentialAndDisablePolicies(): Result<Unit> = processMutex.withLock {
|
||||
runCatching {
|
||||
SupervisedModeStore.forTesting(dataStore).disableAllAndRemoveCredential(KEY_RECORD)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun enrollLocked(
|
||||
secret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): SupervisedParentEnrollment {
|
||||
require(credentialType != SupervisedParentCredentialType.Legacy)
|
||||
val recoveryChars = generateRecoveryPhrase().toCharArray()
|
||||
val parentSalt = ByteArray(SALT_BYTES).also(random::nextBytes)
|
||||
val recoverySalt = ByteArray(SALT_BYTES).also(random::nextBytes)
|
||||
var parentVerifier = ByteArray(0)
|
||||
var recoveryVerifier = ByteArray(0)
|
||||
try {
|
||||
parentVerifier = derive(secret, parentSalt, iterations)
|
||||
recoveryVerifier = derive(recoveryChars, recoverySalt, iterations)
|
||||
val record = PersistedParentAuth(
|
||||
iterations = iterations,
|
||||
parentSalt = encode(parentSalt),
|
||||
parentVerifier = encode(parentVerifier),
|
||||
recoverySalt = encode(recoverySalt),
|
||||
recoveryVerifier = encode(recoveryVerifier),
|
||||
credentialType = credentialType,
|
||||
recoveryFormat = SupervisedRecoveryFormat.WordPhrase,
|
||||
)
|
||||
dataStore.edit { it[KEY_RECORD] = json.encodeToString(record) }
|
||||
return SupervisedParentEnrollment(recoveryChars.concatToString())
|
||||
} finally {
|
||||
recoveryChars.fill('\u0000')
|
||||
parentSalt.fill(0)
|
||||
recoverySalt.fill(0)
|
||||
parentVerifier.fill(0)
|
||||
recoveryVerifier.fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun verifyLocked(
|
||||
candidate: CharArray,
|
||||
target: AuthTarget,
|
||||
): SupervisedParentAuthResult {
|
||||
val decoded = decode(dataStore.data.first()[KEY_RECORD])
|
||||
val record = decoded.record ?: return when (decoded.status) {
|
||||
SupervisedParentAuthStatus.Missing -> SupervisedParentAuthResult.Missing
|
||||
else -> SupervisedParentAuthResult.Corrupt
|
||||
}
|
||||
val now = nowMillis()
|
||||
if (record.blockedUntilEpochMillis > now) {
|
||||
return SupervisedParentAuthResult.Throttled(record.blockedUntilEpochMillis - now)
|
||||
}
|
||||
|
||||
val saltText = when (target) {
|
||||
AuthTarget.ParentSecret -> record.parentSalt
|
||||
AuthTarget.RecoveryCode -> record.recoverySalt
|
||||
}
|
||||
val verifierText = when (target) {
|
||||
AuthTarget.ParentSecret -> record.parentVerifier
|
||||
AuthTarget.RecoveryCode -> record.recoveryVerifier
|
||||
}
|
||||
val salt = decodeBytes(saltText) ?: return SupervisedParentAuthResult.Corrupt
|
||||
val expected = decodeBytes(verifierText) ?: return SupervisedParentAuthResult.Corrupt
|
||||
val actual = try {
|
||||
derive(candidate, salt, record.iterations)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Unable to derive supervised parent verifier", error)
|
||||
return SupervisedParentAuthResult.Corrupt
|
||||
} finally {
|
||||
salt.fill(0)
|
||||
}
|
||||
val matches = try {
|
||||
MessageDigest.isEqual(expected, actual)
|
||||
} finally {
|
||||
expected.fill(0)
|
||||
actual.fill(0)
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
if (record.failedAttempts != 0 || record.blockedUntilEpochMillis != 0L) {
|
||||
save(record.copy(failedAttempts = 0, blockedUntilEpochMillis = 0L))
|
||||
}
|
||||
return SupervisedParentAuthResult.Success
|
||||
}
|
||||
|
||||
val failures = (record.failedAttempts + 1).coerceAtMost(MAX_TRACKED_FAILURES)
|
||||
val delayMillis = backoffMillis(failures)
|
||||
save(
|
||||
record.copy(
|
||||
failedAttempts = failures,
|
||||
blockedUntilEpochMillis = if (delayMillis == 0L) 0L else now + delayMillis,
|
||||
),
|
||||
)
|
||||
return if (delayMillis == 0L) {
|
||||
SupervisedParentAuthResult.Invalid(
|
||||
attemptsBeforeDelay = (FAILURES_BEFORE_BACKOFF - failures).coerceAtLeast(0),
|
||||
)
|
||||
} else {
|
||||
SupervisedParentAuthResult.Throttled(delayMillis)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(record: PersistedParentAuth) {
|
||||
dataStore.edit { it[KEY_RECORD] = json.encodeToString(record) }
|
||||
}
|
||||
|
||||
private suspend fun derive(secret: CharArray, salt: ByteArray, rounds: Int): ByteArray =
|
||||
withContext(Dispatchers.Default) {
|
||||
val spec = PBEKeySpec(secret, salt, rounds, KEY_BITS)
|
||||
try {
|
||||
SecretKeyFactory.getInstance(KDF_ALGORITHM).generateSecret(spec).encoded
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(raw: String?): DecodedRecord {
|
||||
if (raw.isNullOrBlank()) {
|
||||
return DecodedRecord(SupervisedParentAuthStatus.Missing, null)
|
||||
}
|
||||
val record = runCatching { json.decodeFromString<PersistedParentAuth>(raw) }
|
||||
.getOrElse {
|
||||
Log.w(TAG, "Unable to decode supervised parent authentication; failing closed", it)
|
||||
return DecodedRecord(SupervisedParentAuthStatus.Corrupt, null)
|
||||
}
|
||||
val valid = record.version == RECORD_VERSION &&
|
||||
record.algorithm == KDF_ALGORITHM &&
|
||||
record.iterations in minimumAcceptedIterations..MAX_ACCEPTED_ITERATIONS &&
|
||||
decodeBytes(record.parentSalt)?.size == SALT_BYTES &&
|
||||
decodeBytes(record.parentVerifier)?.size == KEY_BITS / 8 &&
|
||||
decodeBytes(record.recoverySalt)?.size == SALT_BYTES &&
|
||||
decodeBytes(record.recoveryVerifier)?.size == KEY_BITS / 8 &&
|
||||
record.failedAttempts in 0..MAX_TRACKED_FAILURES &&
|
||||
record.blockedUntilEpochMillis >= 0
|
||||
return if (valid) {
|
||||
DecodedRecord(SupervisedParentAuthStatus.Configured, record)
|
||||
} else {
|
||||
DecodedRecord(SupervisedParentAuthStatus.Corrupt, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateRecoveryPhrase(): String {
|
||||
val available = RECOVERY_WORDS.toMutableList()
|
||||
val selected = buildList(RECOVERY_WORD_COUNT) {
|
||||
repeat(RECOVERY_WORD_COUNT) {
|
||||
add(available.removeAt(random.nextInt(available.size)))
|
||||
}
|
||||
}
|
||||
return selected.joinToString("-")
|
||||
}
|
||||
|
||||
private fun encode(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
|
||||
private fun decodeBytes(value: String): ByteArray? =
|
||||
runCatching { Base64.getUrlDecoder().decode(value) }.getOrNull()
|
||||
|
||||
private fun backoffMillis(failures: Int): Long = when (failures) {
|
||||
in 0 until FAILURES_BEFORE_BACKOFF -> 0L
|
||||
FAILURES_BEFORE_BACKOFF -> 30_000L
|
||||
FAILURES_BEFORE_BACKOFF + 1 -> 60_000L
|
||||
FAILURES_BEFORE_BACKOFF + 2 -> 120_000L
|
||||
FAILURES_BEFORE_BACKOFF + 3 -> 300_000L
|
||||
else -> MAX_BACKOFF_MILLIS
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class PersistedParentAuth(
|
||||
val version: Int = RECORD_VERSION,
|
||||
val algorithm: String = KDF_ALGORITHM,
|
||||
val iterations: Int,
|
||||
val parentSalt: String,
|
||||
val parentVerifier: String,
|
||||
val recoverySalt: String,
|
||||
val recoveryVerifier: String,
|
||||
val credentialType: SupervisedParentCredentialType = SupervisedParentCredentialType.Legacy,
|
||||
val recoveryFormat: SupervisedRecoveryFormat = SupervisedRecoveryFormat.LegacyCode,
|
||||
val failedAttempts: Int = 0,
|
||||
val blockedUntilEpochMillis: Long = 0L,
|
||||
)
|
||||
|
||||
private data class DecodedRecord(
|
||||
val status: SupervisedParentAuthStatus,
|
||||
val record: PersistedParentAuth?,
|
||||
)
|
||||
|
||||
private enum class AuthTarget { ParentSecret, RecoveryCode }
|
||||
|
||||
class ParentAuthenticationException(
|
||||
val authResult: SupervisedParentAuthResult,
|
||||
) : IllegalStateException("Parent authentication failed: $authResult")
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SupervisedParentAuth"
|
||||
private const val RECORD_VERSION = 1
|
||||
private const val KDF_ALGORITHM = "PBKDF2WithHmacSHA256"
|
||||
private const val DEFAULT_PBKDF2_ITERATIONS = 310_000
|
||||
private const val MIN_ACCEPTED_ITERATIONS = 100_000
|
||||
private const val MAX_ACCEPTED_ITERATIONS = 1_000_000
|
||||
private const val SALT_BYTES = 16
|
||||
private const val KEY_BITS = 256
|
||||
private const val FAILURES_BEFORE_BACKOFF = 5
|
||||
private const val MAX_TRACKED_FAILURES = 9
|
||||
private const val MAX_BACKOFF_MILLIS = 15 * 60_000L
|
||||
private const val RECOVERY_WORD_COUNT = 6
|
||||
private val KEY_RECORD = stringPreferencesKey("supervised_parent_auth_v1")
|
||||
private val processMutex = Mutex()
|
||||
|
||||
fun validateNewSecret(
|
||||
secret: CharArray,
|
||||
credentialType: SupervisedParentCredentialType,
|
||||
): SupervisedParentSecretValidation {
|
||||
if (secret.size > 64) {
|
||||
return SupervisedParentSecretValidation(false, "Use at most 64 characters.")
|
||||
}
|
||||
if (credentialType == SupervisedParentCredentialType.Pin) {
|
||||
return if (secret.size == 6 && secret.all(Char::isDigit)) {
|
||||
SupervisedParentSecretValidation(true)
|
||||
} else {
|
||||
SupervisedParentSecretValidation(false, "Use exactly 6 digits.")
|
||||
}
|
||||
}
|
||||
return if (
|
||||
credentialType == SupervisedParentCredentialType.Password &&
|
||||
secret.size >= 8 && secret.any { !it.isWhitespace() }
|
||||
) {
|
||||
SupervisedParentSecretValidation(true)
|
||||
} else {
|
||||
SupervisedParentSecretValidation(false, "Use a password with at least 8 characters.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeRecoveryPhrase(
|
||||
value: CharArray,
|
||||
format: SupervisedRecoveryFormat,
|
||||
): CharArray = when (format) {
|
||||
SupervisedRecoveryFormat.LegacyCode -> value
|
||||
.filterNot { it == '-' || it.isWhitespace() }
|
||||
.joinToString("")
|
||||
.uppercase()
|
||||
.toCharArray()
|
||||
SupervisedRecoveryFormat.WordPhrase -> value.concatToString()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.split(Regex("[-\\s]+"))
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString("-")
|
||||
.toCharArray()
|
||||
}
|
||||
|
||||
private val RECOVERY_WORDS = listOf(
|
||||
"acorn", "amber", "apple", "april", "arrow", "beach", "berry", "birch",
|
||||
"blue", "breeze", "brook", "button", "cabin", "cactus", "candle", "cedar",
|
||||
"cherry", "cloud", "clover", "cobalt", "comet", "coral", "cotton", "cove",
|
||||
"daisy", "dawn", "delta", "drift", "eagle", "earth", "ember", "fern",
|
||||
"field", "finch", "forest", "frost", "garden", "ginger", "glade", "gold",
|
||||
"grape", "green", "harbor", "hazel", "heron", "honey", "island", "ivory",
|
||||
"jade", "juniper", "kite", "lagoon", "lake", "lantern", "lark", "leaf",
|
||||
"lemon", "lilac", "lotus", "maple", "meadow", "mint", "moon", "morning",
|
||||
"moss", "oasis", "ocean", "olive", "orchid", "otter", "peach", "pearl",
|
||||
"pebble", "pine", "plum", "pond", "poppy", "quartz", "rain", "reed",
|
||||
"river", "robin", "rose", "saffron", "sage", "sand", "shell", "silver",
|
||||
"sky", "snow", "sparrow", "spring", "spruce", "star", "stone", "summer",
|
||||
"sun", "sunset", "teal", "thistle", "tide", "tulip", "valley", "violet",
|
||||
"willow", "wind", "winter", "wood", "wren", "yellow", "zephyr", "zinnia",
|
||||
"anchor", "bamboo", "copper", "cricket", "feather", "harvest", "marble", "ribbon",
|
||||
"rocket", "shadow", "timber", "whistle", "yarrow", "almond", "badger", "canvas",
|
||||
)
|
||||
|
||||
internal fun forTesting(
|
||||
dataStore: DataStore<Preferences>,
|
||||
iterations: Int = MIN_ACCEPTED_ITERATIONS,
|
||||
minimumAcceptedIterations: Int = MIN_ACCEPTED_ITERATIONS,
|
||||
random: SecureRandom = SecureRandom(),
|
||||
nowMillis: () -> Long = System::currentTimeMillis,
|
||||
): SupervisedParentAuthStore = SupervisedParentAuthStore(
|
||||
dataStore = dataStore,
|
||||
iterations = iterations,
|
||||
minimumAcceptedIterations = minimumAcceptedIterations,
|
||||
random = random,
|
||||
nowMillis = nowMillis,
|
||||
)
|
||||
|
||||
internal val recordKeyForTesting: Preferences.Key<String> = KEY_RECORD
|
||||
}
|
||||
}
|
||||
@@ -2436,14 +2436,11 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
|
||||
java.util.IdentityHashMap<Throwable, Boolean>(),
|
||||
)
|
||||
while (current != null && seen.add(current)) {
|
||||
if (
|
||||
current is DashboardHttpException &&
|
||||
current.statusCode == 401 &&
|
||||
(
|
||||
current.message.orEmpty().contains("no_cookie", ignoreCase = true) ||
|
||||
current.message.orEmpty().contains("unauthenticated", ignoreCase = true)
|
||||
)
|
||||
) {
|
||||
// Every 401 from an authenticated Dashboard route means the saved
|
||||
// browser/native session can no longer authorize this request. Older
|
||||
// gateways used `no_cookie`/`unauthenticated`; current builds may return
|
||||
// reason codes such as `session_expired`, or no structured body at all.
|
||||
if (current is DashboardHttpException && current.statusCode == 401) {
|
||||
return true
|
||||
}
|
||||
current = current.cause
|
||||
|
||||
@@ -209,6 +209,7 @@ internal class HermesRuntimeBinder(
|
||||
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
|
||||
connection.loadProfileScopedMessages(profileName, sessionId, mode)
|
||||
}
|
||||
chat.setDashboardSignInRequiredHandler(connection::probeNow)
|
||||
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
|
||||
chat.profileSessionDeleter = connection::deleteSession
|
||||
chat.profileSessionRenamer = connection::renameSession
|
||||
|
||||
@@ -2967,7 +2967,12 @@ fun RelayApp() {
|
||||
}
|
||||
composable(Screen.AdvancedSettings.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
LaunchedEffect(Unit) {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AdvancedSettingsScreen(
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
@@ -2998,7 +3003,12 @@ fun RelayApp() {
|
||||
}
|
||||
composable(Screen.SupervisedControls.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
LaunchedEffect(Unit) {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SupervisedControlsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
|
||||
@@ -138,12 +138,10 @@ internal fun sanitizeSupervisedChatRouteArgs(
|
||||
}
|
||||
}
|
||||
|
||||
/** A disabled policy may become active only after an enrolled credential succeeds. */
|
||||
/** A disabled policy may become active only after the app-specific parent credential succeeds. */
|
||||
internal fun mayEnableSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
deviceSecure: Boolean,
|
||||
deviceCredentialConfirmed: Boolean,
|
||||
parentCredentialConfirmed: Boolean,
|
||||
): Boolean = !policy.enabled &&
|
||||
policy.isConfigured &&
|
||||
deviceSecure &&
|
||||
deviceCredentialConfirmed
|
||||
parentCredentialConfirmed
|
||||
|
||||
+863
@@ -0,0 +1,863 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.content.ClipboardManager
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Dialpad
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.data.SupervisedParentAuthResult
|
||||
import com.hermesandroid.relay.data.SupervisedParentAuthStore
|
||||
import com.hermesandroid.relay.data.SupervisedParentAuthenticator
|
||||
import com.hermesandroid.relay.data.SupervisedParentCredentialType
|
||||
import com.hermesandroid.relay.data.SupervisedParentEnrollment
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun SupervisedParentVerifyDialog(
|
||||
store: SupervisedParentAuthenticator,
|
||||
onDismiss: () -> Unit,
|
||||
onVerified: () -> Unit,
|
||||
onUseRecoveryCode: () -> Unit,
|
||||
) {
|
||||
val storedType by store.credentialTypeFlow.collectAsState(initial = null)
|
||||
var selectedLegacyType by remember { mutableStateOf<SupervisedParentCredentialType?>(null) }
|
||||
val inputType = storedType?.takeUnless { it == SupervisedParentCredentialType.Legacy }
|
||||
?: selectedLegacyType
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun verify(candidateText: String) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
val candidate = candidateText.toCharArray()
|
||||
val result = try {
|
||||
store.verify(candidate)
|
||||
} finally {
|
||||
candidate.fill('\u0000')
|
||||
}
|
||||
busy = false
|
||||
when (result) {
|
||||
SupervisedParentAuthResult.Success -> onVerified()
|
||||
else -> error = result.toUserMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParentAuthDialogSurface(
|
||||
step = null,
|
||||
onBack = if (storedType == SupervisedParentCredentialType.Legacy && inputType != null) {
|
||||
{ selectedLegacyType = null; error = null }
|
||||
} else {
|
||||
onDismiss
|
||||
},
|
||||
) {
|
||||
when (inputType) {
|
||||
SupervisedParentCredentialType.Pin -> PinEntryScreen(
|
||||
title = "Parent PIN",
|
||||
subtitle = "Enter your 6-digit PIN.",
|
||||
busy = busy,
|
||||
error = error,
|
||||
onComplete = ::verify,
|
||||
onUseRecovery = onUseRecoveryCode,
|
||||
)
|
||||
SupervisedParentCredentialType.Password -> PasswordVerifyScreen(
|
||||
busy = busy,
|
||||
error = error,
|
||||
onSubmit = ::verify,
|
||||
onUseRecovery = onUseRecoveryCode,
|
||||
)
|
||||
else -> CredentialChoiceScreen(
|
||||
title = "How do you enter your parent credential?",
|
||||
subtitle = "This existing setup predates the PIN/password choice.",
|
||||
onSelected = { selectedLegacyType = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SupervisedParentSetupDialog(
|
||||
store: SupervisedParentAuthenticator,
|
||||
currentSecretRequired: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onEnrolled: (SupervisedParentEnrollment) -> Unit,
|
||||
) {
|
||||
val storedType by store.credentialTypeFlow.collectAsState(initial = null)
|
||||
var stage by remember(currentSecretRequired) {
|
||||
mutableStateOf(if (currentSecretRequired) SetupStage.VerifyCurrent else SetupStage.Choose)
|
||||
}
|
||||
var legacyInputType by remember { mutableStateOf<SupervisedParentCredentialType?>(null) }
|
||||
var currentSecret by remember { mutableStateOf("") }
|
||||
var credentialType by remember { mutableStateOf<SupervisedParentCredentialType?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun enroll(newSecretText: String) {
|
||||
val type = credentialType ?: return
|
||||
busy = true
|
||||
scope.launch {
|
||||
val current = currentSecret.toCharArray()
|
||||
val replacement = newSecretText.toCharArray()
|
||||
val result = try {
|
||||
if (currentSecretRequired) store.change(current, replacement, type)
|
||||
else store.enroll(replacement, type)
|
||||
} finally {
|
||||
current.fill('\u0000')
|
||||
replacement.fill('\u0000')
|
||||
}
|
||||
busy = false
|
||||
result.fold(onSuccess = onEnrolled, onFailure = { error = it.toUserMessage() })
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyCurrent(candidateText: String) {
|
||||
busy = true
|
||||
scope.launch {
|
||||
val candidate = candidateText.toCharArray()
|
||||
val result = try { store.verify(candidate) } finally { candidate.fill('\u0000') }
|
||||
busy = false
|
||||
if (result == SupervisedParentAuthResult.Success) {
|
||||
currentSecret = candidateText
|
||||
error = null
|
||||
stage = SetupStage.Choose
|
||||
} else {
|
||||
error = result.toUserMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backAction: () -> Unit = when (stage) {
|
||||
SetupStage.VerifyCurrent, SetupStage.Choose -> onDismiss
|
||||
SetupStage.Pin, SetupStage.Password -> {
|
||||
{ stage = SetupStage.Choose; credentialType = null; error = null }
|
||||
}
|
||||
}
|
||||
val step = when (stage) {
|
||||
SetupStage.VerifyCurrent -> 1 to 3
|
||||
SetupStage.Choose -> if (currentSecretRequired) 2 to 3 else 1 to 2
|
||||
SetupStage.Pin, SetupStage.Password -> if (currentSecretRequired) 3 to 3 else 2 to 2
|
||||
}
|
||||
|
||||
ParentAuthDialogSurface(step = step, onBack = backAction) {
|
||||
when (stage) {
|
||||
SetupStage.VerifyCurrent -> {
|
||||
val inputType = storedType?.takeUnless { it == SupervisedParentCredentialType.Legacy }
|
||||
?: legacyInputType
|
||||
when (inputType) {
|
||||
SupervisedParentCredentialType.Pin -> PinEntryScreen(
|
||||
title = "Current parent PIN",
|
||||
subtitle = "Confirm before changing parent access.",
|
||||
busy = busy,
|
||||
error = error,
|
||||
onComplete = ::verifyCurrent,
|
||||
)
|
||||
SupervisedParentCredentialType.Password -> PasswordVerifyScreen(
|
||||
title = "Current parent password",
|
||||
busy = busy,
|
||||
error = error,
|
||||
onSubmit = ::verifyCurrent,
|
||||
)
|
||||
else -> CredentialChoiceScreen(
|
||||
title = "How do you enter the current credential?",
|
||||
subtitle = "Choose the input that matches the existing setup.",
|
||||
onSelected = { legacyInputType = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
SetupStage.Choose -> CredentialChoiceScreen(
|
||||
title = if (currentSecretRequired) "Choose new parent access" else "Choose parent access",
|
||||
subtitle = "Pick one way to unlock parent settings. You can change it later.",
|
||||
onSelected = {
|
||||
credentialType = it
|
||||
stage = if (it == SupervisedParentCredentialType.Pin) SetupStage.Pin else SetupStage.Password
|
||||
},
|
||||
)
|
||||
SetupStage.Pin -> PinSetupScreen(
|
||||
busy = busy,
|
||||
error = error,
|
||||
onComplete = ::enroll,
|
||||
)
|
||||
SetupStage.Password -> PasswordSetupScreen(
|
||||
busy = busy,
|
||||
error = error,
|
||||
onComplete = ::enroll,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SupervisedParentRecoveryDialog(
|
||||
store: SupervisedParentAuthenticator,
|
||||
onDismiss: () -> Unit,
|
||||
onReset: (SupervisedParentEnrollment) -> Unit,
|
||||
) {
|
||||
var stage by remember { mutableStateOf(RecoveryStage.Phrase) }
|
||||
var recoveryPhrase by remember { mutableStateOf("") }
|
||||
var credentialType by remember { mutableStateOf<SupervisedParentCredentialType?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun reset(newSecretText: String) {
|
||||
val type = credentialType ?: return
|
||||
busy = true
|
||||
scope.launch {
|
||||
val recovery = recoveryPhrase.toCharArray()
|
||||
val replacement = newSecretText.toCharArray()
|
||||
val result = try {
|
||||
store.resetWithRecoveryPhrase(recovery, replacement, type)
|
||||
} finally {
|
||||
recovery.fill('\u0000')
|
||||
replacement.fill('\u0000')
|
||||
}
|
||||
busy = false
|
||||
result.fold(onSuccess = onReset, onFailure = { error = it.toUserMessage() })
|
||||
}
|
||||
}
|
||||
|
||||
val step = when (stage) {
|
||||
RecoveryStage.Phrase -> 1 to 3
|
||||
RecoveryStage.Choose -> 2 to 3
|
||||
RecoveryStage.Pin, RecoveryStage.Password -> 3 to 3
|
||||
}
|
||||
ParentAuthDialogSurface(
|
||||
step = step,
|
||||
onBack = when (stage) {
|
||||
RecoveryStage.Phrase -> onDismiss
|
||||
RecoveryStage.Choose -> ({ stage = RecoveryStage.Phrase })
|
||||
RecoveryStage.Pin, RecoveryStage.Password -> ({ stage = RecoveryStage.Choose })
|
||||
},
|
||||
) {
|
||||
when (stage) {
|
||||
RecoveryStage.Phrase -> RecoveryPhraseInputScreen(
|
||||
value = recoveryPhrase,
|
||||
error = error,
|
||||
onValueChange = { recoveryPhrase = it; error = null },
|
||||
onContinue = { stage = RecoveryStage.Choose },
|
||||
)
|
||||
RecoveryStage.Choose -> CredentialChoiceScreen(
|
||||
title = "Choose new parent access",
|
||||
subtitle = "Your recovery phrase will be replaced after reset.",
|
||||
onSelected = {
|
||||
credentialType = it
|
||||
stage = if (it == SupervisedParentCredentialType.Pin) RecoveryStage.Pin
|
||||
else RecoveryStage.Password
|
||||
},
|
||||
)
|
||||
RecoveryStage.Pin -> PinSetupScreen(busy = busy, error = error, onComplete = ::reset)
|
||||
RecoveryStage.Password -> PasswordSetupScreen(busy = busy, error = error, onComplete = ::reset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SupervisedParentRecoveryCodeDialog(
|
||||
enrollment: SupervisedParentEnrollment,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = remember(context) {
|
||||
context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
}
|
||||
ParentAuthDialogSurface(step = 3 to 3, onBack = null) {
|
||||
SupervisedParentRecoveryCodeContent(
|
||||
enrollment = enrollment,
|
||||
onShare = {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, enrollment.recoveryPhrase)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Share recovery phrase"))
|
||||
},
|
||||
onCopy = {
|
||||
clipboard.setPrimaryClip(
|
||||
ClipData.newPlainText("Parent recovery phrase", enrollment.recoveryPhrase),
|
||||
)
|
||||
},
|
||||
onDone = onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParentAuthDialogSurface(
|
||||
step: Pair<Int, Int>?,
|
||||
onBack: (() -> Unit)?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = { onBack?.invoke() },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
ParentAuthScreenSurface(step = step, onBack = onBack, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ParentAuthScreenSurface(
|
||||
step: Pair<Int, Int>?,
|
||||
onBack: (() -> Unit)?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(64.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
step?.let { (current, total) ->
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
repeat(total) { index ->
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.size(width = 46.dp, height = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index < current) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"$current of $total",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} ?: Spacer(Modifier.weight(1f))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CredentialChoiceScreen(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onSelected: (SupervisedParentCredentialType) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AuthHeading(title, subtitle)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
CredentialChoiceRow(
|
||||
icon = { Icon(Icons.Filled.Dialpad, contentDescription = null) },
|
||||
title = "Use a PIN",
|
||||
subtitle = "Fast on this phone · 6 digits",
|
||||
onClick = { onSelected(SupervisedParentCredentialType.Pin) },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
CredentialChoiceRow(
|
||||
icon = { Icon(Icons.Filled.Lock, contentDescription = null) },
|
||||
title = "Use a password",
|
||||
subtitle = "Works with password managers · 8+ characters",
|
||||
onClick = { onSelected(SupervisedParentCredentialType.Password) },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"PIN and password are separate choices.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialChoiceRow(
|
||||
icon: @Composable () -> Unit,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) { icon() }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Text("›", fontSize = 30.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PinEntryScreen(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onComplete: (String) -> Unit,
|
||||
onUseRecovery: (() -> Unit)? = null,
|
||||
) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AuthHeading(title, subtitle)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
PinDots(pin.length)
|
||||
Spacer(Modifier.height(26.dp))
|
||||
NumericKeypad(
|
||||
enabled = !busy,
|
||||
onDigit = { digit ->
|
||||
if (pin.length < 6) {
|
||||
val next = pin + digit
|
||||
pin = next
|
||||
if (next.length == 6) onComplete(next)
|
||||
}
|
||||
},
|
||||
onBackspace = { if (pin.isNotEmpty()) pin = pin.dropLast(1) },
|
||||
)
|
||||
AuthError(error)
|
||||
onUseRecovery?.let {
|
||||
TextButton(enabled = !busy, onClick = it) { Text("Use recovery phrase") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PinSetupScreen(
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onComplete: (String) -> Unit,
|
||||
) {
|
||||
var firstPin by remember { mutableStateOf<String?>(null) }
|
||||
var pin by remember(firstPin) { mutableStateOf("") }
|
||||
var localError by remember { mutableStateOf<String?>(null) }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AuthHeading(
|
||||
if (firstPin == null) "Create a parent PIN" else "Confirm parent PIN",
|
||||
if (firstPin == null) "Choose a 6-digit PIN." else "Enter the same 6 digits again.",
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
PinDots(pin.length)
|
||||
Spacer(Modifier.height(26.dp))
|
||||
NumericKeypad(
|
||||
enabled = !busy,
|
||||
onDigit = { digit ->
|
||||
if (pin.length < 6) {
|
||||
val next = pin + digit
|
||||
pin = next
|
||||
if (next.length == 6) {
|
||||
if (firstPin == null) {
|
||||
firstPin = next
|
||||
} else if (firstPin == next) {
|
||||
onComplete(next)
|
||||
} else {
|
||||
localError = "The PINs do not match. Try again."
|
||||
firstPin = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onBackspace = { if (pin.isNotEmpty()) pin = pin.dropLast(1) },
|
||||
)
|
||||
AuthError(localError ?: error)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NumericKeypad(
|
||||
enabled: Boolean,
|
||||
onDigit: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
) {
|
||||
val rows = listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
row.forEach { digit -> KeypadButton(digit, enabled) { onDigit(digit) } }
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Spacer(Modifier.size(width = 92.dp, height = 58.dp))
|
||||
KeypadButton("0", enabled) { onDigit("0") }
|
||||
Surface(
|
||||
modifier = Modifier.size(width = 92.dp, height = 58.dp).clickable(enabled = enabled, onClick = onBackspace),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.AutoMirrored.Filled.Backspace, contentDescription = "Delete digit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeypadButton(label: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(width = 92.dp, height = 58.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinDots(count: Int) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
repeat(6) { index ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.then(
|
||||
if (index < count) Modifier.background(MaterialTheme.colorScheme.primary)
|
||||
else Modifier.border(2.dp, MaterialTheme.colorScheme.outline, CircleShape),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PasswordSetupScreen(
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onComplete: (String) -> Unit,
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmation by remember { mutableStateOf("") }
|
||||
var reveal by remember { mutableStateOf(false) }
|
||||
var localError by remember { mutableStateOf<String?>(null) }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AuthHeading("Create a parent password", "Use 8 or more characters.")
|
||||
Spacer(Modifier.height(28.dp))
|
||||
PasswordField("Password", password, { password = it; localError = null }, reveal, { reveal = !reveal })
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PasswordField("Confirm password", confirmation, { confirmation = it; localError = null }, reveal, { reveal = !reveal }, ImeAction.Done)
|
||||
AuthError(localError ?: error)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
enabled = !busy && password.isNotEmpty() && confirmation.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
onClick = {
|
||||
when {
|
||||
password != confirmation -> localError = "The passwords do not match."
|
||||
!SupervisedParentAuthStore.validateNewSecret(
|
||||
password.toCharArray(),
|
||||
SupervisedParentCredentialType.Password,
|
||||
).valid -> localError = "Use a password with at least 8 characters."
|
||||
else -> onComplete(password)
|
||||
}
|
||||
},
|
||||
) { Text(if (busy) "Saving…" else "Continue") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PasswordVerifyScreen(
|
||||
title: String = "Parent password",
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onSubmit: (String) -> Unit,
|
||||
onUseRecovery: (() -> Unit)? = null,
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var reveal by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AuthHeading(title, "Enter your password.")
|
||||
Spacer(Modifier.height(28.dp))
|
||||
PasswordField("Password", password, { password = it }, reveal, { reveal = !reveal }, ImeAction.Done)
|
||||
AuthError(error)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
enabled = !busy && password.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
onClick = { onSubmit(password) },
|
||||
) { Text(if (busy) "Checking…" else "Unlock") }
|
||||
onUseRecovery?.let {
|
||||
TextButton(enabled = !busy, onClick = it) { Text("Use recovery phrase") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
reveal: Boolean,
|
||||
onReveal: () -> Unit,
|
||||
imeAction: ImeAction = ImeAction.Next,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { if (it.length <= 64) onValueChange(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onReveal) {
|
||||
Icon(
|
||||
if (reveal) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (reveal) "Hide password" else "Show password",
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecoveryPhraseInputScreen(
|
||||
value: String,
|
||||
error: String?,
|
||||
onValueChange: (String) -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AuthHeading("Enter recovery phrase", "Paste or type the six words.")
|
||||
Spacer(Modifier.height(28.dp))
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Recovery phrase") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done),
|
||||
minLines = 2,
|
||||
)
|
||||
AuthError(error)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
enabled = value.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
onClick = onContinue,
|
||||
) { Text("Continue") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SupervisedParentRecoveryCodeContent(
|
||||
enrollment: SupervisedParentEnrollment,
|
||||
onShare: () -> Unit = {},
|
||||
onCopy: () -> Unit = {},
|
||||
onDone: () -> Unit = {},
|
||||
) {
|
||||
val words = enrollment.recoveryPhrase.split('-')
|
||||
val displayPhrase = if (words.size == 6) {
|
||||
words.take(3).joinToString("-") + "\n" + words.drop(3).joinToString("-")
|
||||
} else {
|
||||
enrollment.recoveryPhrase
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AuthHeading(
|
||||
"Save your recovery phrase",
|
||||
"This is the only way to reset parent access if you forget it.",
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
displayPhrase,
|
||||
modifier = Modifier.padding(20.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium.copy(lineHeight = 28.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text(
|
||||
"Send it somewhere parent-only, then delete the message or saved copy from this phone.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
onClick = onShare,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Share")
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
onClick = onCopy,
|
||||
) { Text("Copy phrase") }
|
||||
TextButton(onClick = onDone) { Text("Done") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthHeading(title: String, subtitle: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthError(error: String?) {
|
||||
error?.let {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SupervisedParentAuthResult.toUserMessage(): String = when (this) {
|
||||
SupervisedParentAuthResult.Success -> ""
|
||||
is SupervisedParentAuthResult.Invalid -> if (attemptsBeforeDelay > 0) {
|
||||
"Incorrect parent credential. $attemptsBeforeDelay attempts remain before a delay."
|
||||
} else {
|
||||
"Incorrect parent credential."
|
||||
}
|
||||
is SupervisedParentAuthResult.Throttled -> {
|
||||
val seconds = ((retryAfterMillis + 999L) / 1_000L).coerceAtLeast(1)
|
||||
"Too many attempts. Try again in $seconds seconds."
|
||||
}
|
||||
SupervisedParentAuthResult.Missing -> "Parent access has not been set up."
|
||||
SupervisedParentAuthResult.Corrupt -> "Parent access data is unavailable. Supervised Mode remains locked."
|
||||
}
|
||||
|
||||
private fun Throwable.toUserMessage(): String = when (this) {
|
||||
is IllegalArgumentException -> message ?: "The new parent credential is not valid."
|
||||
is SupervisedParentAuthStore.ParentAuthenticationException -> authResult.toUserMessage()
|
||||
else -> "Parent access could not be updated. Try again."
|
||||
}
|
||||
|
||||
private enum class SetupStage { VerifyCurrent, Choose, Pin, Password }
|
||||
private enum class RecoveryStage { Phrase, Choose, Pin, Password }
|
||||
+240
-54
@@ -1,8 +1,5 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -52,7 +49,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -65,6 +64,9 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedParentAuthStatus
|
||||
import com.hermesandroid.relay.data.SupervisedParentAuthStore
|
||||
import com.hermesandroid.relay.data.SupervisedParentEnrollment
|
||||
import com.hermesandroid.relay.data.SupervisedSessionActions
|
||||
import com.hermesandroid.relay.data.SupervisedVisibilityPreset
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAvailablePets
|
||||
@@ -77,6 +79,7 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The settings surface available while supervised mode is locked.
|
||||
@@ -100,28 +103,29 @@ fun SupervisedSettingsScreen(
|
||||
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val profileAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val parentAuthStore = remember(context) { SupervisedParentAuthStore(context) }
|
||||
val parentAuthStatus by produceState<SupervisedParentAuthStatus?>(
|
||||
initialValue = null,
|
||||
key1 = parentAuthStore,
|
||||
) {
|
||||
parentAuthStore.statusFlow.collect { value = it }
|
||||
}
|
||||
var authError by remember { mutableStateOf<String?>(null) }
|
||||
var parentAuthDialog by remember { mutableStateOf<ParentAuthDialog?>(null) }
|
||||
var pendingEnrollment by remember { mutableStateOf<SupervisedParentEnrollment?>(null) }
|
||||
var showAbout by remember { mutableStateOf(false) }
|
||||
|
||||
val credentialLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
authError = null
|
||||
onParentAccessGranted()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestParentAccess() {
|
||||
val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
||||
val intent = keyguard?.createConfirmDeviceCredentialIntent(
|
||||
"Parent access",
|
||||
"Unlock full Hermes settings and supervised-mode controls. Device credentials verify an enrolled device user, not a distinct parent identity.",
|
||||
)
|
||||
if (intent == null) {
|
||||
authError = "Set a device screen lock before using parent access."
|
||||
} else {
|
||||
credentialLauncher.launch(intent)
|
||||
when (parentAuthStatus) {
|
||||
SupervisedParentAuthStatus.Configured -> parentAuthDialog = ParentAuthDialog.Verify
|
||||
SupervisedParentAuthStatus.Missing -> {
|
||||
authError = "This legacy supervised policy has no app-specific parent credential and stays locked. " +
|
||||
"Reset this app's local data, reconnect, and configure parent access before enabling Supervised Mode again."
|
||||
}
|
||||
SupervisedParentAuthStatus.Corrupt -> {
|
||||
authError = "Parent access data is unavailable. Supervised Mode remains locked."
|
||||
}
|
||||
null -> authError = "Parent access is still loading."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +190,7 @@ fun SupervisedSettingsScreen(
|
||||
SupervisedNavigationRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = "Parent access",
|
||||
subtitle = "Unlock full settings with the device screen lock",
|
||||
subtitle = "Unlock full settings with the app parent PIN or password",
|
||||
onClick = ::requestParentAccess,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
@@ -217,6 +221,38 @@ fun SupervisedSettingsScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (parentAuthDialog) {
|
||||
ParentAuthDialog.Verify -> SupervisedParentVerifyDialog(
|
||||
store = parentAuthStore,
|
||||
onDismiss = { parentAuthDialog = null },
|
||||
onVerified = {
|
||||
parentAuthDialog = null
|
||||
authError = null
|
||||
onParentAccessGranted()
|
||||
},
|
||||
onUseRecoveryCode = { parentAuthDialog = ParentAuthDialog.Recovery },
|
||||
)
|
||||
ParentAuthDialog.Recovery -> SupervisedParentRecoveryDialog(
|
||||
store = parentAuthStore,
|
||||
onDismiss = { parentAuthDialog = null },
|
||||
onReset = { enrollment ->
|
||||
parentAuthDialog = null
|
||||
pendingEnrollment = enrollment
|
||||
},
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
pendingEnrollment?.let { enrollment ->
|
||||
SupervisedParentRecoveryCodeDialog(
|
||||
enrollment = enrollment,
|
||||
onDone = {
|
||||
pendingEnrollment = null
|
||||
authError = null
|
||||
onParentAccessGranted()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Restricted appearance editor backed by the supervised policy, not global theme settings. */
|
||||
@@ -293,42 +329,41 @@ fun SupervisedControlsScreen(
|
||||
onReturnToSupervisedView: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val keyguardManager = remember(context) {
|
||||
context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
||||
val parentAuthStore = remember(context) { SupervisedParentAuthStore(context) }
|
||||
val parentAuthStatus by produceState<SupervisedParentAuthStatus?>(
|
||||
initialValue = null,
|
||||
key1 = parentAuthStore,
|
||||
) {
|
||||
parentAuthStore.statusFlow.collect { value = it }
|
||||
}
|
||||
val deviceSecure = keyguardManager?.isDeviceSecure == true
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
|
||||
var showProfilePicker by remember { mutableStateOf(false) }
|
||||
var sessionActionsExpanded by remember { mutableStateOf(false) }
|
||||
var enableAuthError by remember { mutableStateOf<String?>(null) }
|
||||
var enableRequested by remember { mutableStateOf(false) }
|
||||
val enableCredentialLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val shouldEnable = enableRequested && mayEnableSupervisedMode(
|
||||
policy = policy,
|
||||
deviceSecure = deviceSecure,
|
||||
deviceCredentialConfirmed = result.resultCode == Activity.RESULT_OK,
|
||||
)
|
||||
enableRequested = false
|
||||
if (shouldEnable) {
|
||||
enableAuthError = null
|
||||
onPolicyChange(policy.copy(enabled = true))
|
||||
}
|
||||
}
|
||||
var parentAuthDialog by remember { mutableStateOf<ParentAuthDialog?>(null) }
|
||||
var pendingEnrollment by remember { mutableStateOf<SupervisedParentEnrollment?>(null) }
|
||||
var enableAfterEnrollment by remember { mutableStateOf(false) }
|
||||
var showRemoveCredentialConfirm by remember { mutableStateOf(false) }
|
||||
var removeCredentialBusy by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun requestFirstEnable() {
|
||||
val intent = keyguardManager?.createConfirmDeviceCredentialIntent(
|
||||
"Enable supervised mode",
|
||||
"Confirm with an enrolled device credential. This does not verify a distinct parent identity.",
|
||||
)
|
||||
if (!deviceSecure || intent == null) {
|
||||
enableAuthError = "Set a secure device screen lock before enabling supervised mode."
|
||||
if (!policy.isConfigured) {
|
||||
enableAuthError = "Choose an agent profile before enabling Supervised Mode."
|
||||
return
|
||||
}
|
||||
enableRequested = true
|
||||
enableCredentialLauncher.launch(intent)
|
||||
when (parentAuthStatus) {
|
||||
SupervisedParentAuthStatus.Missing -> {
|
||||
enableAfterEnrollment = true
|
||||
parentAuthDialog = ParentAuthDialog.Setup
|
||||
}
|
||||
SupervisedParentAuthStatus.Configured -> parentAuthDialog = ParentAuthDialog.Verify
|
||||
SupervisedParentAuthStatus.Corrupt -> {
|
||||
enableAuthError = "Parent access data is unavailable. Reset local app data before enabling Supervised Mode."
|
||||
}
|
||||
null -> enableAuthError = "Parent access is still loading."
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -360,14 +395,18 @@ fun SupervisedControlsScreen(
|
||||
subtitle = when {
|
||||
policy.pinnedProfileName.isNullOrBlank() ->
|
||||
"Choose an agent profile before enabling"
|
||||
!deviceSecure ->
|
||||
"Set a device screen lock before enabling"
|
||||
parentAuthStatus == SupervisedParentAuthStatus.Missing ->
|
||||
"Set an app-specific parent PIN or password"
|
||||
else ->
|
||||
"Show only the approved Android chat surfaces"
|
||||
},
|
||||
checked = policy.enabled,
|
||||
enabled = policy.enabled ||
|
||||
(!policy.pinnedProfileName.isNullOrBlank() && deviceSecure),
|
||||
(!policy.pinnedProfileName.isNullOrBlank() &&
|
||||
parentAuthStatus in setOf(
|
||||
SupervisedParentAuthStatus.Missing,
|
||||
SupervisedParentAuthStatus.Configured,
|
||||
)),
|
||||
onCheckedChange = { enabled ->
|
||||
if (enabled) requestFirstEnable()
|
||||
else onPolicyChange(policy.copy(enabled = false))
|
||||
@@ -404,7 +443,7 @@ fun SupervisedControlsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"Android device credentials authenticate an enrolled device user; they do not establish a separate parent identity. Use a parent-only device credential or managed-device policy where that distinction matters.",
|
||||
"Parent access uses an app-specific PIN or password, separate from the supervised user's Android screen lock and biometrics.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -713,14 +752,42 @@ fun SupervisedControlsScreen(
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(Modifier.padding(start = 12.dp)) {
|
||||
Text("Device authentication", style = MaterialTheme.typography.titleSmall)
|
||||
Text("App parent credential", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Full features require the device screen lock. This verifies an enrolled device user, not a distinct parent identity.",
|
||||
when (parentAuthStatus) {
|
||||
SupervisedParentAuthStatus.Configured -> "A parent PIN or password is configured for this app."
|
||||
SupervisedParentAuthStatus.Missing -> "Set a parent PIN or password before enabling Supervised Mode."
|
||||
SupervisedParentAuthStatus.Corrupt -> "Parent access data is unavailable and fails closed."
|
||||
null -> "Loading parent access…"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
when (parentAuthStatus) {
|
||||
SupervisedParentAuthStatus.Missing -> OutlinedButton(
|
||||
onClick = {
|
||||
enableAfterEnrollment = false
|
||||
parentAuthDialog = ParentAuthDialog.Setup
|
||||
},
|
||||
) { Text("Set parent PIN or password") }
|
||||
SupervisedParentAuthStatus.Configured -> {
|
||||
OutlinedButton(onClick = { parentAuthDialog = ParentAuthDialog.Change }) {
|
||||
Text("Change parent PIN or password")
|
||||
}
|
||||
TextButton(onClick = { parentAuthDialog = ParentAuthDialog.Recovery }) {
|
||||
Text("Reset with recovery phrase")
|
||||
}
|
||||
TextButton(onClick = { showRemoveCredentialConfirm = true }) {
|
||||
Text(
|
||||
"Remove parent credential",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
HorizontalDivider()
|
||||
SupervisedSwitchRow(
|
||||
title = "Relock when the app leaves the screen",
|
||||
@@ -794,6 +861,118 @@ fun SupervisedControlsScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showRemoveCredentialConfirm) {
|
||||
RemoveParentCredentialDialog(
|
||||
busy = removeCredentialBusy,
|
||||
onDismiss = { showRemoveCredentialConfirm = false },
|
||||
onConfirm = {
|
||||
removeCredentialBusy = true
|
||||
scope.launch {
|
||||
val result = parentAuthStore.clearCredentialAndDisablePolicies()
|
||||
removeCredentialBusy = false
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
showRemoveCredentialConfirm = false
|
||||
enableAuthError = null
|
||||
onBack()
|
||||
},
|
||||
onFailure = {
|
||||
enableAuthError = "Parent access could not be removed. Try again."
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (parentAuthDialog) {
|
||||
ParentAuthDialog.Verify -> SupervisedParentVerifyDialog(
|
||||
store = parentAuthStore,
|
||||
onDismiss = { parentAuthDialog = null },
|
||||
onVerified = {
|
||||
parentAuthDialog = null
|
||||
enableAuthError = null
|
||||
if (mayEnableSupervisedMode(policy, parentCredentialConfirmed = true)) {
|
||||
onPolicyChange(policy.copy(enabled = true))
|
||||
}
|
||||
},
|
||||
onUseRecoveryCode = { parentAuthDialog = ParentAuthDialog.Recovery },
|
||||
)
|
||||
ParentAuthDialog.Setup -> SupervisedParentSetupDialog(
|
||||
store = parentAuthStore,
|
||||
currentSecretRequired = false,
|
||||
onDismiss = {
|
||||
parentAuthDialog = null
|
||||
enableAfterEnrollment = false
|
||||
},
|
||||
onEnrolled = { enrollment ->
|
||||
parentAuthDialog = null
|
||||
pendingEnrollment = enrollment
|
||||
},
|
||||
)
|
||||
ParentAuthDialog.Change -> SupervisedParentSetupDialog(
|
||||
store = parentAuthStore,
|
||||
currentSecretRequired = true,
|
||||
onDismiss = { parentAuthDialog = null },
|
||||
onEnrolled = { enrollment ->
|
||||
parentAuthDialog = null
|
||||
pendingEnrollment = enrollment
|
||||
},
|
||||
)
|
||||
ParentAuthDialog.Recovery -> SupervisedParentRecoveryDialog(
|
||||
store = parentAuthStore,
|
||||
onDismiss = { parentAuthDialog = null },
|
||||
onReset = { enrollment ->
|
||||
parentAuthDialog = null
|
||||
pendingEnrollment = enrollment
|
||||
},
|
||||
)
|
||||
null -> Unit
|
||||
}
|
||||
pendingEnrollment?.let { enrollment ->
|
||||
SupervisedParentRecoveryCodeDialog(
|
||||
enrollment = enrollment,
|
||||
onDone = {
|
||||
pendingEnrollment = null
|
||||
enableAuthError = null
|
||||
if (enableAfterEnrollment && mayEnableSupervisedMode(policy, parentCredentialConfirmed = true)) {
|
||||
onPolicyChange(policy.copy(enabled = true))
|
||||
}
|
||||
enableAfterEnrollment = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RemoveParentCredentialDialog(
|
||||
busy: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onDismiss() },
|
||||
title = { Text("Remove parent credential?") },
|
||||
text = {
|
||||
Text(
|
||||
"This disables Supervised Mode on every connection and removes the app-wide " +
|
||||
"PIN or password and recovery phrase. Your supervised settings and toggles are kept. " +
|
||||
"Hermes sessions and server history are not deleted.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = !busy, onClick = onConfirm) {
|
||||
Text(
|
||||
if (busy) "Removing…" else "Remove",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(enabled = !busy, onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -984,6 +1163,13 @@ private fun sessionActionsSummary(actions: SupervisedSessionActions): String = w
|
||||
else -> "${actions.enabledCount} of ${SupervisedSessionActions.TOTAL} allowed"
|
||||
}
|
||||
|
||||
private enum class ParentAuthDialog {
|
||||
Verify,
|
||||
Setup,
|
||||
Change,
|
||||
Recovery,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionActionSwitch(
|
||||
title: String,
|
||||
|
||||
@@ -3184,6 +3184,7 @@ class ChatViewModel : ViewModel() {
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
if (chatHandler !== handler || handler.currentSessionId.value != storedSessionId) return
|
||||
val contextKey = activeProfileContextKey
|
||||
gatewayHistoryReconcileJob?.cancel()
|
||||
gatewayHistoryReconcileJob = viewModelScope.launch {
|
||||
val expected = expectedAssistantText?.trim()?.takeIf { it.isNotEmpty() }
|
||||
@@ -3216,7 +3217,28 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
val transcriptSnapshot = handler.messages.value
|
||||
val serverMessages = loadGatewaySessionHistory(storedSessionId)
|
||||
val serverMessages = try {
|
||||
loadGatewaySessionHistory(
|
||||
sessionId = storedSessionId,
|
||||
requireProfileScope = true,
|
||||
)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// A live completion is already visible and settled locally.
|
||||
// History auth loss must retain that transcript and promote
|
||||
// the existing sign-in recovery instead of escaping this
|
||||
// Main-scope coroutine and crashing the app.
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == contextKey &&
|
||||
handler.currentSessionId.value == storedSessionId
|
||||
) {
|
||||
publishHistoryLoadFailure(storedSessionId, e)
|
||||
}
|
||||
gatewayHistoryReconcileJob = null
|
||||
return@launch
|
||||
}
|
||||
if (chatHandler !== handler || handler.currentSessionId.value != storedSessionId) {
|
||||
return@launch
|
||||
}
|
||||
@@ -3849,6 +3871,12 @@ class ChatViewModel : ViewModel() {
|
||||
profileSessionPageLister = lister
|
||||
}
|
||||
|
||||
private var dashboardSignInRequiredHandler: (() -> Unit)? = null
|
||||
|
||||
fun setDashboardSignInRequiredHandler(handler: () -> Unit) {
|
||||
dashboardSignInRequiredHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session scoped to the active profile on gateway connections
|
||||
* (dashboard `DELETE /api/sessions/{id}?profile=`). The write twin of
|
||||
@@ -4237,7 +4265,19 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private fun publishHistoryLoadFailure(sessionId: String, error: Throwable) {
|
||||
if (error.isDashboardSignInRequiredFailure()) return
|
||||
if (error.isDashboardSignInRequiredFailure()) {
|
||||
dashboardSignInRequiredHandler?.invoke()
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Auth,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Dashboard sign-in required for chat history",
|
||||
detail = "stored_session=$sessionId; dashboard_auth=required",
|
||||
operation = "load chat history",
|
||||
endpointRole = "gateway",
|
||||
suggestion = "Sign in to Dashboard on the active route, then retry this conversation.",
|
||||
)
|
||||
return
|
||||
}
|
||||
val rawError = error.message?.takeIf { it.isNotBlank() }
|
||||
?: "The active profile's conversation history could not be reached."
|
||||
_chatFailure.value = ChatFailureNotice(
|
||||
@@ -5383,6 +5423,7 @@ class ChatViewModel : ViewModel() {
|
||||
// recovery state. Preserve cached history and
|
||||
// mark the directory unavailable without also
|
||||
// emitting a generic turn/error toast.
|
||||
dashboardSignInRequiredHandler?.invoke()
|
||||
} else if (scoped != null) {
|
||||
// The shared API list belongs to the launch/default
|
||||
// database. Preserve the current profile's rows and
|
||||
@@ -5405,7 +5446,9 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
retryUnavailable = true
|
||||
retryReadiness = retryReadiness || !e.isSessionReadTimeout()
|
||||
if (!e.isDashboardSignInRequiredFailure()) {
|
||||
if (e.isDashboardSignInRequiredFailure()) {
|
||||
dashboardSignInRequiredHandler?.invoke()
|
||||
} else {
|
||||
emitError(
|
||||
e,
|
||||
context = if (profileSessionLister != null) {
|
||||
@@ -7824,13 +7867,39 @@ class ChatViewModel : ViewModel() {
|
||||
if (!queuedSuccessorPending.get()) {
|
||||
val expectedSessionId = checkpoint.sessionId
|
||||
viewModelScope.launch {
|
||||
val history = loadSessionHistory(expectedSessionId)
|
||||
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
|
||||
handler.loadMessageHistory(history)
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(expectedSessionId)
|
||||
try {
|
||||
val history = loadSessionHistory(expectedSessionId)
|
||||
if (
|
||||
handler.currentSessionId.value == expectedSessionId &&
|
||||
history.isNotEmpty()
|
||||
) {
|
||||
handler.loadMessageHistory(history)
|
||||
clearMatchingHistoryLoadFailure(expectedSessionId)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Recovery completion has already settled the
|
||||
// local turn. Keep it visible and route an
|
||||
// expired Dashboard session to sign-in.
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == checkpoint.contextKey &&
|
||||
handler.currentSessionId.value == expectedSessionId
|
||||
) {
|
||||
publishHistoryLoadFailure(expectedSessionId, e)
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == checkpoint.contextKey &&
|
||||
handler.currentSessionId.value == expectedSessionId
|
||||
) {
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(expectedSessionId)
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9694,6 +9763,7 @@ class ChatViewModel : ViewModel() {
|
||||
// tool.complete. The structured reload recovers those calls without ever
|
||||
// parsing assistant prose and retains the profile-aware history boundary.
|
||||
val sid = handler.currentSessionId.value
|
||||
val historyContextKey = activeProfileContextKey
|
||||
// A turn that ended in an error (gateway ❌ lifecycle → "Error" badge)
|
||||
// has NO assistant message persisted server-side, so reconciling the
|
||||
// server transcript would WIPE the just-shown error bubble (the user
|
||||
@@ -9712,34 +9782,54 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
if (!turnErrored && !gatewayHistoryReconcileRequired) {
|
||||
// Profile-aware read: a gateway turn on a non-default profile
|
||||
// persists into THAT profile's own state.db, so the bare
|
||||
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
|
||||
// → a silent wipe of the just-finished turn. loadSessionHistory
|
||||
// prefers the `?profile=` dashboard loader on gateway connections.
|
||||
val serverMessages = loadSessionHistory(sid)
|
||||
val missingPersistedToolActivity =
|
||||
completedTransport == "gateway" &&
|
||||
handler.hasMissingPersistedToolActivity(serverMessages)
|
||||
if (shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport = completedTransport,
|
||||
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
|
||||
missingPersistedToolActivity = missingPersistedToolActivity,
|
||||
)
|
||||
) {
|
||||
handler.loadMessageHistory(serverMessages)
|
||||
try {
|
||||
if (!turnErrored && !gatewayHistoryReconcileRequired) {
|
||||
// Profile-aware read: a gateway turn on a non-default profile
|
||||
// persists into THAT profile's own state.db, so the bare
|
||||
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
|
||||
// → a silent wipe of the just-finished turn. loadSessionHistory
|
||||
// prefers the `?profile=` dashboard loader on gateway connections.
|
||||
val serverMessages = loadSessionHistory(sid)
|
||||
val missingPersistedToolActivity =
|
||||
completedTransport == "gateway" &&
|
||||
handler.hasMissingPersistedToolActivity(serverMessages)
|
||||
if (shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport = completedTransport,
|
||||
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
|
||||
missingPersistedToolActivity = missingPersistedToolActivity,
|
||||
)
|
||||
) {
|
||||
handler.loadMessageHistory(serverMessages)
|
||||
clearMatchingHistoryLoadFailure(sid)
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == historyContextKey &&
|
||||
handler.currentSessionId.value == sid
|
||||
) {
|
||||
publishHistoryLoadFailure(sid, e)
|
||||
}
|
||||
} finally {
|
||||
// Re-sync the drawer now that the turn is persisted server-side.
|
||||
// The only other auto-refresh fires ~160ms after session creation
|
||||
// (RelayApp) — mid-stream, BEFORE the new session's first message
|
||||
// is persisted, so a brand-new chat would otherwise stay missing
|
||||
// from the drawer (carried only by the optimistic row) until a
|
||||
// manual reload. By message.complete the dashboard list includes it.
|
||||
if (
|
||||
chatHandler === handler &&
|
||||
activeProfileContextKey == historyContextKey &&
|
||||
handler.currentSessionId.value == sid
|
||||
) {
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(sid)
|
||||
}
|
||||
drainQueue()
|
||||
}
|
||||
// Re-sync the drawer now that the turn is persisted server-side.
|
||||
// The only other auto-refresh fires ~160ms after session creation
|
||||
// (RelayApp) — mid-stream, BEFORE the new session's first message
|
||||
// is persisted, so a brand-new chat would otherwise stay missing
|
||||
// from the drawer (carried only by the optimistic row) until a
|
||||
// manual reload. By message.complete the dashboard list includes it.
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(sid)
|
||||
drainQueue()
|
||||
}
|
||||
Unit
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedParentAuthStoreTest {
|
||||
@Test
|
||||
fun `new credential policy accepts strong pins and passwords`() {
|
||||
assertFalse(SupervisedParentAuthStore.validateNewSecret("12345".toCharArray(), SupervisedParentCredentialType.Pin).valid)
|
||||
assertTrue(SupervisedParentAuthStore.validateNewSecret("123456".toCharArray(), SupervisedParentCredentialType.Pin).valid)
|
||||
assertFalse(SupervisedParentAuthStore.validateNewSecret("1234567".toCharArray(), SupervisedParentCredentialType.Pin).valid)
|
||||
assertFalse(SupervisedParentAuthStore.validateNewSecret("short".toCharArray(), SupervisedParentCredentialType.Password).valid)
|
||||
assertTrue(SupervisedParentAuthStore.validateNewSecret("long passphrase".toCharArray(), SupervisedParentCredentialType.Password).valid)
|
||||
assertFalse(SupervisedParentAuthStore.validateNewSecret(" ".repeat(8).toCharArray(), SupervisedParentCredentialType.Password).valid)
|
||||
assertFalse(SupervisedParentAuthStore.validateNewSecret("x".repeat(65).toCharArray(), SupervisedParentCredentialType.Password).valid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enrollment stores only salted PBKDF2 verifiers and returns six word recovery phrase`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val store = fastStore(dataStore)
|
||||
|
||||
val enrollment = store.enroll(
|
||||
"correct horse".toCharArray(),
|
||||
SupervisedParentCredentialType.Password,
|
||||
).getOrThrow()
|
||||
val raw = dataStore.data.first()[SupervisedParentAuthStore.recordKeyForTesting].orEmpty()
|
||||
|
||||
assertEquals(6, enrollment.recoveryPhrase.split('-').size)
|
||||
assertEquals(6, enrollment.recoveryPhrase.split('-').distinct().size)
|
||||
assertTrue(raw.contains("PBKDF2WithHmacSHA256"))
|
||||
assertTrue(raw.contains("\"iterations\":1"))
|
||||
assertFalse(raw.contains("correct horse"))
|
||||
assertFalse(raw.contains(enrollment.recoveryPhrase))
|
||||
assertTrue(raw.contains("\"credentialType\":\"Password\""))
|
||||
assertTrue(raw.contains("\"recoveryFormat\":\"WordPhrase\""))
|
||||
val salts = Regex("\"(?:parentSalt|recoverySalt)\":\"([^\"]+)\"")
|
||||
.findAll(raw).map { it.groupValues[1] }.toList()
|
||||
assertEquals(2, salts.size)
|
||||
assertNotEquals(salts[0], salts[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `production enrollment records 310000 rounds`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val store = SupervisedParentAuthStore.forTesting(
|
||||
dataStore = dataStore,
|
||||
iterations = 310_000,
|
||||
)
|
||||
|
||||
store.enroll("production-strength".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
|
||||
assertTrue(
|
||||
dataStore.data.first()[SupervisedParentAuthStore.recordKeyForTesting]
|
||||
.orEmpty().contains("\"iterations\":310000"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verification is fail closed when missing or corrupt`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val store = fastStore(dataStore)
|
||||
assertEquals(SupervisedParentAuthStatus.Missing, store.statusFlow.first())
|
||||
assertEquals(SupervisedParentAuthResult.Missing, store.verify("anything".toCharArray()))
|
||||
|
||||
dataStore.edit { it[SupervisedParentAuthStore.recordKeyForTesting] = "not-json" }
|
||||
assertEquals(SupervisedParentAuthStatus.Corrupt, store.statusFlow.first())
|
||||
assertEquals(SupervisedParentAuthResult.Corrupt, store.verify("anything".toCharArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported or weakened records fail closed`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val store = fastStore(dataStore)
|
||||
store.enroll("parent password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
val raw = dataStore.data.first()[SupervisedParentAuthStore.recordKeyForTesting].orEmpty()
|
||||
|
||||
dataStore.edit {
|
||||
it[SupervisedParentAuthStore.recordKeyForTesting] = raw.replace("\"version\":1", "\"version\":2")
|
||||
}
|
||||
assertEquals(SupervisedParentAuthStatus.Corrupt, store.statusFlow.first())
|
||||
|
||||
dataStore.edit {
|
||||
it[SupervisedParentAuthStore.recordKeyForTesting] = raw.replace("\"iterations\":1", "\"iterations\":0")
|
||||
}
|
||||
assertEquals(SupervisedParentAuthStatus.Corrupt, store.statusFlow.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records created before credential type selection remain verifiable`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val store = fastStore(dataStore)
|
||||
store.enroll("parent password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
val current = dataStore.data.first()[SupervisedParentAuthStore.recordKeyForTesting].orEmpty()
|
||||
val legacy = current
|
||||
.replace(Regex(",\"credentialType\":\"Password\""), "")
|
||||
.replace(Regex(",\"recoveryFormat\":\"WordPhrase\""), "")
|
||||
dataStore.edit { it[SupervisedParentAuthStore.recordKeyForTesting] = legacy }
|
||||
|
||||
assertEquals(SupervisedParentCredentialType.Legacy, store.credentialTypeFlow.first())
|
||||
assertEquals(SupervisedParentAuthResult.Success, store.verify("parent password".toCharArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enroll cannot replace an existing or corrupt credential`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val store = fastStore(dataStore)
|
||||
store.enroll("first password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
|
||||
assertTrue(store.enroll("second password".toCharArray(), SupervisedParentCredentialType.Password).isFailure)
|
||||
assertEquals(SupervisedParentAuthResult.Success, store.verify("first password".toCharArray()))
|
||||
|
||||
dataStore.edit { it[SupervisedParentAuthStore.recordKeyForTesting] = "corrupt" }
|
||||
assertTrue(store.enroll("second password".toCharArray(), SupervisedParentCredentialType.Password).isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticated clear removes credential and disables policies without losing settings`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val authStore = fastStore(dataStore)
|
||||
val policyStore = SupervisedModeStore.forTesting(dataStore)
|
||||
authStore.enroll("parent password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
policyStore.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(attachments = false, voice = true),
|
||||
),
|
||||
)
|
||||
policyStore.setPolicy(
|
||||
"connection-b",
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "coder",
|
||||
visibility = SupervisedVisibility(showTimestamps = true),
|
||||
),
|
||||
)
|
||||
val beforeA = policyStore.policyFlow("connection-a").first()
|
||||
val beforeB = policyStore.policyFlow("connection-b").first()
|
||||
|
||||
authStore.clearCredentialAndDisablePolicies().getOrThrow()
|
||||
|
||||
assertEquals(SupervisedParentAuthStatus.Missing, authStore.statusFlow.first())
|
||||
assertEquals(beforeA.copy(enabled = false), policyStore.policyFlow("connection-a").first())
|
||||
assertEquals(beforeB.copy(enabled = false), policyStore.policyFlow("connection-b").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed attempts persist across store recreation and backoff expires by clock`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val clock = AtomicLong(1_000L)
|
||||
var store = fastStore(dataStore, clock)
|
||||
store.enroll("parent password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
|
||||
repeat(4) {
|
||||
assertTrue(store.verify("wrong password".toCharArray()) is SupervisedParentAuthResult.Invalid)
|
||||
}
|
||||
val fifth = store.verify("wrong password".toCharArray())
|
||||
assertEquals(SupervisedParentAuthResult.Throttled(30_000L), fifth)
|
||||
|
||||
store = fastStore(dataStore, clock)
|
||||
assertEquals(
|
||||
SupervisedParentAuthResult.Throttled(30_000L),
|
||||
store.verify("parent password".toCharArray()),
|
||||
)
|
||||
clock.addAndGet(30_001L)
|
||||
assertEquals(SupervisedParentAuthResult.Success, store.verify("parent password".toCharArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent store instances preserve the capped failure sequence`() = runTest {
|
||||
val dataStore = InMemoryParentAuthDataStore()
|
||||
val stores = List(5) { fastStore(dataStore) }
|
||||
stores.first().enroll("parent password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
|
||||
val results = stores.map { store -> async { store.verify("wrong password".toCharArray()) } }.awaitAll()
|
||||
|
||||
assertEquals(4, results.count { it is SupervisedParentAuthResult.Invalid })
|
||||
assertEquals(1, results.count { it == SupervisedParentAuthResult.Throttled(30_000L) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change requires the current credential and rotates recovery`() = runTest {
|
||||
val store = fastStore(InMemoryParentAuthDataStore())
|
||||
val original = store.enroll("old password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
|
||||
assertTrue(store.change("wrong".toCharArray(), "new password".toCharArray(), SupervisedParentCredentialType.Password).isFailure)
|
||||
assertEquals(SupervisedParentAuthResult.Success, store.verify("old password".toCharArray()))
|
||||
|
||||
val replacement = store.change(
|
||||
"old password".toCharArray(),
|
||||
"654321".toCharArray(),
|
||||
SupervisedParentCredentialType.Pin,
|
||||
).getOrThrow()
|
||||
assertNotEquals(original.recoveryPhrase, replacement.recoveryPhrase)
|
||||
assertTrue(store.verify("old password".toCharArray()) is SupervisedParentAuthResult.Invalid)
|
||||
assertEquals(SupervisedParentAuthResult.Success, store.verify("654321".toCharArray()))
|
||||
assertEquals(SupervisedParentCredentialType.Pin, store.credentialTypeFlow.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovery is normalized one time and rotates both secrets`() = runTest {
|
||||
val store = fastStore(InMemoryParentAuthDataStore())
|
||||
val original = store.enroll("old password".toCharArray(), SupervisedParentCredentialType.Password).getOrThrow()
|
||||
val lowerSpaced = original.recoveryPhrase.uppercase().replace("-", " ").toCharArray()
|
||||
|
||||
val replacement = store.resetWithRecoveryPhrase(
|
||||
lowerSpaced,
|
||||
"new password".toCharArray(),
|
||||
SupervisedParentCredentialType.Password,
|
||||
).getOrThrow()
|
||||
|
||||
assertNotEquals(original.recoveryPhrase, replacement.recoveryPhrase)
|
||||
assertEquals(SupervisedParentAuthResult.Success, store.verify("new password".toCharArray()))
|
||||
assertTrue(
|
||||
store.resetWithRecoveryPhrase(
|
||||
original.recoveryPhrase.toCharArray(),
|
||||
"another password".toCharArray(),
|
||||
SupervisedParentCredentialType.Password,
|
||||
)
|
||||
.isFailure,
|
||||
)
|
||||
}
|
||||
|
||||
private fun fastStore(
|
||||
dataStore: DataStore<Preferences>,
|
||||
clock: AtomicLong = AtomicLong(1_000L),
|
||||
): SupervisedParentAuthStore = SupervisedParentAuthStore.forTesting(
|
||||
dataStore = dataStore,
|
||||
iterations = 1,
|
||||
minimumAcceptedIterations = 1,
|
||||
nowMillis = clock::get,
|
||||
)
|
||||
}
|
||||
|
||||
private class InMemoryParentAuthDataStore : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(emptyPreferences())
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -562,17 +562,24 @@ class DashboardApiClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signInRequiredClassifierAcceptsNoCookieButRejectsForbidden() {
|
||||
fun signInRequiredClassifierAcceptsEveryUnauthorizedShapeButRejectsForbidden() {
|
||||
val noCookie = DashboardHttpException(
|
||||
401,
|
||||
"Session failed - HTTP 401: {\"reason\":\"no_cookie\",\"detail\":\"Unauthorized\"}",
|
||||
)
|
||||
val expired = DashboardHttpException(
|
||||
401,
|
||||
"Session failed - HTTP 401: {\"reason\":\"session_expired\",\"detail\":\"invalid_or_expired_session\"}",
|
||||
)
|
||||
val generic = DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized")
|
||||
val forbidden = DashboardHttpException(
|
||||
403,
|
||||
"Session failed - HTTP 403: forbidden",
|
||||
)
|
||||
|
||||
assertTrue(noCookie.isDashboardSignInRequiredFailure())
|
||||
assertTrue(expired.isDashboardSignInRequiredFailure())
|
||||
assertTrue(generic.isDashboardSignInRequiredFailure())
|
||||
assertFalse(forbidden.isDashboardSignInRequiredFailure())
|
||||
}
|
||||
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.SupervisedParentEnrollment
|
||||
import com.hermesandroid.relay.ui.screens.CredentialChoiceScreen
|
||||
import com.hermesandroid.relay.ui.screens.ParentAuthScreenSurface
|
||||
import com.hermesandroid.relay.ui.screens.PasswordSetupScreen
|
||||
import com.hermesandroid.relay.ui.screens.PinSetupScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedParentRecoveryCodeContent
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w400dp-h900dp-432dpi")
|
||||
class SupervisedParentAuthFlowScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun credentialChoice() {
|
||||
render("build/visual-qa/supervised-parent-choice.png", 1 to 2) {
|
||||
CredentialChoiceScreen(
|
||||
title = "Choose parent access",
|
||||
subtitle = "Pick one way to unlock parent settings. You can change it later.",
|
||||
onSelected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinSetup() {
|
||||
render("build/visual-qa/supervised-parent-pin.png", 2 to 2) {
|
||||
PinSetupScreen(busy = false, error = null, onComplete = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passwordSetup() {
|
||||
render("build/visual-qa/supervised-parent-password.png", 2 to 2) {
|
||||
PasswordSetupScreen(busy = false, error = null, onComplete = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryPhrase() {
|
||||
render("build/visual-qa/supervised-parent-recovery.png", 3 to 3) {
|
||||
SupervisedParentRecoveryCodeContent(
|
||||
enrollment = SupervisedParentEnrollment(
|
||||
"maple-river-lantern-copper-sparrow-moon",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(
|
||||
path: String,
|
||||
step: Pair<Int, Int>,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(themePreference = "dark") {
|
||||
ParentAuthScreenSurface(step = step, onBack = {}, content = content)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage(path)
|
||||
}
|
||||
}
|
||||
@@ -172,25 +172,23 @@ class SupervisedNavigationPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
|
||||
@Test fun `first enable requires configured policy and successful app parent credential`() {
|
||||
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
|
||||
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = false,
|
||||
deviceCredentialConfirmed = true,
|
||||
parentCredentialConfirmed = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = true,
|
||||
deviceCredentialConfirmed = false,
|
||||
parentCredentialConfirmed = false,
|
||||
),
|
||||
)
|
||||
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
|
||||
assertTrue(mayEnableSupervisedMode(configured, true, true))
|
||||
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
|
||||
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true))
|
||||
assertTrue(mayEnableSupervisedMode(configured, true))
|
||||
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true))
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.SupervisedParentCredentialType
|
||||
import com.hermesandroid.relay.data.SupervisedParentEnrollment
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(qualifiers = "w400dp-h900dp-432dpi")
|
||||
class SupervisedParentAuthDialogsTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `choice presents mutually exclusive pin and password routes`() {
|
||||
var selected: SupervisedParentCredentialType? = null
|
||||
compose.setContent {
|
||||
HermesRelayTheme {
|
||||
CredentialChoiceScreen("Choose parent access", "Pick one.") { selected = it }
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Use a PIN").assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { assertEquals(SupervisedParentCredentialType.Pin, selected) }
|
||||
compose.onNodeWithText("Use a password").assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { assertEquals(SupervisedParentCredentialType.Password, selected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pin auth uses six positions and a dedicated numeric keypad`() {
|
||||
var submitted: String? = null
|
||||
compose.setContent {
|
||||
HermesRelayTheme {
|
||||
PinEntryScreen("Parent PIN", "Enter your 6-digit PIN.", false, null, { submitted = it })
|
||||
}
|
||||
}
|
||||
|
||||
(0..9).forEach { compose.onNodeWithText(it.toString()).assertIsDisplayed() }
|
||||
compose.onNodeWithContentDescription("Delete digit").assertIsDisplayed()
|
||||
(1..6).forEach { compose.onNodeWithText(it.toString()).performClick() }
|
||||
compose.runOnIdle { assertEquals("123456", submitted) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `password setup uses distinct password fields and visibility controls`() {
|
||||
compose.setContent {
|
||||
HermesRelayTheme { PasswordSetupScreen(false, null, {}) }
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Create a parent password").assertIsDisplayed()
|
||||
compose.onNodeWithText("Password").assertIsDisplayed()
|
||||
compose.onNodeWithText("Confirm password").assertIsDisplayed()
|
||||
compose.onAllNodesWithContentDescription("Show password").assertCountEquals(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovery phrase handoff exposes sharing copy and cleanup guidance`() {
|
||||
var shares = 0
|
||||
var copies = 0
|
||||
compose.setContent {
|
||||
HermesRelayTheme {
|
||||
SupervisedParentRecoveryCodeContent(
|
||||
SupervisedParentEnrollment("maple-river-lantern-copper-sparrow-moon"),
|
||||
onShare = { shares += 1 },
|
||||
onCopy = { copies += 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("maple-river-lantern", substring = true).assertIsDisplayed()
|
||||
compose.onNodeWithText("copper-sparrow-moon", substring = true).assertIsDisplayed()
|
||||
compose.onNodeWithText("Share").assertIsDisplayed()
|
||||
compose.onNodeWithText("Copy phrase").assertIsDisplayed()
|
||||
compose.onNodeWithText("delete the message or saved copy", substring = true).assertIsDisplayed()
|
||||
compose.onNodeWithText("Share").performClick()
|
||||
compose.onNodeWithText("Copy phrase").performClick()
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, shares)
|
||||
assertEquals(1, copies)
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -238,6 +238,63 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals("gateway", diagnostic.endpointRole)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
|
||||
DiagnosticsLog.clear()
|
||||
apiMessageRequestCount.set(0)
|
||||
val owner = Profile(name = "owner", model = "model-a", description = "Owner")
|
||||
val requestedProfiles = mutableListOf<String?>()
|
||||
val sessionRefreshes = AtomicInteger(0)
|
||||
val signInRequests = AtomicInteger(0)
|
||||
viewModel.setSelectedProfileProvider { owner }
|
||||
viewModel.setSessionProfileNameProvider { owner.name }
|
||||
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
|
||||
requestedProfiles += profileName
|
||||
assertEquals(STORED_SESSION_ID, sessionId)
|
||||
Result.failure(
|
||||
DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized"),
|
||||
)
|
||||
}
|
||||
viewModel.setProfileSessionLister { profileName ->
|
||||
assertEquals(owner.name, profileName)
|
||||
sessionRefreshes.incrementAndGet()
|
||||
Result.success(emptyList())
|
||||
}
|
||||
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
|
||||
|
||||
viewModel.sendMessage("Keep this local prompt")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Keep this local answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Keep this local answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
!handler.isStreaming.value &&
|
||||
signInRequests.get() == 1 &&
|
||||
sessionRefreshes.get() >= 1
|
||||
}
|
||||
assertTrue(handler.messages.value.any { it.content == "Keep this local prompt" })
|
||||
assertTrue(handler.messages.value.any { it.content == "Keep this local answer" })
|
||||
assertEquals(listOf(owner.name), requestedProfiles)
|
||||
assertEquals(0, apiMessageRequestCount.get())
|
||||
assertFalse(viewModel.steerableTurn.value)
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
|
||||
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
|
||||
assertTrue(diagnostic.suggestion.orEmpty().contains("Sign in to Dashboard"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingRequiredProfileHistoryLoaderFailsClosedWithoutApiRead() {
|
||||
DiagnosticsLog.clear()
|
||||
@@ -2746,6 +2803,92 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
|
||||
DiagnosticsLog.clear()
|
||||
apiMessageRequestCount.set(0)
|
||||
val checkpointStore = MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = PROFILE_CONTEXT,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = "live-resumed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("prior-user", "Recovered prompt", 1L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "prior-assistant",
|
||||
content = "Recovered partial",
|
||||
timestamp = 2L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = 2L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
val requestedProfiles = mutableListOf<String?>()
|
||||
val sessionRefreshes = AtomicInteger(0)
|
||||
val signInRequests = AtomicInteger(0)
|
||||
var failHistory = false
|
||||
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
|
||||
requestedProfiles += profileName
|
||||
assertEquals(STORED_SESSION_ID, sessionId)
|
||||
if (failHistory) {
|
||||
Result.failure(
|
||||
DashboardHttpException(
|
||||
401,
|
||||
"Session failed - HTTP 401: {\"reason\":\"session_expired\"}",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Result.success(emptyList())
|
||||
}
|
||||
}
|
||||
viewModel.setProfileSessionLister { profileName ->
|
||||
assertNull(profileName)
|
||||
sessionRefreshes.incrementAndGet()
|
||||
Result.success(emptyList())
|
||||
}
|
||||
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
|
||||
gatewayHarness.recoveryRunning = true
|
||||
gatewayHarness.recoveryAssistant = "Recovered partial"
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
gatewayHarness.awaitRpc("session.activate")
|
||||
awaitCondition {
|
||||
handler.messages.value.any { it.id == "prior-assistant" && it.isStreaming }
|
||||
}
|
||||
failHistory = true
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Recovered answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
!handler.isStreaming.value &&
|
||||
signInRequests.get() == 1 &&
|
||||
sessionRefreshes.get() >= 1
|
||||
}
|
||||
assertTrue(handler.messages.value.any {
|
||||
it.id == "prior-assistant" &&
|
||||
it.content.contains("Recovered answer") &&
|
||||
!it.isStreaming
|
||||
})
|
||||
assertTrue(requestedProfiles.isNotEmpty())
|
||||
assertTrue(requestedProfiles.all { it == null })
|
||||
assertEquals(0, apiMessageRequestCount.get())
|
||||
assertFalse(viewModel.steerableTurn.value)
|
||||
awaitCondition { checkpointStore.checkpoint == null }
|
||||
assertNull(viewModel.chatFailure.value)
|
||||
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
|
||||
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateCanceledCompletionDrainsBeforeImmediateNextTurn() {
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
|
||||
@@ -26,17 +26,23 @@ Check recent runs before dispatching so another task does not duplicate the
|
||||
same SHA and preset:
|
||||
|
||||
```powershell
|
||||
gh run list --workflow android-on-demand.yml --event workflow_dispatch --limit 20
|
||||
$base = git merge-base origin/dev HEAD
|
||||
$sha = git rev-parse HEAD
|
||||
gh workflow run android-on-demand.yml --ref dev -f head_sha=$sha -f preset=focused
|
||||
gh run list --workflow android-on-demand.yml --event workflow_dispatch --limit 5
|
||||
gh run list --workflow ci-required.yml --event workflow_dispatch --limit 20
|
||||
gh workflow run ci-required.yml --ref dev `
|
||||
-f base_sha=$base `
|
||||
-f head_sha=$sha `
|
||||
-f android_preset=focused
|
||||
gh run list --workflow ci-required.yml --event workflow_dispatch --limit 5
|
||||
```
|
||||
|
||||
The SHA must already exist on GitHub. Do not push solely to obtain cloud compute
|
||||
without push authorization. On-demand jobs read shared Gradle cache state but
|
||||
do not write it, so task commits cannot replace the cache populated by trusted
|
||||
`dev`/`main` CI. The on-demand result supplements rather than replaces required
|
||||
PR checks.
|
||||
PR checks. `Required checks` is the registered dispatcher because GitHub only
|
||||
registers manual workflow entry points from the default branch; it calls the
|
||||
Android workflow from the selected `dev` ref.
|
||||
|
||||
## Local use
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Android emulator testing
|
||||
|
||||
Hermes-Relay Android uses individually selected Gradle Managed Devices for
|
||||
repeatable, on-demand instrumentation. The routine virtual baseline is API 36.
|
||||
There is deliberately no aggregate matrix task and no scheduled emulator job:
|
||||
choose the smallest lane that can prove the behavior under review.
|
||||
|
||||
## Lanes
|
||||
|
||||
| Evidence lane | Gradle device | Hardware profile | Use it for |
|
||||
|---|---|---|---|
|
||||
| Real Device | None | Explicitly selected physical hardware | Firmware, radio, audio, camera, biometrics, background limits, accessibility, and release-candidate claims |
|
||||
| Compact Phone | `compactPhoneApi36` | Pixel 2 | Narrow phone layouts, compact height, keyboard pressure |
|
||||
| Standard Phone | `standardPhoneApi36` | Pixel 6 | Default functional and regression instrumentation |
|
||||
| Large Phone | `largePhoneApi36` | Pixel 7 Pro | Large handset layout and reachability |
|
||||
| Foldable | `foldableApi36` | Pixel Fold | Fold/unfold, posture, continuity, and width-class changes |
|
||||
| Tablet | `tabletApi36` | Pixel Tablet | Expanded layout, panes, and large-window behavior |
|
||||
| Future platform / native canary | `futureApi37Ps16k` | Pixel 7 Pro, API 37, forced 16 KB pages | On-demand platform and native-library compatibility only |
|
||||
|
||||
Routine API 36 lanes use the AOSP x86_64 image so deterministic app tests do not
|
||||
spend host capacity on unrelated Google-service startup. The API 37/16 KB device
|
||||
is not a screen-size lane and is not part of routine testing. Gradle Managed
|
||||
Devices may download a missing image on first use; that setup can be large and
|
||||
slow.
|
||||
|
||||
## Commands
|
||||
|
||||
List the registered tasks:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle :app:tasks --all |
|
||||
Select-String 'Api36|Ps16k'
|
||||
```
|
||||
|
||||
Compile the app and instrumentation APK without starting an emulator:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:assembleSideloadDebug `
|
||||
:app:assembleSideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
Run one complete lane, normally Standard Phone first:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:standardPhoneApi36SideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
Run one test class on one lane:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:standardPhoneApi36SideloadDebugAndroidTest `
|
||||
'-Pandroid.testInstrumentationRunnerArguments.class=com.hermesandroid.relay.viewmodel.GatewayForegroundRecoveryInstrumentedTest'
|
||||
```
|
||||
|
||||
Run the other virtual lanes only when their form factor is relevant:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle :app:compactPhoneApi36SideloadDebugAndroidTest
|
||||
.\scripts\android-lane.ps1 gradle :app:largePhoneApi36SideloadDebugAndroidTest
|
||||
.\scripts\android-lane.ps1 gradle :app:foldableApi36SideloadDebugAndroidTest
|
||||
.\scripts\android-lane.ps1 gradle :app:tabletApi36SideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
Run the future-platform/native canary explicitly:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle `
|
||||
:app:futureApi37Ps16kSideloadDebugAndroidTest
|
||||
```
|
||||
|
||||
All Windows commands use the repository's machine-wide build lane; see
|
||||
[`docs/android-build-lane.md`](android-build-lane.md). Check the lane without
|
||||
starting work with:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 status
|
||||
```
|
||||
|
||||
Do not invoke every device task as one command. Run lanes serially, record each
|
||||
result, and stop when the relevant evidence is complete or the host reaches a
|
||||
capacity limit.
|
||||
|
||||
## Configuration coverage
|
||||
|
||||
Form factor is only one axis. Select additional states according to the change:
|
||||
|
||||
- Test dark mode first; also cover light mode when colors, contrast, system bars,
|
||||
or theme persistence changed.
|
||||
- Cover portrait and landscape when layout, keyboard, media, drawers, or panes
|
||||
changed. Foldable work must include a posture or width-class transition.
|
||||
- Check default font scale and at least one enlarged scale for text-heavy or
|
||||
accessibility-sensitive UI.
|
||||
- Use the default locale for functional regressions; add a long-string locale
|
||||
and an RTL locale when copy, formatting, or layout direction changed.
|
||||
- Record gesture versus three-button navigation when bottom insets, edge-to-edge,
|
||||
back handling, sheets, or overlays changed.
|
||||
|
||||
These dimensions are selected test conditions, not permanent duplicated device
|
||||
definitions. Record any non-default setting in the evidence.
|
||||
|
||||
## Deterministic fixtures and live servers
|
||||
|
||||
Embedded MockWebServer tests own deterministic transport regressions. They use
|
||||
production clients and view models against loopback HTTP/WebSocket boundaries,
|
||||
require no credentials, mutate no real sessions, and are the correct lane for
|
||||
authentication loss, reconnect gaps, malformed frames, profile isolation, and
|
||||
repeatable lifecycle assertions.
|
||||
|
||||
Live-server testing is separate and on demand. Use a disposable test or staging
|
||||
Hermes server with disposable profiles and sessions. Normally run only the
|
||||
Standard Phone emulator plus one explicitly selected real device when physical
|
||||
evidence is required. Never multiply live mutation testing across the full size
|
||||
matrix, use a production server, or use personal conversation data. Sanitize
|
||||
logs and exports before attaching them to a pull request.
|
||||
|
||||
## Evidence
|
||||
|
||||
For each executed lane, record:
|
||||
|
||||
```text
|
||||
Commit: <exact SHA>
|
||||
Artifact/variant: sideloadDebug app + androidTest
|
||||
Lane: Standard Phone (standardPhoneApi36), API 36
|
||||
Test selection: <class or package>
|
||||
Configuration: dark/light, orientation/posture, font scale, locale, navigation
|
||||
Result: pass/fail/blocked, test count, report path
|
||||
Notes: retries, emulator/image limitation, relevant sanitized observation
|
||||
```
|
||||
|
||||
Keep claims lane-specific. Emulator proof is not physical-device proof. A
|
||||
passing API 37/16 KB canary proves only that selected platform/native lane; it
|
||||
does not replace API 36 form-factor coverage or physical firmware evidence.
|
||||
+57
-5
@@ -3772,7 +3772,7 @@ be considered later without being silently introduced now.
|
||||
|
||||
## ADR 66 — Android Supervised Mode is a parent-controlled client policy
|
||||
|
||||
**Status:** Implemented in code; physical managed-device certification pending (2026-08-24).
|
||||
**Status:** Implemented in code; app-specific parent credential and physical managed-device certification pending (2026-08-31).
|
||||
|
||||
**Context.** Some operators prepare a deliberately restricted Hermes profile
|
||||
for use through a parent-supervised Android client. The profile remains the
|
||||
@@ -3784,9 +3784,10 @@ child security or as a server-enforced account type.
|
||||
**Decision.** Android will treat Supervised Mode as an opt-in, locally enforced
|
||||
policy pinned to one existing Connection and one existing Hermes profile. The
|
||||
parent is responsible for preparing and reviewing that profile before enabling
|
||||
the mode. Entering, changing, or leaving the parent policy requires Android
|
||||
device authentication. That prompt authenticates an enrolled device user, not
|
||||
a distinct server-side parent identity. While the policy is active, the app restores directly
|
||||
the mode. Entering, changing, or leaving the parent policy requires the
|
||||
app-global parent PIN or password. Android's screen lock, device credential,
|
||||
and enrolled biometrics are not parent authority because the supervised user
|
||||
may legitimately control them. While the policy is active, the app restores directly
|
||||
into a restricted root and never renders the ordinary app behind an
|
||||
authentication prompt. A missing Connection, missing profile, malformed policy,
|
||||
failed authentication, process restart, or restored route that cannot prove its
|
||||
@@ -3800,6 +3801,37 @@ recreation, and leaving parent settings relock parent access according to the
|
||||
policy. Deep links, notification actions, restored navigation, shortcuts, and
|
||||
programmatic routes pass the same gate.
|
||||
|
||||
The parent credential store persists only salted verifiers in app-private
|
||||
DataStore. Parent and recovery verifiers use independent 128-bit salts and
|
||||
PBKDF2-HMAC-SHA256 with 310,000 iterations; candidate comparison is
|
||||
constant-time. Five failures start a persisted 30-second delay, repeated
|
||||
failures increase it to a capped 15 minutes, and successful verification clears
|
||||
the counter. Enrollment first requires an explicit choice: an exactly six-digit
|
||||
PIN entered through the app keypad, or a password of at least eight and at most
|
||||
64 characters entered through the normal password keyboard. It returns a randomly
|
||||
generated six-word recovery phrase exactly once. Six distinct words from a
|
||||
128-word vocabulary provide about 42 bits of entropy: deliberately less than the
|
||||
previous opaque code, but materially easier to read, type, and send for this
|
||||
family-facing client restriction. Authenticated change and recovery
|
||||
reset replace both verifiers and issue a new recovery phrase; unauthenticated
|
||||
enrollment cannot overwrite an existing or corrupt record.
|
||||
|
||||
An authenticated parent may remove the app-global credential without presenting
|
||||
the recovery phrase. Removal atomically deletes the credential record and sets
|
||||
every supervised policy to `enabled = false`, so no policy can remain active
|
||||
without an unlock path. All other policy configuration is retained for later
|
||||
re-enrollment. It does not delete server-owned Hermes sessions or history. If both the parent
|
||||
credential and recovery phrase are lost, the deliberate last-resort escape hatch
|
||||
is Android's **Clear data** action for the app. Uninstall/reinstall is not the
|
||||
documented recovery path because Android backup restore may restore local state.
|
||||
|
||||
Missing, malformed, unsupported-version, weakened-KDF, and unreadable records
|
||||
fail closed. A legacy enabled policy has no trustworthy app parent identity to
|
||||
migrate, so it stays at the restricted root. Recovery requires resetting local
|
||||
app data, reconnecting, and configuring Supervised Mode again; Android must not
|
||||
disable the policy or promote the current device user automatically. Server
|
||||
sessions and history are not deleted by that local reset.
|
||||
|
||||
The parent policy controls capabilities rather than imposing a special
|
||||
attachment count. Initial capabilities are text chat, new chat, cancel, steer,
|
||||
attachments, standard voice, generated-media viewing, save/share media, copy,
|
||||
@@ -3872,8 +3904,28 @@ or applicable legal obligations. Public language uses **Supervised Mode** or
|
||||
**parent-controlled client**, not "child account," "safe for children," or
|
||||
"server enforced."
|
||||
|
||||
The verifier design raises the cost of an offline guess but cannot make a
|
||||
six-digit PIN high entropy. A privileged attacker who can copy or roll back the
|
||||
app-private store can attempt guesses offline or weaken the persisted backoff;
|
||||
device integrity, backup policy, and a strong parent password remain relevant.
|
||||
The recovery phrase may be copied or shared with a brief instruction to remove
|
||||
the message or saved copy from the phone after it reaches a parent-only place.
|
||||
It must otherwise be stored outside the supervised user's reach. Stock
|
||||
Android also cannot give one app a parent-only biometric enrollment or tell the
|
||||
app which enrolled fingerprint or face authenticated. Biometric convenience may
|
||||
be considered only as an explicit second layer over this app credential, never
|
||||
as proof of a distinct parent.
|
||||
|
||||
**Localization decision.** Until physical certification and fluent security-copy
|
||||
review, the Supervised Mode and parent-authentication surface remains canonical
|
||||
English in every app locale. It intentionally falls back to English and must not
|
||||
be described as localized. Security-critical setup, recovery, migration, and
|
||||
lockout wording will move into the translated catalogs together after review;
|
||||
machine-translating only part of this boundary is not accepted.
|
||||
|
||||
**Verification gate.** Implementation requires policy, authentication,
|
||||
navigation, process-death, deep-link, notification, capability, attachment,
|
||||
navigation, KDF-record validation, persisted throttling, change/recovery
|
||||
rotation, corruption/migration, process-death, deep-link, notification, capability, attachment,
|
||||
voice, session-ownership, Relay-tag, and revocation tests. Physical testing must
|
||||
cover the exact Android build on a managed/restricted device, including relock,
|
||||
restart, offline recovery, and attempts to escape the restricted root. Until
|
||||
|
||||
+22
-1
@@ -7,7 +7,7 @@ Android's declarative plugin surface is specified in
|
||||
|
||||
**Status:** v1.0.0 stable. The default path supports chat, Manage, and voice on vanilla upstream Hermes without installing the Relay plugin. Relay is additive: terminal, bridge/device control, notification companion, remote access, extra/provider-native voice, desktop tooling, and dashboard Relay management. Historical phase notes remain in this file for context; the current route ownership source of truth is [`docs/upstream-surface-matrix.md`](upstream-surface-matrix.md).
|
||||
**Repo:** [Codename-11/hermes-relay](https://github.com/Codename-11/hermes-relay)
|
||||
**Updated:** 2026-08-29
|
||||
**Updated:** 2026-08-31
|
||||
|
||||
---
|
||||
|
||||
@@ -51,6 +51,27 @@ token, terminal/bridge grants, and optional network candidates.
|
||||
selected Hermes profile, server, or agent child-safe. See ADR 66 and the
|
||||
[Supervised Mode guide](../user-docs/guide/supervised-mode.md).
|
||||
|
||||
Supervised Mode parent authority is an app-global PIN or password, not Android's
|
||||
screen lock, device credential, or biometric prompt. The app stores only
|
||||
independently salted PBKDF2-HMAC-SHA256 verifiers (310,000 iterations) for the
|
||||
parent credential and a one-time six-word recovery phrase in app-private DataStore.
|
||||
The phrase uses six distinct words from a 128-word app vocabulary (about 42 bits)
|
||||
to favor accurate reading, typing, and parent-to-parent handoff for this client policy.
|
||||
Verification uses constant-time byte comparison and a persisted, capped backoff.
|
||||
Missing, malformed, unsupported, or weakened records fail closed. Enrollment is
|
||||
allowed only when the record is missing; changing it requires the current
|
||||
credential, and recovery reset requires the current recovery phrase. Both
|
||||
successful rotation paths issue a new recovery phrase and invalidate the old one.
|
||||
An authenticated parent may remove the app-global credential without the
|
||||
recovery phrase; the same atomic write sets every supervised policy to disabled
|
||||
while preserving its pinned profile, capability toggles, appearance, visibility,
|
||||
session controls, and relock settings.
|
||||
If both the credential and recovery phrase are lost, the supported local escape
|
||||
hatch is Android Settings → Apps → Hermes-Relay → Storage → Clear data.
|
||||
An existing enabled policy from before this credential scheme has no safe parent
|
||||
identity to migrate, so it remains restricted and requires local app-data reset
|
||||
and supervised reconfiguration rather than silently trusting a device user.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
@@ -41,11 +41,16 @@ From full Android Settings, the parent:
|
||||
|
||||
1. Open **Settings → Advanced → Supervised Mode** for the active Hermes
|
||||
Connection.
|
||||
2. Choose one existing named profile. Android requires a secure device screen
|
||||
lock before the mode can be enabled.
|
||||
3. Select the allowed features and any stricter attachment or history limits.
|
||||
4. Choose a visibility preset or customize what appears in Chat.
|
||||
5. Review the summary, then enable the mode.
|
||||
2. Choose one existing named profile.
|
||||
3. Choose either an app-specific six-digit parent PIN or a parent password of
|
||||
at least eight characters. The PIN uses the app keypad; passwords use the
|
||||
normal keyboard and password-manager flow. This is separate from the phone's screen lock.
|
||||
4. Save the one-time six-word recovery phrase somewhere the supervised user
|
||||
cannot access. You may share it to a parent-only destination; delete the
|
||||
message or saved copy from this phone afterward.
|
||||
5. Select the allowed features and any stricter attachment or history limits.
|
||||
6. Choose a visibility preset or customize what appears in Chat.
|
||||
7. Review the summary, then verify the parent credential to enable the mode.
|
||||
|
||||
The app returns to the pinned profile's Chat screen. If the Connection or
|
||||
profile is unavailable, the restricted client shows a recovery state
|
||||
@@ -58,8 +63,9 @@ Supervised Mode banner consuming conversation space. The agent name and avatar
|
||||
remain the primary identity, with a small connection state when permitted.
|
||||
|
||||
The existing Settings button opens **Restricted Settings**, which contains only
|
||||
approved preferences. A clearly labelled **Parent access** row starts device
|
||||
authentication before any parent controls or full application settings appear.
|
||||
approved preferences. A clearly labelled **Parent access** row asks for the
|
||||
app-specific parent PIN or password before any parent controls or full
|
||||
application settings appear.
|
||||
|
||||
Restricted Settings may include:
|
||||
|
||||
@@ -166,14 +172,38 @@ and avatar provide the normal identity in the Simple preset.
|
||||
|
||||
## Parent access and relocking
|
||||
|
||||
Enabling, changing, or ending Supervised Mode requires Android device
|
||||
authentication. Parent access should relock when its authenticated task closes,
|
||||
after the configured inactivity period, when the app backgrounds, or after
|
||||
process recreation.
|
||||
Enabling, changing, or ending Supervised Mode requires the app-specific parent
|
||||
PIN or password. Parent access relocks when its authenticated task closes, after
|
||||
the configured inactivity period, when the app backgrounds, or after process
|
||||
recreation. Failure delays persist across app restart.
|
||||
|
||||
Android's device-credential prompt authenticates any user enrolled for that
|
||||
device; it does not establish a separate parent identity. Use a device lock the
|
||||
supervised user does not know, or keep the device under direct supervision.
|
||||
The credential is global to this Android app installation, not scoped to one
|
||||
Connection. Changing it or resetting it with the current recovery phrase rotates
|
||||
the phrase, which is shown only once. If the credential record is missing
|
||||
or damaged, Supervised Mode fails closed. A legacy installation that was already
|
||||
enabled before app-specific parent credentials existed must reset local app
|
||||
data, reconnect, and configure the mode again; it does not silently trust the
|
||||
current Android user. That reset does not delete server-owned Hermes history.
|
||||
|
||||
While parent access is unlocked, **Remove parent credential** is available in
|
||||
the parent controls even if the recovery phrase has been lost. Confirming it
|
||||
disables Supervised Mode for every Connection and removes the app-wide PIN or
|
||||
password and recovery verifier. Pinned profiles, capability toggles, appearance,
|
||||
visibility, session controls, and relock settings are preserved. Server sessions
|
||||
and history are preserved.
|
||||
|
||||
If both the parent credential and recovery phrase are lost, use Android
|
||||
**Settings → Apps → Hermes-Relay → Storage → Clear data**. This also removes
|
||||
local Connections, sign-ins, preferences, and caches, but does not delete
|
||||
server-owned Hermes sessions. Uninstall/reinstall is not the documented escape
|
||||
hatch because Android may restore backed-up local app state.
|
||||
|
||||
The app stores salted PBKDF2 verifiers, not the parent password or recovery phrase,
|
||||
and applies persisted attempt delays. Prefer a strong password: a six-digit PIN
|
||||
still has limited resistance if a privileged attacker copies the app-private
|
||||
data and guesses offline. Stock Android cannot create parent-only biometric
|
||||
enrollment for one app or tell the app which enrolled fingerprint or face was
|
||||
used, so device biometrics are not accepted as parent identity.
|
||||
|
||||
The restricted root is restored before the first interactive screen. Deep
|
||||
links, notification actions, shortcuts, saved back stacks, and share intents
|
||||
@@ -209,6 +239,10 @@ Supervised Mode cannot control:
|
||||
- the developmental suitability or factual accuracy of model output;
|
||||
- Android behavior outside the Hermes-Relay app.
|
||||
|
||||
The experimental Supervised Mode and parent-authentication screens currently use
|
||||
canonical English in every app locale pending fluent review of the complete
|
||||
security and recovery wording. Do not assume those screens are localized.
|
||||
|
||||
Use it alongside a restrictive Hermes profile, parental supervision, Android
|
||||
parental or enterprise controls where appropriate, and regular review of the
|
||||
profile and its conversations.
|
||||
|
||||
Reference in New Issue
Block a user