Compare commits

...
Author SHA1 Message Date
Bailey Dixon de8f5558c2 Merge origin/dev into fix/android-emulator-testing 2026-08-31 16:37:42 -04:00
Bailey Dixon adcf4ded79 Merge pull request #517 from Codename-11/fix/android-supervised-parent-secret
fix(android): require app-specific supervised parent access
2026-08-31 16:36:44 -04:00
Bailey Dixon 05d6ee4d7c Merge origin/dev into fix/android-emulator-testing 2026-08-31 16:25:48 -04:00
Bailey Dixon 41cbafddba Merge remote-tracking branch 'origin/dev' into fix/android-supervised-parent-secret 2026-08-31 16:24:21 -04:00
Bailey Dixon 8632ced503 Merge pull request #516 from Codename-11/fix/android-on-demand-dispatch
fix(ci): route Android presets through registered workflow
2026-08-31 16:21:53 -04:00
Bailey Dixon ae18bbee24 Merge origin/dev into fix/android-emulator-testing
# Conflicts:
#	AGENTS.md
#	app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt
#	app/src/main/kotlin/com/hermesandroid/relay/runtime/HermesRuntimeBinder.kt
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt
#	app/src/test/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClientTest.kt
2026-08-31 16:13:34 -04:00
Bailey Dixon d367cd3a24 Merge remote-tracking branch 'origin/dev' into fix/android-supervised-parent-secret 2026-08-31 15:59:55 -04:00
Bailey Dixon 6f0948ca01 Merge remote-tracking branch 'origin/dev' into fix/android-supervised-parent-secret 2026-08-31 15:58:43 -04:00
Bailey Dixon 541a7c078d fix(android): require app-specific supervised parent access 2026-08-31 15:51:20 -04:00
Bailey Dixon 105da550e7 docs: note Android history auth recovery 2026-08-31 15:32:40 -04:00
Bailey Dixon febc26fe35 Merge origin/dev into fix/android-emulator-testing 2026-08-31 15:12:55 -04:00
Bailey Dixon 28a906215d test(android): add managed emulator coverage 2026-08-31 15:12:44 -04:00
22 changed files with 2628 additions and 104 deletions
+1
View File
@@ -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)
+2
View File
@@ -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.
+13
View File
@@ -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
+58
View File
@@ -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)
@@ -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
@@ -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,6 +2436,10 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
java.util.IdentityHashMap<Throwable, Boolean>(),
)
while (current != null && seen.add(current)) {
// 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
}
@@ -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
@@ -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 }
@@ -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
}
@@ -5401,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
@@ -5423,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) {
@@ -7854,11 +7879,22 @@ class ChatViewModel : ViewModel() {
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == expectedSessionId) {
// 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 (handler.currentSessionId.value == expectedSessionId) {
if (
chatHandler === handler &&
activeProfileContextKey == checkpoint.contextKey &&
handler.currentSessionId.value == expectedSessionId
) {
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
}
@@ -9727,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
@@ -9769,7 +9806,11 @@ class ChatViewModel : ViewModel() {
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == sid) {
if (
chatHandler === handler &&
activeProfileContextKey == historyContextKey &&
handler.currentSessionId.value == sid
) {
publishHistoryLoadFailure(sid, e)
}
} finally {
@@ -9779,8 +9820,14 @@ class ChatViewModel : ViewModel() {
// 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)
if (
chatHandler === handler &&
activeProfileContextKey == historyContextKey &&
handler.currentSessionId.value == sid
) {
refreshSessions()
scheduleTitleReconcile(sid)
}
drainQueue()
}
}
@@ -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
}
}
@@ -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))
}
}
@@ -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)
}
}
}
+135
View File
@@ -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
View File
@@ -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
View File
@@ -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
+48 -14
View File
@@ -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.