diff --git a/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt b/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt index 9595d11..96bdcfb 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/ArcaneClientManager.kt @@ -40,8 +40,11 @@ class ArcaneClientManager(context: Context) { private val appContext = context.applicationContext private val prefs = Prefs(appContext) private val mainTabSelectionStore = MainTabSelectionStore(appContext) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var sessionJob = SupervisorJob() + private var scope = CoroutineScope(sessionJob + Dispatchers.Main.immediate) + private val cleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val cookieJar = ArcaneCookieJar() + private var clientGeneration = 0L var authStatus by mutableStateOf(AuthStatus.AUTHENTICATING); private set var serverUrl by mutableStateOf(""); private set @@ -77,42 +80,124 @@ class ArcaneClientManager(context: Context) { val isOidcAvailable: Boolean get() = oidc?.let { it.envConfigured || it.envForced || it.providerName?.isNotBlank() == true } ?: false val isDemoActive: Boolean get() = demoEndsAt != null + val serverSessionIdentity: String get() = + ServerIdentities.from(serverUrl)?.canonicalOrigin.orEmpty() init { scope.launch { - try { - val saved = prefs.serverUrl.first() - prefs.activeEnvId.first()?.let { id -> - activeEnvironmentId = EnvironmentId(id) - activeEnvironmentName = prefs.activeEnvName.first() ?: "Local Docker" - } - if (!saved.isNullOrBlank()) { - serverUrl = saved - client = makeClient(saved) - authStatus = AuthStatus.AUTHENTICATING - checkExistingAuth() - } else { - authStatus = AuthStatus.SETUP - } - } catch (e: CancellationException) { - authStatus = AuthStatus.SETUP - throw e - } catch (e: Throwable) { - authStatus = AuthStatus.SETUP - } + var allowsLegacyTokenMigration = false + restoreAuthenticationSession( + loadSavedState = { + val savedServer = prefs.serverUrl.first() + val activeEnvironmentId = prefs.activeEnvId.first() + SavedAuthState( + serverUrl = savedServer, + activeEnvironmentId = activeEnvironmentId, + activeEnvironmentName = + if (activeEnvironmentId == null) null else prefs.activeEnvName.first(), + credentialOrigin = prefs.credentialOrigin.first(), + ) + }, + applySavedState = { savedState -> + allowsLegacyTokenMigration = + savedState.credentialOrigin == null && + !savedState.serverUrl.isNullOrBlank() + savedState.activeEnvironmentId?.let { id -> + activeEnvironmentId = EnvironmentId(id) + activeEnvironmentName = savedState.activeEnvironmentName ?: "Local Docker" + } + }, + openSavedServer = { savedServer -> + serverUrl = savedServer + client = makeClient( + savedServer, + allowsLegacyTokenMigration = allowsLegacyTokenMigration, + ) + clientGeneration++ + }, + validateSavedSession = { + val c = requireNotNull(client) + currentUser = c.auth.me() + capabilities = c.serverCapabilities() + }, + refreshLoginMethods = ::refreshOidc, + updateStatus = { authStatus = it }, + ) } } - private fun makeClient(url: String, defaultHeaders: Map = emptyMap()): ArcaneClient = - ArcaneClient( + private fun makeClient( + url: String, + defaultHeaders: Map = emptyMap(), + allowsLegacyTokenMigration: Boolean = false, + ): ArcaneClient { + val identity = requireNotNull(ServerIdentities.from(url)) { "Invalid Arcane server URL" } + return ArcaneClient( ArcaneConfiguration( - baseUrl = url, - tokenStore = AndroidSecureTokenStore(appContext), + baseUrl = identity.normalizedUrl, + tokenStore = tokenStore(identity, allowsLegacyTokenMigration), defaultEnvironmentId = activeEnvironmentId, defaultHeaders = defaultHeaders, engine = makeHttpEngine(), ), ) + } + + private fun tokenStore( + identity: ServerIdentity, + allowsLegacyTokenMigration: Boolean, + ): ServerBoundTokenStore = + ServerBoundTokenStore( + origin = identity.canonicalOrigin, + originStore = AndroidSecureTokenStore(appContext, account = identity.tokenAccount), + legacyStore = AndroidSecureTokenStore(appContext), + allowsLegacyMigration = allowsLegacyTokenMigration, + credentialOrigin = { prefs.credentialOrigin.first() }, + bindCredentialOrigin = prefs::setCredentialOrigin, + unbindCredentialOrigin = prefs::clearCredentialOrigin, + ) + + private fun replaceSessionScope() { + sessionJob.cancel() + sessionJob = SupervisorJob() + scope = CoroutineScope(sessionJob + Dispatchers.Main.immediate) + } + + private fun resetEnvironment() { + activeEnvironmentId = EnvironmentId.LOCAL_DOCKER + activeEnvironmentName = "Local Docker" + } + + private fun isCurrentClient(generation: Long, expectedClient: ArcaneClient? = null): Boolean = + generation == clientGeneration && (expectedClient == null || client === expectedClient) + + private fun cleanupServer( + previousUrl: String, + identity: ServerIdentity, + endingClient: ArcaneClient?, + endDemoSession: Boolean, + ) { + cleanupScope.launch { + runCleanupStep { + tokenStore(identity, allowsLegacyTokenMigration = false).clearTokens() + } + runCleanupStep { prefs.clearServerState(previousUrl, identity.canonicalOrigin) } + runCleanupStep { mainTabSelectionStore.clear() } + if (endDemoSession) runCleanupStep { DemoService.endSession() } + runCleanupStep { endingClient?.auth?.logout() } + runCleanupStep { endingClient?.close() } + } + } + + private suspend fun runCleanupStep(block: suspend () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // Cleanup is best-effort, but one failed boundary must not retain the remaining state. + } + } private fun makeHttpEngine(): HttpClientEngine = OkHttp.create { @@ -123,46 +208,75 @@ class ArcaneClientManager(context: Context) { /** Setup mode: validate + persist the server URL and create the client, then go to login. */ fun configure(rawUrl: String) { + errorMessage = null + val nextIdentity = ServerIdentities.from(rawUrl) + if (nextIdentity == null) { + errorMessage = "Enter a valid server URL (e.g. https://arcane.example.com)." + return + } + + val previousUrl = serverUrl + val previousIdentity = ServerIdentities.from(previousUrl) + val previousClient = client + replaceSessionScope() + if (previousIdentity != null && previousIdentity != nextIdentity) { + cleanupServer(previousUrl, previousIdentity, previousClient, endDemoSession = isDemoActive) + } else { + runCatching { previousClient?.close() } + } + + resetEnvironment() + currentUser = null + capabilities = ServerCapabilities.UNKNOWN + oidc = null + cookieJar.clear() + serverUrl = nextIdentity.normalizedUrl + client = makeClient(nextIdentity.normalizedUrl) + clientGeneration++ + authStatus = AuthStatus.LOGIN + val generation = clientGeneration scope.launch { - errorMessage = null - val normalized = ServerUrl.normalize(rawUrl) - if (normalized == null) { - errorMessage = "Enter a valid server URL (e.g. https://arcane.example.com)." - return@launch - } - serverUrl = normalized - prefs.setServerUrl(normalized) - oidc = null - cookieJar.clear() - client?.close() - client = makeClient(normalized) - authStatus = AuthStatus.LOGIN + prefs.setServerUrl(nextIdentity.normalizedUrl) + if (!isCurrentClient(generation)) return@launch refreshOidc() } } fun login(username: String, password: String) { val c = client ?: return + val generation = clientGeneration scope.launch { isLoading = true errorMessage = null try { val response = c.auth.login(username, password) + val detectedCapabilities = c.serverCapabilities() + if (!isCurrentClient(generation, c)) return@launch currentUser = response.user - capabilities = c.serverCapabilities() + capabilities = detectedCapabilities authStatus = AuthStatus.AUTHENTICATED + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - errorMessage = friendlyErrorMessage(e) + if (isCurrentClient(generation, c)) errorMessage = friendlyErrorMessage(e) } finally { - isLoading = false + if (isCurrentClient(generation, c)) isLoading = false } } } fun logout() { val c = client ?: return + val generation = clientGeneration scope.launch { - runCatching { c.auth.logout() } + try { + c.auth.logout() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // SDK logout clears local credentials even when the remote request fails. + } + if (!isCurrentClient(generation, c)) return@launch authStatus = AuthStatus.LOGIN mainTabSelectionStore.clear() cookieJar.clear() @@ -175,15 +289,18 @@ class ArcaneClientManager(context: Context) { fun startOidcSignIn(context: Context) { val c = client ?: return + val generation = clientGeneration scope.launch { isLoading = true errorMessage = null try { OidcAuthenticator(c).startSignIn(context = context, redirectUri = oidcRedirectUri) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - errorMessage = friendlyErrorMessage(e) + if (isCurrentClient(generation, c)) errorMessage = friendlyErrorMessage(e) } finally { - isLoading = false + if (isCurrentClient(generation, c)) isLoading = false } } } @@ -191,6 +308,7 @@ class ArcaneClientManager(context: Context) { fun handleOidcRedirect(uri: Uri?) { val callback = uri?.takeIf(::isExpectedOidcCallback) ?: return val c = client ?: return + val generation = clientGeneration scope.launch { isLoading = true errorMessage = null @@ -208,20 +326,73 @@ class ArcaneClientManager(context: Context) { } else { c.auth.oidcCallback(code = code, state = state, mobileRedirectUri = oidcRedirectUri) } + val detectedCapabilities = c.serverCapabilities() + if (!isCurrentClient(generation, c)) return@launch currentUser = response.user - capabilities = c.serverCapabilities() + capabilities = detectedCapabilities authStatus = AuthStatus.AUTHENTICATED + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - errorMessage = friendlyErrorMessage(e) + if (isCurrentClient(generation, c)) errorMessage = friendlyErrorMessage(e) } finally { - isLoading = false + if (isCurrentClient(generation, c)) isLoading = false } } } fun changeServer() { + val endingUrl = serverUrl + val endingIdentity = ServerIdentities.from(endingUrl) + val endingClient = client + val endingDemo = isDemoActive + replaceSessionScope() + demoExpiryJob?.cancel() + demoExpiryJob = null + clientGeneration++ + val resetGeneration = clientGeneration + client = null + serverUrl = "" + currentUser = null + capabilities = ServerCapabilities.UNKNOWN + oidc = null + isLoading = false + isStartingDemo = false + demoEndsAt = null + demoExpiredMessage = null + resetEnvironment() + cookieJar.clear() errorMessage = null - authStatus = AuthStatus.SETUP + + if (endingIdentity != null) { + // Keep the loading surface up until DataStore has durably removed the server and + // credential binding. Setup must never become visible while an immediate process stop + // could still restore the old server. The slower token/client cleanup remains + // asynchronous after this persistence boundary. + authStatus = AuthStatus.AUTHENTICATING + cleanupScope.launch { + try { + prefs.clearServerState(endingUrl, endingIdentity.canonicalOrigin) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + if (isCurrentClient(resetGeneration)) { + serverUrl = endingUrl + client = endingClient + errorMessage = "Couldn't clear the saved server. Try again." + authStatus = AuthStatus.LOGIN + } + return@launch + } + if (!isCurrentClient(resetGeneration)) return@launch + authStatus = AuthStatus.SETUP + cleanupServer(endingUrl, endingIdentity, endingClient, endDemoSession = endingDemo) + } + } else { + authStatus = AuthStatus.SETUP + runCatching { endingClient?.close() } + cleanupScope.launch { mainTabSelectionStore.clear() } + } } /** Dismiss the "your demo ended" notice shown on the login screen. */ @@ -234,42 +405,61 @@ class ArcaneClientManager(context: Context) { * generated credentials. Port of iOS `startDemo()`. */ fun startDemo() { + val startingGeneration = clientGeneration scope.launch { + var operationGeneration = startingGeneration isLoading = true isStartingDemo = true errorMessage = null demoExpiredMessage = null try { val session = DemoService.startInstance() - serverUrl = DemoService.DEMO_BASE_URL - prefs.setServerUrl(DemoService.DEMO_BASE_URL) + if (!isCurrentClient(startingGeneration)) { + DemoService.endSession() + return@launch + } + val identity = requireNotNull(ServerIdentities.from(DemoService.DEMO_BASE_URL)) + serverUrl = identity.normalizedUrl + prefs.setServerUrl(identity.normalizedUrl) + resetEnvironment() client?.close() // The demo router uses the session-id cookie to route API calls to the provisioned // instance; iOS gets this via shared cookie storage, so inject it on every request. client = makeClient( - DemoService.DEMO_BASE_URL, + identity.normalizedUrl, defaultHeaders = mapOf("Cookie" to "session-id=${session.sessionId}"), ) + clientGeneration++ + operationGeneration = clientGeneration val c = client!! + val generation = clientGeneration try { val response = c.auth.login(session.username, session.password) + val detectedCapabilities = c.serverCapabilities() + if (!isCurrentClient(generation, c)) return@launch currentUser = response.user - capabilities = c.serverCapabilities() + capabilities = detectedCapabilities demoEndsAt = session.endsAtMillis authStatus = AuthStatus.AUTHENTICATED DemoService.startHeartbeat(scope) scheduleDemoExpiry(session.endsAtMillis) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - errorMessage = friendlyErrorMessage(e) + if (isCurrentClient(generation, c)) errorMessage = friendlyErrorMessage(e) DemoService.endSession() } + } catch (e: CancellationException) { + throw e } catch (e: DemoService.DemoException) { - errorMessage = e.message + if (isCurrentClient(startingGeneration)) errorMessage = e.message } catch (e: Throwable) { - errorMessage = friendlyErrorMessage(e) + if (isCurrentClient(startingGeneration)) errorMessage = friendlyErrorMessage(e) } finally { - isLoading = false - isStartingDemo = false + if (isCurrentClient(operationGeneration)) { + isLoading = false + isStartingDemo = false + } } } } @@ -279,21 +469,30 @@ class ArcaneClientManager(context: Context) { demoExpiryJob?.cancel() demoExpiryJob = null // Flip UI state immediately so the user is returned to setup without waiting on cleanup. - val ending = client + val endingUrl = serverUrl + val endingIdentity = ServerIdentities.from(endingUrl) + val endingClient = client + replaceSessionScope() + clientGeneration++ currentUser = null capabilities = ServerCapabilities.UNKNOWN + oidc = null demoEndsAt = null serverUrl = "" client = null + isLoading = false + isStartingDemo = false + resetEnvironment() + cookieJar.clear() authStatus = AuthStatus.SETUP if (expired) { demoExpiredMessage = "Your demo ended. Start a new one or connect to your own server." } - scope.launch { - DemoService.endSession() - runCatching { ending?.auth?.logout() } - runCatching { ending?.close() } - prefs.setServerUrl("") + if (endingIdentity != null) { + cleanupServer(endingUrl, endingIdentity, endingClient, endDemoSession = true) + } else { + runCatching { endingClient?.close() } + cleanupScope.launch { DemoService.endSession() } } } @@ -316,22 +515,24 @@ class ArcaneClientManager(context: Context) { scope.launch { prefs.setActiveEnv(id.rawValue, name) } } - private suspend fun checkExistingAuth() { - val c = client ?: run { authStatus = AuthStatus.LOGIN; return } - try { - currentUser = c.auth.me() - capabilities = c.serverCapabilities() - authStatus = AuthStatus.AUTHENTICATED - } catch (e: Throwable) { - authStatus = AuthStatus.LOGIN - refreshOidc() - } - } - private suspend fun refreshOidc() { val c = client ?: return - val settings = runCatching { c.settings.getPublicSettings() }.getOrNull() - val status = runCatching { c.auth.oidcStatus() }.getOrNull() + val generation = clientGeneration + val settings = try { + c.settings.getPublicSettings() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + val status = try { + c.auth.oidcStatus() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + if (!isCurrentClient(generation, c)) return if (settings == null) { oidc = status return @@ -343,6 +544,7 @@ class ArcaneClientManager(context: Context) { val providerLogoUrl = public["oidcProviderLogoUrl"] val mergeAccounts = public["oidcMergeAccounts"]?.equals("true", ignoreCase = true) == true + if (!isCurrentClient(generation, c)) return oidc = OidcStatusInfo( envConfigured = status?.envConfigured ?: oidcEnabled, envForced = status?.envForced ?: false, diff --git a/app/src/main/kotlin/app/getarcane/android/core/AuthSessionRestorer.kt b/app/src/main/kotlin/app/getarcane/android/core/AuthSessionRestorer.kt new file mode 100644 index 0000000..04fb2ef --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/core/AuthSessionRestorer.kt @@ -0,0 +1,60 @@ +package app.getarcane.android.core + +import kotlinx.coroutines.CancellationException + +internal data class SavedAuthState( + val serverUrl: String?, + val activeEnvironmentId: String?, + val activeEnvironmentName: String?, + val credentialOrigin: String? = null, +) + +/** + * Restores the persisted server and session without exposing the login screen before validation. + * + * Android dependencies stay in [ArcaneClientManager]; keeping the transition coordinator here + * makes startup failure, invalid-session, and cancellation behavior deterministic and testable. + */ +internal suspend fun restoreAuthenticationSession( + loadSavedState: suspend () -> SavedAuthState, + applySavedState: (SavedAuthState) -> Unit, + openSavedServer: (String) -> Unit, + validateSavedSession: suspend () -> Unit, + refreshLoginMethods: suspend () -> Unit, + updateStatus: (AuthStatus) -> Unit, +) { + try { + val savedState = loadSavedState() + applySavedState(savedState) + + val savedServer = savedState.serverUrl + if (savedServer.isNullOrBlank()) { + updateStatus(AuthStatus.SETUP) + return + } + + openSavedServer(savedServer) + updateStatus(AuthStatus.AUTHENTICATING) + + try { + validateSavedSession() + updateStatus(AuthStatus.AUTHENTICATED) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + updateStatus(AuthStatus.LOGIN) + try { + refreshLoginMethods() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // Login remains available even when optional OIDC discovery fails. + } + } + } catch (e: CancellationException) { + updateStatus(AuthStatus.SETUP) + throw e + } catch (_: Throwable) { + updateStatus(AuthStatus.SETUP) + } +} diff --git a/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt b/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt index 4c5115e..af745f9 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt @@ -19,18 +19,45 @@ class Prefs(context: Context) { val accentHex: Flow = store.data.map { it[ACCENT_HEX] } val activeEnvId: Flow = store.data.map { it[ACTIVE_ENV_ID] } val activeEnvName: Flow = store.data.map { it[ACTIVE_ENV_NAME] } + val credentialOrigin: Flow = store.data.map { it[CREDENTIAL_ORIGIN] } - suspend fun setServerUrl(value: String) = store.edit { it[SERVER_URL] = value }.let {} + suspend fun setServerUrl(value: String) = store.edit { + if (it[SERVER_URL] != value) { + it.remove(ACTIVE_ENV_ID) + it.remove(ACTIVE_ENV_NAME) + } + it[SERVER_URL] = value + }.let {} suspend fun setAccentHex(value: String) = store.edit { it[ACCENT_HEX] = value }.let {} suspend fun setActiveEnv(id: String, name: String) = store.edit { it[ACTIVE_ENV_ID] = id it[ACTIVE_ENV_NAME] = name }.let {} + suspend fun setCredentialOrigin(origin: String) = store.edit { + it[CREDENTIAL_ORIGIN] = origin + }.let {} + + suspend fun clearCredentialOrigin(origin: String) = store.edit { + if (it[CREDENTIAL_ORIGIN] == origin) it.remove(CREDENTIAL_ORIGIN) + }.let {} + + suspend fun clearServerState(expectedServerUrl: String, expectedCredentialOrigin: String?) = store.edit { + if (it[SERVER_URL] == expectedServerUrl) { + it.remove(SERVER_URL) + it.remove(ACTIVE_ENV_ID) + it.remove(ACTIVE_ENV_NAME) + } + if (expectedCredentialOrigin != null && it[CREDENTIAL_ORIGIN] == expectedCredentialOrigin) { + it.remove(CREDENTIAL_ORIGIN) + } + }.let {} + companion object { private val SERVER_URL = stringPreferencesKey("server_url") private val ACCENT_HEX = stringPreferencesKey("accent_hex") private val ACTIVE_ENV_ID = stringPreferencesKey("active_env_id") private val ACTIVE_ENV_NAME = stringPreferencesKey("active_env_name") + private val CREDENTIAL_ORIGIN = stringPreferencesKey("credential_origin") } } diff --git a/app/src/main/kotlin/app/getarcane/android/core/ServerBoundTokenStore.kt b/app/src/main/kotlin/app/getarcane/android/core/ServerBoundTokenStore.kt new file mode 100644 index 0000000..214193a --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/core/ServerBoundTokenStore.kt @@ -0,0 +1,59 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.auth.TokenPair +import app.getarcane.sdk.auth.TokenStore +import kotlinx.coroutines.CancellationException + +/** + * Binds SDK token persistence to one canonical server origin. + * + * The legacy store is consulted only during an explicitly allowed upgrade migration. A separate + * credential-origin binding prevents an origin-scoped token from becoming active merely because a + * user enters another server URL. + */ +internal class ServerBoundTokenStore( + private val origin: String, + private val originStore: TokenStore, + private val legacyStore: TokenStore, + private val allowsLegacyMigration: Boolean, + private val credentialOrigin: suspend () -> String?, + private val bindCredentialOrigin: suspend (String) -> Unit, + private val unbindCredentialOrigin: suspend (String) -> Unit, +) : TokenStore { + override suspend fun loadTokens(): TokenPair? { + val boundOrigin = credentialOrigin() + val canUseOrigin = boundOrigin == origin || (boundOrigin == null && allowsLegacyMigration) + if (!canUseOrigin) return null + + originStore.loadTokens()?.let { tokens -> + if (boundOrigin == null) bindCredentialOrigin(origin) + return tokens + } + if (!allowsLegacyMigration) return null + + val legacyTokens = legacyStore.loadTokens() ?: return null + originStore.saveTokens(legacyTokens) + legacyStore.clearTokens() + bindCredentialOrigin(origin) + return legacyTokens + } + + override suspend fun saveTokens(tokens: TokenPair) { + originStore.saveTokens(tokens) + bindCredentialOrigin(origin) + } + + override suspend fun clearTokens() { + var firstFailure: Throwable? = null + for (store in listOf(originStore, legacyStore)) { + try { + store.clearTokens() + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + if (firstFailure == null) firstFailure = failure + } + } + unbindCredentialOrigin(origin) + firstFailure?.let { throw it } + } +} diff --git a/app/src/main/kotlin/app/getarcane/android/core/ServerIdentity.kt b/app/src/main/kotlin/app/getarcane/android/core/ServerIdentity.kt new file mode 100644 index 0000000..5fd8290 --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/core/ServerIdentity.kt @@ -0,0 +1,38 @@ +package app.getarcane.android.core + +import java.net.URI +import java.security.MessageDigest + +internal data class ServerIdentity( + val normalizedUrl: String, + val canonicalOrigin: String, + val tokenAccount: String, +) + +internal object ServerIdentities { + fun from(rawUrl: String): ServerIdentity? { + val normalizedUrl = ServerUrl.normalize(rawUrl) ?: return null + val uri = URI(normalizedUrl) + val scheme = uri.scheme ?: return null + val host = uri.host ?: return null + val port = uri.port.takeIf { it >= 0 } ?: when (scheme) { + "http" -> 80 + "https" -> 443 + else -> return null + } + val authorityHost = if (host.contains(':') && !host.startsWith('[')) "[$host]" else host + val path = uri.rawPath.orEmpty().let { if (it == "/") "" else it } + val canonicalOrigin = "$scheme://$authorityHost:$port$path" + + return ServerIdentity( + normalizedUrl = normalizedUrl, + canonicalOrigin = canonicalOrigin, + tokenAccount = "server.${canonicalOrigin.sha256()}", + ) + } +} + +private fun String.sha256(): String = + MessageDigest.getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } diff --git a/app/src/main/kotlin/app/getarcane/android/core/ServerUrl.kt b/app/src/main/kotlin/app/getarcane/android/core/ServerUrl.kt index 7f8a784..4b7fcd7 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/ServerUrl.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/ServerUrl.kt @@ -18,16 +18,29 @@ object ServerUrl { return null } - val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return null - val host = uri.host?.takeIf { it.isNotBlank() } ?: return null + val scheme = uri.scheme + ?.lowercase(Locale.US) + ?.takeIf { it == "http" || it == "https" } + ?: return null + if (uri.rawUserInfo != null) return null + val host = uri.host + ?.lowercase(Locale.US) + ?.trimEnd('.') + ?.takeIf { it.isNotBlank() } + ?: return null + val port = when { + scheme == "http" && uri.port == 80 -> -1 + scheme == "https" && uri.port == 443 -> -1 + else -> uri.port + } val normalizedPath = normalizePath(uri.rawPath) return try { URI( - scheme.lowercase(Locale.US), - uri.rawUserInfo, + scheme, + null, host, - uri.port, + port, normalizedPath.ifEmpty { null }, null, null, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortDetailScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortDetailScreen.kt index 730e536..c1b7078 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortDetailScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortDetailScreen.kt @@ -26,12 +26,14 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.ui.components.ContentUnavailable @OptIn(ExperimentalMaterial3Api::class) @Composable fun PortDetailScreen(portId: String, onBack: () -> Unit) { - val port = PortStore.get(portId) + val manager = LocalArcaneManager.current + val port = PortStore.get(manager.serverSessionIdentity, portId) val title = port?.containerName ?: "Port" Scaffold( diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt index e36c70f..94a8374 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/ports/PortListScreen.kt @@ -62,13 +62,14 @@ fun PortListScreen(onOpen: (String) -> Unit) { val manager = LocalArcaneManager.current val client = manager.client val envId = manager.activeEnvironmentId + val serverIdentity = manager.serverSessionIdentity var state by remember { mutableStateOf>>(Loadable.Loading) } var search by remember { mutableStateOf("") } var refreshKey by remember { mutableStateOf(0) } var refreshing by remember { mutableStateOf(false) } - LaunchedEffect(envId.rawValue, refreshKey) { + LaunchedEffect(serverIdentity, envId.rawValue, refreshKey) { if (client == null) return@LaunchedEffect if (state !is Loadable.Success) state = Loadable.Loading state = try { @@ -76,7 +77,7 @@ fun PortListScreen(onOpen: (String) -> Unit) { envId = envId, query = SearchPaginationSort(start = 0, limit = 500) ).data - PortStore.put(ports) + PortStore.put(serverIdentity, ports) Loadable.Success(ports) } catch (e: Throwable) { Loadable.Error(friendlyErrorMessage(e)) @@ -300,10 +301,19 @@ internal fun protocolTint(protocol: String): Color = when (protocol.lowercase()) * list -> detail navigation within the Ports tab. */ internal object PortStore { + private var serverIdentity: String = "" private var byId: Map = emptyMap() - fun put(ports: List) { + + fun put(serverIdentity: String, ports: List) { + this.serverIdentity = serverIdentity byId = ports.associateBy { it.id } } - fun get(id: String): PortMapping? = byId[id] + fun get(serverIdentity: String, id: String): PortMapping? = + if (this.serverIdentity == serverIdentity) byId[id] else null + + fun clear() { + serverIdentity = "" + byId = emptyMap() + } } diff --git a/app/src/test/java/app/getarcane/android/core/AuthSessionRestorerTest.kt b/app/src/test/java/app/getarcane/android/core/AuthSessionRestorerTest.kt new file mode 100644 index 0000000..3b8c133 --- /dev/null +++ b/app/src/test/java/app/getarcane/android/core/AuthSessionRestorerTest.kt @@ -0,0 +1,132 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.errors.ArcaneError +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthSessionRestorerTest { + @Test + fun `fresh install routes from authentication gate to setup`() = runBlocking { + val statuses = mutableListOf(AuthStatus.AUTHENTICATING) + var openedServer: String? = null + + restoreAuthenticationSession( + loadSavedState = { SavedAuthState(null, null, null) }, + applySavedState = {}, + openSavedServer = { openedServer = it }, + validateSavedSession = { error("should not validate") }, + refreshLoginMethods = { error("should not refresh") }, + updateStatus = statuses::add, + ) + + assertEquals(listOf(AuthStatus.AUTHENTICATING, AuthStatus.SETUP), statuses) + assertNull(openedServer) + } + + @Test + fun `process recreation keeps auth gate visible until saved session validates`() = runBlocking { + val statuses = mutableListOf(AuthStatus.AUTHENTICATING) + var appliedState: SavedAuthState? = null + var openedServer: String? = null + val state = SavedAuthState("https://arcane.example.com", "edge", "Edge") + + restoreAuthenticationSession( + loadSavedState = { state }, + applySavedState = { appliedState = it }, + openSavedServer = { openedServer = it }, + validateSavedSession = {}, + refreshLoginMethods = { error("should not refresh") }, + updateStatus = statuses::add, + ) + + assertEquals(state, appliedState) + assertEquals(state.serverUrl, openedServer) + assertEquals( + listOf(AuthStatus.AUTHENTICATING, AuthStatus.AUTHENTICATING, AuthStatus.AUTHENTICATED), + statuses, + ) + assertTrue(AuthStatus.LOGIN !in statuses) + } + + @Test + fun `invalid saved session routes to login and refreshes login methods`() = runBlocking { + val statuses = mutableListOf(AuthStatus.AUTHENTICATING) + var refreshCount = 0 + + restoreAuthenticationSession( + loadSavedState = { SavedAuthState("https://arcane.example.com", null, null) }, + applySavedState = {}, + openSavedServer = {}, + validateSavedSession = { throw ArcaneError.Unauthorized }, + refreshLoginMethods = { refreshCount++ }, + updateStatus = statuses::add, + ) + + assertEquals( + listOf(AuthStatus.AUTHENTICATING, AuthStatus.AUTHENTICATING, AuthStatus.LOGIN), + statuses, + ) + assertEquals(1, refreshCount) + } + + @Test + fun `OIDC discovery failure leaves password login available`() = runBlocking { + val statuses = mutableListOf(AuthStatus.AUTHENTICATING) + + restoreAuthenticationSession( + loadSavedState = { SavedAuthState("https://arcane.example.com", null, null) }, + applySavedState = {}, + openSavedServer = {}, + validateSavedSession = { throw IOException("unauthorized") }, + refreshLoginMethods = { throw IOException("discovery unavailable") }, + updateStatus = statuses::add, + ) + + assertEquals(AuthStatus.LOGIN, statuses.last()) + } + + @Test + fun `preference failure clears authentication gate to setup`() = runBlocking { + val statuses = mutableListOf(AuthStatus.AUTHENTICATING) + + restoreAuthenticationSession( + loadSavedState = { throw IOException("preferences unavailable") }, + applySavedState = {}, + openSavedServer = {}, + validateSavedSession = {}, + refreshLoginMethods = {}, + updateStatus = statuses::add, + ) + + assertEquals(listOf(AuthStatus.AUTHENTICATING, AuthStatus.SETUP), statuses) + } + + @Test + fun `cancellation clears authentication gate and is rethrown`() { + val statuses = mutableListOf(AuthStatus.AUTHENTICATING) + val cancellation = CancellationException("manager stopped") + + val thrown = assertThrows(CancellationException::class.java) { + runBlocking { + restoreAuthenticationSession( + loadSavedState = { SavedAuthState("https://arcane.example.com", null, null) }, + applySavedState = {}, + openSavedServer = {}, + validateSavedSession = { throw cancellation }, + refreshLoginMethods = {}, + updateStatus = statuses::add, + ) + } + } + + assertSame(cancellation, thrown) + assertEquals(AuthStatus.SETUP, statuses.last()) + } +} diff --git a/app/src/test/java/app/getarcane/android/core/ServerBoundTokenStoreTest.kt b/app/src/test/java/app/getarcane/android/core/ServerBoundTokenStoreTest.kt new file mode 100644 index 0000000..104325e --- /dev/null +++ b/app/src/test/java/app/getarcane/android/core/ServerBoundTokenStoreTest.kt @@ -0,0 +1,168 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.auth.TokenPair +import app.getarcane.sdk.auth.TokenStore +import java.io.IOException +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test + +class ServerBoundTokenStoreTest { + @Test + fun `matching origin loads only its scoped tokens`() = runBlocking { + val scoped = RecordingTokenStore(tokens("scoped")) + val legacy = RecordingTokenStore(tokens("legacy")) + val binding = CredentialBinding(ORIGIN) + val store = store(scoped, legacy, binding, allowsLegacyMigration = true) + + assertEquals("scoped", store.loadTokens()?.accessToken) + assertEquals(1, scoped.loadCount) + assertEquals(0, legacy.loadCount) + assertEquals(ORIGIN, binding.value) + } + + @Test + fun `different bound origin cannot load scoped or legacy tokens`() = runBlocking { + val scoped = RecordingTokenStore(tokens("scoped")) + val legacy = RecordingTokenStore(tokens("legacy")) + val binding = CredentialBinding("https://other.example.com:443") + val store = store(scoped, legacy, binding, allowsLegacyMigration = true) + + assertNull(store.loadTokens()) + assertEquals(0, scoped.loadCount) + assertEquals(0, legacy.loadCount) + } + + @Test + fun `unbound store does not activate existing credentials outside migration`() = runBlocking { + val scoped = RecordingTokenStore(tokens("scoped")) + val legacy = RecordingTokenStore(tokens("legacy")) + val binding = CredentialBinding(null) + val store = store(scoped, legacy, binding, allowsLegacyMigration = false) + + assertNull(store.loadTokens()) + assertEquals(0, scoped.loadCount) + assertEquals(0, legacy.loadCount) + assertNull(binding.value) + } + + @Test + fun `upgrade migration moves legacy token to saved origin once`() = runBlocking { + val scoped = RecordingTokenStore(null) + val legacy = RecordingTokenStore(tokens("legacy")) + val binding = CredentialBinding(null) + val store = store(scoped, legacy, binding, allowsLegacyMigration = true) + + assertEquals("legacy", store.loadTokens()?.accessToken) + assertEquals("legacy", scoped.value?.accessToken) + assertNull(legacy.value) + assertEquals(1, legacy.clearCount) + assertEquals(ORIGIN, binding.value) + + assertEquals("legacy", store.loadTokens()?.accessToken) + assertEquals(1, legacy.loadCount) + } + + @Test + fun `save persists to origin and binds it`() = runBlocking { + val scoped = RecordingTokenStore(null) + val binding = CredentialBinding(null) + val store = store(scoped, RecordingTokenStore(null), binding) + + store.saveTokens(tokens("new")) + + assertEquals("new", scoped.value?.accessToken) + assertEquals(ORIGIN, binding.value) + } + + @Test + fun `clear removes scoped and legacy tokens and matching binding`() = runBlocking { + val scoped = RecordingTokenStore(tokens("scoped")) + val legacy = RecordingTokenStore(tokens("legacy")) + val binding = CredentialBinding(ORIGIN) + val store = store(scoped, legacy, binding) + + store.clearTokens() + + assertNull(scoped.value) + assertNull(legacy.value) + assertEquals(1, scoped.clearCount) + assertEquals(1, legacy.clearCount) + assertNull(binding.value) + } + + @Test + fun `clear attempts every store and unbinds before surfacing failure`() { + val failure = IOException("scoped clear failed") + val scoped = RecordingTokenStore(tokens("scoped"), clearFailure = failure) + val legacy = RecordingTokenStore(tokens("legacy")) + val binding = CredentialBinding(ORIGIN) + val store = store(scoped, legacy, binding) + + val thrown = assertThrows(IOException::class.java) { + runBlocking { store.clearTokens() } + } + + assertSame(failure, thrown) + assertEquals(1, scoped.clearCount) + assertEquals(1, legacy.clearCount) + assertNull(legacy.value) + assertNull(binding.value) + } + + private fun store( + scoped: TokenStore, + legacy: TokenStore, + binding: CredentialBinding, + allowsLegacyMigration: Boolean = false, + ): ServerBoundTokenStore = + ServerBoundTokenStore( + origin = ORIGIN, + originStore = scoped, + legacyStore = legacy, + allowsLegacyMigration = allowsLegacyMigration, + credentialOrigin = { binding.value }, + bindCredentialOrigin = { binding.value = it }, + unbindCredentialOrigin = { if (binding.value == it) binding.value = null }, + ) + + private fun tokens(accessToken: String): TokenPair = + TokenPair( + accessToken = accessToken, + refreshToken = "refresh-$accessToken", + expiresAt = Instant.fromEpochMilliseconds(4_102_444_800_000), + ) + + private class CredentialBinding(var value: String?) + + private class RecordingTokenStore( + var value: TokenPair?, + private val clearFailure: Throwable? = null, + ) : TokenStore { + var loadCount = 0 + var clearCount = 0 + + override suspend fun loadTokens(): TokenPair? { + loadCount++ + return value + } + + override suspend fun saveTokens(tokens: TokenPair) { + value = tokens + } + + override suspend fun clearTokens() { + clearCount++ + clearFailure?.let { throw it } + value = null + } + } + + private companion object { + const val ORIGIN = "https://arcane.example.com:443" + } +} diff --git a/app/src/test/java/app/getarcane/android/core/ServerIdentityTest.kt b/app/src/test/java/app/getarcane/android/core/ServerIdentityTest.kt new file mode 100644 index 0000000..6b123b5 --- /dev/null +++ b/app/src/test/java/app/getarcane/android/core/ServerIdentityTest.kt @@ -0,0 +1,53 @@ +package app.getarcane.android.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ServerIdentityTest { + @Test + fun `equivalent server spellings share one identity and token account`() { + val identities = listOf( + "arcane.example.com", + "HTTPS://ARCANE.EXAMPLE.COM.:443/", + "https://arcane.example.com/dashboard?tab=updates#images", + ).map { requireNotNull(ServerIdentities.from(it)) } + + assertEquals(listOf("https://arcane.example.com:443"), identities.map { it.canonicalOrigin }.distinct()) + assertEquals(1, identities.map { it.tokenAccount }.distinct().size) + assertEquals(listOf("https://arcane.example.com"), identities.map { it.normalizedUrl }.distinct()) + } + + @Test + fun `scheme host path and non-default port remain distinct`() { + val identities = listOf( + "https://arcane.example.com", + "http://arcane.example.com", + "https://other.example.com", + "https://arcane.example.com/tenant", + "https://arcane.example.com:8443", + ).map { requireNotNull(ServerIdentities.from(it)) } + + assertEquals(identities.size, identities.map { it.canonicalOrigin }.distinct().size) + assertEquals(identities.size, identities.map { it.tokenAccount }.distinct().size) + } + + @Test + fun `token account is a stable pref-key-safe digest`() { + val identity = requireNotNull(ServerIdentities.from("https://arcane.example.com")) + val repeated = requireNotNull(ServerIdentities.from("https://arcane.example.com:443")) + + assertEquals(identity.tokenAccount, repeated.tokenAccount) + assertTrue(identity.tokenAccount.matches(Regex("server\\.[0-9a-f]{64}"))) + assertNotEquals(identity.canonicalOrigin, identity.tokenAccount) + } + + @Test + fun `invalid server URL has no identity`() { + assertNull(ServerIdentities.from("")) + assertNull(ServerIdentities.from("ftp://arcane.example.com")) + assertNull(ServerIdentities.from("https://user:password@arcane.example.com")) + } +} diff --git a/app/src/test/java/app/getarcane/android/core/ServerUrlTest.kt b/app/src/test/java/app/getarcane/android/core/ServerUrlTest.kt index dbc2bb9..ef08271 100644 --- a/app/src/test/java/app/getarcane/android/core/ServerUrlTest.kt +++ b/app/src/test/java/app/getarcane/android/core/ServerUrlTest.kt @@ -38,6 +38,19 @@ class ServerUrlTest { ) } + @Test + fun normalizeCanonicalizesHostAndDefaultPorts() { + assertEquals("https://arcane.example.com", ServerUrl.normalize("HTTPS://ARCANE.EXAMPLE.COM.:443/")) + assertEquals("http://arcane.example.com", ServerUrl.normalize("http://ARCANE.EXAMPLE.COM:80")) + assertEquals("https://arcane.example.com:8443", ServerUrl.normalize("https://ARCANE.EXAMPLE.COM:8443")) + } + + @Test + fun normalizeRejectsUnsupportedSchemesAndEmbeddedCredentials() { + assertNull(ServerUrl.normalize("ftp://arcane.example.com")) + assertNull(ServerUrl.normalize("https://user:password@arcane.example.com")) + } + @Test fun normalizeRejectsBlankOrHostlessUrls() { assertNull(ServerUrl.normalize("")) diff --git a/app/src/test/java/app/getarcane/android/ui/screens/ports/PortStoreTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/ports/PortStoreTest.kt new file mode 100644 index 0000000..e5f804a --- /dev/null +++ b/app/src/test/java/app/getarcane/android/ui/screens/ports/PortStoreTest.kt @@ -0,0 +1,49 @@ +package app.getarcane.android.ui.screens.ports + +import app.getarcane.sdk.models.port.PortMapping +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PortStoreTest { + @Test + fun `port cache cannot cross server identity`() { + val port = port("port-a") + PortStore.put("https://server-a.example.com:443", listOf(port)) + + assertEquals(port, PortStore.get("https://server-a.example.com:443", port.id)) + assertNull(PortStore.get("https://server-b.example.com:443", port.id)) + } + + @Test + fun `replacing server cache removes previous rows even when ids match`() { + val serverA = port("shared", containerName = "server-a") + val serverB = port("shared", containerName = "server-b") + PortStore.put("https://server-a.example.com:443", listOf(serverA)) + + PortStore.put("https://server-b.example.com:443", listOf(serverB)) + + assertNull(PortStore.get("https://server-a.example.com:443", "shared")) + assertEquals(serverB, PortStore.get("https://server-b.example.com:443", "shared")) + } + + @Test + fun `clear removes cached rows`() { + PortStore.put("https://server-a.example.com:443", listOf(port("port-a"))) + + PortStore.clear() + + assertNull(PortStore.get("https://server-a.example.com:443", "port-a")) + } + + private fun port(id: String, containerName: String = "container"): PortMapping = + PortMapping( + id = id, + containerId = "container-id", + containerName = containerName, + hostPort = 8080, + containerPort = 80, + protocolName = "tcp", + isPublished = true, + ) +} diff --git a/docs/ios-android-gap-analysis.md b/docs/ios-android-gap-analysis.md index e1acf7a..36b3c6f 100644 --- a/docs/ios-android-gap-analysis.md +++ b/docs/ios-android-gap-analysis.md @@ -1,6 +1,6 @@ # iOS-to-Android gap analysis -Last reviewed: 2026-07-17 +Last reviewed: 2026-08-21 This document compares Arcane's iOS application with the Android application to guide Android product planning. It is a source-analysis snapshot, not a promise that Android will reproduce every @@ -12,27 +12,27 @@ The analysis is pinned to these product revisions: | Component | Revision | Notes | | --- | --- | --- | -| iOS | [`03f2f3d11e40f759ca62f0207bb3d59418a42933`](https://github.com/getarcaneapp/iOS/tree/03f2f3d11e40f759ca62f0207bb3d59418a42933) | `main`, app version 0.5.4, dated 2026-07-10 | -| iOS resolved Swift SDK | [`c1016b2e0aaebffc112893179560c2462c1a013a`](https://github.com/getarcaneapp/libarcane-swift/tree/c1016b2e0aaebffc112893179560c2462c1a013a) | `libarcane-swift` revision resolved by the iOS project | -| Android | [`c500c262e4e71b094e16ca8afc049f2286d22cfa`](https://github.com/getarcaneapp/android/tree/c500c262e4e71b094e16ca8afc049f2286d22cfa) | `origin/main` product baseline | +| iOS | [`2d7f277fe322d67c88d62b826f068fa92785e3fe`](https://github.com/getarcaneapp/ios/tree/2d7f277fe322d67c88d62b826f068fa92785e3fe) | `main`, app version 0.7.0, dated 2026-08-18 | +| iOS resolved Swift SDK | [`38b5c32dde5b17eb0bc22b1c13fb4204699c8faf`](https://github.com/getarcaneapp/libarcane-swift/tree/38b5c32dde5b17eb0bc22b1c13fb4204699c8faf) | `libarcane-swift` revision resolved by the iOS project | +| Android | [`10b26b2275fb8b9772ff69f2e1f6418225be532a`](https://github.com/getarcaneapp/android/tree/10b26b2275fb8b9772ff69f2e1f6418225be532a) | Current `origin/main`; product code is unchanged after the container completeness work in `fdfabe3` | | Kotlin SDK | [`991dfdc1ee747c171ebf1b5953fe5fb61ceadfb8`](https://github.com/getarcaneapp/libarcane-kotlin/tree/991dfdc1ee747c171ebf1b5953fe5fb61ceadfb8) | `origin/main` product baseline | -The Android and Kotlin SDK checkouts also had `agent/add-agent-guidance` commits at review time. -Those commits add only repository guidance and are not treated as product functionality. +The previous analysis was pinned to iOS 0.5.4. This refresh inspected all 14 iOS commits through +0.7.0, the resolved Swift SDK, the unchanged Kotlin SDK head, Android `origin/main`, and the +canonical backlog. The Android commits after the old product pin are documentation plus the +completed container-list hardening recorded as PAR-003. + +Parity status also reflects the locally validated PAR-001 session-restoration hardening and PAR-002 +server-session scoping layered on the pinned Android base. Their exact implementation and validation +evidence is maintained in the canonical task list. ### Method and limitations This is a static source comparison of application structure, visible routes, stores, service calls, -models, persistence, tests, and release automation. No iOS build was run because the review was -performed on Windows. No emulator/device or live Arcane server was used, and neither application's -complete runtime behavior was manually exercised. Items that depend on server version, runtime -permissions, signing, background execution limits, or App Store/Play distribution should therefore -be validated before implementation decisions are made. - -The current Android/Kotlin composite checkout did pass -`./gradlew :app:testDebugUnitTest :app:assembleDebug` during this analysis. That result confirms the -reviewed source compiles and its JVM unit tests pass; it does not validate device or live-server -behavior. +models, persistence, tests, release notes, and release automation. No iOS build, emulator/device, +or live Arcane server was used. Items that depend on server version, WebAuthn/passkey configuration, +runtime permissions, signing, background execution limits, or distribution must therefore be +validated before implementation decisions are final. The comparison distinguishes product capabilities from platform-specific mechanisms. For example, an iOS Live Activity does not imply that Android needs a literal copy; the Android question is @@ -63,27 +63,30 @@ The largest difference is depth and continuity, not the count of resource screen mature application shell and operational layer: disk-backed stale-while-revalidate caching, adaptive tablet navigation, profile management, a complete project-file workspace, richer log workflows, image attestations, persistent deployment progress, and several native entry points. -iOS also contains an optional on-device AI assistant, although that implementation is tied to -Apple's Foundation Models and should be treated as a product concept rather than a direct Android -port. - -The most urgent Android work is smaller than those strategic gaps. Change-server can leave -prior-server state or credentials alive; some admin destinations lose their drill-down callbacks -when selected as main tabs; the appearance selector is non-persistent and does not drive the app -theme; and Settings links point to the iOS repository. Those should be fixed before broad parity -work. Separately, decide whether to expose the currently unreachable environment -list/detail/test surface. +iOS 0.7.0 also adds passkey sign-in and MFA, scoped global variables, image layer history, +configurable project deploy options, richer template discovery, activity-start feedback, and an +interactive network-to-container topology. It removes the Arcane Assistant, so AI is no longer an +iOS-parity gap. + +The most urgent Android work is smaller than those strategic gaps. PAR-002 now closes the +change-server state and credential-scoping defect. Some admin destinations still lose their +drill-down callbacks when selected as main tabs; the appearance selector is non-persistent and does +not drive the app theme; and Settings links point to the iOS repository. Those should be fixed +before broad parity work. The old recommendation to expose a separate environment list is no longer +a parity blocker: iOS 0.7.0 deliberately makes the dashboard its single fleet destination, which is +compatible with Android's dashboard-plus-detail outcome. The recommended sequence is: 1. Fix reachable-navigation and settings defects. 2. Complete high-value daily workflows: project files, profile/account management, logs, image - attestations, and missing container actions. + attestations/history, passkeys, variables, deploy options, template discovery, and missing + container actions. 3. Add resilient cached reads and persistent long-running operation state. 4. Add Android-native equivalents for adaptive navigation, widgets, shortcuts, deep links, and ongoing-operation notifications. -5. Consider optional strategic features such as AI assistance and multi-server profiles only after - the operational foundation is reliable. +5. Consider optional product expansion such as multi-server profiles only after the operational + foundation is reliable. ## Detailed capability matrix @@ -108,9 +111,10 @@ The recommended sequence is: | Server setup | Server URL setup with DNS/bootstrap retry and local-server allowances. | URL normalization and server setup exist. | **Parity** for the primary outcome; compare error recovery during live testing. | | Password authentication | Password login, secure persisted credentials/tokens, session restore, and logout. | Password login, encrypted token storage, restoration, and logout via `ArcaneClientManager`. | **Parity.** | | OIDC | Uses `ASWebAuthenticationSession` and public provider information. | Current and legacy OIDC callback/deep-link handling. | **Parity** at the product level; device-test provider variants. | +| Passkeys and MFA | Passkey sign-in, passkey enrollment/rename/delete, password or passkey step-up, MFA policy, and recovery on supported Arcane servers. | No passkey or MFA flow exists, and the Kotlin SDK has no passkey service or Android credential bridge. | **Android plus SDK gap.** Add the typed Arcane contract to `libarcane-kotlin`, then use Android Credential Manager with capability-gated login, recovery, and account settings. | | Demo mode | Demo provisioning and session behavior. | Demo provisioning, heartbeat, and countdown. | **Parity**, with Android exposing explicit heartbeat/countdown behavior. | | User profile | View/update display name and email, change password, avatar/Gravatar handling, sign out, and change server. | Current-user data is held for authorization, but no comparable end-user profile/account workflow was found. | **Android gap.** Add a profile route distinct from admin user management. | -| Multiple server profiles | No complete multi-profile manager was identified; change-server flow exists. | One active server is persisted. `changeServer` only transitions to setup; stale client, user, capabilities, cookies, or tokens may survive, and stored tokens are not scoped by server. | **Shared profile gap plus Android security/correctness defect.** Immediately invalidate all prior-server state and scope credentials by server before considering profiles. | +| Multiple server profiles | No complete multi-profile manager was identified; change-server flow exists. | One active server is persisted. PAR-002 canonicalizes its origin, scopes tokens and process caches, rotates client/session ownership, and durably clears the saved server and credential binding before exposing setup. | **Shared profile gap; single-server switching is hardened.** Keep multi-profile work deferred until cache, operation, and route identity are equally scoped. | | Biometric application lock | No core capability identified. | No core capability identified. | **Shared gap**, not required for iOS parity. Consider separately if threat modeling supports it. | ### Dashboard and environment management @@ -120,7 +124,7 @@ The recommended sequence is: | Fleet dashboard | Fleet totals, server cards, stats/sparklines, needs-attention groups, failed activities, pinned resources/actions, update-all, and card actions for environment sync/system/upgrade/prune. | Fleet totals/cards, stats/sparklines, needs attention, failed activities, pins/actions, update-all, and per-environment prune/detail/active behavior; sync/system/upgrade card actions are absent. | **Partial.** Add the missing high-value card actions with permission and server-capability gating. | | Live dashboard updates | v2 stream with legacy fallback and bounded concurrent stats streams. | Dashboard streaming with reconnect behavior and resource statistics streams. | **Partial.** Validate fallback/version behavior and connection limits under many environments. | | Environment selection | Active environment selection and environment-aware navigation. | Active environment selection, detail/test, persistence, and client rebuild. | **Parity** for selection. | -| Environment management | Neither pinned client exposes a confirmed full create/edit/delete workflow. | `ui/screens/environments/EnvironmentsScreen.kt` contains an unreachable list/detail/set-active/metadata/version/test surface; it is not full CRUD. | **Shared/undefined capability.** Decide the supported server workflow before adding full management. Android may separately expose its existing read/detail/test surface if that has product value. | +| Environment management | The dashboard is the single fleet destination and opens environment details/actions; it does not claim full CRUD. | Dashboard cards open environment details and selection; a separate list/detail/test surface exists but is not a primary route or full CRUD. | **Parity** for the current read/select/detail outcome. Exposing the extra Android list is a product choice, not a parity prerequisite. | | Fleet pagination | Environment-backed views load the complete relevant fleet. | The SDK environment list defaults to 20. `DashboardScreen`, `UpdatesScreen`, `AllEnvironmentsImageUpdatesScreen`, and `EnvironmentListScreen` call it without pagination, silently omitting environments above 20. | **Android correctness defect.** Implement explicit paging or a deliberate complete-fleet query and test fleets of 0, 20, 21, and multiple pages. | | Offline dashboard snapshot | Disk cache and last-known server snapshots support stale display. | No disk-backed response cache/database was found. | **Android gap.** See the resilience section. | @@ -134,7 +138,6 @@ The recommended sequence is: | Statistics | Live CPU, memory, network, and I/O presentation. | Live statistics and charts. | **Parity.** | | Logs | Search/filter, pause, timestamps, retention, ANSI rendering, copy/share/export. | Live logs and ANSI handling are present, but the iOS-level copy/share/export workflow was not identified. | **Partial.** Add select/copy/share/export and verify cancellation/reconnect behavior. | | Terminal | Interactive terminal with special keys, copy, and clear. | Interactive terminal exists. | **Partial to parity.** Device-test IME, lifecycle, special-key, and reconnect behavior. | -| Ask AI from resource context | Resource-aware entry to the on-device assistant. | No AI assistant. | **Android gap**, but strategic and optional rather than core container parity. | ### Projects and Compose workflows @@ -142,6 +145,7 @@ The recommended sequence is: | --- | --- | --- | --- | | Project list and lifecycle | Active/archived projects, create, deploy/redeploy, start/stop/restart, logs, archive/delete. | Active/archived projects, create from blank/template, lifecycle streams, logs, archive/delete. | **Parity** for broad lifecycle coverage. | | Project creation | Compose and `.env` input, templates, variable-resolution support. | Blank/template creation with Compose and `.env` input. | **Parity** for initial creation. | +| Deploy options | Per-project deploy supports pull-policy and force-recreate choices and remembers them by server/environment/project. | The Kotlin SDK exposes `DeployOptions`, but Android always starts the default deploy stream and has no options UI or scoped preference. | **Android UI/state gap.** Add capability-safe options and scope persistence so settings cannot bleed across servers or projects. | | Existing project files | File tree, Compose/`.env` editor, save, create, rename, move, and delete. GitOps/archived projects are read-only where appropriate. | Existing-project Compose is effectively read-only; there is no complete file workspace. | **Major Android gap.** This is the highest-value feature-depth gap for users managing projects from mobile. | | Variable resolution | Resolution preview plus resolved YAML in the editing workflow. | Preview exists, but it is not part of a full editable existing-project workspace. | **Partial.** Fold it into the file editor rather than building another isolated preview. | | Persistent deployment progress | Operation store survives sheet dismissal; floating progress pill, activity IDs, reconnect/cancel, and background grace. | Streaming action screens exist, but no equivalent process-resilient or app-wide operation presentation was found. | **Android gap.** Add application-owned operation state and an ongoing notification where appropriate. | @@ -160,6 +164,7 @@ release. Do not add application-local HTTP calls or duplicate DTOs. | Image inventory and lifecycle | List/detail, pull, streamed tar upload through `UploadImageView`, delete/prune, inspect/config/layers, and update workflows. | Filtered inventory, streamed pull, tar upload, remove/prune, inspect/config/layers, and update flows. | **Parity.** | | Vulnerability scanning | Scan, filter, ignore, and inspect vulnerabilities. Some DTOs are app-local raw REST because of an iOS SDK mismatch. | Scan/filter/ignore and aggregate/detail vulnerability flows are present through the Kotlin stack. | **Parity/Android strength.** Keep DTOs in the SDK and verify unknown values defensively. | | Image attestations | Attestation list/filter/detail and statement copy. | No attestation UI was identified; the pinned Kotlin SDK exposes attestation operations. | **Android UI gap.** Add the workflow using SDK types and confirm payload behavior against the target server. | +| Image layer history | Image detail shows Docker layer history with command, size, date, and tags. | No image layer-history route exists. The Kotlin SDK exposes image build history, which is a different API, but not per-image Docker layer history. | **Android plus SDK gap.** Add the typed history endpoint first, then an environment- and digest-scoped detail tab. | | Image updates | Per-image and fleet update flows. | Per-image, update overview, updater, and fleet-update flows. | **Parity/Android strength.** Android has substantial explicit updater behavior. | ### Volumes, networks, and ports @@ -167,7 +172,7 @@ release. Do not add application-local HTTP calls or duplicate DTOs. | Capability | iOS baseline | Android baseline | Status and action | | --- | --- | --- | --- | | Volumes | Create/remove/prune, detail, browser, backups, restore/delete/download. | Create/remove/prune, detail, browser, backups, restore/delete/download. | **Parity.** | -| Network management | List/create/delete/detail plus a topology view. The current topology is a list rather than a graph. | List/create/delete/detail plus topology presented as nodes/list. | **Parity with a shared gap.** A graph is a future product enhancement, not an Android parity defect. | +| Network management | List/create/delete/detail plus an interactive network-to-container diagram. | List/create/delete/detail plus topology presented as grouped rows. | **Partial/Android gap.** Preserve the readable list as an accessibility/fallback mode while adding a bounded interactive graph for useful topology parity. | | Network summary accuracy | Some internal/container-count values are stubbed in the iOS app. | Android data should be compared with server responses rather than copied from iOS summaries. | **Validate.** Do not treat known iOS stubs as a target. | | Ports | Read-only port inventory. | Read-only port inventory. | **Parity.** | @@ -176,6 +181,7 @@ release. Do not add application-local HTTP calls or duplicate DTOs. | Capability | iOS baseline | Android baseline | Status and action | | --- | --- | --- | --- | | Activities | v2 activity stream, filtering, cancellation/clearing, environment context. | All-environment v2 live stream, filtering, cancel, and clear. | **Parity.** The pinned Kotlin SDK includes activity error/heartbeat support; validate event behavior against the target server. | +| Activity-start feedback | User-configurable toasts surface user/system activity and open the app-wide Activity Center. | Activity Center and failed-count badge exist, but no configurable app-wide activity-start surface was found. | **Partial.** Treat this as a projection of the future operation/activity store, with bounded noise and permission-safe environment context. | | Events | Event inventory and details. | Event inventory and details. | **Parity.** | | Live event updates | Event presentation refreshes as server events arrive. | The Android screen loads paginated snapshots; no live polling or event stream refresh was identified. | **Partial.** Add lifecycle-aware polling or a server-supported stream, with visible refresh/error state. | | Jobs | Job inventory and actions/details. | Job surfaces are present. | **Parity at screen level; validate** live lifecycle operations. | @@ -191,6 +197,9 @@ release. Do not add application-local HTTP calls or duplicate DTOs. | API keys | API key management. | API key management. | **Parity.** | | Roles and OIDC mappings | Role/RBAC and OIDC mapping administration. | Roles/RBAC and OIDC mapping administration. | **Parity** at screen level. | | Notification providers and webhooks | Provider-specific notification configuration and webhooks. | Notification and webhook configuration. | **Parity** in broad coverage; compare provider-specific validation. | +| Global variables | v2 global variables support create/edit/delete, secret values, all/specific-environment scoping, sync status, and explicit sync. | Only Compose placeholder resolution and older per-environment template-variable SDK calls exist; there is no global-variable management route. | **Android plus SDK gap.** Model the current variables contract in the Kotlin SDK before adding permission-gated Android state and UI. | +| Template discovery | Search, source filtering, metadata, preview, remote download, deploy, and registry management. | Registry CRUD, grouped browsing, preview, and deploy exist; search, source filters, rich metadata, and remote download are absent. | **Partial.** Complete the discovery/import workflow using existing typed template APIs and add paging/error coverage. | +| Container registry names | Registries expose a user-facing repository name in addition to URL and credentials. | Android and the Kotlin SDK model URL/credentials but not the current optional name field. | **Android plus SDK gap.** Add the optional field defensively in the SDK and expose it in create/edit/list UI. | | Authentication/system/build/upgrade | Server authentication settings, system information/settings, builds, and upgrade. | Authentication, system, build, and upgrade surfaces. | **Parity** in broad coverage. | | Admin destinations as swappable tabs | Destinations retain their expected drill-down behavior. | Users, Notifications, System, and Roles use empty drill-down callbacks when selected as primary tabs in `nav/MainTabView.kt`; they work through Settings. | **Android defect.** Reuse one route owner or pass functional callbacks in both entry contexts. | | Documentation/support links | iOS repository links are appropriate to the app. | App Settings GitHub/issue links point to the iOS repository. | **Android defect.** Point source and issue links to the Android repository or a deliberate cross-project destination. | @@ -224,7 +233,7 @@ These rows compare user outcomes, not identical APIs. | Share/export | Native log sharing/export. | Android Sharesheet and Storage Access Framework/MediaStore as appropriate. | **Partial.** Android already uses MediaStore/download and share primitives in some flows, but not consistently for logs. | | File input/output | Native pickers and share sheets. | Android file picker, MediaStore downloads, clipboard, share, and autofill are already used. | **Parity/Android strength.** | | Alternate application icon | Supported. | Launcher alias approach is possible but launcher-dependent. | **Optional platform difference**, not a parity priority. | -| On-device AI | Apple Foundation Models on supported iOS 26 hardware, with streaming, read tools, and staged confirmed mutations. | If pursued, define provider, privacy boundary, device/server capability, tool permissions, and mandatory confirmation independently. | **Strategic Android gap**, not a literal port. | +| AI assistant | Removed in iOS 0.7.0. | No AI assistant. | **No current parity gap.** Any future assistant is an independent product/security project. | No Android notification, widget, shortcut, share-in, resource app-link, QR setup, or biometric-lock system was identified at the baseline. These should not be delivered as one monolithic “native @@ -243,12 +252,12 @@ features” project; each needs a clear user scenario and data-security review. | Capability | iOS baseline | Android baseline | Status and action | | --- | --- | --- | --- | -| Unit tests | Approximately 19 XCTest methods across five files, focused mainly on utilities. | 19 JVM test files and roughly 84 test methods, with useful coverage of navigation, dashboard/updater logic, URL handling, and ANSI parsing. | **Android strength.** | +| Unit tests | 29 XCTest methods across six files, still focused mainly on utilities and post-0.6 pagination/security regressions. | 24 JVM test files and 120 test methods, with useful coverage of authentication restoration, server credential/cache scoping, navigation, dashboard/updater logic, URL handling, ANSI parsing, and container completeness. | **Android strength.** | | UI/instrumented tests | No meaningful UI test suite identified. | Only the template/example instrumentation test was identified. | **Shared gap.** Add a small navigation/auth/destructive-confirmation suite before attempting broad UI automation. | | Integration/network contract tests | No broad suite identified. | No broad end-to-end contract suite identified. | **Shared gap.** SDK serialization/service tests should carry most wire-contract coverage. | | CI | No repository CI workflow was identified in the inspected iOS baseline. | CI uses JDK 21/API 35 and runs unit tests/build, with optional signed tag release support. | **Android strength.** | | Static quality/security gates | No comprehensive suite identified. | No lint, detekt, ktlint, instrumentation, or security scan gate was identified in CI. | **Android gap.** Add targeted gates incrementally; do not create a noisy all-at-once migration. | -| Release maturity | Version 0.5.4/build 260710 and more distribution-oriented product surfaces. App Store status was not confirmed. | Version 0.1.0; minification disabled; repository messaging still warns that the app is not intended for devices. | **Android maturity gap.** Define alpha/beta support criteria before production claims. | +| Release maturity | Version 0.7.0 and more distribution-oriented product surfaces. App Store status was not confirmed. | Version 0.1.0; minification disabled; repository messaging still warns that the app is not intended for devices. | **Android maturity gap.** Define alpha/beta support criteria before production claims. | | Release-note integrity | Notes correspond to iOS releases and platform behavior. | `ui/screens/whatsnew/ReleaseNotes.kt` begins at 0.2.1 while the app reports 0.1.0 and contains copied iOS-specific claims. | **Android release-hygiene defect.** Replace with Android-verified notes and enforce version ordering. | ## SDK/API prerequisites versus Android-only work @@ -266,8 +275,7 @@ tests, then build the Android UI. These items appear to have sufficient application or SDK foundations and are primarily Android composition, persistence, or platform work: -- decide whether to expose the currently unreachable environment list/detail/test surface, and - repair admin-tab drill-down callbacks; +- repair admin-tab drill-down callbacks; - persist and apply Light/Dark/Auto appearance; - correct Android source/issue links; - add an end-user profile route using the SDK's existing user update, password-change, and avatar @@ -278,6 +286,8 @@ composition, persistence, or platform work: - add response caching around existing read services; - fix all complete-fleet callers to page through `EnvironmentsService` explicitly; - implement the project-file workspace using the pinned typed SDK operations; +- expose project deploy options already supported by `ProjectsService`; +- complete template search/filter/metadata/download with the existing template service; - harden coroutine cancellation and stream ownership; - define Android backup/extraction rules. @@ -285,6 +295,10 @@ composition, persistence, or platform work: These need an explicit SDK/server capability check at the pinned revisions: +- passkey sign-in, step-up, MFA policy, recovery, and Android Credential Manager ceremony support; +- v2 scoped global-variable models, permissions, mutation, and sync status; +- per-image Docker layer history (distinct from the Kotlin SDK's image-build history); +- the optional container-registry name field; - exact activity stream error, heartbeat, and forward-compatible event handling; - dynamic/generic resource descriptors, if a generic fallback UI is desired; - server-version fallbacks for dashboard and fleet updates. @@ -295,18 +309,20 @@ changes, and activity/stream APIs. Profile, attestation, container-action, and p gaps should therefore begin as Android UI/state work, while still receiving focused serialization and contract tests against the target server version. -No current SDK blocker was identified for these top gaps. The pinned SDK revision, the sibling -composite-build revision, and the target server version must nevertheless be recorded together -because active SDK development can change that conclusion. +The Kotlin SDK remains at its 2026-07-10 parity commit while the Swift SDK advanced through +2026-08-17. Passkeys/MFA, current global variables, image layer history, and registry names are +confirmed SDK prerequisites. Profile, attestations, container actions, project files, deploy +options, and template download already have typed Kotlin foundations. The pinned SDK revision, the +sibling composite-build revision, and the target server version must be recorded together because +active SDK development can change those conclusions. ### Server or product-definition prerequisites - Swarm remains a placeholder in both clients and needs a defined product/API workflow. -- A true network topology graph is not present in either app and should be specified as a shared - enhancement. - Multi-server profiles require an explicit credential, cache, deep-link, and active-operation model. -- AI assistance requires a separate product/security design, especially for mutations. +- AI assistance is not present in either current mobile client; any future work needs a separate + product/security design. ## Android strengths to preserve @@ -329,21 +345,20 @@ views, create a second client/cache owner, or reproduce Apple-specific UI metaph ### P0: Correctness and reachable functionality -1. Make change-server safe: clear the prior client, current user, capabilities, cookies, and active - environment, and store credentials/tokens under a normalized server identity. -2. Repair the empty callbacks for Users, Notifications, System, and Roles when used as swappable +PAR-002 completed the change-server foundation: prior client/user/capability/environment state is +invalidated and credentials are scoped to a normalized server identity. Remaining P0 work is: + +1. Repair the empty callbacks for Users, Notifications, System, and Roles when used as swappable main tabs. -3. Decide whether to expose Android's existing environment list/detail/test surface; do not label - it full CRUD without a defined shared workflow. -4. Persist Light/Dark/Auto and apply it at the application theme root. -5. Correct source, documentation, and issue links that point to the iOS repository. -6. Fix silent 20-environment truncation in dashboard, updates, all-environment image updates, and +2. Persist Light/Dark/Auto and apply it at the application theme root. +3. Correct source, documentation, and issue links that point to the iOS repository. +4. Fix silent 20-environment truncation in dashboard, updates, all-environment image updates, and environment management; add multi-page tests. -7. Replace copied iOS release notes with Android-specific, version-consistent notes, then add +5. Replace copied iOS release notes with Android-specific, version-consistent notes, then add version-gated automatic presentation. -8. Define backup/data-extraction exclusions for tokens, server data, future caches, and operation +6. Define backup/data-extraction exclusions for tokens, server data, future caches, and operation state. -9. Audit broad coroutine exception catches and rethrow cancellation. +7. Audit broad coroutine exception catches and rethrow cancellation. ### P1: Complete high-frequency operational workflows @@ -353,9 +368,13 @@ views, create a second client/cache owner, or reproduce Apple-specific UI metaph 3. Complete log search/copy/share/export and lifecycle consistency across container/project logs. 4. Add image attestation list/detail/filter/copy using the existing SDK support. 5. Fill missing container lifecycle/detail actions that the server and SDK support. -6. Add application-owned long-running operation state with reconnect/cancel and an in-app progress +6. Add passkey sign-in/MFA through a typed Kotlin SDK and Android Credential Manager. +7. Add scoped global-variable management through a typed Kotlin SDK. +8. Add image layer history, deploy options, registry names, and the remaining template + search/filter/download workflow. +9. Add application-owned long-running operation state with reconnect/cancel and an in-app progress surface. -7. Add lifecycle-aware event refresh and an explicit Activity Center retry path. +10. Add lifecycle-aware event refresh and an explicit Activity Center retry path. ### P2: Resilience and Android-native continuity @@ -366,15 +385,14 @@ views, create a second client/cache owner, or reproduce Apple-specific UI metaph 4. Add authenticated resource deep links and dynamic shortcuts. 5. Add adaptive navigation and list-detail layouts for tablets/foldables. 6. Add one or two privacy-reviewed Glance widgets using sanitized snapshots. +7. Add an interactive, bounded network topology with a readable list fallback. ### P3: Product expansion 1. Evaluate a multi-server profile model. 2. Establish a localization path and move existing text incrementally to resources. -3. Evaluate AI assistance as a platform-neutral product capability with read-only tools first and - explicit confirmation for mutations. -4. Add broader UI/integration testing and incremental lint/static-analysis gates. -5. Revisit Swarm and topology visualization only after shared product requirements exist. +3. Add broader UI/integration testing and incremental lint/static-analysis gates. +4. Revisit Swarm only after shared product requirements exist. ## Acceptance criteria for parity work diff --git a/docs/ios-parity-task-list.md b/docs/ios-parity-task-list.md index 960ed9d..9c7dd0d 100644 --- a/docs/ios-parity-task-list.md +++ b/docs/ios-parity-task-list.md @@ -1,6 +1,6 @@ # Android iOS-parity task list -Last updated: 2026-07-17 +Last updated: 2026-08-21 This is the working backlog for bringing Arcane Android to product-outcome parity with iOS. It turns the findings in [the pinned gap analysis](ios-android-gap-analysis.md) into issue-sized work; @@ -11,14 +11,36 @@ this canonical backlog through local validation and a review-ready pull request. The source comparison is pinned to: -- iOS `03f2f3d11e40f759ca62f0207bb3d59418a42933` -- libarcane-swift `c1016b2e0aaebffc112893179560c2462c1a013a` -- Android `c500c262e4e71b094e16ca8afc049f2286d22cfa` +- iOS `2d7f277fe322d67c88d62b826f068fa92785e3fe` +- libarcane-swift `38b5c32dde5b17eb0bc22b1c13fb4204699c8faf` +- Android `10b26b2275fb8b9772ff69f2e1f6418225be532a` - libarcane-kotlin `991dfdc1ee747c171ebf1b5953fe5fb61ceadfb8` Revalidate conclusions against current source before starting an item. Record the Android, Kotlin SDK, and Arcane server revisions in the resulting issue or pull request. +The 2026-08-21 refresh advances the iOS comparison from 0.5.4 to 0.7.0. It adds explicit backlog +coverage for passkeys/MFA, current scoped variables, image layer history, deploy options, template +discovery, container-registry names, and appearance persistence. It also promotes topology from a +shared enhancement to an Android gap and removes AI from the active parity roadmap because iOS 0.7.0 +removed the Arcane Assistant. + +## Recommended starting queue + +Work the correctness foundation before selecting a large feature: + +1. **PAR-003 verification**, then **PAR-004** — finish the pending device check and remove silent + complete-list truncation outside Containers. +2. **PAR-005**, **PAR-008**, and **PAR-009** — repair reachable navigation, cancellation ownership, + and app-wide theme state. +3. **PAR-006** and **PAR-007** — correct release/support metadata and establish the sensitive-data + backup boundary. +4. **PAR-101** — validate System Prune now that PAR-002's server scoping is proven. + +After that foundation, the highest-value feature slice is **PAR-103** (project files), followed by +**PAR-104** (account/profile), **PAR-110** (passkeys/MFA), and **PAR-111** (global variables). Tasks +without dependencies can move sooner when they do not distract from the P0 queue. + ## Status legend | Status | Meaning | @@ -78,38 +100,64 @@ The standard checks are: ## Phase 0: Revalidate active history and stop correctness leaks -- [ ] **PAR-001 — Revalidate PR #29 authentication/session unlock** +- [x] **PAR-001 — Revalidate PR #29 authentication/session unlock** -- **Status:** Needs revalidation +- **Status:** Complete - **Priority:** P0 - **Dependencies:** None - **Scope:** Inspect the current branch, PR #29 state, review feedback, and final CI results. Reproduce the original session-unlock failure before deciding whether any code remains. - **Acceptance criteria:** - - [ ] The PR's merge/close state, head revision, reviews, and CI conclusion are recorded. - - [ ] Login restoration and unlock are exercised for fresh login, restored session, invalid token, + - [x] The PR's merge/close state, head revision, reviews, and CI conclusion are recorded. + - [x] Login restoration and unlock are exercised for fresh login, restored session, invalid token, logout, and process recreation. - - [ ] The task is closed if current code already fixes the issue; otherwise a new issue describes the + - [x] The task is closed if current code already fixes the issue; otherwise a new issue describes the still-reproducible behavior and contains focused regression coverage. +- **Evidence:** PR #29 merged as `7a99c89` from final head + `6a04b3bc514702aef10726f8aeb2793328bef2c2`; its Android workflow run 29106832332 succeeded. The + sole P1 review thread was fixed, replied to, and resolved. Current-source revalidation found no + later changes to the restore path. On 2026-08-21, + `./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug` passed all 104 tests, including + six focused restore tests, and assembled the debug APK; `git diff --check` passed. A follow-up + focused run passed after changing the invalid-session case to the SDK's exact + `ArcaneError.Unauthorized`. Michael confirmed password login on 2026-08-21; OIDC is not configured + on the test server and is not required for this task. A force-stop/relaunch restored the valid + session without flashing login; logout/relaunch did not flash authenticated content; and process + recreation restored without a login flash. The current source fixes the reported behavior, so no + new issue is required. + +- [x] **PAR-002 — Make change-server state and credential scoping safe** -- [ ] **PAR-002 — Make change-server state and credential scoping safe** - -- **Status:** Ready +- **Status:** Complete - **Priority:** P0 - **Dependencies:** PAR-001 - **Scope:** Ensure changing servers cannot reuse the prior server's client, current user, capabilities, cookies, active environment, token, cache, or operation state. Normalize server identity and scope credentials by that identity. - **Acceptance criteria:** - - [ ] Selecting change server immediately invalidates all in-memory state belonging to the old server. - - [ ] Persisted tokens and other sensitive state cannot be loaded for a different normalized server. - - [ ] Tests cover two servers, equivalent URL spellings, logout, invalid credentials, and process + - [x] Selecting change server immediately invalidates all in-memory state belonging to the old server. + - [x] Persisted tokens and other sensitive state cannot be loaded for a different normalized server. + - [x] Tests cover two servers, equivalent URL spellings, logout, invalid credentials, and process recreation. - - [ ] Device testing confirms no prior-server data flashes or actions remain available. - -- [x] **PAR-003 — Fix complete-container loading before local filtering** + - [x] Device testing confirms no prior-server data flashes or actions remain available. +- **Evidence:** Canonical HTTP(S) origins and SHA-256 token namespaces follow the current iOS model. + SDK `AndroidSecureTokenStore` accounts are origin-bound with guarded one-time legacy migration. + Change server rotates the session scope/client generation and immediately resets client, user, + capabilities, cookies, environment, loading/demo state, and visible navigation state. The saved + URL, environment, and credential-origin binding are durably cleared before setup is shown; token, + remote-session, and old-client cleanup then continue independently. The process port cache is + origin-scoped. The focused auth/server/cache matrix passed 28 tests. On 2026-08-21, + `./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug` passed all 120 tests and assembled + the debug APK; `git diff --check` passed. On a physical device, Michael confirmed that sign-out + retained only the intended server selection, Change Server exposed blank setup without prior + credentials/content, and an immediate force-stop/relaunch still restored blank setup. That test + exposed and then verified the persistence-ordering fix in `bc0368d`. A live switch to a second + server origin and manual equivalent-URL check were not performed; those cases are covered by the + focused JVM matrix rather than claimed as device evidence. + +- [ ] **PAR-003 — Fix complete-container loading before local filtering** -- **Status:** Complete +- **Status:** Done/verify - **Priority:** P0 - **Dependencies:** None - **Scope:** Make the Containers tab filter a complete result set rather than the SDK's default @@ -123,9 +171,12 @@ The standard checks are: - [x] Search/status filters are proven to run after complete loading, or are moved server-side with equivalent semantics. - [x] Loading, partial-page failure, refresh, cancellation, and empty states are covered. -- **Completion evidence (2026-07-17):** - - Review: draft PR [#41](https://github.com/getarcaneapp/android/pull/41); automated validation - is complete and focused manual device validation is pending. + - [ ] A device/emulator against a live server with more than 20 containers confirms display, + filtering, refresh, and environment-change behavior without duplicates or omissions. +- **Validation evidence (updated 2026-08-21):** + - Review: PR [#41](https://github.com/getarcaneapp/android/pull/41) merged as `fdfabe3`; its focused + Greptile finding was fixed and verified in `2d97dad`. Automated validation is complete and focused + manual device/live-server validation remains pending. - Source pins: Android base `ca211804fcb3223b7b65abb0d13a97afad81799e`, libarcane-kotlin `89c8dd58886a099cdbea9cb9362c9262ba5851d9`, and Arcane `b501c49cc9f3d3433494f8334178ac65a59a013d`. @@ -213,6 +264,21 @@ The standard checks are: - [ ] At most one intended stream/job owner remains for each screen-level operation. - [ ] No stale result from a canceled prior environment can overwrite current state. +- [ ] **PAR-009 — Persist and apply Light/Dark/Auto appearance** + +- **Status:** Ready +- **Priority:** P0 +- **Dependencies:** None +- **Scope:** Replace the screen-local theme selection with one persisted preference owned at the app + level and applied at the `ArcaneTheme` root. Preserve the existing accent-color behavior and + system-theme default. +- **Acceptance criteria:** + - [ ] Light, Dark, and Auto update the whole application immediately and survive process recreation. + - [ ] Auto follows system night-mode changes without reopening Settings. + - [ ] Invalid or missing persisted values fall back to Auto, and migration does not disturb accent. + - [ ] State mapping and persistence have focused tests; representative screens are device-checked in + light/dark mode. + ## Phase 1: Validate destructive behavior and complete daily workflows - [ ] **PAR-101 — Validate System Prune end to end** @@ -339,6 +405,107 @@ The standard checks are: - [ ] Tests cover terminal stream error, heartbeat timeout, one-environment failure, full failure, and successful recovery. +- [ ] **PAR-110 — Add passkey sign-in and MFA management** + +- **Status:** Ready +- **Priority:** P1 +- **Dependencies:** PAR-001, PAR-002 +- **Scope:** Inspect the current Arcane passkey/WebAuthn handlers and Swift SDK, add typed passkey, + step-up, MFA-policy, and recovery support to `libarcane-kotlin`, then integrate Android Credential + Manager for login and signed-in account management. Do not duplicate ceremony JSON or endpoints in + the app. +- **Acceptance criteria:** + - [ ] Server capabilities gate passkey login, enrollment, rename/delete, step-up, MFA policy, and + recovery; older/unsupported servers retain password/OIDC paths. + - [ ] Credential creation/assertion maps origin, RP ID, challenge, cancellation, and provider errors + through typed SDK models without logging sensitive ceremony data. + - [ ] Login, pending MFA, account management, last-passkey restrictions, recovery, process recreation, + and server/account changes fail safely. + - [ ] SDK contract tests, Android state tests, and device/live-server passkey validation are recorded + separately with Android, SDK, and Arcane revisions. + +- [ ] **PAR-111 — Add scoped global-variable management** + +- **Status:** Ready +- **Priority:** P1 +- **Dependencies:** PAR-002, PAR-004 +- **Scope:** Model the current v2 variables API in `libarcane-kotlin`, then add permission-gated Android + list/search/create/edit/delete/sync flows for secret and non-secret values scoped to all or selected + environments. The older template-variable endpoints are not the same contract. +- **Acceptance criteria:** + - [ ] Variable models, permission constants, mutations, sync requests, and per-environment sync status + are typed and tested in the SDK first. + - [ ] Secret values never appear in logs, clipboard actions, accessibility text, or stale UI; copying + non-secret keys/values is explicit. + - [ ] Unsupported, unauthorized, empty, partial-sync, failed-sync, concurrent edit, and server change + states preserve scope and report accurate outcomes. + - [ ] More than 20 environments can be selected and reported without omissions or duplicate sync work. + +- [ ] **PAR-112 — Add image layer history** + +- **Status:** Ready +- **Priority:** P1 +- **Dependencies:** None +- **Scope:** Add the typed per-image Docker layer-history contract to `libarcane-kotlin`, then expose a + History destination in image detail. Keep this distinct from the existing image-build history API. +- **Acceptance criteria:** + - [ ] Layer ID/missing-layer, command, size, created time, and tags decode unknown/optional fields + defensively in SDK tests. + - [ ] Loading, empty, error, unauthorized, and unsupported-server states identify the image and + environment without leaking a prior selection. + - [ ] Refresh and environment/server changes cannot publish history for the wrong image digest. + - [ ] Focused Android tests and live-server validation cover a multi-layer image and a history-less + image. + +- [ ] **PAR-113 — Add scoped project deploy options** + +- **Status:** Ready +- **Priority:** P1 +- **Dependencies:** PAR-002 +- **Scope:** Use the Kotlin SDK's existing `DeployOptions` to let users choose pull policy and force + recreation before deploy. Store defaults by normalized server, user, environment, and project. + PAR-202 will later adopt the same options when it becomes the operation owner. +- **Acceptance criteria:** + - [ ] Default, always-pull, never-pull, force-recreate, cancel, unsupported, and server-error behavior + are explicit and map to typed SDK values. + - [ ] Preferences cannot cross servers, accounts, environments, or projects and are cleared or + migrated according to PAR-002. + - [ ] The launched stream receives exactly the selected options and reports the server result without + fabricating success after failure or cancellation. + - [ ] Mapping/persistence tests and device/live-server deploy evidence are recorded. + +- [ ] **PAR-114 — Complete template discovery, import, and deployment** + +- **Status:** Ready +- **Priority:** P1 +- **Dependencies:** PAR-004 +- **Scope:** Extend the existing Android registry CRUD, grouped browser, preview, and deploy flow with + current iOS outcomes: search, local/remote source filtering, metadata, remote download, and complete + result loading through the typed Kotlin template service. +- **Acceptance criteria:** + - [ ] Search and source filters cover all loaded templates and clearly distinguish local, configured- + registry, and remote entries. + - [ ] Metadata/preview and remote download handle unsupported, malformed, duplicate, unauthorized, + offline, and partial-page states without losing the current selection. + - [ ] Deploying a selected template preserves its identity and content through project creation and + hands long-running work to PAR-202 when applicable. + - [ ] Pagination/filter/download state has focused tests and a live-server import/deploy check. + +- [ ] **PAR-115 — Add container-registry display names** + +- **Status:** Ready +- **Priority:** P1 +- **Dependencies:** None +- **Scope:** Add the current optional registry `name` field to Kotlin SDK read/create/update/sync models, + then expose it in Android list and form UI while retaining URL fallback for older records. +- **Acceptance criteria:** + - [ ] Missing, blank, duplicate, and unknown-server values decode safely and display a stable URL/ID + fallback. + - [ ] Create/edit preserves credentials and unrelated registry fields and never logs token/secret + values. + - [ ] List, preview, pull-usage, and destructive confirmations identify the same registry clearly. + - [ ] SDK serialization plus Android mapping/form tests pass against old and current payload fixtures. + ## Phase 2: Own long-running operations before adding system surfaces - [ ] **PAR-201 — Specify the app-level operation store** @@ -365,12 +532,15 @@ The standard checks are: - **Priority:** P1 - **Dependencies:** PAR-201 - **Scope:** Implement the approved store and an in-app operation center/floating progress surface. - Migrate one representative operation first, then the remaining approved operation types. + Migrate one representative operation first, then the remaining approved operation types. Treat + configurable activity-start feedback as a bounded projection of this store, not a second owner. - **Acceptance criteria:** - [ ] Operations survive screen changes and expose progress, bounded logs, reconnect, cancel, success, failure, and indeterminate/unknown states from one owner. - [ ] Server, account, and environment changes cannot cross-contaminate operation state. - [ ] Process-death recovery follows the spec and never fabricates successful completion. + - [ ] Optional activity-start feedback distinguishes user/system work, keeps environment context, and + opens the authoritative operation/activity destination without notification spam. - [ ] State-machine, persistence, concurrent-operation, cancellation, and migration tests pass. - [ ] **PAR-203 — Add Android ongoing operation notifications** @@ -462,6 +632,22 @@ The standard checks are: - [ ] Shortcut publication removes stale or unauthorized entities. - [ ] Navigation and device tests cover external intents and back-stack construction. +- [ ] **PAR-505 — Add interactive network topology visualization** + +- **Status:** Ready +- **Priority:** P2 +- **Dependencies:** None +- **Scope:** iOS 0.7.0 now renders an interactive network-to-container diagram while Android presents + the same typed graph as grouped rows. Add a bounded, zoomable/pannable Android visualization while + retaining the current list as an accessible and large-graph fallback. Do not copy known iOS summary + stubs. +- **Acceptance criteria:** + - [ ] Node, edge, grouping, scale, interaction, and accessibility requirements are defined. + - [ ] Counts and relationships come from authoritative server data. + - [ ] Large, cyclic, malformed, and partially unavailable graphs remain bounded and have a usable + non-graph fallback. + - [ ] Selection, environment changes, rotation, font scaling, and TalkBack are device-tested. + ## Phase 4: Quality, accessibility, localization, and distribution - [ ] **PAR-401 — Establish an incremental localization path** @@ -588,10 +774,11 @@ The standard checks are: - [ ] **PAR-503 — Evaluate an Android AI assistant** - **Status:** Deferred -- **Priority:** P3 +- **Priority:** Not an Android-parity priority - **Dependencies:** Stable operational foundation -- **Scope:** Treat iOS Foundation Models as a product concept, not a portable implementation. Define - provider/device support, privacy, cost, context, tool permissions, and confirmation independently. +- **Scope:** iOS 0.7.0 removed the Arcane Assistant, so there is no current parity gap. Retain this only + as a possible independent product/security investigation; define provider/device support, privacy, + cost, context, tool permissions, and confirmation before any implementation. - **Acceptance criteria:** - [ ] A product/security design establishes data boundaries and starts with read-only tools. - [ ] Every mutation is staged, explained, scoped, and explicitly confirmed. @@ -610,18 +797,6 @@ The standard checks are: - [ ] SDK work precedes Android UI where required. - [ ] The gap analysis is updated from **Shared gap** only after a real product target exists. -- [ ] **PAR-505 — Network topology visualization** - -- **Status:** Deferred -- **Priority:** Not an Android-parity priority -- **Dependencies:** Shared product definition and accurate server data -- **Scope:** Both clients present topology primarily as a list. A graph is a shared enhancement and - must not copy known iOS summary stubs. -- **Acceptance criteria:** - - [ ] Node, edge, grouping, scale, interaction, and accessibility requirements are defined. - - [ ] Counts and relationships come from authoritative server data. - - [ ] Large and partially unavailable environments have a usable non-graph fallback. - ## Done/verify candidates These items appear to have progressed or landed in later workspace notes. They are not active