feat: add first-run setup wizard
This commit is contained in:
@@ -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)
|
||||
|
||||
+182
-22
@@ -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)
|
||||
|
||||
+20
-4
@@ -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()
|
||||
|
||||
+107
@@ -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)
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+525
@@ -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
|
||||
)
|
||||
+745
@@ -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
|
||||
)
|
||||
}
|
||||
+1
@@ -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 {
|
||||
|
||||
+49
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -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"
|
||||
}
|
||||
}
|
||||
+245
@@ -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"
|
||||
}
|
||||
}
|
||||
+611
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user