feat: add first-run setup wizard

This commit is contained in:
Hermes Agent
2026-07-24 18:15:30 +00:00
parent 3b883bd534
commit 5b3263ef14
17 changed files with 2831 additions and 103 deletions
+5
View File
@@ -5,6 +5,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
## Unreleased
### Added
- Added a first-run Compose Setup Wizard with welcome/privacy guidance, required Remote HTTPS and optional Local HTTPS routes, None/Bearer/username-password authentication, local-first connection testing, and completion gating on a successful test.
- Added direct password login with allowlisted session-cookie handling, encrypted credential/session storage, redacted credential models, and focused reducer, login, cookie-storage, migration, and routing tests.
- Added semantic Android beta package metadata coverage and a direct-gateway-only `v0.1.0-beta.1` build, metadata inspection, clean-install, launch, configuration, evidence, and limitations guide.
- Added deterministic small-phone, landscape, and tablet layout policy with navigation rail adaptation, tablet two-pane chat, and narrow/large-font control stacking.
- Added accessibility semantics for headings, panes, streaming announcements, selected/busy states, and actionable rows, plus 48dp minimum touch targets and bounded large message/code rendering with retained full-copy actions for code and tool output.
@@ -33,6 +35,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
### Changed
- Changed Settings to edit the same Setup Wizard profile, while valid existing configurations skip first-run setup and legacy/current profiles receive a one-time setup-version migration.
- Changed gateway probing to support None, Bearer, and password-session cookie authentication without exposing raw credentials in connection forms, diagnostics, or logs.
- Changed Android `versionName` to `0.1.0-beta.1`, monotonic `versionCode` to `10001`, and the debug APK base name to `hermes-mobile-v0.1.0-beta.1`; the npm Android build command now runs the native Gradle project without Capacitor synchronization.
- Removed the Android portrait lock, enabled `adjustResize` IME behavior, and suppressed top-level screen slides when the system animator duration scale is disabled.
- Kept direct artifact handoff actions gated off because the advertised gateway contract has no verified artifact URI/download capability; no upstream route or backend behavior was inferred.
@@ -60,6 +64,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
### Verification
- First-run Setup Wizard source verification: the source-current offline Android `:app:testDebugUnitTest :app:assembleDebug` run passed on 2026-07-24 with 43 tasks (4 executed, 39 up-to-date) and 104 tests (0 failures); a final identical rerun was blocked before project execution by the sandbox TCP restriction. `git diff --check` passed. No device, release, or real gateway credential test is claimed.
- `/root/.openclaw/workspace/scripts/hermes-mobile-preflight.sh` — passed; Android `:app:assembleDebug` BUILD SUCCESSFUL (37 tasks, 14 executed).
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --offline --no-daemon -Dorg.gradle.jvmargs= -Dorg.gradle.daemon=false -Pkotlin.compiler.execution.strategy=in-process :app:testDebugUnitTest :app:assembleDebug` — passed (43 tasks, 5 executed), including `BetaPackageMetadataTest` for application ID `cloud.molberg.hermesmobile`, `versionName` `0.1.0-beta.1`, and `versionCode` `10001`.
- `git diff --check` — passed for R2 source packaging.
+5
View File
@@ -53,6 +53,11 @@ Complete the Android/client contract pivot before implementing backend behavior.
_Move finished items here with date, commit, and verification. Keep this section factual; do not claim unverified work._
- [x] **P0 — First-run Setup Wizard** — 2026-07-24, commit `1b4abfa` (`feat: add first-run setup wizard`).
- Added a Compose first-run flow covering welcome/privacy guidance, required Remote HTTPS and optional Local HTTPS routes, None/Bearer/username-password authentication, local-first connection testing, and completion only after a successful test.
- Added direct password login through `/auth/password-login`, allowlisted session-cookie extraction, encrypted cookie/password storage, credential redaction, replacement/retention rules, and cookie-authenticated gateway probes without persisting raw credentials in UI state or connection forms.
- Added Settings entry for editing the same profile, skip behavior for existing valid configurations, one-time legacy/current-profile setup-version migration, and accessible adaptive Compose semantics plus phone/tablet/light/dark previews.
- Verification on 2026-07-24: the source-current offline `:app:testDebugUnitTest :app:assembleDebug` run passed (43 tasks, 4 executed; 104 tests, 0 failures), and `git diff --check` passed. A final identical Gradle rerun was blocked before project execution by the sandbox TCP restriction (`java.net.SocketException: Operation not permitted`). No device, release, or real gateway credential test is claimed.
- [x] **R2a — Establish beta source packaging and install documentation** — 2026-07-24, commit `98b183e` (`build: establish Android beta packaging`).
- Set Android package metadata to semantic `versionName` `0.1.0-beta.1` with monotonic `versionCode` `10001`, enabled generated metadata coverage, and named the debug APK `hermes-mobile-v0.1.0-beta.1-debug.apk`.
- Added a direct-gateway-only build, metadata inspection, clean-install, launch, configuration, evidence, and known-limitations guide; changed the npm Android build command to run the native Gradle project without Capacitor synchronization.
@@ -124,11 +124,19 @@ import cloud.molberg.hermesmobile.connection.ConnectionReevaluationTrigger
import cloud.molberg.hermesmobile.connection.ConnectionStateReducer
import cloud.molberg.hermesmobile.connection.ConnectionStatus
import cloud.molberg.hermesmobile.connection.ConnectionUiState
import cloud.molberg.hermesmobile.connection.GatewayPasswordLoginClient
import cloud.molberg.hermesmobile.connection.GatewayHttpProbe
import cloud.molberg.hermesmobile.connection.GatewayRouteCoordinator
import cloud.molberg.hermesmobile.connection.GatewayRouteSelector
import cloud.molberg.hermesmobile.connection.PasswordLoginService
import cloud.molberg.hermesmobile.connection.SecureConnectionStorage
import cloud.molberg.hermesmobile.connection.StoredConnectionRepository
import cloud.molberg.hermesmobile.onboarding.SetupEntry
import cloud.molberg.hermesmobile.onboarding.SetupSubmission
import cloud.molberg.hermesmobile.onboarding.SetupWizardAction
import cloud.molberg.hermesmobile.onboarding.SetupWizardReducer
import cloud.molberg.hermesmobile.onboarding.SetupWizardScreen
import cloud.molberg.hermesmobile.onboarding.SetupWizardState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -187,7 +195,10 @@ class CompanionApi(context: Context) {
val connectionStorage = SecureConnectionStorage(context)
private val routeCoordinator = GatewayRouteCoordinator(GatewayRouteSelector(GatewayHttpProbe()))
private val connectionRepository = StoredConnectionRepository(connectionStorage, routeCoordinator)
private val passwordLoginService = PasswordLoginService(GatewayPasswordLoginClient(), connectionStorage)
private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE)
private val hadLegacyConfiguration = connectionStorage.hasLegacyConnection ||
prefs.contains("baseUrl") || prefs.contains("accessKey")
init {
migrateLegacyConnectionPrefs()
@@ -233,6 +244,34 @@ class CompanionApi(context: Context) {
fun connectionConfig() = connectionRepository.config()
fun setupEntry(): SetupEntry {
val resolution = SetupWizardReducer.resolveEntry(
config = connectionConfig(),
setupVersion = connectionStorage.setupVersion,
hasLegacyConfiguration = hadLegacyConfiguration
)
if (resolution.migrateExistingUser) {
connectionStorage.markSetupComplete(SetupWizardReducer.CURRENT_SETUP_VERSION)
}
return resolution.entry
}
fun editSetupState(): SetupWizardState = when (val entry = SetupWizardReducer.entry(connectionConfig())) {
is SetupEntry.ExistingConfiguration -> entry.editState
is SetupEntry.Mandatory -> entry.state
}
suspend fun testSetup(submission: SetupSubmission): Result<ConnectionUiState> =
connectionRepository.saveAndCheck(
form = submission.connectionForm(),
password = submission.credentials.password.reveal(),
passwordLoginService = passwordLoginService
)
fun markSetupComplete() {
connectionStorage.markSetupComplete(SetupWizardReducer.CURRENT_SETUP_VERSION)
}
fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> =
connectionRepository.save(form)
@@ -310,17 +349,79 @@ fun HermesNativeApp() {
var screen by remember { mutableStateOf(Screen.Inbox) }
var connectionState by remember { mutableStateOf(ConnectionStateReducer.initial(api.connectionConfig())) }
var resetInboxToken by remember { mutableStateOf(0) }
val initialSetupEntry = remember { api.setupEntry() }
var mandatorySetup by remember {
mutableStateOf((initialSetupEntry as? SetupEntry.Mandatory)?.state)
}
var editingSetup by remember { mutableStateOf<SetupWizardState?>(null) }
val activeSetup = mandatorySetup ?: editingSetup
fun notify(message: String) {
scope.launch { snackbar.showSnackbar(message) }
}
LaunchedEffect(Unit) {
connectionState = api.checkConnection(ConnectionReevaluationTrigger.Foreground)
LaunchedEffect(activeSetup == null) {
if (activeSetup == null) {
connectionState = api.checkConnection(ConnectionReevaluationTrigger.Foreground)
}
}
if (activeSetup == null) {
GatewayReevaluationHooks(api) { connectionState = it }
}
GatewayReevaluationHooks(api) { connectionState = it }
HermesMobileTheme {
if (activeSetup != null) {
SetupWizardScreen(
state = activeSetup,
onAction = { action ->
if (mandatorySetup != null) {
mandatorySetup = SetupWizardReducer.reduce(mandatorySetup!!, action)
} else if (editingSetup != null) {
editingSetup = SetupWizardReducer.reduce(editingSetup!!, action)
}
},
onTestConnection = { submission ->
val attemptId = (mandatorySetup ?: editingSetup)?.activeTestAttemptId
if (attemptId != null) {
scope.launch {
api.testSetup(submission).fold(
onSuccess = { tested ->
connectionState = tested
val action = if (tested.connected) {
SetupWizardAction.TestSucceeded(attemptId)
} else {
SetupWizardAction.TestFailed(attemptId, tested.message)
}
if (mandatorySetup != null) {
mandatorySetup = SetupWizardReducer.reduce(mandatorySetup!!, action)
} else if (editingSetup != null) {
editingSetup = SetupWizardReducer.reduce(editingSetup!!, action)
}
},
onFailure = { failure ->
val action = SetupWizardAction.TestFailed(
attemptId,
failure.message ?: "Connection test failed."
)
if (mandatorySetup != null) {
mandatorySetup = SetupWizardReducer.reduce(mandatorySetup!!, action)
} else if (editingSetup != null) {
editingSetup = SetupWizardReducer.reduce(editingSetup!!, action)
}
}
)
}
}
},
onFinish = {
api.markSetupComplete()
mandatorySetup = null
editingSetup = null
},
onExit = { editingSetup = null }
)
return@HermesMobileTheme
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val fontScale = LocalDensity.current.fontScale
val layout = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), fontScale)
@@ -357,7 +458,7 @@ fun HermesNativeApp() {
screen = screen,
api = api,
connectionState = connectionState,
onConnectionState = { connectionState = it },
onEditConnection = { editingSetup = api.editSetupState() },
resetInboxToken = resetInboxToken,
onNewChat = {
resetInboxToken++
@@ -378,7 +479,7 @@ fun HermesNativeApp() {
screen = active,
api = api,
connectionState = connectionState,
onConnectionState = { connectionState = it },
onEditConnection = { editingSetup = api.editSetupState() },
resetInboxToken = resetInboxToken,
onNewChat = {
resetInboxToken++
@@ -400,7 +501,7 @@ private fun NativeScreenContent(
screen: Screen,
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
onEditConnection: () -> Unit,
resetInboxToken: Int,
onNewChat: () -> Unit,
notify: (String) -> Unit,
@@ -410,7 +511,7 @@ private fun NativeScreenContent(
when (screen) {
Screen.Inbox -> FakeInboxScreen(resetInboxToken, notify)
Screen.Terminals -> TerminalsScreen(api, notify, onNewChat = onNewChat)
Screen.Settings -> SettingsScreen(api, connectionState, onConnectionState, notify)
Screen.Settings -> SettingsScreen(api, connectionState, onEditConnection, notify)
}
}
}
@@ -899,14 +1000,19 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
fun SettingsScreen(
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
onEditConnection: () -> Unit,
notify: (String) -> Unit
) {
var panel by remember { mutableStateOf(SettingsPanel.Home) }
BackHandler(panel != SettingsPanel.Home) { panel = SettingsPanel.Home }
when (panel) {
SettingsPanel.Home -> SettingsHome(api, connectionState, onConnectionState, notify, onPanel = { panel = it })
SettingsPanel.Home -> SettingsHome(
api,
connectionState,
onEditConnection,
onPanel = { panel = it }
)
SettingsPanel.Appearance -> AppearanceSettings(api, notify, onBack = { panel = SettingsPanel.Home })
SettingsPanel.Defaults -> AgentDefaultSettings(api, notify, onBack = { panel = SettingsPanel.Home })
SettingsPanel.Health -> HealthSettings(api, notify, onBack = { panel = SettingsPanel.Home })
@@ -918,80 +1024,20 @@ fun SettingsScreen(
fun SettingsHome(
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
notify: (String) -> Unit,
onEditConnection: () -> Unit,
onPanel: (SettingsPanel) -> Unit
) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
val initialConfig = remember { api.connectionConfig() }
var localUrl by remember { mutableStateOf(initialConfig.localUrl) }
var remoteUrl by remember { mutableStateOf(initialConfig.remoteUrl) }
var authMode by remember { mutableStateOf(initialConfig.authMode) }
var accessKey by remember { mutableStateOf("") }
var sessionLabel by remember { mutableStateOf(initialConfig.sessionLabel) }
fun saveAndCheck() {
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
val saved = api.saveConnection(form).getOrElse {
val invalid = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
onConnectionState(invalid)
notify(invalid.message)
return
}
onConnectionState(saved)
accessKey = ""
scope.launch {
val checked = api.checkConnectionState()
onConnectionState(checked)
notify(checked.message)
}
}
val config = api.connectionConfig()
ScreenList {
SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.")
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Remote HTTPS URL")
AppTextField(remoteUrl, { remoteUrl = it }, "https://hermes.example.com", keyboardType = KeyboardType.Uri)
FieldLabel("Local HTTPS URL (optional)")
AppTextField(localUrl, { localUrl = it }, "https://hermes.home.arpa", keyboardType = KeyboardType.Uri)
FieldLabel("Authentication")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Bearer", authMode == ConnectionAuthMode.BearerToken, Modifier.weight(1f)) {
authMode = ConnectionAuthMode.BearerToken
}
ModeChip("None", authMode == ConnectionAuthMode.None, Modifier.weight(1f)) {
authMode = ConnectionAuthMode.None
}
}
if (authMode == ConnectionAuthMode.BearerToken) {
FieldLabel("Access key")
AppTextField(
accessKey,
{ accessKey = it },
if (initialConfig.hasBearerToken) "Stored token unchanged" else "hm_...",
password = true
)
}
FieldLabel("Session label")
AppTextField(sessionLabel, { sessionLabel = it }, "Default session")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
val form = ConnectionForm(localUrl, remoteUrl, authMode, accessKey, sessionLabel)
var saved = api.saveConnection(form).getOrElse {
ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
}
if (saved.status == ConnectionStatus.Checking) {
saved = ConnectionStateReducer.savedForManualCheck(saved.config)
accessKey = ""
}
onConnectionState(saved)
notify(saved.message)
}
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
saveAndCheck()
}
}
MetricRow("Remote", config.remoteUrl)
MetricRow("Local", config.localUrl.ifBlank { "Not configured" })
MetricRow("Authentication", config.authMode.name)
MetricRow("Session", config.sessionLabel)
PrimaryButton("Edit connection", Icons.Filled.Settings, onClick = onEditConnection)
}
}
ConnectionNotice(connectionState)
@@ -5,7 +5,7 @@ import java.net.URISyntaxException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class ConnectionAuthMode { None, BearerToken }
enum class ConnectionAuthMode { None, BearerToken, PasswordLogin }
enum class ConnectionRoute { Local, Remote }
@@ -18,7 +18,17 @@ data class ConnectionConfig(
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val hasBearerToken: Boolean = false,
val passwordLoginUsername: String = "",
val hasPasswordSession: Boolean = false,
val sessionLabel: String = "Default session"
) {
val profile: ConnectionProfile
get() = ConnectionProfile(remoteUrl, sessionLabel)
}
data class ConnectionProfile(
val remoteUrl: String,
val sessionLabel: String
)
data class ConnectionForm(
@@ -26,7 +36,8 @@ data class ConnectionForm(
val remoteUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val bearerToken: String = "",
val sessionLabel: String = "Default session"
val sessionLabel: String = "Default session",
val passwordLoginUsername: String = ""
)
data class ConnectionUiState(
@@ -65,6 +76,15 @@ fun interface GatewayProbe {
suspend fun probe(baseUrl: String, authMode: ConnectionAuthMode, bearerToken: String): GatewayProbeResult
}
interface SessionCookieGatewayProbe : GatewayProbe {
suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String,
sessionCookie: String
): GatewayProbeResult
}
sealed interface RouteSelectionResult {
data class Connected(
val route: ConnectionRoute,
@@ -109,7 +129,11 @@ object ConnectionValidator {
return Result.success(trimmed)
}
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> {
fun validateForm(
form: ConnectionForm,
hasStoredBearerToken: Boolean = false,
hasStoredPasswordSession: Boolean = false
): Result<ConnectionConfig> {
val local = normalizeGatewayUrl(form.localUrl, required = false).getOrElse { return Result.failure(it) }
val remote = normalizeGatewayUrl(form.remoteUrl, required = true).getOrElse { return Result.failure(it) }
if (local.isNotBlank() && local == remote) {
@@ -119,12 +143,18 @@ object ConnectionValidator {
if (form.authMode == ConnectionAuthMode.BearerToken && !hasBearerToken) {
return Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
}
val passwordLoginUsername = form.passwordLoginUsername.trim()
if (form.authMode == ConnectionAuthMode.PasswordLogin && passwordLoginUsername.isBlank()) {
return Result.failure(IllegalArgumentException("Username is required for password authentication."))
}
return Result.success(
ConnectionConfig(
localUrl = local,
remoteUrl = remote,
authMode = form.authMode,
hasBearerToken = form.authMode == ConnectionAuthMode.BearerToken && hasBearerToken,
passwordLoginUsername = if (form.authMode == ConnectionAuthMode.PasswordLogin) passwordLoginUsername else "",
hasPasswordSession = form.authMode == ConnectionAuthMode.PasswordLogin && hasStoredPasswordSession,
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
)
)
@@ -132,10 +162,14 @@ object ConnectionValidator {
}
class GatewayRouteSelector(private val probe: GatewayProbe) {
suspend fun select(config: ConnectionConfig, bearerToken: String): RouteSelectionResult {
suspend fun select(
config: ConnectionConfig,
bearerToken: String,
sessionCookie: String = ""
): RouteSelectionResult {
val failures = mutableListOf<GatewayProbeResult>()
if (config.localUrl.isNotBlank()) {
when (val local = probe.probe(config.localUrl, config.authMode, bearerToken)) {
when (val local = probeGateway(config.localUrl, config.authMode, bearerToken, sessionCookie)) {
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
ConnectionRoute.Local,
config.localUrl,
@@ -144,7 +178,7 @@ class GatewayRouteSelector(private val probe: GatewayProbe) {
else -> failures += local
}
}
when (val remote = probe.probe(config.remoteUrl, config.authMode, bearerToken)) {
when (val remote = probeGateway(config.remoteUrl, config.authMode, bearerToken, sessionCookie)) {
is GatewayProbeResult.Healthy -> return RouteSelectionResult.Connected(
ConnectionRoute.Remote,
config.remoteUrl,
@@ -159,6 +193,18 @@ class GatewayRouteSelector(private val probe: GatewayProbe) {
RouteSelectionResult.Offline("Neither configured gateway route is reachable and compatible.")
}
}
private suspend fun probeGateway(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String,
sessionCookie: String
): GatewayProbeResult = (probe as? SessionCookieGatewayProbe)?.probe(
baseUrl,
authMode,
bearerToken,
sessionCookie
) ?: probe.probe(baseUrl, authMode, bearerToken)
}
class GatewayRouteCoordinator(private val selector: GatewayRouteSelector) {
@@ -167,8 +213,12 @@ class GatewayRouteCoordinator(private val selector: GatewayRouteSelector) {
private var activeLease: GatewayRouteLease? = null
private var generation = 0L
suspend fun reevaluate(config: ConnectionConfig, bearerToken: String): RouteSelectionResult = mutex.withLock {
val result = selector.select(config, bearerToken)
suspend fun reevaluate(
config: ConnectionConfig,
bearerToken: String,
sessionCookie: String = ""
): RouteSelectionResult = mutex.withLock {
val result = selector.select(config, bearerToken, sessionCookie)
activeLease = if (result is RouteSelectionResult.Connected) {
GatewayRouteLease(result.route, result.baseUrl, ++generation)
} else {
@@ -177,11 +227,15 @@ class GatewayRouteCoordinator(private val selector: GatewayRouteSelector) {
result
}
suspend fun routeForRequest(config: ConnectionConfig, bearerToken: String): GatewayRouteLease {
suspend fun routeForRequest(
config: ConnectionConfig,
bearerToken: String,
sessionCookie: String = ""
): GatewayRouteLease {
activeLease?.takeIf { lease ->
lease.baseUrl == config.localUrl || lease.baseUrl == config.remoteUrl
}?.let { return it }
return when (val selected = reevaluate(config, bearerToken)) {
return when (val selected = reevaluate(config, bearerToken, sessionCookie)) {
is RouteSelectionResult.Connected -> activeLease!!
is RouteSelectionResult.Unauthorized -> throw UnauthorizedConnectionException(selected.message)
is RouteSelectionResult.Offline -> throw IllegalStateException(selected.message)
@@ -252,6 +306,7 @@ object ConnectionStateReducer {
remoteUrl = form.remoteUrl.trim(),
authMode = form.authMode,
hasBearerToken = form.bearerToken.isNotBlank(),
passwordLoginUsername = form.passwordLoginUsername.trim(),
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
),
status = ConnectionStatus.Unconfigured,
@@ -268,30 +323,58 @@ interface ConnectionStore {
var sessionLabel: String
var authMode: ConnectionAuthMode
var bearerToken: String
var passwordLoginUsername: String
fun config(): ConnectionConfig = ConnectionConfig(
localUrl = ConnectionValidator.normalizeGatewayUrl(localUrl, required = false).getOrDefault(""),
remoteUrl = ConnectionValidator.normalizeGatewayUrl(remoteUrl, required = true).getOrDefault(""),
authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(),
sessionLabel = sessionLabel
)
fun config(): ConnectionConfig {
val config = ConnectionConfig(
localUrl = ConnectionValidator.normalizeGatewayUrl(localUrl, required = false).getOrDefault(""),
remoteUrl = ConnectionValidator.normalizeGatewayUrl(remoteUrl, required = true).getOrDefault(""),
authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(),
passwordLoginUsername = passwordLoginUsername.trim(),
sessionLabel = sessionLabel
)
return config.copy(
hasPasswordSession = config.authMode == ConnectionAuthMode.PasswordLogin &&
(this as? PasswordSessionStore)?.sessionCookie(config.profile).isNullOrBlank().not()
)
}
fun save(form: ConnectionForm, config: ConnectionConfig) {
fun save(
form: ConnectionForm,
config: ConnectionConfig,
preservePasswordSession: Boolean = false
) {
val previous = this.config()
val sessionStore = this as? PasswordSessionStore
if (previous.profile != config.profile || previous.authMode != form.authMode ||
previous.passwordLoginUsername != config.passwordLoginUsername
) {
if (previous.profile != config.profile) sessionStore?.clearSessionCookie(previous.profile)
if (!preservePasswordSession) sessionStore?.clearSessionCookie(config.profile)
}
localUrl = config.localUrl
remoteUrl = config.remoteUrl
authMode = form.authMode
sessionLabel = config.sessionLabel
bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else ""
passwordLoginUsername = config.passwordLoginUsername
}
}
interface PasswordSessionStore {
fun sessionCookie(profile: ConnectionProfile): String
fun saveSessionCookie(profile: ConnectionProfile, cookie: String)
fun clearSessionCookie(profile: ConnectionProfile)
}
interface ConnectionRepository {
fun config(): ConnectionConfig
fun save(form: ConnectionForm): Result<ConnectionUiState>
suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState
suspend fun routeForRequest(): GatewayRouteLease
fun reportRequestFailure(lease: GatewayRouteLease)
fun signOut()
}
class StoredConnectionRepository(
@@ -310,7 +393,19 @@ class StoredConnectionRepository(
} else {
form
}
return ConnectionValidator.validateForm(effectiveForm, store.bearerToken.isNotBlank()).mapCatching { config ->
val passwordSessionProfile = ConnectionProfile(
remoteUrl = ConnectionValidator.normalizeGatewayUrl(effectiveForm.remoteUrl, required = true).getOrDefault(""),
sessionLabel = effectiveForm.sessionLabel.trim().ifBlank { "Default session" }
)
val hasStoredPasswordSession = (store as? PasswordSessionStore)
?.sessionCookie(passwordSessionProfile)
.isNullOrBlank()
.not()
return ConnectionValidator.validateForm(
effectiveForm,
hasStoredBearerToken = store.bearerToken.isNotBlank(),
hasStoredPasswordSession = hasStoredPasswordSession
).mapCatching { config ->
store.save(effectiveForm, config)
coordinator.clear()
ConnectionStateReducer.saved(store.config())
@@ -319,21 +414,86 @@ class StoredConnectionRepository(
}
}
suspend fun saveAndCheck(
form: ConnectionForm,
password: String,
passwordLoginService: PasswordLoginService,
trigger: ConnectionReevaluationTrigger = ConnectionReevaluationTrigger.ExplicitReconnect
): Result<ConnectionUiState> = runCatching {
val effectiveForm = if (
form.authMode == ConnectionAuthMode.BearerToken &&
form.bearerToken.isBlank() &&
store.bearerToken.isNotBlank()
) {
form.copy(bearerToken = store.bearerToken)
} else {
form
}
val normalizedRemote = ConnectionValidator.normalizeGatewayUrl(
effectiveForm.remoteUrl,
required = true
).getOrThrow()
val profile = ConnectionProfile(
remoteUrl = normalizedRemote,
sessionLabel = effectiveForm.sessionLabel.trim().ifBlank { "Default session" }
)
val storedPasswordSession = (store as? PasswordSessionStore)
?.sessionCookie(profile)
.isNullOrBlank()
.not()
val suppliedPassword = effectiveForm.authMode == ConnectionAuthMode.PasswordLogin && password.isNotBlank()
val config = ConnectionValidator.validateForm(
effectiveForm,
hasStoredBearerToken = store.bearerToken.isNotBlank(),
hasStoredPasswordSession = storedPasswordSession || suppliedPassword
).getOrThrow()
if (suppliedPassword) {
when (val login = passwordLoginService.login(profile, config.passwordLoginUsername, password)) {
is PasswordLoginResult.Success -> Unit
PasswordLoginResult.InvalidCredentials -> throw IllegalArgumentException("Username or password was rejected.")
PasswordLoginResult.RateLimited -> throw IllegalStateException("Password login is temporarily rate limited.")
is PasswordLoginResult.Failed -> throw IllegalStateException(login.message)
}
}
store.save(effectiveForm, config, preservePasswordSession = suppliedPassword)
coordinator.clear()
check(trigger)
}
override suspend fun check(trigger: ConnectionReevaluationTrigger): ConnectionUiState =
store.config().let { config ->
if (config.remoteUrl.isBlank()) {
ConnectionStateReducer.initial(config)
} else {
ConnectionStateReducer.result(config, coordinator.reevaluate(config, store.bearerToken))
ConnectionStateReducer.result(
config,
coordinator.reevaluate(config, store.bearerToken, sessionCookie(config))
)
}
}
override suspend fun routeForRequest(): GatewayRouteLease =
coordinator.routeForRequest(store.config(), store.bearerToken)
override suspend fun routeForRequest(): GatewayRouteLease = store.config().let { config ->
coordinator.routeForRequest(config, store.bearerToken, sessionCookie(config))
}
override fun reportRequestFailure(lease: GatewayRouteLease) {
coordinator.reportFailure(lease)
}
override fun signOut() {
val config = store.config()
(store as? PasswordSessionStore)?.clearSessionCookie(config.profile)
coordinator.clear()
}
private fun sessionCookie(config: ConnectionConfig): String =
if (config.authMode == ConnectionAuthMode.PasswordLogin) {
(store as? PasswordSessionStore)?.sessionCookie(config.profile).orEmpty()
} else {
""
}
}
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
@@ -16,27 +16,43 @@ class GatewayHttpProbe(
.followRedirects(false)
.followSslRedirects(false)
.build()
) : GatewayProbe {
) : SessionCookieGatewayProbe {
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult = probe(baseUrl, authMode, bearerToken, sessionCookie = "")
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String,
sessionCookie: String
): GatewayProbeResult {
val health = getJson("$baseUrl/health")
if (health != HttpProbeResult.Success) return health.toGatewayResult("Health probe")
if (authMode == ConnectionAuthMode.None) {
return GatewayProbeResult.Healthy("Gateway health check passed.")
}
val compatibility = getJson("$baseUrl/v1/models", bearerToken)
val compatibility = when (authMode) {
ConnectionAuthMode.None -> error("Anonymous probes return after the health check.")
ConnectionAuthMode.BearerToken -> getJson("$baseUrl/v1/models", bearerToken = bearerToken)
ConnectionAuthMode.PasswordLogin -> getJson("$baseUrl/v1/models", sessionCookie = sessionCookie)
}
return when (compatibility) {
HttpProbeResult.Success -> GatewayProbeResult.Healthy("Gateway health and bearer authentication checks passed.")
HttpProbeResult.Success -> GatewayProbeResult.Healthy("Gateway health and authentication checks passed.")
else -> compatibility.toGatewayResult("Authenticated compatibility check")
}
}
private fun getJson(url: String, bearerToken: String = ""): HttpProbeResult {
private fun getJson(
url: String,
bearerToken: String = "",
sessionCookie: String = ""
): HttpProbeResult {
val builder = Request.Builder().url(url).get().header("Accept", "application/json")
if (bearerToken.isNotBlank()) builder.header("Authorization", "Bearer $bearerToken")
if (sessionCookie.isNotBlank()) builder.header("Cookie", sessionCookie)
return try {
client.newCall(builder.build()).execute().use { response ->
val body = response.body?.string().orEmpty()
@@ -0,0 +1,107 @@
package cloud.molberg.hermesmobile.connection
import java.io.IOException
import java.util.concurrent.TimeUnit
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
sealed interface PasswordLoginResult {
class Success internal constructor(internal val sessionCookie: String) : PasswordLoginResult {
override fun toString(): String = "Success(sessionCookie=[REDACTED])"
}
data object InvalidCredentials : PasswordLoginResult
data object RateLimited : PasswordLoginResult
data class Failed(val message: String) : PasswordLoginResult
}
fun interface PasswordLoginClient {
suspend fun login(baseUrl: String, username: String, password: String): PasswordLoginResult
}
class GatewayPasswordLoginClient(
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(LOGIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(LOGIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(LOGIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.callTimeout(LOGIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
) : PasswordLoginClient {
override suspend fun login(baseUrl: String, username: String, password: String): PasswordLoginResult {
return try {
val request = Request.Builder()
.url("${baseUrl.trimEnd('/')}/auth/password-login")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.post(passwordLoginBody(username, password).toRequestBody(JSON_MEDIA_TYPE))
.build()
client.newCall(request).execute().use { response ->
when (response.code) {
200 -> success(response.body?.string().orEmpty(), response.headers.values("Set-Cookie"))
401 -> PasswordLoginResult.InvalidCredentials
429 -> PasswordLoginResult.RateLimited
else -> PasswordLoginResult.Failed("Password login failed with HTTP ${response.code}.")
}
}
} catch (_: IOException) {
PasswordLoginResult.Failed("Password login could not reach the gateway.")
} catch (_: IllegalArgumentException) {
PasswordLoginResult.Failed("Password login received an invalid gateway response.")
}
}
private fun success(body: String, setCookies: List<String>): PasswordLoginResult {
if (runCatching { JSONObject(body) }.isFailure) {
return PasswordLoginResult.Failed("Password login returned an incompatible response.")
}
val sessionCookie = setCookies
.asSequence()
.mapNotNull(::cookiePair)
.firstOrNull { it.substringBefore('=') in SESSION_COOKIE_NAMES }
?: return PasswordLoginResult.Failed("Password login did not return a session cookie.")
return PasswordLoginResult.Success(sessionCookie)
}
private fun cookiePair(setCookie: String): String? {
val value = setCookie.substringBefore(';').trim()
val separator = value.indexOf('=')
return value.takeIf { separator > 0 && separator < value.lastIndex && !it.contains('\r') && !it.contains('\n') }
}
private fun passwordLoginBody(username: String, password: String): String =
"""{"provider":"basic","username":${JSONObject.quote(username)},"password":${JSONObject.quote(password)},"next":"/"}"""
private companion object {
const val LOGIN_TIMEOUT_SECONDS = 10L
val SESSION_COOKIE_NAMES = setOf(
"hermes_session_at",
"__Host-hermes_session_at",
"__Secure-hermes_session_at"
)
val JSON_MEDIA_TYPE = "application/json".toMediaType()
}
}
class PasswordLoginService(
private val client: PasswordLoginClient,
private val sessionStore: PasswordSessionStore
) {
suspend fun login(profile: ConnectionProfile, username: String, password: String): PasswordLoginResult {
sessionStore.clearSessionCookie(profile)
return when (val result = client.login(profile.remoteUrl, username, password)) {
is PasswordLoginResult.Success -> result.also {
sessionStore.saveSessionCookie(profile, result.sessionCookie)
}
else -> result
}
}
fun signOut(profile: ConnectionProfile) {
sessionStore.clearSessionCookie(profile)
}
}
@@ -10,8 +10,9 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import android.util.Base64
import java.security.MessageDigest
class SecureConnectionStorage(context: Context) : ConnectionStore {
class SecureConnectionStorage(context: Context) : ConnectionStore, PasswordSessionStore {
private val prefs: SharedPreferences =
context.getSharedPreferences("connection", Context.MODE_PRIVATE)
@@ -49,6 +50,36 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
}.apply()
}
override var passwordLoginUsername: String
get() = prefs.getString(KEY_PASSWORD_LOGIN_USERNAME, "") ?: ""
set(value) {
prefs.edit().putString(KEY_PASSWORD_LOGIN_USERNAME, value.trim()).apply()
}
val setupVersion: Int
get() = prefs.getInt(KEY_SETUP_VERSION, 0)
val hasLegacyConnection: Boolean
get() = prefs.contains(KEY_LEGACY_URL)
fun markSetupComplete(version: Int) {
prefs.edit().putInt(KEY_SETUP_VERSION, version).apply()
}
override fun sessionCookie(profile: ConnectionProfile): String =
decrypt(prefs.getString(sessionCookieKey(profile), null))
override fun saveSessionCookie(profile: ConnectionProfile, cookie: String) {
prefs.edit().apply {
if (cookie.isBlank()) remove(sessionCookieKey(profile))
else putString(sessionCookieKey(profile), encrypt(cookie.trim()))
}.apply()
}
override fun clearSessionCookie(profile: ConnectionProfile) {
prefs.edit().remove(sessionCookieKey(profile)).apply()
}
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
@@ -85,6 +116,13 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
return generator.generateKey()
}
private fun sessionCookieKey(profile: ConnectionProfile): String =
"$KEY_SESSION_COOKIE_PREFIX${profile.remoteUrl}\u0000${profile.sessionLabel}".sha256()
private fun String.sha256(): String = MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
private companion object {
const val KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "hermes_mobile_connection"
@@ -96,5 +134,8 @@ class SecureConnectionStorage(context: Context) : ConnectionStore {
const val KEY_SESSION = "sessionLabel"
const val KEY_AUTH_MODE = "authMode"
const val KEY_BEARER_TOKEN = "bearerToken"
const val KEY_PASSWORD_LOGIN_USERNAME = "passwordLoginUsername"
const val KEY_SESSION_COOKIE_PREFIX = "sessionCookie."
const val KEY_SETUP_VERSION = "setupVersion"
}
}
@@ -0,0 +1,525 @@
package cloud.molberg.hermesmobile.onboarding
import cloud.molberg.hermesmobile.connection.ConnectionAuthMode
import cloud.molberg.hermesmobile.connection.ConnectionConfig
import cloud.molberg.hermesmobile.connection.ConnectionForm
import cloud.molberg.hermesmobile.connection.ConnectionValidator
enum class SetupWizardEntryMode { MandatoryFirstRun, EditExisting }
enum class SetupWizardStep { WelcomePrivacy, GatewayUrls, Authentication, TestAndFinish }
enum class SetupAuthChoice { None, Bearer, UsernamePassword }
enum class SetupTestStatus { NotStarted, Testing, Succeeded, Failed }
class SensitiveValue private constructor(private val value: String) {
val isBlank: Boolean get() = value.isBlank()
fun reveal(): String = value
override fun equals(other: Any?): Boolean = other is SensitiveValue && value == other.value
override fun hashCode(): Int = value.hashCode()
override fun toString(): String = "[REDACTED]"
companion object {
val Empty = SensitiveValue("")
fun of(value: String): SensitiveValue = if (value.isEmpty()) Empty else SensitiveValue(value)
}
}
data class SetupGatewayUrls(
val localUrl: String,
val remoteUrl: String
)
data class StoredGatewayConfiguration(
val localUrl: String,
val remoteUrl: String,
val authChoice: SetupAuthChoice,
val hasBearerCredential: Boolean = false,
val username: String = "",
val hasPasswordCredential: Boolean = false,
val sessionLabel: String = "Default session"
)
data class SetupGateResolution(
val entry: SetupEntry,
val migrateExistingUser: Boolean
)
data class SetupTransientCredentials(
val bearerToken: SensitiveValue = SensitiveValue.Empty,
val password: SensitiveValue = SensitiveValue.Empty
)
data class SetupSubmission(
val configuration: StoredGatewayConfiguration,
val credentials: SetupTransientCredentials
) {
fun connectionForm(): ConnectionForm = ConnectionForm(
localUrl = configuration.localUrl,
remoteUrl = configuration.remoteUrl,
authMode = when (configuration.authChoice) {
SetupAuthChoice.None -> ConnectionAuthMode.None
SetupAuthChoice.Bearer -> ConnectionAuthMode.BearerToken
SetupAuthChoice.UsernamePassword -> ConnectionAuthMode.PasswordLogin
},
bearerToken = credentials.bearerToken.reveal(),
sessionLabel = configuration.sessionLabel,
passwordLoginUsername = configuration.username
)
}
data class SetupFieldErrors(
val localUrl: String? = null,
val remoteUrl: String? = null,
val bearerToken: String? = null,
val username: String? = null,
val password: String? = null
)
data class SetupWizardState(
val entryMode: SetupWizardEntryMode,
val step: SetupWizardStep,
val privacyAccepted: Boolean = false,
val localUrl: String = "",
val remoteUrl: String = "",
val normalizedUrls: SetupGatewayUrls? = null,
val authChoice: SetupAuthChoice = SetupAuthChoice.None,
val hasStoredBearerCredential: Boolean = false,
val bearerToken: SensitiveValue = SensitiveValue.Empty,
val username: String = "",
val hasStoredPasswordCredential: Boolean = false,
val password: SensitiveValue = SensitiveValue.Empty,
val sessionLabel: String = "Default session",
val errors: SetupFieldErrors = SetupFieldErrors(),
val testStatus: SetupTestStatus = SetupTestStatus.NotStarted,
val testMessage: String? = null,
val activeTestAttemptId: Long? = null,
val nextTestAttemptId: Long = 1L
) {
val canExit: Boolean get() = entryMode == SetupWizardEntryMode.EditExisting
val canGoBack: Boolean
get() = when (step) {
SetupWizardStep.WelcomePrivacy -> false
SetupWizardStep.GatewayUrls -> entryMode == SetupWizardEntryMode.MandatoryFirstRun
SetupWizardStep.Authentication, SetupWizardStep.TestAndFinish -> true
}
val canContinue: Boolean
get() = when (step) {
SetupWizardStep.WelcomePrivacy -> privacyAccepted
SetupWizardStep.GatewayUrls -> validateUrls(localUrl, remoteUrl).isSuccess
SetupWizardStep.Authentication -> when (authChoice) {
SetupAuthChoice.None -> true
SetupAuthChoice.Bearer -> hasStoredBearerCredential || !bearerToken.isBlank
SetupAuthChoice.UsernamePassword ->
username.isNotBlank() && hasUsablePasswordCredential
}
SetupWizardStep.TestAndFinish -> false
}
val canTest: Boolean
get() = step == SetupWizardStep.TestAndFinish &&
testStatus != SetupTestStatus.Testing &&
submissionOrNull() != null
val canFinish: Boolean
get() = step == SetupWizardStep.TestAndFinish &&
testStatus == SetupTestStatus.Succeeded &&
submissionOrNull() != null
fun submissionOrNull(): SetupSubmission? {
if (step != SetupWizardStep.TestAndFinish) return null
val urls = validateUrls(localUrl, remoteUrl).getOrNull() ?: return null
val normalizedUsername = username.trim()
val configuration = when (authChoice) {
SetupAuthChoice.None -> StoredGatewayConfiguration(
localUrl = urls.localUrl,
remoteUrl = urls.remoteUrl,
authChoice = authChoice,
sessionLabel = sessionLabel
)
SetupAuthChoice.Bearer -> {
if (!hasStoredBearerCredential && bearerToken.isBlank) return null
StoredGatewayConfiguration(
localUrl = urls.localUrl,
remoteUrl = urls.remoteUrl,
authChoice = authChoice,
hasBearerCredential = true,
sessionLabel = sessionLabel
)
}
SetupAuthChoice.UsernamePassword -> {
if (normalizedUsername.isBlank() || !hasUsablePasswordCredential) return null
StoredGatewayConfiguration(
localUrl = urls.localUrl,
remoteUrl = urls.remoteUrl,
authChoice = authChoice,
username = normalizedUsername,
hasPasswordCredential = true,
sessionLabel = sessionLabel
)
}
}
return SetupSubmission(
configuration = configuration,
credentials = SetupTransientCredentials(
bearerToken = if (authChoice == SetupAuthChoice.Bearer) bearerToken else SensitiveValue.Empty,
password = if (authChoice == SetupAuthChoice.UsernamePassword) password else SensitiveValue.Empty
)
)
}
val hasReplacementPasswordCredential: Boolean
get() = !password.isBlank
private val hasUsablePasswordCredential: Boolean
get() = hasStoredPasswordCredential || hasReplacementPasswordCredential
}
sealed interface SetupEntry {
data class Mandatory(val state: SetupWizardState) : SetupEntry
data class ExistingConfiguration(
val configuration: StoredGatewayConfiguration,
val editState: SetupWizardState
) : SetupEntry
}
sealed interface SetupWizardAction {
data class SetPrivacyAccepted(val accepted: Boolean) : SetupWizardAction
data class SetLocalUrl(val value: String) : SetupWizardAction
data class SetRemoteUrl(val value: String) : SetupWizardAction
data class SelectAuth(val choice: SetupAuthChoice) : SetupWizardAction
class SetBearerToken(value: String) : SetupWizardAction {
internal val secret: SensitiveValue = SensitiveValue.of(value)
override fun toString(): String = "SetBearerToken(value=[REDACTED])"
}
data class SetUsername(val value: String) : SetupWizardAction
class SetPassword(value: String) : SetupWizardAction {
internal val secret: SensitiveValue = SensitiveValue.of(value)
override fun toString(): String = "SetPassword(value=[REDACTED])"
}
data object Continue : SetupWizardAction
data object Back : SetupWizardAction
class TestStarted(internal val attemptId: Long) : SetupWizardAction {
companion object : SetupWizardAction
override fun toString(): String = "TestStarted(attemptId=$attemptId)"
}
class TestSucceeded(internal val attemptId: Long) : SetupWizardAction {
companion object : SetupWizardAction
override fun toString(): String = "TestSucceeded(attemptId=$attemptId)"
}
class TestFailed : SetupWizardAction {
internal val attemptId: Long?
internal val message: String
constructor(message: String) {
attemptId = null
this.message = message
}
constructor(attemptId: Long, message: String) {
this.attemptId = attemptId
this.message = message
}
override fun toString(): String = "TestFailed(message=[REDACTED])"
}
}
object SetupWizardReducer {
const val CURRENT_SETUP_VERSION = 1
fun firstRun(): SetupWizardState = SetupWizardState(
entryMode = SetupWizardEntryMode.MandatoryFirstRun,
step = SetupWizardStep.WelcomePrivacy
)
fun entry(config: ConnectionConfig?): SetupEntry {
val stored = config?.toStoredGatewayConfiguration()
return if (stored != null && stored.isValid()) {
SetupEntry.ExistingConfiguration(stored, edit(stored))
} else {
SetupEntry.Mandatory(firstRun())
}
}
fun resolveEntry(
config: ConnectionConfig?,
setupVersion: Int,
hasLegacyConfiguration: Boolean
): SetupGateResolution {
val entry = entry(config)
val existing = entry is SetupEntry.ExistingConfiguration
val migrateExistingUser = setupVersion < CURRENT_SETUP_VERSION &&
hasLegacyConfiguration &&
existing
return SetupGateResolution(
entry = entry,
migrateExistingUser = migrateExistingUser
)
}
fun entryStored(config: StoredGatewayConfiguration?): SetupEntry = if (config != null && config.isValid()) {
SetupEntry.ExistingConfiguration(config, edit(config))
} else {
SetupEntry.Mandatory(firstRun())
}
fun edit(config: StoredGatewayConfiguration): SetupWizardState = SetupWizardState(
entryMode = SetupWizardEntryMode.EditExisting,
step = SetupWizardStep.GatewayUrls,
localUrl = config.localUrl,
remoteUrl = config.remoteUrl,
normalizedUrls = validateUrls(config.localUrl, config.remoteUrl).getOrNull(),
authChoice = config.authChoice,
hasStoredBearerCredential = config.hasBearerCredential,
username = config.username,
hasStoredPasswordCredential = config.hasPasswordCredential,
sessionLabel = config.sessionLabel
)
fun reduce(state: SetupWizardState, action: SetupWizardAction): SetupWizardState = when (action) {
is SetupWizardAction.SetPrivacyAccepted -> state.copy(
privacyAccepted = action.accepted,
errors = SetupFieldErrors(),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
is SetupWizardAction.SetLocalUrl -> state.copy(
localUrl = action.value,
normalizedUrls = null,
errors = state.errors.copy(localUrl = null, remoteUrl = null),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
is SetupWizardAction.SetRemoteUrl -> state.copy(
remoteUrl = action.value,
normalizedUrls = null,
hasStoredBearerCredential = state.hasStoredBearerCredential && state.sameRemoteUrl(action.value),
bearerToken = state.bearerToken.takeIf { state.sameRemoteUrl(action.value) } ?: SensitiveValue.Empty,
username = state.username.takeIf { state.sameRemoteUrl(action.value) }.orEmpty(),
hasStoredPasswordCredential = state.hasStoredPasswordCredential && state.sameRemoteUrl(action.value),
password = state.password.takeIf { state.sameRemoteUrl(action.value) } ?: SensitiveValue.Empty,
errors = state.errors.copy(localUrl = null, remoteUrl = null),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
is SetupWizardAction.SelectAuth -> state.selectAuth(action.choice)
is SetupWizardAction.SetBearerToken -> state.copy(
bearerToken = action.secret,
errors = state.errors.copy(bearerToken = null),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
is SetupWizardAction.SetUsername -> state.copy(
username = action.value,
hasStoredPasswordCredential = state.hasStoredPasswordCredential &&
action.value.trim() == state.username.trim(),
password = state.password.takeIf { action.value.trim() == state.username.trim() }
?: SensitiveValue.Empty,
errors = state.errors.copy(username = null),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
is SetupWizardAction.SetPassword -> state.copy(
password = action.secret,
errors = state.errors.copy(password = null),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
SetupWizardAction.Continue -> continueFrom(state)
SetupWizardAction.Back -> backFrom(state)
SetupWizardAction.TestStarted -> startTest(state, state.nextTestAttemptId)
is SetupWizardAction.TestStarted -> startTest(state, action.attemptId)
SetupWizardAction.TestSucceeded -> completeTest(state, state.activeTestAttemptId)
is SetupWizardAction.TestSucceeded -> completeTest(state, action.attemptId)
is SetupWizardAction.TestFailed -> if (
state.testStatus == SetupTestStatus.Testing &&
(action.attemptId == null || action.attemptId == state.activeTestAttemptId)
) {
state.copy(
testStatus = SetupTestStatus.Failed,
testMessage = action.message.redacting(state.password, state.bearerToken),
activeTestAttemptId = null
)
} else {
state
}
}
private fun continueFrom(state: SetupWizardState): SetupWizardState = when (state.step) {
SetupWizardStep.WelcomePrivacy -> if (state.privacyAccepted) {
state.copy(step = SetupWizardStep.GatewayUrls, errors = SetupFieldErrors())
} else {
state
}
SetupWizardStep.GatewayUrls -> validateUrls(state.localUrl, state.remoteUrl).fold(
onSuccess = { urls ->
state.copy(
step = SetupWizardStep.Authentication,
normalizedUrls = urls,
errors = SetupFieldErrors()
)
},
onFailure = { failure -> state.copy(errors = urlErrors(state, failure.message.orEmpty())) }
)
SetupWizardStep.Authentication -> authErrors(state).let { errors ->
if (errors == SetupFieldErrors()) {
state.copy(step = SetupWizardStep.TestAndFinish, errors = errors)
} else {
state.copy(errors = errors)
}
}
SetupWizardStep.TestAndFinish -> state
}
private fun backFrom(state: SetupWizardState): SetupWizardState = when (state.step) {
SetupWizardStep.WelcomePrivacy -> state
SetupWizardStep.GatewayUrls -> if (state.entryMode == SetupWizardEntryMode.MandatoryFirstRun) {
state.copy(step = SetupWizardStep.WelcomePrivacy, errors = SetupFieldErrors())
} else {
state
}
SetupWizardStep.Authentication -> state.copy(step = SetupWizardStep.GatewayUrls, errors = SetupFieldErrors())
SetupWizardStep.TestAndFinish -> state.copy(
step = SetupWizardStep.Authentication,
errors = SetupFieldErrors(),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
}
private fun startTest(state: SetupWizardState, attemptId: Long): SetupWizardState = if (
state.step == SetupWizardStep.TestAndFinish && state.submissionOrNull() != null
) {
state.copy(
testStatus = SetupTestStatus.Testing,
testMessage = null,
activeTestAttemptId = attemptId,
nextTestAttemptId = maxOf(state.nextTestAttemptId, attemptId + 1)
)
} else {
state
}
private fun completeTest(state: SetupWizardState, attemptId: Long?): SetupWizardState = if (
state.testStatus == SetupTestStatus.Testing &&
attemptId != null &&
attemptId == state.activeTestAttemptId
) {
state.copy(
testStatus = SetupTestStatus.Succeeded,
testMessage = null,
activeTestAttemptId = null
)
} else {
state
}
private fun authErrors(state: SetupWizardState): SetupFieldErrors = when (state.authChoice) {
SetupAuthChoice.None -> SetupFieldErrors()
SetupAuthChoice.Bearer -> SetupFieldErrors(
bearerToken = "Bearer token is required.".takeIf {
!state.hasStoredBearerCredential && state.bearerToken.isBlank
}
)
SetupAuthChoice.UsernamePassword -> SetupFieldErrors(
username = "Username is required.".takeIf { state.username.isBlank() },
password = "Password is required.".takeIf {
!state.hasStoredPasswordCredential && state.password.isBlank
}
)
}
private fun urlErrors(state: SetupWizardState, message: String): SetupFieldErrors {
val localFailure = ConnectionValidator.normalizeGatewayUrl(state.localUrl, required = false).exceptionOrNull()
val remoteFailure = ConnectionValidator.normalizeGatewayUrl(state.remoteUrl, required = true).exceptionOrNull()
return when {
localFailure != null -> SetupFieldErrors(localUrl = localFailure.message)
remoteFailure != null -> SetupFieldErrors(remoteUrl = remoteFailure.message)
else -> SetupFieldErrors(localUrl = message, remoteUrl = message)
}
}
}
private fun SetupWizardState.selectAuth(choice: SetupAuthChoice): SetupWizardState {
if (choice == authChoice) {
return copy(
errors = SetupFieldErrors(),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
}
return copy(
authChoice = choice,
hasStoredBearerCredential = false,
bearerToken = SensitiveValue.Empty,
username = "",
hasStoredPasswordCredential = false,
password = SensitiveValue.Empty,
errors = SetupFieldErrors(),
testStatus = SetupTestStatus.NotStarted,
testMessage = null,
activeTestAttemptId = null
)
}
private fun SetupWizardState.sameRemoteUrl(candidate: String): Boolean =
candidate.trim().trimEnd('/') == remoteUrl.trim().trimEnd('/')
private fun String.redacting(vararg secrets: SensitiveValue): String = secrets.fold(this) { message, secret ->
if (secret.isBlank) message else message.replace(secret.reveal(), "[REDACTED]")
}
private fun validateUrls(localUrl: String, remoteUrl: String): Result<SetupGatewayUrls> {
val local = ConnectionValidator.normalizeGatewayUrl(localUrl, required = false).getOrElse {
return Result.failure(it)
}
val remote = ConnectionValidator.normalizeGatewayUrl(remoteUrl, required = true).getOrElse {
return Result.failure(it)
}
if (local.isNotBlank() && local == remote) {
return Result.failure(IllegalArgumentException("Local and remote gateway URLs must be different."))
}
return Result.success(SetupGatewayUrls(localUrl = local, remoteUrl = remote))
}
private fun StoredGatewayConfiguration.isValid(): Boolean {
if (validateUrls(localUrl, remoteUrl).isFailure) return false
return when (authChoice) {
SetupAuthChoice.None -> true
SetupAuthChoice.Bearer -> hasBearerCredential
SetupAuthChoice.UsernamePassword -> username.isNotBlank() && hasPasswordCredential
}
}
private fun ConnectionConfig.toStoredGatewayConfiguration(): StoredGatewayConfiguration =
StoredGatewayConfiguration(
localUrl = localUrl,
remoteUrl = remoteUrl,
authChoice = when (authMode) {
ConnectionAuthMode.None -> SetupAuthChoice.None
ConnectionAuthMode.BearerToken -> SetupAuthChoice.Bearer
ConnectionAuthMode.PasswordLogin -> SetupAuthChoice.UsernamePassword
},
hasBearerCredential = authMode == ConnectionAuthMode.BearerToken && hasBearerToken,
username = passwordLoginUsername,
hasPasswordCredential = authMode == ConnectionAuthMode.PasswordLogin && hasPasswordSession,
sessionLabel = sessionLabel
)
@@ -0,0 +1,745 @@
package cloud.molberg.hermesmobile.onboarding
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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.focus.FocusDirection
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cloud.molberg.hermesmobile.design.FlatCard
import cloud.molberg.hermesmobile.design.HermesMobileTheme
import cloud.molberg.hermesmobile.design.HermesTheme
import cloud.molberg.hermesmobile.design.NoticeCard
import cloud.molberg.hermesmobile.design.PrimaryButton
import cloud.molberg.hermesmobile.design.SecondaryButton
import java.util.Locale
import kotlinx.coroutines.launch
@Composable
fun SetupWizardScreen(
state: SetupWizardState,
onAction: (SetupWizardAction) -> Unit,
onTestConnection: (SetupSubmission) -> Unit,
onFinish: (SetupSubmission) -> Unit,
onExit: () -> Unit,
modifier: Modifier = Modifier
) {
BackHandler(enabled = state.canGoBack || state.canExit) {
if (state.canGoBack) onAction(SetupWizardAction.Back) else onExit()
}
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = HermesTheme.colors.background,
topBar = {
SetupTopBar(
editExisting = state.entryMode == SetupWizardEntryMode.EditExisting,
canExit = state.canExit,
onExit = onExit
)
}
) { contentPadding ->
BoxWithConstraints(
Modifier
.fillMaxSize()
.padding(contentPadding)
.imePadding()
) {
val horizontalPadding = if (maxWidth < 360.dp) HermesTheme.spacing.md else HermesTheme.spacing.xl
LazyColumn(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
contentPadding = PaddingValues(
start = horizontalPadding,
top = HermesTheme.spacing.lg,
end = horizontalPadding,
bottom = HermesTheme.spacing.xl
),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Column(
modifier = Modifier
.widthIn(max = 680.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.xl)
) {
StepProgress(state.step)
when (state.step) {
SetupWizardStep.WelcomePrivacy -> WelcomePrivacyStep(state, onAction)
SetupWizardStep.GatewayUrls -> GatewayUrlsStep(state, onAction)
SetupWizardStep.Authentication -> AuthenticationStep(state, onAction)
SetupWizardStep.TestAndFinish -> TestAndFinishStep(state)
}
WizardActions(state, onAction, onTestConnection, onFinish)
}
}
}
}
}
}
@Composable
private fun SetupTopBar(editExisting: Boolean, canExit: Boolean, onExit: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(HermesTheme.colors.background)
.statusBarsPadding()
.heightIn(min = 64.dp)
.padding(horizontal = HermesTheme.spacing.lg),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(
text = if (editExisting) "Edit connection" else "Set up Hermes",
modifier = Modifier.semantics { heading() },
color = HermesTheme.colors.text,
style = HermesTheme.typography.rowTitle
)
Text(
text = if (editExisting) "Update your gateway settings" else "Private gateway connection",
color = HermesTheme.colors.textMuted,
style = HermesTheme.typography.caption
)
}
if (canExit) {
IconButton(onClick = onExit) {
Icon(Icons.Filled.Close, contentDescription = "Close connection setup", tint = HermesTheme.colors.text)
}
}
}
}
@Composable
private fun StepProgress(step: SetupWizardStep) {
val steps = SetupWizardStep.entries
val stepIndex = steps.indexOf(step)
Column(
modifier = Modifier.semantics(mergeDescendants = true) {
stateDescription = "Step ${stepIndex + 1} of ${steps.size}"
progressBarRangeInfo = ProgressBarRangeInfo(
current = (stepIndex + 1).toFloat(),
range = 1f..steps.size.toFloat(),
steps = (steps.size - 2).coerceAtLeast(0)
)
},
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm)
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
steps.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.background(
color = if (index <= stepIndex) HermesTheme.colors.primary else HermesTheme.colors.border,
shape = CircleShape
)
)
}
}
Text(
text = "Step ${stepIndex + 1} of ${steps.size}",
color = HermesTheme.colors.textMuted,
style = HermesTheme.typography.label
)
}
}
@Composable
private fun WelcomePrivacyStep(state: SetupWizardState, onAction: (SetupWizardAction) -> Unit) {
StepHeader(
icon = Icons.Filled.Security,
title = "Connect on your terms",
detail = "Hermes connects only to gateway addresses you provide. Review the privacy details before continuing."
)
FlatCard {
Column(
Modifier.padding(HermesTheme.spacing.lg),
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)
) {
PrivacyPoint("Your gateway URLs are entered by you and can be changed later.")
PrivacyPoint("Credentials are masked on screen and are never included in app logs.")
PrivacyPoint("Connection testing starts only when you request it.")
HorizontalDivider(color = HermesTheme.colors.border)
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(
value = state.privacyAccepted,
role = Role.Checkbox,
onValueChange = { onAction(SetupWizardAction.SetPrivacyAccepted(it)) }
)
.heightIn(min = 48.dp)
.padding(vertical = HermesTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = state.privacyAccepted,
onCheckedChange = null,
colors = CheckboxDefaults.colors(
checkedColor = HermesTheme.colors.primary,
checkmarkColor = HermesTheme.colors.onPrimary,
uncheckedColor = HermesTheme.colors.textMuted
)
)
Text(
text = "I understand and want to configure a gateway.",
modifier = Modifier.padding(start = HermesTheme.spacing.sm),
color = HermesTheme.colors.text,
style = HermesTheme.typography.bodyStrong
)
}
}
}
}
@Composable
private fun PrivacyPoint(text: String) {
Row(horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = HermesTheme.status.success,
modifier = Modifier.size(20.dp)
)
Text(text, color = HermesTheme.colors.text, style = HermesTheme.typography.body)
}
}
@Composable
private fun GatewayUrlsStep(state: SetupWizardState, onAction: (SetupWizardAction) -> Unit) {
StepHeader(
icon = Icons.Filled.Wifi,
title = "Where is your gateway?",
detail = "Add the remote address you use away from home. A local network address is optional."
)
Column(verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)) {
SetupTextField(
value = state.remoteUrl,
onValueChange = { onAction(SetupWizardAction.SetRemoteUrl(it)) },
label = "Remote gateway URL",
supportingText = "Required · Include http:// or https://",
error = state.errors.remoteUrl,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
)
SetupTextField(
value = state.localUrl,
onValueChange = { onAction(SetupWizardAction.SetLocalUrl(it)) },
label = "Local gateway URL",
supportingText = "Optional · Used when the local address is reachable",
error = state.errors.localUrl,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done
)
NoticeCard("Hermes does not assume a gateway host. Only the addresses entered here are used.")
}
}
@Composable
private fun AuthenticationStep(state: SetupWizardState, onAction: (SetupWizardAction) -> Unit) {
StepHeader(
icon = Icons.Filled.Lock,
title = "Choose authentication",
detail = "Select the method required by your gateway. Secrets remain masked while you type."
)
Column(verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)) {
AuthChoiceCard(
title = "None",
detail = "Use a gateway that does not require credentials.",
selected = state.authChoice == SetupAuthChoice.None,
onClick = { onAction(SetupWizardAction.SelectAuth(SetupAuthChoice.None)) }
)
AuthChoiceCard(
title = "Bearer token",
detail = "Send a bearer token with gateway requests.",
selected = state.authChoice == SetupAuthChoice.Bearer,
onClick = { onAction(SetupWizardAction.SelectAuth(SetupAuthChoice.Bearer)) }
)
AuthChoiceCard(
title = "Username and password",
detail = "Authenticate with gateway account credentials.",
selected = state.authChoice == SetupAuthChoice.UsernamePassword,
onClick = { onAction(SetupWizardAction.SelectAuth(SetupAuthChoice.UsernamePassword)) }
)
}
when (state.authChoice) {
SetupAuthChoice.None -> NoticeCard("No authorization credential will be sent.")
SetupAuthChoice.Bearer -> SetupTextField(
value = state.bearerToken.reveal(),
onValueChange = { onAction(SetupWizardAction.SetBearerToken(it)) },
label = "Bearer token",
supportingText = if (state.hasStoredBearerCredential) {
"A token is saved. Leave blank to keep it."
} else {
"Required for bearer authentication"
},
error = state.errors.bearerToken,
password = true,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
SetupAuthChoice.UsernamePassword -> Column(
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)
) {
SetupTextField(
value = state.username,
onValueChange = { onAction(SetupWizardAction.SetUsername(it)) },
label = "Username",
supportingText = "Required",
error = state.errors.username,
imeAction = ImeAction.Next
)
SetupTextField(
value = state.password.reveal(),
onValueChange = { onAction(SetupWizardAction.SetPassword(it)) },
label = "Password",
supportingText = if (state.hasStoredPasswordCredential) {
"A password is saved. Leave blank to keep it."
} else {
"Required"
},
error = state.errors.password,
password = true,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
}
}
}
@Composable
private fun AuthChoiceCard(title: String, detail: String, selected: Boolean, onClick: () -> Unit) {
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.semantics(mergeDescendants = true) {},
color = if (selected) HermesTheme.colors.surfaceRaised else HermesTheme.colors.surface,
shape = RoundedCornerShape(HermesTheme.shapes.card),
border = BorderStroke(
1.dp,
if (selected) HermesTheme.colors.textMuted else HermesTheme.colors.border
)
) {
Row(
Modifier.padding(HermesTheme.spacing.lg),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
) {
RadioButton(
selected = selected,
onClick = null,
colors = RadioButtonDefaults.colors(
selectedColor = HermesTheme.colors.primary,
unselectedColor = HermesTheme.colors.textMuted
)
)
Column(Modifier.weight(1f)) {
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.bodyStrong)
Text(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
}
}
}
@Composable
private fun TestAndFinishStep(state: SetupWizardState) {
StepHeader(
icon = Icons.Filled.Key,
title = "Test your connection",
detail = "Review the non-secret settings below, then confirm the gateway is reachable."
)
FlatCard {
Column(
Modifier.padding(HermesTheme.spacing.lg),
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
) {
SummaryRow("Remote", state.normalizedUrls?.remoteUrl ?: state.remoteUrl)
if ((state.normalizedUrls?.localUrl ?: state.localUrl).isNotBlank()) {
HorizontalDivider(color = HermesTheme.colors.border)
SummaryRow("Local", state.normalizedUrls?.localUrl ?: state.localUrl)
}
HorizontalDivider(color = HermesTheme.colors.border)
SummaryRow("Authentication", state.authChoice.displayName())
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.then(
if (state.testStatus == SetupTestStatus.NotStarted) {
Modifier
} else {
Modifier.semantics { liveRegion = LiveRegionMode.Polite }
}
)
) {
when (state.testStatus) {
SetupTestStatus.NotStarted -> NoticeCard("No connection test has run yet.")
SetupTestStatus.Testing -> TestStatusCard("Testing gateway connection…", testing = true)
SetupTestStatus.Succeeded -> TestStatusCard("Connection successful. You can finish setup.")
SetupTestStatus.Failed -> NoticeCard(
state.testMessage?.takeIf(String::isNotBlank)
?: "Connection test failed. Check the settings and try again.",
danger = true
)
}
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.xs)) {
Text(label.uppercase(Locale.US), color = HermesTheme.colors.textMuted, style = HermesTheme.typography.tinyLabel)
Text(value, color = HermesTheme.colors.text, style = HermesTheme.typography.bodyStrong)
}
}
@Composable
private fun TestStatusCard(text: String, testing: Boolean = false) {
val containerColor = if (testing) HermesTheme.colors.surfaceRaised else HermesTheme.status.successPanel
val contentColor = if (testing) HermesTheme.colors.text else HermesTheme.status.success
Surface(
color = containerColor,
shape = RoundedCornerShape(HermesTheme.shapes.card),
modifier = Modifier.fillMaxWidth()
) {
Row(
Modifier.padding(HermesTheme.spacing.lg),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
) {
if (testing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = contentColor,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Filled.Check, contentDescription = null, tint = contentColor)
}
Text(text, color = contentColor, style = HermesTheme.typography.bodyStrong)
}
}
}
@Composable
private fun StepHeader(icon: ImageVector, title: String, detail: String) {
Row(horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)) {
Surface(
modifier = Modifier.size(48.dp),
color = HermesTheme.colors.surfaceRaised,
shape = RoundedCornerShape(HermesTheme.shapes.iconTile)
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, tint = HermesTheme.colors.text)
}
}
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm)
) {
Text(
title,
modifier = Modifier.semantics { heading() },
color = HermesTheme.colors.text,
style = HermesTheme.typography.panelTitle
)
Text(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.body)
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun SetupTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
supportingText: String,
error: String?,
keyboardType: KeyboardType = KeyboardType.Text,
password: Boolean = false,
imeAction: ImeAction
) {
val focusManager = LocalFocusManager.current
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val coroutineScope = rememberCoroutineScope()
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusEvent { focusState ->
if (focusState.isFocused) {
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
}
}
.semantics {
stateDescription = error ?: supportingText
},
label = { Text(label) },
supportingText = {
Text(error ?: supportingText)
},
isError = error != null,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = {
focusManager.clearFocus()
}
),
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = TextFieldDefaults.colors(
focusedTextColor = HermesTheme.colors.text,
unfocusedTextColor = HermesTheme.colors.text,
focusedContainerColor = HermesTheme.colors.surfaceInput,
unfocusedContainerColor = HermesTheme.colors.surfaceInput,
errorContainerColor = HermesTheme.colors.surfaceInput,
focusedIndicatorColor = HermesTheme.colors.textMuted,
unfocusedIndicatorColor = HermesTheme.colors.border,
errorIndicatorColor = HermesTheme.status.danger,
focusedLabelColor = HermesTheme.colors.textMuted,
unfocusedLabelColor = HermesTheme.colors.textMuted,
errorLabelColor = HermesTheme.status.danger,
focusedSupportingTextColor = HermesTheme.colors.textMuted,
unfocusedSupportingTextColor = HermesTheme.colors.textMuted,
errorSupportingTextColor = HermesTheme.status.danger,
cursorColor = HermesTheme.colors.text
)
)
}
@Composable
private fun WizardActions(
state: SetupWizardState,
onAction: (SetupWizardAction) -> Unit,
onTestConnection: (SetupSubmission) -> Unit,
onFinish: (SetupSubmission) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)) {
when (state.step) {
SetupWizardStep.WelcomePrivacy,
SetupWizardStep.GatewayUrls,
SetupWizardStep.Authentication -> PrimaryButton(
text = "Continue",
icon = Icons.Filled.ArrowForward,
modifier = Modifier.fillMaxWidth(),
enabled = state.canContinue,
onClick = { onAction(SetupWizardAction.Continue) }
)
SetupWizardStep.TestAndFinish -> {
val submission = state.submissionOrNull()
PrimaryButton(
text = when (state.testStatus) {
SetupTestStatus.Testing -> "Testing connection"
SetupTestStatus.Succeeded -> "Test again"
else -> "Test connection"
},
icon = Icons.Filled.PlayArrow,
modifier = Modifier.fillMaxWidth(),
enabled = submission != null && state.testStatus != SetupTestStatus.Testing,
onClick = {
if (submission != null) {
onAction(SetupWizardAction.TestStarted)
onTestConnection(submission)
}
}
)
PrimaryButton(
text = "Finish setup",
icon = Icons.Filled.Done,
modifier = Modifier.fillMaxWidth(),
enabled = state.canFinish && submission != null,
onClick = { if (submission != null) onFinish(submission) }
)
}
}
if (state.canGoBack) {
SecondaryButton(
text = "Back",
icon = Icons.Filled.ArrowBack,
modifier = Modifier.fillMaxWidth(),
onClick = { onAction(SetupWizardAction.Back) }
)
}
}
}
private fun SetupAuthChoice.displayName(): String = when (this) {
SetupAuthChoice.None -> "None"
SetupAuthChoice.Bearer -> "Bearer token"
SetupAuthChoice.UsernamePassword -> "Username and password"
}
@Composable
private fun InteractiveSetupPreview(initialState: SetupWizardState, darkTheme: Boolean) {
var state by remember(initialState) { mutableStateOf(initialState) }
HermesMobileTheme(darkTheme = darkTheme) {
SetupWizardScreen(
state = state,
onAction = { state = SetupWizardReducer.reduce(state, it) },
onTestConnection = { state = SetupWizardReducer.reduce(state, SetupWizardAction.TestSucceeded) },
onFinish = {},
onExit = {}
)
}
}
@Preview(name = "Setup · Phone · Welcome · Light", widthDp = 393, heightDp = 852, showBackground = true)
@Composable
private fun SetupPhoneWelcomeLightPreview() {
InteractiveSetupPreview(SetupWizardReducer.firstRun(), darkTheme = false)
}
@Preview(
name = "Setup · Phone · Auth · Dark",
widthDp = 393,
heightDp = 852,
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true
)
@Composable
private fun SetupPhoneAuthDarkPreview() {
InteractiveSetupPreview(
SetupWizardState(
entryMode = SetupWizardEntryMode.MandatoryFirstRun,
step = SetupWizardStep.Authentication,
privacyAccepted = true,
remoteUrl = "Remote gateway configured",
authChoice = SetupAuthChoice.Bearer,
hasStoredBearerCredential = true
),
darkTheme = true
)
}
@Preview(name = "Setup · Tablet · Finish · Light", widthDp = 840, heightDp = 900, showBackground = true)
@Composable
private fun SetupTabletFinishLightPreview() {
InteractiveSetupPreview(
SetupWizardState(
entryMode = SetupWizardEntryMode.MandatoryFirstRun,
step = SetupWizardStep.TestAndFinish,
privacyAccepted = true,
localUrl = "https://local.example.invalid",
remoteUrl = "https://remote.example.invalid",
normalizedUrls = SetupGatewayUrls(
"https://local.example.invalid",
"https://remote.example.invalid"
),
authChoice = SetupAuthChoice.None,
testStatus = SetupTestStatus.Succeeded
),
darkTheme = false
)
}
@Preview(
name = "Setup · Tablet · Edit Existing · Dark",
widthDp = 840,
heightDp = 900,
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true
)
@Composable
private fun SetupTabletEditExistingDarkPreview() {
InteractiveSetupPreview(
SetupWizardState(
entryMode = SetupWizardEntryMode.EditExisting,
step = SetupWizardStep.GatewayUrls,
localUrl = "https://local.example.invalid",
remoteUrl = "https://remote.example.invalid",
authChoice = SetupAuthChoice.UsernamePassword,
hasStoredPasswordCredential = true
),
darkTheme = true
)
}
@@ -199,6 +199,7 @@ class ConnectionStateTest {
override var sessionLabel: String = "Default session"
override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken
override var bearerToken: String = ""
override var passwordLoginUsername: String = ""
}
private companion object {
@@ -55,6 +55,54 @@ class GatewayHttpProbeTest {
assertFalse(result.message.contains(TOKEN))
}
@Test
fun passwordSessionProbeUsesCookieOnlyForAuthenticatedCompatibilityCheck() = runBlocking {
val requests = mutableListOf<Triple<String, String?, String?>>()
val probe = GatewayHttpProbe(client { chain ->
requests += Triple(
chain.request().url.encodedPath,
chain.request().header("Authorization"),
chain.request().header("Cookie")
)
jsonResponse(chain, 200)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.PasswordLogin, TOKEN, SESSION_COOKIE)
assertTrue(result is GatewayProbeResult.Healthy)
assertEquals(
listOf(
Triple("/health", null, null),
Triple("/v1/models", null, SESSION_COOKIE)
),
requests
)
}
@Test
fun bearerProbeIgnoresAnySuppliedSessionCookie() = runBlocking {
val requests = mutableListOf<Triple<String, String?, String?>>()
val probe = GatewayHttpProbe(client { chain ->
requests += Triple(
chain.request().url.encodedPath,
chain.request().header("Authorization"),
chain.request().header("Cookie")
)
jsonResponse(chain, 200)
})
val result = probe.probe(BASE_URL, ConnectionAuthMode.BearerToken, TOKEN, SESSION_COOKIE)
assertTrue(result is GatewayProbeResult.Healthy)
assertEquals(
listOf(
Triple("/health", null, null),
Triple("/v1/models", "Bearer $TOKEN", null)
),
requests
)
}
private fun client(interceptor: (Interceptor.Chain) -> Response): OkHttpClient =
OkHttpClient.Builder()
.followRedirects(false)
@@ -73,5 +121,6 @@ class GatewayHttpProbeTest {
private companion object {
const val BASE_URL = "https://hermes.example.test"
const val TOKEN = "secret-test-token"
const val SESSION_COOKIE = "session=deterministic-cookie"
}
}
@@ -0,0 +1,147 @@
package cloud.molberg.hermesmobile.connection
import kotlinx.coroutines.runBlocking
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PasswordLoginClientTest {
@Test
fun passwordLoginPostsExactUpstreamContractAndReturnsSessionCookie() = runBlocking {
var recordedRequest: okhttp3.Request? = null
val client = GatewayPasswordLoginClient(client { chain ->
recordedRequest = chain.request()
jsonResponse(chain, 200, setCookie = "$SESSION_COOKIE; Path=/; HttpOnly; Secure")
})
val result = client.login(BASE_URL, USERNAME, PASSWORD)
val request = requireNotNull(recordedRequest)
assertEquals("POST", request.method)
assertEquals("/auth/password-login", request.url.encodedPath)
assertEquals("application/json", request.header("Accept"))
assertEquals("application/json", request.header("Content-Type"))
val rawBody = requireNotNull(request.body).writeToString()
assertEquals(
"""{"provider":"basic","username":"alice","password":"not-a-real-password","next":"/"}""",
rawBody
)
val body = JSONObject(rawBody)
assertEquals(4, body.length())
assertEquals("basic", body.getString("provider"))
assertEquals(USERNAME, body.getString("username"))
assertEquals(PASSWORD, body.getString("password"))
assertEquals("/", body.getString("next"))
assertEquals(SESSION_COOKIE, (result as PasswordLoginResult.Success).sessionCookie)
assertFalse(result.toString().contains("deterministic-cookie"))
}
@Test
fun passwordLoginMapsInvalidCredentialsAndRateLimits() = runBlocking {
val invalidCredentials = GatewayPasswordLoginClient(client { chain -> jsonResponse(chain, 401) })
val rateLimited = GatewayPasswordLoginClient(client { chain -> jsonResponse(chain, 429) })
assertEquals(PasswordLoginResult.InvalidCredentials, invalidCredentials.login(BASE_URL, USERNAME, PASSWORD))
assertEquals(PasswordLoginResult.RateLimited, rateLimited.login(BASE_URL, USERNAME, PASSWORD))
}
@Test
fun passwordLoginRejectsSuccessfulResponseWithoutJsonAndCookie() = runBlocking {
val invalidJson = GatewayPasswordLoginClient(client { chain ->
response(chain, 200, "not-json", setCookie = "$SESSION_COOKIE; Path=/")
})
val missingCookie = GatewayPasswordLoginClient(client { chain -> jsonResponse(chain, 200) })
assertTrue(invalidJson.login(BASE_URL, USERNAME, PASSWORD) is PasswordLoginResult.Failed)
assertTrue(missingCookie.login(BASE_URL, USERNAME, PASSWORD) is PasswordLoginResult.Failed)
}
@Test
fun passwordLoginSelectsNamedSessionCookieAndRejectsUnrelatedCookies() = runBlocking {
val namedSession = GatewayPasswordLoginClient(client { chain ->
response(
chain,
200,
"{}",
setCookies = listOf(
"csrf=deterministic-csrf; Path=/",
"$SESSION_COOKIE; Path=/; HttpOnly; Secure"
)
)
})
val unrelatedOnly = GatewayPasswordLoginClient(client { chain ->
response(chain, 200, "{}", setCookies = listOf("csrf=deterministic-csrf; Path=/"))
})
val success = namedSession.login(BASE_URL, USERNAME, PASSWORD) as PasswordLoginResult.Success
assertEquals(SESSION_COOKIE, success.sessionCookie)
assertTrue(unrelatedOnly.login(BASE_URL, USERNAME, PASSWORD) is PasswordLoginResult.Failed)
}
@Test
fun passwordLoginAcceptsDashboardCookieNameVariants() = runBlocking {
val variants = listOf(
"hermes_session_at=bare-cookie",
"__Host-hermes_session_at=host-cookie",
"__Secure-hermes_session_at=secure-cookie"
)
variants.forEach { cookie ->
val client = GatewayPasswordLoginClient(client { chain ->
jsonResponse(chain, 200, setCookie = "$cookie; Path=/; HttpOnly")
})
val result = client.login(BASE_URL, USERNAME, PASSWORD) as PasswordLoginResult.Success
assertEquals(cookie, result.sessionCookie)
}
}
private fun client(interceptor: (Interceptor.Chain) -> Response): OkHttpClient =
OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.addInterceptor(Interceptor { chain -> interceptor(chain) })
.build()
private fun jsonResponse(chain: Interceptor.Chain, code: Int, setCookie: String? = null): Response =
response(chain, code, "{}", setCookies = listOfNotNull(setCookie))
private fun response(
chain: Interceptor.Chain,
code: Int,
body: String,
setCookie: String? = null,
setCookies: List<String> = listOfNotNull(setCookie)
): Response = Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("test")
.headers(Headers.Builder().apply { setCookies.forEach { add("Set-Cookie", it) } }.build())
.body(body.toResponseBody("application/json".toMediaType()))
.build()
private fun okhttp3.RequestBody.writeToString(): String {
val buffer = okio.Buffer()
writeTo(buffer)
return buffer.readUtf8()
}
private companion object {
const val BASE_URL = "https://hermes.example.test"
const val USERNAME = "alice"
const val PASSWORD = "not-a-real-password"
const val SESSION_COOKIE = "__Host-hermes_session_at=deterministic-cookie"
}
}
@@ -0,0 +1,245 @@
package cloud.molberg.hermesmobile.connection
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PasswordSessionStoreTest {
@Test
fun sessionCookieIsScopedToLogicalProfile() = runBlocking {
val store = InMemoryPasswordStore()
val service = PasswordLoginService(successfulClient(), store)
val first = ConnectionProfile(REMOTE, "Personal")
val second = ConnectionProfile(REMOTE, "Work")
service.login(first, USERNAME, PASSWORD)
assertEquals(SESSION_COOKIE, store.sessionCookie(first))
assertEquals("", store.sessionCookie(second))
}
@Test
fun changingPasswordLoginCredentialsOrAuthModeClearsCookie() {
val store = configuredPasswordStore()
val repository = StoredConnectionRepository(store, GatewayRouteCoordinator(GatewayRouteSelector(offlineProbe())))
val profile = store.config().profile
store.saveSessionCookie(profile, SESSION_COOKIE)
repository.save(
ConnectionForm(
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.PasswordLogin,
sessionLabel = "Personal",
passwordLoginUsername = "bob"
)
).getOrThrow()
assertEquals("", store.sessionCookie(profile))
store.saveSessionCookie(profile, SESSION_COOKIE)
repository.save(
ConnectionForm(
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken,
bearerToken = "replacement-token",
sessionLabel = "Personal"
)
).getOrThrow()
assertEquals("", store.sessionCookie(profile))
}
@Test
fun changingLogicalProfileClearsSourceAndDestinationCookies() {
val store = configuredPasswordStore()
val repository = StoredConnectionRepository(store, GatewayRouteCoordinator(GatewayRouteSelector(offlineProbe())))
val source = store.config().profile
val destination = ConnectionProfile(OTHER_REMOTE, "Work")
store.saveSessionCookie(source, SESSION_COOKIE)
store.saveSessionCookie(destination, OTHER_SESSION_COOKIE)
repository.save(
ConnectionForm(
remoteUrl = OTHER_REMOTE,
authMode = ConnectionAuthMode.PasswordLogin,
sessionLabel = "Work",
passwordLoginUsername = USERNAME
)
).getOrThrow()
assertEquals("", store.sessionCookie(source))
assertEquals("", store.sessionCookie(destination))
}
@Test
fun signOutClearsCurrentProfileCookie() {
val store = configuredPasswordStore()
val repository = StoredConnectionRepository(store, GatewayRouteCoordinator(GatewayRouteSelector(offlineProbe())))
val profile = store.config().profile
store.saveSessionCookie(profile, SESSION_COOKIE)
repository.signOut()
assertEquals("", store.sessionCookie(profile))
}
@Test
fun passwordLoginNeverPersistsRawPassword() = runBlocking {
val store = InMemoryPasswordStore()
val service = PasswordLoginService(successfulClient(), store)
val profile = ConnectionProfile(REMOTE, "Personal")
val result = service.login(profile, USERNAME, PASSWORD)
assertTrue(result is PasswordLoginResult.Success)
assertFalse(store.persistedValues().any { it.contains(PASSWORD) })
assertFalse(result.toString().contains(PASSWORD))
assertEquals(SESSION_COOKIE, store.sessionCookie(profile))
}
@Test
fun passwordSessionUsesExistingLocalFirstRouteSelection() = runBlocking {
val attempts = mutableListOf<ProbeAttempt>()
val probe = object : SessionCookieGatewayProbe {
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String
): GatewayProbeResult = error("Password probes must use the session-aware contract.")
override suspend fun probe(
baseUrl: String,
authMode: ConnectionAuthMode,
bearerToken: String,
sessionCookie: String
): GatewayProbeResult {
attempts += ProbeAttempt(baseUrl, authMode, bearerToken, sessionCookie)
return if (baseUrl == LOCAL) {
GatewayProbeResult.Healthy("local")
} else {
GatewayProbeResult.Offline("remote should not be reached")
}
}
}
val config = ConnectionConfig(
localUrl = LOCAL,
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.PasswordLogin,
passwordLoginUsername = USERNAME,
hasPasswordSession = true,
sessionLabel = "Personal"
)
val result = GatewayRouteSelector(probe).select(config, bearerToken = "", sessionCookie = SESSION_COOKIE)
assertTrue(result is RouteSelectionResult.Connected)
assertEquals(ConnectionRoute.Local, (result as RouteSelectionResult.Connected).route)
assertEquals(
listOf(ProbeAttempt(LOCAL, ConnectionAuthMode.PasswordLogin, "", SESSION_COOKIE)),
attempts
)
}
@Test
fun failedPasswordLoginClearsPreviousSession() = runBlocking {
val store = configuredPasswordStore()
val profile = store.config().profile
store.saveSessionCookie(profile, SESSION_COOKIE)
val service = PasswordLoginService(
PasswordLoginClient { _, _, _ -> PasswordLoginResult.InvalidCredentials },
store
)
val result = service.login(profile, USERNAME, "different-not-real-password")
assertEquals(PasswordLoginResult.InvalidCredentials, result)
assertEquals("", store.sessionCookie(profile))
}
@Test
fun changedPasswordClearsPreviousSessionBeforeSavingReplacement() = runBlocking {
val store = configuredPasswordStore()
val profile = store.config().profile
store.saveSessionCookie(profile, SESSION_COOKIE)
val service = PasswordLoginService(
PasswordLoginClient { _, username, password ->
assertEquals(USERNAME, username)
assertEquals(CHANGED_PASSWORD, password)
assertEquals("", store.sessionCookie(profile))
PasswordLoginResult.Success(OTHER_SESSION_COOKIE)
},
store
)
val result = service.login(profile, USERNAME, CHANGED_PASSWORD)
assertTrue(result is PasswordLoginResult.Success)
assertEquals(OTHER_SESSION_COOKIE, store.sessionCookie(profile))
assertFalse(store.persistedValues().any { it.contains(CHANGED_PASSWORD) })
}
private fun configuredPasswordStore(): InMemoryPasswordStore = InMemoryPasswordStore().apply {
remoteUrl = REMOTE
sessionLabel = "Personal"
authMode = ConnectionAuthMode.PasswordLogin
passwordLoginUsername = USERNAME
}
private fun successfulClient(): PasswordLoginClient = PasswordLoginClient { _, username, password ->
assertEquals(USERNAME, username)
assertEquals(PASSWORD, password)
PasswordLoginResult.Success(SESSION_COOKIE)
}
private fun offlineProbe(): GatewayProbe = GatewayProbe { _, _, _ ->
GatewayProbeResult.Offline("offline")
}
private class InMemoryPasswordStore : ConnectionStore, PasswordSessionStore {
private val sessionCookies = mutableMapOf<ConnectionProfile, String>()
override var localUrl: String = ""
override var remoteUrl: String = ""
override var sessionLabel: String = "Default session"
override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken
override var bearerToken: String = ""
override var passwordLoginUsername: String = ""
override fun sessionCookie(profile: ConnectionProfile): String = sessionCookies[profile].orEmpty()
override fun saveSessionCookie(profile: ConnectionProfile, cookie: String) {
sessionCookies[profile] = cookie
}
override fun clearSessionCookie(profile: ConnectionProfile) {
sessionCookies.remove(profile)
}
fun persistedValues(): List<String> = listOf(
localUrl,
remoteUrl,
sessionLabel,
authMode.name,
bearerToken,
passwordLoginUsername
) + sessionCookies.values
}
private data class ProbeAttempt(
val baseUrl: String,
val authMode: ConnectionAuthMode,
val bearerToken: String,
val sessionCookie: String
)
private companion object {
const val REMOTE = "https://hermes.example.test"
const val OTHER_REMOTE = "https://work.hermes.example.test"
const val LOCAL = "https://hermes.home.arpa"
const val USERNAME = "alice"
const val PASSWORD = "not-a-real-password"
const val CHANGED_PASSWORD = "different-not-real-password"
const val SESSION_COOKIE = "__Host-hermes_session_at=deterministic-cookie"
const val OTHER_SESSION_COOKIE = "__Host-hermes_session_at=other-deterministic-cookie"
}
}
@@ -0,0 +1,611 @@
package cloud.molberg.hermesmobile.onboarding
import cloud.molberg.hermesmobile.connection.ConnectionAuthMode
import cloud.molberg.hermesmobile.connection.ConnectionConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SetupWizardReducerTest {
@Test
fun mandatoryFirstRunFollowsOrderedSequenceAndBackNavigation() {
var state = SetupWizardReducer.firstRun()
assertEquals(SetupWizardStep.WelcomePrivacy, state.step)
assertFalse(state.canContinue)
state = reduce(state, SetupWizardAction.SetPrivacyAccepted(true), SetupWizardAction.Continue)
assertEquals(SetupWizardStep.GatewayUrls, state.step)
state = reduce(
state,
SetupWizardAction.SetRemoteUrl(REMOTE),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.Authentication, state.step)
state = reduce(
state,
SetupWizardAction.SelectAuth(SetupAuthChoice.None),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.TestAndFinish, state.step)
state = SetupWizardReducer.reduce(state, SetupWizardAction.Back)
assertEquals(SetupWizardStep.Authentication, state.step)
state = SetupWizardReducer.reduce(state, SetupWizardAction.Back)
assertEquals(SetupWizardStep.GatewayUrls, state.step)
state = SetupWizardReducer.reduce(state, SetupWizardAction.Back)
assertEquals(SetupWizardStep.WelcomePrivacy, state.step)
}
@Test
fun urlValidationUsesDirectGatewayRulesAndRetainsTypedFields() {
var state = reduce(
SetupWizardReducer.firstRun(),
SetupWizardAction.SetPrivacyAccepted(true),
SetupWizardAction.Continue,
SetupWizardAction.SetRemoteUrl("http://remote.example.test/path"),
SetupWizardAction.SetLocalUrl(" https://local.example.test/ "),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.GatewayUrls, state.step)
assertEquals("http://remote.example.test/path", state.remoteUrl)
assertEquals(" https://local.example.test/ ", state.localUrl)
assertEquals("Gateway URLs must use HTTPS.", state.errors.remoteUrl)
assertNull(state.errors.localUrl)
state = reduce(
state,
SetupWizardAction.SetRemoteUrl(" https://remote.example.test/ "),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.Authentication, state.step)
assertEquals("https://remote.example.test", state.normalizedUrls?.remoteUrl)
assertEquals("https://local.example.test", state.normalizedUrls?.localUrl)
}
@Test
fun continueAndTestEligibilityRequireCurrentValidInputs() {
var state = reduce(
SetupWizardReducer.firstRun(),
SetupWizardAction.SetPrivacyAccepted(true),
SetupWizardAction.Continue,
SetupWizardAction.SetRemoteUrl("http://remote.example.test")
)
assertFalse(state.canContinue)
state = reduce(
state,
SetupWizardAction.SetRemoteUrl(REMOTE),
SetupWizardAction.Continue,
SetupWizardAction.SelectAuth(SetupAuthChoice.Bearer),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.Authentication, state.step)
assertFalse(state.canContinue)
assertFalse(state.canTest)
state = reduce(
state,
SetupWizardAction.SetBearerToken(TOKEN),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.TestAndFinish, state.step)
assertTrue(state.canTest)
assertFalse(state.canFinish)
}
@Test
fun changingAuthClearsCredentialsFromThePreviousChoice() {
var state = authenticationState()
state = reduce(
state,
SetupWizardAction.SelectAuth(SetupAuthChoice.Bearer),
SetupWizardAction.SetBearerToken(TOKEN),
SetupWizardAction.SelectAuth(SetupAuthChoice.UsernamePassword)
)
assertTrue(state.bearerToken.isBlank)
assertFalse(state.hasStoredBearerCredential)
state = reduce(
state,
SetupWizardAction.SetUsername("alice"),
SetupWizardAction.SetPassword(PASSWORD),
SetupWizardAction.SelectAuth(SetupAuthChoice.None)
)
assertEquals("", state.username)
assertFalse(state.hasStoredPasswordCredential)
assertFalse(state.hasReplacementPasswordCredential)
assertTrue(state.password.isBlank)
}
@Test
fun usernamePasswordRequiresBothFieldsAndProducesPasswordMetadataOnly() {
var state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.UsernamePassword),
SetupWizardAction.SetUsername("alice"),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.Authentication, state.step)
assertEquals("Password is required.", state.errors.password)
state = reduce(
state,
SetupWizardAction.SetPassword(PASSWORD),
SetupWizardAction.Continue
)
val submission = state.submissionOrNull()!!
assertEquals(SetupWizardStep.TestAndFinish, state.step)
assertEquals("alice", submission.configuration.username)
assertTrue(submission.configuration.hasPasswordCredential)
assertTrue(state.hasReplacementPasswordCredential)
assertEquals(PASSWORD, state.password.reveal())
assertEquals(PASSWORD, submission.credentials.password.reveal())
assertFalse(state.toString().contains(PASSWORD))
assertFalse(submission.toString().contains(PASSWORD))
assertFalse(submission.configuration.toString().contains(PASSWORD))
}
@Test
fun validStoredConfigSkipsSetupAndExposesEditingEntry() {
val entry = SetupWizardReducer.entry(
ConnectionConfig(
localUrl = LOCAL,
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true
)
)
assertTrue(entry is SetupEntry.ExistingConfiguration)
entry as SetupEntry.ExistingConfiguration
assertEquals(SetupWizardEntryMode.EditExisting, entry.editState.entryMode)
assertEquals(SetupWizardStep.GatewayUrls, entry.editState.step)
assertEquals(REMOTE, entry.editState.remoteUrl)
assertTrue(entry.editState.canExit)
}
@Test
fun validLegacyConfigSkipsSetupAndRequestsVersionMigration() {
val resolution = SetupWizardReducer.resolveEntry(
config = ConnectionConfig(
localUrl = LOCAL,
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true
),
setupVersion = 0,
hasLegacyConfiguration = true
)
assertTrue(resolution.entry is SetupEntry.ExistingConfiguration)
assertTrue(resolution.migrateExistingUser)
val editState = (resolution.entry as SetupEntry.ExistingConfiguration).editState
assertEquals(SetupWizardEntryMode.EditExisting, editState.entryMode)
assertEquals(SetupWizardStep.GatewayUrls, editState.step)
assertTrue(editState.canExit)
}
@Test
fun migrationNeverTurnsInvalidLegacyConfigIntoCompletedSetup() {
val resolution = SetupWizardReducer.resolveEntry(
config = ConnectionConfig(
remoteUrl = "http://legacy.example.test",
authMode = ConnectionAuthMode.None
),
setupVersion = 0,
hasLegacyConfiguration = true
)
assertTrue(resolution.entry is SetupEntry.Mandatory)
assertFalse(resolution.migrateExistingUser)
}
@Test
fun currentOrNonLegacyExistingConfigDoesNotRequestMigration() {
val config = ConnectionConfig(
remoteUrl = REMOTE,
authMode = ConnectionAuthMode.None
)
val current = SetupWizardReducer.resolveEntry(
config = config,
setupVersion = SetupWizardReducer.CURRENT_SETUP_VERSION,
hasLegacyConfiguration = true
)
val nonLegacy = SetupWizardReducer.resolveEntry(
config = config,
setupVersion = 0,
hasLegacyConfiguration = false
)
assertTrue(current.entry is SetupEntry.ExistingConfiguration)
assertFalse(current.migrateExistingUser)
assertTrue(nonLegacy.entry is SetupEntry.ExistingConfiguration)
assertFalse(nonLegacy.migrateExistingUser)
}
@Test
fun editingCanRetainExistingPasswordWithoutPuttingItInState() {
val entry = SetupWizardReducer.entryStored(
StoredGatewayConfiguration(
localUrl = LOCAL,
remoteUrl = REMOTE,
authChoice = SetupAuthChoice.UsernamePassword,
username = "alice",
hasPasswordCredential = true
)
) as SetupEntry.ExistingConfiguration
val finalState = reduce(entry.editState, SetupWizardAction.Continue, SetupWizardAction.Continue)
val submission = finalState.submissionOrNull()!!
assertEquals(SetupWizardStep.TestAndFinish, finalState.step)
assertTrue(submission.configuration.hasPasswordCredential)
assertFalse(finalState.hasReplacementPasswordCredential)
}
@Test
fun editingPreservesTheExistingSessionProfile() {
val entry = SetupWizardReducer.entryStored(
StoredGatewayConfiguration(
localUrl = LOCAL,
remoteUrl = REMOTE,
authChoice = SetupAuthChoice.UsernamePassword,
username = "alice",
hasPasswordCredential = true,
sessionLabel = "Personal"
)
) as SetupEntry.ExistingConfiguration
val finalState = reduce(entry.editState, SetupWizardAction.Continue, SetupWizardAction.Continue)
val submission = finalState.submissionOrNull()!!
assertEquals("Personal", finalState.sessionLabel)
assertEquals("Personal", submission.configuration.sessionLabel)
assertEquals("Personal", submission.connectionForm().sessionLabel)
}
@Test
fun passwordSubmissionKeepsRawPasswordOutOfConnectionForm() {
val finalState = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.UsernamePassword),
SetupWizardAction.SetUsername("alice"),
SetupWizardAction.SetPassword(PASSWORD),
SetupWizardAction.Continue
)
val submission = finalState.submissionOrNull()!!
val form = submission.connectionForm()
assertEquals(ConnectionAuthMode.PasswordLogin, form.authMode)
assertEquals("alice", form.passwordLoginUsername)
assertEquals("", form.bearerToken)
assertFalse(form.toString().contains(PASSWORD))
assertEquals(PASSWORD, submission.credentials.password.reveal())
}
@Test
fun editingUsernameRequiresAReplacementPassword() {
val entry = SetupWizardReducer.entryStored(
StoredGatewayConfiguration(
localUrl = LOCAL,
remoteUrl = REMOTE,
authChoice = SetupAuthChoice.UsernamePassword,
username = "alice",
hasPasswordCredential = true
)
) as SetupEntry.ExistingConfiguration
val state = reduce(
entry.editState,
SetupWizardAction.Continue,
SetupWizardAction.SetUsername("bob"),
SetupWizardAction.Continue
)
assertEquals(SetupWizardStep.Authentication, state.step)
assertFalse(state.hasStoredPasswordCredential)
assertEquals("Password is required.", state.errors.password)
assertNull(state.submissionOrNull())
}
@Test
fun changingUsernameClearsAReplacementPassword() {
val state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.UsernamePassword),
SetupWizardAction.SetUsername("alice"),
SetupWizardAction.SetPassword(PASSWORD),
SetupWizardAction.SetUsername("bob")
)
assertTrue(state.password.isBlank)
assertFalse(state.hasReplacementPasswordCredential)
}
@Test
fun changingRemoteUrlClearsCredentialsBoundToThePreviousGateway() {
val entry = SetupWizardReducer.entryStored(
StoredGatewayConfiguration(
localUrl = LOCAL,
remoteUrl = REMOTE,
authChoice = SetupAuthChoice.Bearer,
hasBearerCredential = true
)
) as SetupEntry.ExistingConfiguration
val state = SetupWizardReducer.reduce(
entry.editState,
SetupWizardAction.SetRemoteUrl("https://changed.example.test")
)
assertFalse(state.hasStoredBearerCredential)
assertTrue(state.bearerToken.isBlank)
}
@Test
fun missingInvalidOrCredentialIncompleteStoredConfigRequiresSetup() {
val missing = SetupWizardReducer.entry(null)
val insecure = SetupWizardReducer.entry(
ConnectionConfig(remoteUrl = "http://legacy.example.test", authMode = ConnectionAuthMode.None)
)
val missingToken = SetupWizardReducer.entry(
ConnectionConfig(remoteUrl = REMOTE, authMode = ConnectionAuthMode.BearerToken)
)
assertTrue(missing is SetupEntry.Mandatory)
assertTrue(insecure is SetupEntry.Mandatory)
assertTrue(missingToken is SetupEntry.Mandatory)
}
@Test
fun passwordInputIsTransientAndRedactedFromStateActionsAndSubmissionText() {
val passwordAction = SetupWizardAction.SetPassword(PASSWORD)
var state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.UsernamePassword),
SetupWizardAction.SetUsername("alice"),
passwordAction,
SetupWizardAction.Continue
)
val submission = state.submissionOrNull()!!
assertEquals(PASSWORD, state.password.reveal())
assertEquals(PASSWORD, submission.credentials.password.reveal())
assertFalse(state.toString().contains(PASSWORD))
assertFalse(submission.toString().contains(PASSWORD))
assertFalse(submission.configuration.toString().contains(PASSWORD))
assertTrue(passwordAction.javaClass.declaredFields.none { it.type == String::class.java })
assertTrue(StoredGatewayConfiguration::class.java.declaredFields.none { it.name == "password" })
state = reduce(
state,
SetupWizardAction.TestStarted,
SetupWizardAction.TestFailed("Login failed for $PASSWORD")
)
assertFalse(state.toString().contains(PASSWORD))
assertFalse(state.testMessage.orEmpty().contains(PASSWORD))
assertTrue(state.testMessage.orEmpty().contains("[REDACTED]"))
}
@Test
fun secretActionsRedactTheirRawValues() {
val passwordAction = SetupWizardAction.SetPassword(PASSWORD)
val bearerAction = SetupWizardAction.SetBearerToken(TOKEN)
assertFalse(passwordAction.toString().contains(PASSWORD))
assertFalse(bearerAction.toString().contains(TOKEN))
assertTrue(passwordAction.toString().contains("[REDACTED]"))
assertTrue(bearerAction.toString().contains("[REDACTED]"))
}
@Test
fun testFailureActionDoesNotRenderAPasswordInDiagnosticText() {
val action = SetupWizardAction.TestFailed("Login failed for $PASSWORD")
assertFalse(action.toString().contains(PASSWORD))
}
@Test
fun submissionAlwaysValidatesCurrentUrlsInsteadOfCachedNormalization() {
val state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.None),
SetupWizardAction.Continue
).copy(remoteUrl = "http://changed.example.test")
assertNull(state.submissionOrNull())
}
@Test
fun testResultControlsFinishAndEditsResetPriorResult() {
var state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.None),
SetupWizardAction.Continue,
SetupWizardAction.TestStarted,
SetupWizardAction.TestSucceeded
)
assertTrue(state.canFinish)
state = SetupWizardReducer.reduce(state, SetupWizardAction.SetRemoteUrl("https://changed.example.test"))
assertEquals(SetupTestStatus.NotStarted, state.testStatus)
assertFalse(state.canFinish)
}
@Test
fun withdrawingPrivacyAcceptanceResetsPriorTestResult() {
var state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.None),
SetupWizardAction.Continue,
SetupWizardAction.TestStarted,
SetupWizardAction.TestSucceeded
)
assertTrue(state.canFinish)
state = SetupWizardReducer.reduce(state, SetupWizardAction.SetPrivacyAccepted(false))
assertEquals(SetupTestStatus.NotStarted, state.testStatus)
assertFalse(state.canFinish)
}
@Test
fun testResultsRequireAnActiveTestForTheCurrentValidSubmission() {
var state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.None),
SetupWizardAction.Continue,
SetupWizardAction.TestSucceeded
)
assertEquals(SetupTestStatus.NotStarted, state.testStatus)
assertFalse(state.canFinish)
state = reduce(
state,
SetupWizardAction.SetRemoteUrl("http://invalid.example.test"),
SetupWizardAction.TestStarted
)
assertEquals(SetupTestStatus.NotStarted, state.testStatus)
assertFalse(state.canFinish)
}
@Test
fun staleTestResultAfterEditingIsIgnored() {
var state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.None),
SetupWizardAction.Continue,
SetupWizardAction.TestStarted
)
assertEquals(SetupTestStatus.Testing, state.testStatus)
state = reduce(
state,
SetupWizardAction.SetRemoteUrl("https://changed.example.test"),
SetupWizardAction.TestSucceeded
)
assertEquals(SetupTestStatus.NotStarted, state.testStatus)
assertFalse(state.canFinish)
}
@Test
fun staleResultFromAnOlderAttemptCannotCompleteARetest() {
var state = reduce(
authenticationState(),
SetupWizardAction.SelectAuth(SetupAuthChoice.None),
SetupWizardAction.Continue,
SetupWizardAction.TestStarted(41)
)
state = reduce(
state,
SetupWizardAction.TestStarted(42),
SetupWizardAction.TestSucceeded(41)
)
assertEquals(SetupTestStatus.Testing, state.testStatus)
assertEquals(42L, state.activeTestAttemptId)
assertFalse(state.canFinish)
state = SetupWizardReducer.reduce(state, SetupWizardAction.TestSucceeded(42))
assertEquals(SetupTestStatus.Succeeded, state.testStatus)
assertNull(state.activeTestAttemptId)
assertTrue(state.canFinish)
}
@Test
fun editingSupportsDeterministicFailureAndRetest() {
val entry = SetupWizardReducer.entryStored(
StoredGatewayConfiguration(
localUrl = LOCAL,
remoteUrl = REMOTE,
authChoice = SetupAuthChoice.None
)
) as SetupEntry.ExistingConfiguration
val failed = reduce(
entry.editState,
SetupWizardAction.Continue,
SetupWizardAction.Continue,
SetupWizardAction.TestStarted,
SetupWizardAction.TestFailed("offline")
)
val succeeded = reduce(
failed,
SetupWizardAction.TestStarted,
SetupWizardAction.TestSucceeded
)
assertEquals(SetupTestStatus.Failed, failed.testStatus)
assertEquals("offline", failed.testMessage)
assertFalse(failed.canFinish)
assertEquals(SetupTestStatus.Succeeded, succeeded.testStatus)
assertTrue(succeeded.canFinish)
}
@Test
fun reducerIsDeterministicForTheSameInitialStateAndActions() {
val actions = arrayOf(
SetupWizardAction.SetPrivacyAccepted(true),
SetupWizardAction.Continue,
SetupWizardAction.SetRemoteUrl(REMOTE),
SetupWizardAction.SetLocalUrl(LOCAL),
SetupWizardAction.Continue,
SetupWizardAction.SelectAuth(SetupAuthChoice.UsernamePassword),
SetupWizardAction.SetUsername("alice"),
SetupWizardAction.SetPassword(PASSWORD),
SetupWizardAction.Continue,
SetupWizardAction.TestStarted,
SetupWizardAction.TestSucceeded
)
val first = reduce(SetupWizardReducer.firstRun(), *actions)
val second = reduce(SetupWizardReducer.firstRun(), *actions)
assertEquals(first, second)
assertTrue(first.canFinish)
}
private fun authenticationState(): SetupWizardState = reduce(
SetupWizardReducer.firstRun(),
SetupWizardAction.SetPrivacyAccepted(true),
SetupWizardAction.Continue,
SetupWizardAction.SetRemoteUrl(REMOTE),
SetupWizardAction.SetLocalUrl(LOCAL),
SetupWizardAction.Continue
)
private fun reduce(initial: SetupWizardState, vararg actions: SetupWizardAction): SetupWizardState =
actions.fold(initial, SetupWizardReducer::reduce)
private companion object {
const val LOCAL = "https://local.example.test"
const val REMOTE = "https://remote.example.test"
const val TOKEN = "bearer-secret"
const val PASSWORD = "password-secret"
}
}
+2
View File
@@ -10,6 +10,8 @@ Required unblock evidence: run the required preflight and focused Android tests
The `v0.1.0-beta.1` Android package metadata and direct-gateway build/install guide are present in source. On 2026-07-24, the required recovery preflight passed and the source-current offline Gradle `:app:testDebugUnitTest :app:assembleDebug` command passed. Installation verification cannot be completed in the current sandbox.
The first-run Setup Wizard is also source-complete and unit/build verified, including None/Bearer/password choices and direct password-session cookie handling. No real None/Bearer/password credential flow has been exercised against a live gateway, so R2 must include the chosen authentication mode and observed connection result without recording credentials.
ADB is installed, but the R1 evidence above confirms that its daemon sockets are prohibited and that no emulator executable, AVD, `/dev/kvm`, USB device bus, or running target is available. Therefore no source-current beta APK package inspection, clean uninstall/install, launch, or direct-gateway device connection was verified here. No APK was published or uploaded, and no final release criterion is claimed.
Required unblock evidence: in a permitted environment, run the preflight and Android checks from `docs/ANDROID_BETA_BUILD.md`, record `aapt` package/version output and the APK SHA-256, clean-install on one named real device or emulator, launch the app, configure the upstream Hermes gateway directly, and record the connection result in `docs/ROADMAP.md`.
+14
View File
@@ -95,6 +95,20 @@ Security and reliability rules:
- A later milestone may add opt-in mDNS/QR setup, certificate pinning/identity binding, and richer connectivity observation only after direct-gateway basics are verified.
- The current upstream identity-binding limitation and the stronger contract needed are tracked in `docs/BLOCKERS.md`.
## First-run setup and authentication
The Android app owns one versioned setup profile. First launch shows welcome/privacy guidance and requires an explicit Remote HTTPS URL; a Local HTTPS URL is optional and represents the same logical gateway for local-first routing. A valid existing profile bypasses mandatory setup, while legacy/current valid profiles are marked migrated once without requiring credential re-entry. Settings reopens the same wizard state for later edits.
Supported client authentication modes are:
- **None** — probes and compatible requests carry no authorization credential.
- **Bearer** — the token is encrypted at rest and sent only to the selected configured gateway through the existing authorization-header path.
- **Username/password** — the client posts directly to the configured Remote gateway's `/auth/password-login`, accepts only allowlisted Hermes session-cookie names from `Set-Cookie`, encrypts the password and resulting cookie at rest, and sends only the cookie pair on subsequent authenticated checks.
Raw bearer tokens, passwords, and session cookies are excluded from Compose wizard state string output, connection forms, diagnostics, and logs. Changing the remote URL or username invalidates credentials bound to the previous gateway/account; editing may retain an existing encrypted credential only while its binding remains unchanged. Login redirects are disabled, cookie values are validated against header injection, and unsuccessful login clears the prior session cookie.
Setup completion is gated on the same local-first compatibility test used by connection Settings: Local is attempted first when configured, then Remote is used as fallback. This source contract has unit/build verification only; no real gateway credentials, attached Android target, clean install, or release flow has been verified.
## Android Contract Milestones
1. Auth and connection: validate local + remote URLs, store bearer tokens securely, probe `GET /health`, use `GET /v1/models` as the first authenticated check, and make local-first fallback observable/retryable.
+9
View File
@@ -2,6 +2,15 @@
## Current Milestone: Native Android Direct Gateway Contract
### P0 First-run Setup Wizard — completed 2026-07-24
- Welcome/privacy guidance precedes configuration; Remote HTTPS is required and Local HTTPS is optional for the same logical gateway profile.
- Authentication choices are None, Bearer token, or username/password. Password login exchanges credentials directly with the configured remote gateway and stores encrypted credentials/session state outside Compose UI models.
- Test-and-finish uses the existing local-first route selection, requires a successful compatibility check before completion, and exposes the same profile for later Settings edits.
- Existing valid profiles skip mandatory setup; legacy/current valid profiles receive a one-time setup-version migration without forcing re-entry.
- Compose semantics, minimum touch targets, adaptive layout behavior, and phone/tablet/light/dark previews cover the wizard source contract.
- Source verification passed with 104 unit tests and debug assembly. No real device, release, or real gateway credential walkthrough has been performed; those remain part of R1/R2 evidence.
Status: in progress. Android foundation work is complete, and the beta path now targets a remote upstream Hermes Agent gateway/API server directly. The legacy `apps/companion` server is compatibility-only and not required for future Android releases.
Done: