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
@@ -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"
}
}