From cc8eeebdc46d4e850046e58c738184fa72fed82c Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 21:58:04 +0800 Subject: [PATCH 01/26] feat: add provider login flow --- android/app/build.gradle.kts | 6 + .../abox/manager/ui/login/LoginScreenTest.kt | 76 ++++++ .../provider/ProviderSelectionScreenTest.kt | 52 ++++ .../abox/manager/di/DebugSessionModule.kt | 27 +++ .../abox/manager/di/FoundationModule.kt | 3 +- .../flagship/abox/manager/di/SessionModule.kt | 67 ++++++ .../abox/manager/ui/ABoxManagerApp.kt | 118 ++++----- .../ui/authenticated/AuthenticatedScreen.kt | 76 ++++++ .../abox/manager/ui/login/LoginScreen.kt | 212 +++++++++++++++++ .../abox/manager/ui/login/LoginViewModel.kt | 98 ++++++++ .../abox/manager/ui/navigation/Routes.kt | 10 + .../ui/provider/ProviderAvailability.kt | 28 +++ .../ui/provider/ProviderSelectionScreen.kt | 225 ++++++++++++++++++ .../ui/provider/ProviderSelectionViewModel.kt | 69 ++++++ .../manager/ui/session/AppSessionViewModel.kt | 64 +++++ .../ui/session/SessionActionsViewModel.kt | 44 ++++ .../src/main/res/values-zh-rCN/strings.xml | 29 ++- android/app/src/main/res/values/strings.xml | 29 ++- .../abox/manager/di/ReleaseSessionModule.kt | 27 +++ android/core/session/build.gradle.kts | 34 +++ .../AndroidKeystoreCredentialCipherTest.kt | 27 +++ .../core/session/src/main/AndroidManifest.xml | 1 + .../AndroidKeystoreCredentialCipher.kt | 68 ++++++ .../session/credential/CredentialCipher.kt | 9 + .../credential/KeystoreCredentialStore.kt | 102 ++++++++ .../gateway/LocalSessionGatewayImpl.kt | 38 +++ .../UnavailableRemoteSessionGateway.kt | 31 +++ .../profile/ProviderProfileStoreImpl.kt | 120 ++++++++++ .../session/profile/SessionDataStore.kt | 12 + .../manager/session/CredentialStoreTest.kt | 47 ++++ .../session/ProviderProfileStoreTest.kt | 34 +++ docs/architecture.md | 22 +- .../flagship/abox/manager/domain/Gateways.kt | 35 +++ .../flagship/abox/manager/domain/Models.kt | 7 + .../abox/manager/domain/SessionCoordinator.kt | 206 ++++++++++++++++ gradle/libs.versions.toml | 3 + settings.gradle.kts | 1 + .../testing/fakes/FakeCredentialStore.kt | 28 +++ .../testing/fakes/FakeLocalSessionGateway.kt | 38 +++ .../testing/fakes/FakeProviderProfileStore.kt | 57 +++++ .../testing/fakes/FakeCredentialStoreTest.kt | 29 +++ .../fakes/FakeProviderProfileStoreTest.kt | 30 +++ .../testing/fakes/SessionCoordinatorTest.kt | 129 ++++++++++ 43 files changed, 2277 insertions(+), 91 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt create mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt create mode 100644 android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt create mode 100644 android/core/session/build.gradle.kts create mode 100644 android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt create mode 100644 android/core/session/src/main/AndroidManifest.xml create mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt create mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt create mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt create mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt create mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt create mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt create mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt create mode 100644 android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt create mode 100644 android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStore.kt create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeLocalSessionGateway.kt create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStore.kt create mode 100644 testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStoreTest.kt create mode 100644 testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStoreTest.kt create mode 100644 testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9991fb1..cac7fec 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,12 +54,16 @@ kapt { dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) + implementation(project(":android:core:session")) debugImplementation(project(":testing:fakes")) implementation(platform(libs.compose.bom)) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.datastore.preferences) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) @@ -68,8 +72,10 @@ dependencies { kapt(libs.hilt.compiler) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.compose.ui.test.junit4) debugImplementation(libs.compose.ui.tooling) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt new file mode 100644 index 0000000..49f928c --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt @@ -0,0 +1,76 @@ +package com.flagship.abox.manager.ui.login + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val profile = ProviderProfile( + ProviderProfileId("abox"), + ProviderType.ABOX, + "ABox", + ) + + @Test + fun backControlsSwitchProviderBeforeLogin() { + var switches = 0 + composeRule.setContent { + ABoxTheme { + LoginScreen( + profile = profile, + state = LoginUiState(), + onUsernameChange = {}, + onPasswordChange = {}, + onRememberUsernameChange = {}, + onAutoLoginChange = {}, + onSubmit = {}, + onSwitchProvider = { switches++ }, + ) + } + } + + composeRule.onNodeWithContentDescription("back-to-providers") + .assertIsEnabled() + .performClick() + composeRule.onNodeWithText("Back to providers").performClick() + assertEquals(2, switches) + } + + @Test + fun backControlsAreDisabledWhileSubmitting() { + composeRule.setContent { + ABoxTheme { + LoginScreen( + profile = profile, + state = LoginUiState(isSubmitting = true), + onUsernameChange = {}, + onPasswordChange = {}, + onRememberUsernameChange = {}, + onAutoLoginChange = {}, + onSubmit = {}, + onSwitchProvider = {}, + ) + } + } + + composeRule.onNodeWithContentDescription("back-to-providers").assertIsNotEnabled() + composeRule.onNodeWithText("Back to providers").assertIsNotEnabled() + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt new file mode 100644 index 0000000..b5fb6f9 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt @@ -0,0 +1,52 @@ +package com.flagship.abox.manager.ui.provider + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProviderSelectionScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun enabledOptionCanBeSelectedAndDisabledOptionCannot() { + val local = ProviderOption( + ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"), + Availability.Enabled, + ) + val compatible = ProviderOption( + ProviderProfile( + ProviderProfileId("compatible"), + ProviderType.COMPATIBLE_BACKEND, + "Compatible", + ), + Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), + ) + var selected: ProviderOption? = null + composeRule.setContent { + ABoxTheme { + ProviderSelectionScreen( + state = ProviderSelectionUiState(listOf(local, compatible)), + onSelect = { selected = it }, + ) + } + } + + composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick() + assertEquals(local, selected) + composeRule.onNodeWithText("Compatible backend").assertIsNotEnabled() + } +} diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt new file mode 100644 index 0000000..ec992e9 --- /dev/null +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.testing.fakes.FakeSessionGateway +import com.flagship.abox.manager.ui.provider.Availability +import com.flagship.abox.manager.ui.provider.ProviderAvailability +import com.flagship.abox.manager.ui.provider.UnavailableReason +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DebugSessionModule { + @Provides + @Singleton + fun provideRemoteSessionGateway(fake: FakeSessionGateway): SessionGateway = fake + + @Provides + @Singleton + fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability( + abox = Availability.Simulation, + compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), + ) +} diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt index 4c1e6c9..2052c9e 100644 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt @@ -2,7 +2,6 @@ package com.flagship.abox.manager.di import com.flagship.abox.manager.domain.DeviceCatalog import com.flagship.abox.manager.domain.InfraredGateway -import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog @@ -21,7 +20,7 @@ import javax.inject.Singleton object FoundationModule { @Provides @Singleton - fun provideSessionGateway(): SessionGateway = FakeSessionGateway() + fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway() @Provides @Singleton diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt new file mode 100644 index 0000000..2c123f8 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt @@ -0,0 +1,67 @@ +package com.flagship.abox.manager.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.flagship.abox.manager.domain.CredentialStore +import com.flagship.abox.manager.domain.LocalSessionGateway +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.session.credential.AndroidKeystoreCredentialCipher +import com.flagship.abox.manager.session.credential.CredentialCipher +import com.flagship.abox.manager.session.credential.KeystoreCredentialStore +import com.flagship.abox.manager.session.gateway.LocalSessionGatewayImpl +import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl +import com.flagship.abox.manager.session.profile.sessionDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SessionModule { + @Provides + @Singleton + fun provideSessionDataStore( + @ApplicationContext context: Context, + ): DataStore = context.sessionDataStore + + @Provides + @Singleton + fun provideProviderProfileStore( + dataStore: DataStore, + ): ProviderProfileStore = ProviderProfileStoreImpl(dataStore) + + @Provides + @Singleton + fun provideCredentialCipher(): CredentialCipher = AndroidKeystoreCredentialCipher() + + @Provides + @Singleton + fun provideCredentialStore( + dataStore: DataStore, + cipher: CredentialCipher, + ): CredentialStore = KeystoreCredentialStore(dataStore, cipher) + + @Provides + @Singleton + fun provideLocalSessionGateway(): LocalSessionGateway = LocalSessionGatewayImpl() + + @Provides + @Singleton + fun provideSessionCoordinator( + profileStore: ProviderProfileStore, + credentialStore: CredentialStore, + remoteGateway: SessionGateway, + localGateway: LocalSessionGateway, + ): SessionCoordinator = SessionCoordinator( + profileStore, + credentialStore, + remoteGateway, + localGateway, + ) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index e31f830..0a1b959 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -1,94 +1,60 @@ package com.flagship.abox.manager.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.flagship.abox.manager.R -import com.flagship.abox.manager.designsystem.ABoxButton +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme - -private const val FOUNDATION_ROUTE = "foundation" -private const val PLACEHOLDER_ROUTE = "placeholder" +import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen +import com.flagship.abox.manager.ui.login.LoginRoute +import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute +import com.flagship.abox.manager.ui.session.AppSessionUiState +import com.flagship.abox.manager.ui.session.AppSessionViewModel +import com.flagship.abox.manager.ui.session.SessionActionsViewModel @Composable -fun ABoxManagerApp() { +fun ABoxManagerApp( + appSessionViewModel: AppSessionViewModel = hiltViewModel(), + sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), +) { + val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value ABoxTheme { ABoxSurface(modifier = Modifier.fillMaxSize()) { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = FOUNDATION_ROUTE, + when ( + val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value ) { - composable(FOUNDATION_ROUTE) { - FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) }) - } - composable(PLACEHOLDER_ROUTE) { - MessageScreen( - title = stringResource(R.string.placeholder_title), - description = stringResource(R.string.placeholder_description), - ) + AppSessionUiState.Loading -> Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = Alignment.Center, + ) { + ABoxLoadingIndicator() } + AppSessionUiState.ProviderSelection -> ProviderSelectionRoute() + is AppSessionUiState.Login -> LoginRoute( + profile = state.profile, + bootstrapError = state.bootstrapError, + actionError = actionState.errorMessage, + actionInProgress = actionState.inProgress, + onSwitchProvider = sessionActionsViewModel::switchProvider, + ) + is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen( + profile = state.profile, + actionInProgress = actionState.inProgress, + errorMessage = actionState.errorMessage, + onLogout = sessionActionsViewModel::logout, + onSwitchProvider = sessionActionsViewModel::switchProvider, + ) } } } } - -@Composable -private fun FoundationScreen(onContinue: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - MessageScreen( - title = stringResource(R.string.foundation_title), - description = stringResource(R.string.foundation_description), - ) - ABoxButton( - onClick = onContinue, - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - ) { - Text(stringResource(R.string.continue_action)) - } - } -} - -@Composable -private fun MessageScreen( - title: String, - description: String, -) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = title, - style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - Text( - text = description, - style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - } -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt new file mode 100644 index 0000000..3015930 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt @@ -0,0 +1,76 @@ +package com.flagship.abox.manager.ui.authenticated + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.R +import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.domain.ProviderProfile + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AuthenticatedPlaceholderScreen( + profile: ProviderProfile, + actionInProgress: Boolean, + errorMessage: String?, + onLogout: () -> Unit, + onSwitchProvider: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.authenticated_title, profile.displayName)) }, + ) + }, + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 520.dp) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = stringResource(R.string.authenticated_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } + ABoxButton( + onClick = onLogout, + enabled = !actionInProgress, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_logout)) + } + ABoxButton( + onClick = onSwitchProvider, + enabled = !actionInProgress, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_switch_provider)) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt new file mode 100644 index 0000000..b65ae1d --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -0,0 +1,212 @@ +package com.flagship.abox.manager.ui.login + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderType + +@Composable +fun LoginRoute( + profile: ProviderProfile, + bootstrapError: String? = null, + actionError: String? = null, + actionInProgress: Boolean = false, + onSwitchProvider: () -> Unit, + viewModel: LoginViewModel = hiltViewModel(), +) { + LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } + val state = viewModel.uiState.collectAsStateWithLifecycle().value + LoginScreen( + profile = profile, + state = state.copy( + errorMessage = state.errorMessage ?: actionError ?: bootstrapError, + ), + onUsernameChange = viewModel::setUsername, + onPasswordChange = viewModel::setPassword, + onRememberUsernameChange = viewModel::setRememberUsername, + onAutoLoginChange = viewModel::setAutoLogin, + onSubmit = viewModel::submit, + onSwitchProvider = onSwitchProvider, + switchingProvider = actionInProgress, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + profile: ProviderProfile, + state: LoginUiState, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onRememberUsernameChange: (Boolean) -> Unit, + onAutoLoginChange: (Boolean) -> Unit, + onSubmit: () -> Unit, + onSwitchProvider: () -> Unit, + switchingProvider: Boolean = false, +) { + val navigationEnabled = !state.isSubmitting && !switchingProvider + BackHandler(enabled = navigationEnabled, onBack = onSwitchProvider) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.login_title, providerName(profile.type))) }, + navigationIcon = { + IconButton( + onClick = onSwitchProvider, + enabled = navigationEnabled, + modifier = Modifier.semantics { + contentDescription = "back-to-providers" + }, + ) { + Text( + text = "‹", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + }, + ) + }, + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 520.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + stringResource(R.string.login_provider_context, providerName(profile.type)), + style = MaterialTheme.typography.titleMedium, + ) + Text( + providerDescription(profile.type), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + OutlinedTextField( + value = state.username, + onValueChange = onUsernameChange, + label = { Text(stringResource(R.string.login_username)) }, + enabled = !state.isSubmitting, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.login_password)) }, + visualTransformation = PasswordVisualTransformation(), + enabled = !state.isSubmitting, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + ToggleRow( + label = stringResource(R.string.login_remember_username), + checked = state.rememberUsername, + enabled = !state.isSubmitting, + onCheckedChange = onRememberUsernameChange, + ) + ToggleRow( + label = stringResource(R.string.login_auto_login), + checked = state.autoLogin, + enabled = !state.isSubmitting, + onCheckedChange = onAutoLoginChange, + ) + state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } + ABoxButton( + onClick = onSubmit, + enabled = !state.isSubmitting && !switchingProvider, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isSubmitting || switchingProvider) ABoxLoadingIndicator() + else Text(stringResource(R.string.action_login)) + } + ABoxButton( + onClick = onSwitchProvider, + enabled = navigationEnabled, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.login_back_to_providers)) + } + } + } + } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + Text(label) + } +} + +@Composable +private fun providerName(type: ProviderType): String = stringResource( + when (type) { + ProviderType.ABOX -> R.string.provider_abox_name + ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_name + ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name + }, +) + +@Composable +private fun providerDescription(type: ProviderType): String = stringResource( + when (type) { + ProviderType.ABOX -> R.string.provider_abox_description + ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_description + ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description + }, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..eecd8ab --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -0,0 +1,98 @@ +package com.flagship.abox.manager.ui.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.Username +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val coordinator: SessionCoordinator, + private val profileStore: ProviderProfileStore, +) : ViewModel() { + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var profile: ProviderProfile? = null + private var loadingJob: Job? = null + + fun loadProfile(value: ProviderProfile) { + if (profile?.id == value.id && loadingJob != null) return + profile = value + loadingJob = viewModelScope.launch { + val preferences = profileStore.loginPreferences(value.id) + _uiState.value = _uiState.value.copy( + username = preferences.rememberedUsername?.value.orEmpty(), + rememberUsername = preferences.rememberUsername, + autoLogin = preferences.autoLoginEnabled, + ) + } + } + + fun setUsername(value: String) { + _uiState.value = _uiState.value.copy(username = value, errorMessage = null) + } + + fun setPassword(value: String) { + _uiState.value = _uiState.value.copy(password = value, errorMessage = null) + } + + fun setRememberUsername(value: Boolean) { + _uiState.value = _uiState.value.copy(rememberUsername = value) + } + + fun setAutoLogin(value: Boolean) { + _uiState.value = _uiState.value.copy(autoLogin = value) + } + + fun submit() { + val activeProfile = profile ?: return + val state = _uiState.value + if (state.username.isBlank() || state.password.isEmpty() || state.isSubmitting) { + _uiState.value = state.copy(errorMessage = "Username and password are required") + return + } + viewModelScope.launch { + _uiState.value = state.copy(isSubmitting = true, errorMessage = null) + val result = coordinator.login( + activeProfile.id, + LoginCredentials(Username(state.username.trim()), Password(state.password)), + rememberUsername = state.rememberUsername, + autoLogin = state.autoLogin, + ) + _uiState.value = _uiState.value.copy( + password = "", + isSubmitting = false, + errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(), + ) + } + } + + private fun DomainError.toUiMessage(): String = when (this) { + is DomainError.Authentication -> "Invalid username or password" + is DomainError.Network -> "Network unavailable" + is DomainError.TokenInvalid -> "Session expired" + else -> detail ?: "Login failed" + } +} + +data class LoginUiState( + val username: String = "", + val password: String = "", + val rememberUsername: Boolean = false, + val autoLogin: Boolean = false, + val isSubmitting: Boolean = false, + val errorMessage: String? = null, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt new file mode 100644 index 0000000..ee3cb43 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt @@ -0,0 +1,10 @@ +package com.flagship.abox.manager.ui.navigation + +object Routes { + const val STARTUP = "startup" + const val PROVIDER_SELECTION = "provider_selection" + const val LOGIN = "login" + + /** Login-complete route contract; the device navigation owner replaces its placeholder. */ + const val AUTHENTICATED = "authenticated" +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt new file mode 100644 index 0000000..c5e59a7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt @@ -0,0 +1,28 @@ +package com.flagship.abox.manager.ui.provider + +import com.flagship.abox.manager.domain.ProviderType + +data class ProviderAvailability( + val abox: Availability, + val compatible: Availability, + val local: Availability = Availability.Enabled, +) { + fun forType(type: ProviderType): Availability = when (type) { + ProviderType.ABOX -> abox + ProviderType.COMPATIBLE_BACKEND -> compatible + ProviderType.LOCAL_EXPERIENCE -> local + } +} + +sealed interface Availability { + data object Enabled : Availability + + data object Simulation : Availability + + data class Disabled(val reason: UnavailableReason) : Availability +} + +enum class UnavailableReason { + PROTOCOL_PENDING, + TRANSPORT_SECURITY_PENDING, +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt new file mode 100644 index 0000000..aaeabdb --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt @@ -0,0 +1,225 @@ +package com.flagship.abox.manager.ui.provider + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.domain.ProviderType + +@Composable +fun ProviderSelectionRoute( + viewModel: ProviderSelectionViewModel = hiltViewModel(), +) { + val state = viewModel.uiState.collectAsStateWithLifecycle().value + ProviderSelectionScreen(state = state, onSelect = viewModel::select) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProviderSelectionScreen( + state: ProviderSelectionUiState, + onSelect: (ProviderOption) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(R.string.provider_selection_title)) }) + }, + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 640.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = stringResource(R.string.provider_selection_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp), + ) + state.providers.forEach { option -> + ProviderOptionCard( + option = option, + selecting = state.isSelecting, + onSelect = onSelect, + ) + } + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + if (state.isSelecting) { + ABoxLoadingIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp), + ) + } + Spacer(Modifier.padding(bottom = 12.dp)) + } + } + } +} + +@Composable +private fun ProviderOptionCard( + option: ProviderOption, + selecting: Boolean, + onSelect: (ProviderOption) -> Unit, +) { + val enabled = option.availability !is Availability.Disabled && !selecting + val displayName = providerName(option.profile.type) + val description = providerDescription(option.profile.type) + val status = availabilityLabel(option.availability) + val statusColor = availabilityColor(option.availability) + + OutlinedCard( + onClick = { onSelect(option) }, + enabled = enabled, + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.outlinedCardColors( + containerColor = when (option.availability) { + Availability.Enabled -> MaterialTheme.colorScheme.surface + Availability.Simulation -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) + is Availability.Disabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + }, + ), + modifier = Modifier + .fillMaxWidth() + .alpha(if (enabled || selecting) 1f else 0.72f) + .semantics { + role = Role.Button + contentDescription = "$displayName, $status" + }, + ) { + Row( + modifier = Modifier.padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(statusColor.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = providerMark(option.profile.type), + style = MaterialTheme.typography.titleMedium, + color = statusColor, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(displayName, style = MaterialTheme.typography.titleMedium) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(statusColor), + ) + Text( + status, + style = MaterialTheme.typography.labelLarge, + color = statusColor, + ) + } + } + Text("›", style = MaterialTheme.typography.headlineSmall, color = statusColor) + } + } +} + +@Composable +private fun providerName(type: ProviderType): String = stringResource( + when (type) { + ProviderType.ABOX -> R.string.provider_abox_name + ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_name + ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name + }, +) + +@Composable +private fun providerDescription(type: ProviderType): String = stringResource( + when (type) { + ProviderType.ABOX -> R.string.provider_abox_description + ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_description + ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description + }, +) + +private fun providerMark(type: ProviderType): String = when (type) { + ProviderType.ABOX -> "A" + ProviderType.COMPATIBLE_BACKEND -> "C" + ProviderType.LOCAL_EXPERIENCE -> "L" +} + +@Composable +private fun availabilityLabel(availability: Availability): String = stringResource( + when (availability) { + Availability.Enabled -> R.string.provider_status_available + Availability.Simulation -> R.string.provider_status_simulation + is Availability.Disabled -> when (availability.reason) { + UnavailableReason.PROTOCOL_PENDING -> R.string.provider_status_protocol_pending + UnavailableReason.TRANSPORT_SECURITY_PENDING -> R.string.provider_status_transport_pending + } + }, +) + +@Composable +private fun availabilityColor(availability: Availability) = when (availability) { + Availability.Enabled -> MaterialTheme.colorScheme.primary + Availability.Simulation -> MaterialTheme.colorScheme.tertiary + is Availability.Disabled -> MaterialTheme.colorScheme.error +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt new file mode 100644 index 0000000..1debcd7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt @@ -0,0 +1,69 @@ +package com.flagship.abox.manager.ui.provider + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SessionCoordinator +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class ProviderSelectionViewModel @Inject constructor( + private val coordinator: SessionCoordinator, + availability: ProviderAvailability, +) : ViewModel() { + private val _uiState = MutableStateFlow( + ProviderSelectionUiState( + providers = PROFILES.map { profile -> + ProviderOption(profile, availability.forType(profile.type)) + }, + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + fun select(option: ProviderOption) { + if (option.availability is Availability.Disabled || _uiState.value.isSelecting) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null) + val result = coordinator.selectProvider(option.profile) + _uiState.value = _uiState.value.copy( + isSelecting = false, + errorMessage = (result as? com.flagship.abox.manager.domain.DomainResult.Failure) + ?.error?.detail, + ) + } + } + + companion object { + private val PROFILES = listOf( + ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"), + ProviderProfile( + ProviderProfileId("compatible-default"), + ProviderType.COMPATIBLE_BACKEND, + "Compatible backend", + ), + ProviderProfile( + ProviderProfileId("local-experience"), + ProviderType.LOCAL_EXPERIENCE, + "Local experience", + ), + ) + } +} + +data class ProviderSelectionUiState( + val providers: List, + val isSelecting: Boolean = false, + val errorMessage: String? = null, +) + +data class ProviderOption( + val profile: ProviderProfile, + val availability: Availability, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt new file mode 100644 index 0000000..a123d38 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -0,0 +1,64 @@ +package com.flagship.abox.manager.ui.session + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionCoordinator +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class AppSessionViewModel @Inject constructor( + private val coordinator: SessionCoordinator, + profileStore: ProviderProfileStore, +) : ViewModel() { + private val bootstrapResult = MutableStateFlow?>(null) + + val uiState: StateFlow = combine( + bootstrapResult, + profileStore.observeActiveProfile(), + coordinator.observeActiveSession(), + ) { result, profile, session -> + if (result == null) AppSessionUiState.Loading + else stateFor(profile, session, (result as? DomainResult.Failure)?.error?.detail) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AppSessionUiState.Loading, + ) + + init { + viewModelScope.launch { + bootstrapResult.value = coordinator.bootstrap() + } + } + + private fun stateFor( + profile: ProviderProfile?, + session: Session?, + bootstrapError: String?, + ): AppSessionUiState = when { + profile == null -> AppSessionUiState.ProviderSelection + session != null -> AppSessionUiState.Authenticated(profile) + else -> AppSessionUiState.Login(profile, bootstrapError) + } +} + +sealed interface AppSessionUiState { + data object Loading : AppSessionUiState + data object ProviderSelection : AppSessionUiState + data class Login( + val profile: ProviderProfile, + val bootstrapError: String? = null, + ) : AppSessionUiState + data class Authenticated(val profile: ProviderProfile) : AppSessionUiState +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt new file mode 100644 index 0000000..f5adb34 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt @@ -0,0 +1,44 @@ +package com.flagship.abox.manager.ui.session + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.SessionCoordinator +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class SessionActionsViewModel @Inject constructor( + private val coordinator: SessionCoordinator, +) : ViewModel() { + private val _uiState = MutableStateFlow(SessionActionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun logout() = runAction(coordinator::logout) + + fun switchProvider() = runAction(coordinator::switchProvider) + + fun dismissError() { + _uiState.value = _uiState.value.copy(errorMessage = null) + } + + private fun runAction(action: suspend () -> DomainResult) { + if (_uiState.value.inProgress) return + viewModelScope.launch { + _uiState.value = SessionActionsUiState(inProgress = true) + val result = action() + _uiState.value = SessionActionsUiState( + errorMessage = (result as? DomainResult.Failure)?.error?.detail, + ) + } + } +} + +data class SessionActionsUiState( + val inProgress: Boolean = false, + val errorMessage: String? = null, +) diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index bc378ad..3a812f4 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -1,8 +1,27 @@ ABox Manager - 工程基础已就绪 - 公共领域接口和本地开发提供方已经可用。 - 继续 - 功能模块可以开始开发 - 设备、红外和情景页面将由对应模块负责人实现。 + 选择提供方 + 选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。 + ABox + 通过兼容 ABSDK 的 ABox 服务连接设备。 + 自有兼容后端 + 在协议准备完成后连接自行部署的 ABSDK 兼容服务。 + 本地体验 + 无需网络,通过本地模拟设备体验完整应用流程。 + 可用 + 开发模拟 + 协议待确认 + 等待传输安全方案批准 + 登录 %1$s + 当前提供方:%1$s + 返回提供方选择 + 用户名 + 密码 + 记住用户名 + 自动登录 + 登录 + 退出登录 + 切换提供方 + 已连接到 %1$s + 登录后入口已经就绪,设备导航将由对应模块负责人接入。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ec5c8d7..0a63752 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,8 +1,27 @@ ABox Manager - Project foundation ready - The shared domain contracts and local development providers are available. - Continue - Feature development can start - Device, infrared and scene screens will be added by their module owners. + Choose a provider + Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers. + ABox + Connect through the ABSDK-compatible ABox service. + Compatible backend + Use a self-hosted ABSDK-compatible service when its protocol is ready. + Local experience + Explore the complete app flow with local simulated devices and no network. + Available + Development simulation + Protocol pending + Transport security approval required + Sign in to %1$s + Selected provider: %1$s + Back to providers + Username + Password + Remember username + Sign in automatically + Sign in + Sign out + Switch provider + Connected to %1$s + The authenticated route is ready. Device navigation will be added by its module owner. diff --git a/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt b/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt new file mode 100644 index 0000000..ff92f2a --- /dev/null +++ b/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.session.gateway.UnavailableRemoteSessionGateway +import com.flagship.abox.manager.ui.provider.Availability +import com.flagship.abox.manager.ui.provider.ProviderAvailability +import com.flagship.abox.manager.ui.provider.UnavailableReason +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReleaseSessionModule { + @Provides + @Singleton + fun provideRemoteSessionGateway(): SessionGateway = UnavailableRemoteSessionGateway() + + @Provides + @Singleton + fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability( + abox = Availability.Disabled(UnavailableReason.TRANSPORT_SECURITY_PENDING), + compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), + ) +} diff --git a/android/core/session/build.gradle.kts b/android/core/session/build.gradle.kts new file mode 100644 index 0000000..2fb4697 --- /dev/null +++ b/android/core/session/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.flagship.abox.manager.session" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + api(project(":domain")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.datastore.preferences) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) +} diff --git a/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt new file mode 100644 index 0000000..e67e936 --- /dev/null +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.session.credential + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.ProviderProfileId +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidKeystoreCredentialCipherTest { + @Test + fun encryptsWithRandomIvAndProfileBoundAad() { + val cipher = AndroidKeystoreCredentialCipher( + keyAlias = "abox_manager_test_${System.nanoTime()}", + ) + val profile = ProviderProfileId("profile") + val plaintext = "secret".toByteArray() + + val first = cipher.encrypt(profile, plaintext) + val second = cipher.encrypt(profile, plaintext) + + assertFalse(first.contentEquals(second)) + assertArrayEquals(plaintext, cipher.decrypt(profile, first)) + assertArrayEquals(plaintext, cipher.decrypt(profile, second)) + } +} diff --git a/android/core/session/src/main/AndroidManifest.xml b/android/core/session/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/android/core/session/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt new file mode 100644 index 0000000..e865be9 --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt @@ -0,0 +1,68 @@ +package com.flagship.abox.manager.session.credential + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import com.flagship.abox.manager.domain.ProviderProfileId +import java.nio.ByteBuffer +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class AndroidKeystoreCredentialCipher( + private val keyAlias: String = KEY_ALIAS, +) : CredentialCipher { + override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8)) + val ciphertext = cipher.doFinal(plaintext) + return ByteBuffer.allocate(2 + cipher.iv.size + ciphertext.size) + .put(VERSION) + .put(cipher.iv.size.toByte()) + .put(cipher.iv) + .put(ciphertext) + .array() + } + + override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray { + val buffer = ByteBuffer.wrap(encrypted) + require(buffer.get() == VERSION) { "Unsupported credential format" } + val ivLength = buffer.get().toInt() and 0xFF + require(ivLength in 12..16 && buffer.remaining() > ivLength) { "Invalid credential payload" } + val iv = ByteArray(ivLength).also(buffer::get) + val ciphertext = ByteArray(buffer.remaining()).also(buffer::get) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8)) + return cipher.doFinal(ciphertext) + } + + private fun secretKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } + (keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run { + init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(KEY_SIZE_BITS) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + generateKey() + } + } + + companion object { + private const val ANDROID_KEY_STORE = "AndroidKeyStore" + private const val KEY_ALIAS = "abox_manager_credentials_v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val KEY_SIZE_BITS = 256 + private const val GCM_TAG_BITS = 128 + private const val VERSION: Byte = 1 + } +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt new file mode 100644 index 0000000..ea3fbab --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.session.credential + +import com.flagship.abox.manager.domain.ProviderProfileId + +interface CredentialCipher { + fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray + + fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt new file mode 100644 index 0000000..02f801f --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -0,0 +1,102 @@ +package com.flagship.abox.manager.session.credential + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.flagship.abox.manager.domain.CredentialStore +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Username +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.security.InvalidKeyException +import java.security.KeyStoreException +import java.security.UnrecoverableKeyException +import java.util.Base64 +import javax.crypto.AEADBadTagException +import kotlinx.coroutines.flow.first + +class KeystoreCredentialStore( + private val dataStore: DataStore, + private val cipher: CredentialCipher, +) : CredentialStore { + override suspend fun saveCredentials( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = operation { + val encrypted = cipher.encrypt(profileId, encode(credentials)) + dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) } + } + + override suspend fun loadCredentials( + profileId: ProviderProfileId, + ): DomainResult { + return try { + val value = dataStore.data.first()[credentialKey(profileId)] + ?: return DomainResult.Success(null) + val encrypted = Base64.getDecoder().decode(value) + DomainResult.Success(decode(cipher.decrypt(profileId, encrypted))) + } catch (error: Exception) { + if (error.isPermanentCredentialFailure()) { + dataStore.edit { it.remove(credentialKey(profileId)) } + DomainResult.Success(null) + } else { + DomainResult.Failure( + DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), + ) + } + } + } + + override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult = operation { + dataStore.edit { it.remove(credentialKey(profileId)) } + } + + private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output -> + DataOutputStream(output).use { data -> + data.writeUTF(credentials.username.value) + data.writeUTF(credentials.password.value) + } + output.toByteArray() + } + + private fun decode(value: ByteArray): LoginCredentials = DataInputStream( + ByteArrayInputStream(value), + ).use { data -> + val username = data.readUTF() + val password = data.readUTF() + require(data.available() == 0) { "Unexpected credential data" } + LoginCredentials(Username(username), Password(password)) + } + + private suspend fun operation(block: suspend () -> Unit): DomainResult = try { + block() + DomainResult.Success(Unit) + } catch (error: Exception) { + DomainResult.Failure( + DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), + ) + } + + companion object { + private fun credentialKey(profileId: ProviderProfileId) = + stringPreferencesKey("profile_${profileId.value}_credential") + } +} + +private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) { + is AEADBadTagException, + is InvalidKeyException, + is KeyStoreException, + is UnrecoverableKeyException, + is IllegalArgumentException, + -> true + + else -> cause?.isPermanentCredentialFailure() == true +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt new file mode 100644 index 0000000..1fbfb01 --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt @@ -0,0 +1,38 @@ +package com.flagship.abox.manager.session.gateway + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LocalSessionGateway +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class LocalSessionGatewayImpl : LocalSessionGateway { + private val sessions = ConcurrentHashMap>() + + override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = DomainResult.Success( + Session(profileId, credentials.username).also { flow(profileId).value = it }, + ) + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success( + if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, + ) + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + flow(profileId).value = null + return DomainResult.Success(Unit) + } + + private fun flow(profileId: ProviderProfileId): MutableStateFlow = + sessions.getOrPut(profileId) { MutableStateFlow(null) } +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt new file mode 100644 index 0000000..5a8111c --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.session.gateway + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.domain.SessionValidity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class UnavailableRemoteSessionGateway : SessionGateway { + override fun observeSession(profileId: ProviderProfileId): Flow = flowOf(null) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = unavailable() + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = unavailable() + + override suspend fun logout(profileId: ProviderProfileId): DomainResult = + DomainResult.Success(Unit) + + private fun unavailable(): DomainResult = DomainResult.Failure( + DomainError.InvalidRequest(detail = "Remote providers are not enabled in this build"), + ) +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt new file mode 100644 index 0000000..2ddadcc --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -0,0 +1,120 @@ +package com.flagship.abox.manager.session.profile + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginPreferences +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.Username +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class ProviderProfileStoreImpl( + private val dataStore: DataStore, +) : ProviderProfileStore { + override fun observeProfiles(): Flow> = dataStore.data.map(::profilesFrom) + + override fun observeActiveProfile(): Flow = dataStore.data.map(::activeProfileFrom) + + override fun observeLoginPreferences(profileId: ProviderProfileId): Flow = + dataStore.data.map { preferencesFrom(it, profileId) } + + override suspend fun profiles(): List = profilesFrom(dataStore.data.first()) + + override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(dataStore.data.first()) + + override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences = + preferencesFrom(dataStore.data.first(), profileId) + + override suspend fun saveProfile(profile: ProviderProfile): DomainResult = operation { + dataStore.edit { preferences -> + preferences[PROFILE_IDS] = preferences[PROFILE_IDS].orEmpty() + profile.id.value + preferences[profileTypeKey(profile.id)] = profile.type.name + preferences[profileNameKey(profile.id)] = profile.displayName + } + } + + override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult = operation { + dataStore.edit { preferences -> + if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID) + else preferences[ACTIVE_PROFILE_ID] = profileId.value + } + } + + override suspend fun saveLoginPreferences( + preferences: LoginPreferences, + ): DomainResult = operation { + dataStore.edit { values -> + val profileId = preferences.profileId + values[rememberUsernameKey(profileId)] = preferences.rememberUsername + values[autoLoginKey(profileId)] = preferences.autoLoginEnabled + val username = preferences.rememberedUsername?.value + if (username == null) values.remove(usernameKey(profileId)) + else values[usernameKey(profileId)] = username + } + } + + private fun profilesFrom(preferences: Preferences): List = + preferences[PROFILE_IDS].orEmpty().mapNotNull { idValue -> + val id = ProviderProfileId(idValue) + val type = preferences[profileTypeKey(id)] + ?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() } + ?: return@mapNotNull null + val displayName = preferences[profileNameKey(id)] ?: return@mapNotNull null + ProviderProfile(id, type, displayName) + }.sortedBy { it.id.value } + + private fun activeProfileFrom(preferences: Preferences): ProviderProfile? { + val activeId = preferences[ACTIVE_PROFILE_ID] ?: return null + return profilesFrom(preferences).firstOrNull { it.id.value == activeId } + } + + private fun preferencesFrom( + preferences: Preferences, + profileId: ProviderProfileId, + ): LoginPreferences = LoginPreferences( + profileId = profileId, + rememberedUsername = preferences[usernameKey(profileId)]?.let(::Username), + rememberUsername = preferences[rememberUsernameKey(profileId)] ?: false, + autoLoginEnabled = preferences[autoLoginKey(profileId)] ?: false, + ) + + private suspend fun operation(block: suspend () -> Unit): DomainResult = try { + block() + DomainResult.Success(Unit) + } catch (error: Exception) { + DomainResult.Failure( + com.flagship.abox.manager.domain.DomainError.Unknown( + detail = error.message ?: error::class.java.simpleName, + ), + ) + } + + companion object { + private val PROFILE_IDS = stringSetPreferencesKey("profile_ids") + private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") + + private fun profileTypeKey(id: ProviderProfileId) = + stringPreferencesKey("profile_${id.value}_type") + + private fun profileNameKey(id: ProviderProfileId) = + stringPreferencesKey("profile_${id.value}_name") + + private fun usernameKey(id: ProviderProfileId) = + stringPreferencesKey("profile_${id.value}_username") + + private fun rememberUsernameKey(id: ProviderProfileId) = + booleanPreferencesKey("profile_${id.value}_remember_username") + + private fun autoLoginKey(id: ProviderProfileId) = + booleanPreferencesKey("profile_${id.value}_auto_login") + } +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt new file mode 100644 index 0000000..19d9c14 --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt @@ -0,0 +1,12 @@ +package com.flagship.abox.manager.session.profile + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore + +private const val SESSION_DATA_STORE_NAME = "provider_session" + +val Context.sessionDataStore: DataStore by preferencesDataStore( + name = SESSION_DATA_STORE_NAME, +) diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt new file mode 100644 index 0000000..b185166 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -0,0 +1,47 @@ +package com.flagship.abox.manager.session + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Username +import com.flagship.abox.manager.session.credential.CredentialCipher +import com.flagship.abox.manager.session.credential.KeystoreCredentialStore +import java.io.File +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CredentialStoreTest { + @Test + fun storesClearsAndIsolatesCredentials() = runTest { + val file = File.createTempFile("credentials", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val store = KeystoreCredentialStore(dataStore, ReversingCipher()) + val first = ProviderProfileId("alpha") + val second = ProviderProfileId("bravo") + val firstCredentials = LoginCredentials(Username("one"), Password("secret-one")) + val secondCredentials = LoginCredentials(Username("two"), Password("secret-two")) + + store.saveCredentials(first, firstCredentials) + store.saveCredentials(second, secondCredentials) + store.clearCredentials(first) + + assertNull((store.loadCredentials(first) as DomainResult.Success).value) + assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value) + } + + private class ReversingCipher : CredentialCipher { + override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = + (profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray() + + override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray { + val decoded = encrypted.reversedArray() + val delimiter = decoded.indexOf(0) + require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value) + return decoded.copyOfRange(delimiter + 1, decoded.size) + } + } +} diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt new file mode 100644 index 0000000..8616cd5 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt @@ -0,0 +1,34 @@ +package com.flagship.abox.manager.session + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginPreferences +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.Username +import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProviderProfileStoreTest { + @Test + fun persistsActiveProfileAndLoginPreferences() = runTest { + val file = File.createTempFile("profiles", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val store = ProviderProfileStoreImpl(dataStore) + val id = ProviderProfileId("abox") + val profile = ProviderProfile(id, ProviderType.ABOX, "ABox") + val preferences = LoginPreferences(id, Username("demo"), true, true) + + assertEquals(DomainResult.Success(Unit), store.saveProfile(profile)) + store.setActiveProfile(id) + store.saveLoginPreferences(preferences) + + assertEquals(profile, store.observeActiveProfile().first()) + assertEquals(preferences, store.observeLoginPreferences(id).first()) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index c00b4c7..1f01750 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,11 +108,27 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证 Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 -## 9. 后续边界 +## 9. Provider Session + +`:android:core:session` 负责提供方登录的持久化与 Android 安全边界: + +- `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关; +- `CredentialStore` 使用 Android Keystore 中的 AES-256-GCM 密钥加密自动登录凭据,DataStore 只保存带随机 IV 的密文; +- `ProviderProfileId` 作为 GCM AAD,防止不同 profile 的密文互换; +- `LocalSessionGateway` 只维护本地内存 Session,不调用 HTTP 或 ABSDK; +- `UnavailableRemoteSessionGateway` 在远端入口未获批准时明确拒绝登录。 + +纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。远端退出清除密码和 Session,但保留 profile 与 remembered username;本地体验退出后回到提供方选择。 + +App 按构建变体声明远端可用性:debug 的 ABox 入口明确是开发模拟,release 的 ABox 因传输安全待确认而禁用;兼容后端在协议明确前始终禁用;本地体验始终可用。App 没有依赖或创建 `AbsdkBridge`,也没有对 `@CleartextAbsdkTransport` opt-in。 + +登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。 + +## 10. 后续边界 后续 KremeCN 工作保持为独立短期分支: -- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定; +- 在获批传输控制明确后,单独评审真实 ABox Bridge 的 App 绑定; - `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。 -Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 +Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt index 6fe9ee7..4108621 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt @@ -15,6 +15,41 @@ interface SessionGateway { suspend fun logout(profileId: ProviderProfileId): DomainResult } +interface ProviderProfileStore { + fun observeProfiles(): Flow> + + fun observeActiveProfile(): Flow + + fun observeLoginPreferences(profileId: ProviderProfileId): Flow + + suspend fun profiles(): List + + suspend fun activeProfile(): ProviderProfile? + + suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences + + suspend fun saveProfile(profile: ProviderProfile): DomainResult + + suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult + + suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult +} + +interface CredentialStore { + suspend fun saveCredentials( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult + + suspend fun loadCredentials( + profileId: ProviderProfileId, + ): DomainResult + + suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult +} + +interface LocalSessionGateway : SessionGateway + interface DeviceCatalog { fun observeDevices( profileId: ProviderProfileId, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt index fe0828d..d27c801 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt @@ -53,6 +53,13 @@ data class LoginCredentials( val password: Password, ) +data class LoginPreferences( + val profileId: ProviderProfileId, + val rememberedUsername: Username? = null, + val rememberUsername: Boolean = false, + val autoLoginEnabled: Boolean = false, +) + data class Session( val profileId: ProviderProfileId, val username: Username, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt new file mode 100644 index 0000000..4923c23 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt @@ -0,0 +1,206 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionCoordinator( + private val profileStore: ProviderProfileStore, + private val credentialStore: CredentialStore, + private val remoteGateway: SessionGateway, + private val localGateway: LocalSessionGateway, +) { + fun observeActiveSession(): Flow = profileStore.observeActiveProfile() + .flatMapLatest { profile -> + profile?.gateway()?.observeSession(profile.id) ?: flowOf(null) + } + + suspend fun bootstrap(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(null) + if (profile.type == ProviderType.LOCAL_EXPERIENCE) { + return@storeOperation localGateway.login(profile.id, localCredentials()) + .mapSuccess { it } + } + val preferences = profileStore.loginPreferences(profile.id) + if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null) + when (val credentials = credentialStore.loadCredentials(profile.id)) { + is DomainResult.Failure -> credentials + is DomainResult.Success -> { + val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null) + if (profileStore.activeProfile()?.id != profile.id) { + return@storeOperation DomainResult.Success(null) + } + profile.gateway().login(profile.id, savedCredentials).mapSuccess { it } + } + } + } + + suspend fun selectProvider(profile: ProviderProfile): DomainResult = storeOperation { + val current = profileStore.activeProfile() + if (current?.id != profile.id && current != null) { + when (val switched = switchProvider()) { + is DomainResult.Failure -> return@storeOperation switched + is DomainResult.Success -> Unit + } + } + when (val saved = profileStore.saveProfile(profile)) { + is DomainResult.Failure -> return@storeOperation saved + is DomainResult.Success -> Unit + } + when (val activated = profileStore.setActiveProfile(profile.id)) { + is DomainResult.Failure -> return@storeOperation activated + is DomainResult.Success -> Unit + } + if (profile.type == ProviderType.LOCAL_EXPERIENCE) { + localGateway.login(profile.id, localCredentials()).mapSuccess { it } + } else { + DomainResult.Success(null) + } + } + + suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + rememberUsername: Boolean, + autoLogin: Boolean, + ): DomainResult = storeOperation { + val profile = profileStore.activeProfile() + ?: return@storeOperation DomainResult.Failure( + DomainError.InvalidRequest(detail = "No active provider profile"), + ) + if (profile.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) { + return@storeOperation DomainResult.Failure( + DomainError.InvalidRequest(detail = "The active provider does not require this login"), + ) + } + when (val result = remoteGateway.login(profileId, credentials)) { + is DomainResult.Failure -> result + is DomainResult.Success -> { + val previousPreferences = profileStore.loginPreferences(profileId) + val previousCredentials = credentialStore.loadCredentials(profileId) + if (previousCredentials is DomainResult.Failure) { + remoteGateway.logout(profileId) + return@storeOperation previousCredentials + } + val preferences = LoginPreferences( + profileId = profileId, + rememberedUsername = credentials.username.takeIf { rememberUsername }, + rememberUsername = rememberUsername, + autoLoginEnabled = autoLogin, + ) + val credentialResult = if (autoLogin) { + credentialStore.saveCredentials(profileId, credentials) + } else { + credentialStore.clearCredentials(profileId) + } + if (credentialResult is DomainResult.Failure) { + remoteGateway.logout(profileId) + return@storeOperation credentialResult + } + when (val preferencesResult = profileStore.saveLoginPreferences(preferences)) { + is DomainResult.Success -> result + is DomainResult.Failure -> { + when (previousCredentials) { + is DomainResult.Success -> previousCredentials.value?.let { + credentialStore.saveCredentials(profileId, it) + } ?: credentialStore.clearCredentials(profileId) + is DomainResult.Failure -> Unit + } + profileStore.saveLoginPreferences(previousPreferences) + remoteGateway.logout(profileId) + preferencesResult + } + } + } + } + } + + suspend fun recoverSession(profileId: ProviderProfileId): DomainResult = storeOperation { + val profile = profileStore.activeProfile() + if (profile?.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) { + return@storeOperation DomainResult.Success(null) + } + val preferences = profileStore.loginPreferences(profileId) + if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null) + when (val credentials = credentialStore.loadCredentials(profileId)) { + is DomainResult.Failure -> credentials + is DomainResult.Success -> { + val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null) + when (val result = remoteGateway.login(profileId, savedCredentials)) { + is DomainResult.Success -> result.mapSuccess { it } + is DomainResult.Failure -> { + if (result.error is DomainError.Authentication || + result.error is DomainError.TokenInvalid + ) { + credentialStore.clearCredentials(profileId) + profileStore.saveLoginPreferences( + preferences.copy(autoLoginEnabled = false), + ) + } + result + } + } + } + } + } + + suspend fun logout(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) + when (val credentialResult = credentialStore.clearCredentials(profile.id)) { + is DomainResult.Failure -> return@storeOperation credentialResult + is DomainResult.Success -> Unit + } + val preferences = profileStore.loginPreferences(profile.id) + when ( + val preferencesResult = profileStore.saveLoginPreferences( + preferences.copy(autoLoginEnabled = false), + ) + ) { + is DomainResult.Failure -> return@storeOperation preferencesResult + is DomainResult.Success -> Unit + } + when (val logoutResult = profile.gateway().logout(profile.id)) { + is DomainResult.Failure -> return@storeOperation logoutResult + is DomainResult.Success -> Unit + } + if (profile.type == ProviderType.LOCAL_EXPERIENCE) { + profileStore.setActiveProfile(null) + } else { + DomainResult.Success(Unit) + } + } + + suspend fun switchProvider(): DomainResult = storeOperation { + when (val logoutResult = logout()) { + is DomainResult.Failure -> logoutResult + is DomainResult.Success -> profileStore.setActiveProfile(null) + } + } + + private fun ProviderProfile.gateway(): SessionGateway = + if (type == ProviderType.LOCAL_EXPERIENCE) localGateway else remoteGateway + + private fun localCredentials(): LoginCredentials = LoginCredentials( + username = Username("local"), + password = Password(""), + ) + + private suspend fun storeOperation(block: suspend () -> DomainResult): DomainResult = + try { + block() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + DomainResult.Failure( + DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), + ) + } +} + +private inline fun DomainResult.mapSuccess(transform: (T) -> R): DomainResult = when (this) { + is DomainResult.Failure -> this + is DomainResult.Success -> DomainResult.Success(transform(value)) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d6f33fb..2b00e05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ espresso = "3.6.1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } @@ -35,6 +37,7 @@ room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } junit = { module = "junit:junit", version.ref = "junit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } +androidx-test-runner = { module = "androidx.test:runner", version = "1.6.1" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } [plugins] diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a69ab0..398735c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,5 @@ include(":domain") include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") +include(":android:core:session") include(":android:app") diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStore.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStore.kt new file mode 100644 index 0000000..0883791 --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStore.kt @@ -0,0 +1,28 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.CredentialStore +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.concurrent.ConcurrentHashMap + +class FakeCredentialStore : CredentialStore { + private val values = ConcurrentHashMap() + + override suspend fun saveCredentials( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult { + values[profileId] = credentials + return DomainResult.Success(Unit) + } + + override suspend fun loadCredentials( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success(values[profileId]) + + override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult { + values.remove(profileId) + return DomainResult.Success(Unit) + } +} diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeLocalSessionGateway.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeLocalSessionGateway.kt new file mode 100644 index 0000000..2ac6f3d --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeLocalSessionGateway.kt @@ -0,0 +1,38 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LocalSessionGateway +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeLocalSessionGateway : LocalSessionGateway { + private val sessions = ConcurrentHashMap>() + + override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = DomainResult.Success( + Session(profileId, credentials.username).also { flow(profileId).value = it }, + ) + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success( + if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, + ) + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + flow(profileId).value = null + return DomainResult.Success(Unit) + } + + private fun flow(profileId: ProviderProfileId): MutableStateFlow = + sessions.getOrPut(profileId) { MutableStateFlow(null) } +} diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStore.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStore.kt new file mode 100644 index 0000000..1e3b86e --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStore.kt @@ -0,0 +1,57 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginPreferences +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeProviderProfileStore : ProviderProfileStore { + private val profileValues = ConcurrentHashMap() + private val preferenceValues = ConcurrentHashMap() + private val profilesFlow = MutableStateFlow>(emptyList()) + private val activeProfileFlow = MutableStateFlow(null) + private val preferenceFlows = ConcurrentHashMap>() + + override fun observeProfiles(): Flow> = profilesFlow + + override fun observeActiveProfile(): Flow = activeProfileFlow + + override fun observeLoginPreferences(profileId: ProviderProfileId): Flow = + preferenceFlow(profileId) + + override suspend fun profiles(): List = profilesFlow.value + + override suspend fun activeProfile(): ProviderProfile? = activeProfileFlow.value + + override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences = + preferenceFlow(profileId).value + + override suspend fun saveProfile(profile: ProviderProfile): DomainResult { + profileValues[profile.id] = profile + profilesFlow.value = profileValues.values.sortedBy { it.id.value } + if (activeProfileFlow.value?.id == profile.id) activeProfileFlow.value = profile + return DomainResult.Success(Unit) + } + + override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult { + activeProfileFlow.value = profileId?.let(profileValues::get) + return DomainResult.Success(Unit) + } + + override suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult { + preferenceValues[preferences.profileId] = preferences + preferenceFlow(preferences.profileId).value = preferences + return DomainResult.Success(Unit) + } + + private fun preferenceFlow(profileId: ProviderProfileId): MutableStateFlow = + preferenceFlows.getOrPut(profileId) { + MutableStateFlow( + preferenceValues[profileId] ?: LoginPreferences(profileId), + ) + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStoreTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStoreTest.kt new file mode 100644 index 0000000..4679831 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStoreTest.kt @@ -0,0 +1,29 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Username +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FakeCredentialStoreTest { + @Test + fun credentialsAreIsolatedByProfileAndClearIndependently() = runTest { + val store = FakeCredentialStore() + val first = ProviderProfileId("first") + val second = ProviderProfileId("second") + val firstCredentials = LoginCredentials(Username("one"), Password("secret-one")) + val secondCredentials = LoginCredentials(Username("two"), Password("secret-two")) + + store.saveCredentials(first, firstCredentials) + store.saveCredentials(second, secondCredentials) + store.clearCredentials(first) + + assertNull((store.loadCredentials(first) as DomainResult.Success).value) + assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value) + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStoreTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStoreTest.kt new file mode 100644 index 0000000..e340ec1 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStoreTest.kt @@ -0,0 +1,30 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginPreferences +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.Username +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class FakeProviderProfileStoreTest { + @Test + fun storesProfilesActiveSelectionAndPreferences() = runTest { + val store = FakeProviderProfileStore() + val id = ProviderProfileId("local") + val profile = ProviderProfile(id, ProviderType.LOCAL_EXPERIENCE, "Local") + val preferences = LoginPreferences(id, Username("demo"), true, false) + + assertEquals(DomainResult.Success(Unit), store.saveProfile(profile)) + store.setActiveProfile(id) + store.saveLoginPreferences(preferences) + + assertEquals(listOf(profile), store.observeProfiles().first()) + assertEquals(profile, store.observeActiveProfile().first()) + assertEquals(preferences, store.observeLoginPreferences(id).first()) + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt new file mode 100644 index 0000000..89b3e94 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt @@ -0,0 +1,129 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.LoginPreferences +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.Username +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionCoordinatorTest { + private val remoteProfile = ProviderProfile( + ProviderProfileId("abox"), + ProviderType.ABOX, + "ABox", + ) + private val localProfile = ProviderProfile( + ProviderProfileId("local"), + ProviderType.LOCAL_EXPERIENCE, + "Local", + ) + private val credentials = LoginCredentials(Username("demo"), Password("secret")) + + @Test + fun localProviderCreatesSessionWithoutRemoteLogin() = runTest { + val fixture = Fixture() + + val result = fixture.coordinator.selectProvider(localProfile) + + assertTrue(result is DomainResult.Success) + assertEquals(localProfile.id, fixture.coordinator.observeActiveSession().first()?.profileId) + assertNull(fixture.remote.observeSession(localProfile.id).first()) + } + + @Test + fun loginPersistsIndependentUsernameAndAutoLoginChoices() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = false, + autoLogin = true, + ) + + val preferences = fixture.profiles.loginPreferences(remoteProfile.id) + assertNull(preferences.rememberedUsername) + assertTrue(preferences.autoLoginEnabled) + assertEquals( + credentials, + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + } + + @Test + fun bootstrapUsesSavedAutoLoginCredentials() = runTest { + val fixture = Fixture() + fixture.profiles.saveProfile(remoteProfile) + fixture.profiles.setActiveProfile(remoteProfile.id) + fixture.profiles.saveLoginPreferences( + LoginPreferences(remoteProfile.id, autoLoginEnabled = true), + ) + fixture.credentials.saveCredentials(remoteProfile.id, credentials) + + val result = fixture.coordinator.bootstrap() + + assertEquals(remoteProfile.id, (result as DomainResult.Success).value?.profileId) + } + + @Test + fun remoteLogoutClearsPasswordButKeepsProfileAndUsername() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + fixture.coordinator.logout() + + assertEquals(remoteProfile, fixture.profiles.activeProfile()) + val preferences = fixture.profiles.loginPreferences(remoteProfile.id) + assertEquals(credentials.username, preferences.rememberedUsername) + assertTrue(!preferences.autoLoginEnabled) + assertNull( + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + } + + @Test + fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest { + val fixture = Fixture() + fixture.profiles.saveProfile(remoteProfile) + fixture.profiles.setActiveProfile(remoteProfile.id) + fixture.profiles.saveLoginPreferences( + LoginPreferences(remoteProfile.id, autoLoginEnabled = true), + ) + fixture.credentials.saveCredentials(remoteProfile.id, credentials) + fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication()) + + val result = fixture.coordinator.recoverSession(remoteProfile.id) + + assertTrue(result is DomainResult.Failure) + assertNull( + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) + } + + private class Fixture { + val profiles = FakeProviderProfileStore() + val credentials = FakeCredentialStore() + val remote = FakeSessionGateway() + private val local = FakeLocalSessionGateway() + val coordinator = SessionCoordinator(profiles, credentials, remote, local) + } +} -- 2.54.0 From 3764acb7ec710cfd46761f994b4e8c4c2cd34346 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 22:25:21 +0800 Subject: [PATCH 02/26] fix: isolate login credentials across providers --- .../abox/manager/ui/login/LoginScreenTest.kt | 23 +++++++++++++++++++ .../abox/manager/ui/login/LoginScreen.kt | 10 ++++++-- .../abox/manager/ui/login/LoginViewModel.kt | 15 ++++++++++-- .../AndroidKeystoreCredentialCipherTest.kt | 7 ++++++ .../AndroidKeystoreCredentialCipher.kt | 2 ++ .../manager/session/CredentialStoreTest.kt | 23 +++++++++++++++++++ 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt index 49f928c..451585b 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt @@ -53,6 +53,29 @@ class LoginScreenTest { assertEquals(2, switches) } + @Test + fun systemBackIsConsumedWhileSubmitting() { + var switches = 0 + composeRule.setContent { + ABoxTheme { + LoginScreen( + profile = profile, + state = LoginUiState(isSubmitting = true), + onUsernameChange = {}, + onPasswordChange = {}, + onRememberUsernameChange = {}, + onAutoLoginChange = {}, + onSubmit = {}, + onSwitchProvider = { switches++ }, + ) + } + } + + composeRule.activity.onBackPressedDispatcher.onBackPressed() + composeRule.waitForIdle() + assertEquals(0, switches) + } + @Test fun backControlsAreDisabledWhileSubmitting() { composeRule.setContent { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index b65ae1d..8981b99 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -48,6 +48,10 @@ fun LoginRoute( ) { LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } val state = viewModel.uiState.collectAsStateWithLifecycle().value + val switchProvider = { + viewModel.clearSensitiveState() + onSwitchProvider() + } LoginScreen( profile = profile, state = state.copy( @@ -58,7 +62,7 @@ fun LoginRoute( onRememberUsernameChange = viewModel::setRememberUsername, onAutoLoginChange = viewModel::setAutoLogin, onSubmit = viewModel::submit, - onSwitchProvider = onSwitchProvider, + onSwitchProvider = switchProvider, switchingProvider = actionInProgress, ) } @@ -77,7 +81,9 @@ fun LoginScreen( switchingProvider: Boolean = false, ) { val navigationEnabled = !state.isSubmitting && !switchingProvider - BackHandler(enabled = navigationEnabled, onBack = onSwitchProvider) + BackHandler { + if (navigationEnabled) onSwitchProvider() + } Scaffold( topBar = { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index eecd8ab..9ebef78 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -29,11 +29,15 @@ class LoginViewModel @Inject constructor( private var loadingJob: Job? = null fun loadProfile(value: ProviderProfile) { - if (profile?.id == value.id && loadingJob != null) return + val profileChanged = profile?.id != value.id + if (!profileChanged && loadingJob != null) return + loadingJob?.cancel() profile = value + _uiState.value = LoginUiState() loadingJob = viewModelScope.launch { val preferences = profileStore.loginPreferences(value.id) - _uiState.value = _uiState.value.copy( + if (profile?.id != value.id) return@launch + _uiState.value = LoginUiState( username = preferences.rememberedUsername?.value.orEmpty(), rememberUsername = preferences.rememberUsername, autoLogin = preferences.autoLoginEnabled, @@ -41,6 +45,13 @@ class LoginViewModel @Inject constructor( } } + fun clearSensitiveState() { + loadingJob?.cancel() + loadingJob = null + profile = null + _uiState.value = LoginUiState() + } + fun setUsername(value: String) { _uiState.value = _uiState.value.copy(username = value, errorMessage = null) } diff --git a/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt index e67e936..2b92890 100644 --- a/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt @@ -24,4 +24,11 @@ class AndroidKeystoreCredentialCipherTest { assertArrayEquals(plaintext, cipher.decrypt(profile, first)) assertArrayEquals(plaintext, cipher.decrypt(profile, second)) } + + @Test(expected = IllegalArgumentException::class) + fun rejectsTruncatedPayloadBeforeReadingHeader() { + AndroidKeystoreCredentialCipher( + keyAlias = "abox_manager_test_${System.nanoTime()}", + ).decrypt(ProviderProfileId("profile"), byteArrayOf(1)) + } } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt index e865be9..13b4f7c 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt @@ -27,6 +27,7 @@ class AndroidKeystoreCredentialCipher( } override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray { + require(encrypted.size >= HEADER_SIZE) { "Credential payload is truncated" } val buffer = ByteBuffer.wrap(encrypted) require(buffer.get() == VERSION) { "Unsupported credential format" } val ivLength = buffer.get().toInt() and 0xFF @@ -63,6 +64,7 @@ class AndroidKeystoreCredentialCipher( private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val KEY_SIZE_BITS = 256 private const val GCM_TAG_BITS = 128 + private const val HEADER_SIZE = 2 private const val VERSION: Byte = 1 } } diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt index b185166..dd9a204 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -33,6 +33,29 @@ class CredentialStoreTest { assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value) } + @Test + fun malformedCredentialIsRemoved() = runTest { + val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val profile = ProviderProfileId("profile") + val store = KeystoreCredentialStore(dataStore, RejectingCipher()) + store.saveCredentials( + profile, + LoginCredentials(Username("demo"), Password("secret")), + ) + + assertNull((store.loadCredentials(profile) as DomainResult.Success).value) + assertNull((store.loadCredentials(profile) as DomainResult.Success).value) + } + + private class RejectingCipher : CredentialCipher { + override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = byteArrayOf(1) + + override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray { + throw IllegalArgumentException("Credential payload is truncated") + } + } + private class ReversingCipher : CredentialCipher { override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = (profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray() -- 2.54.0 From 344fa6dafad71337cbccc4c17466363e1aa49746 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 23:02:01 +0800 Subject: [PATCH 03/26] fix: preserve login input during preference load --- .../abox/manager/ui/login/LoginScreen.kt | 17 +++++--- .../abox/manager/ui/login/LoginViewModel.kt | 42 +++++++++++++++--- .../credential/KeystoreCredentialStore.kt | 12 +++++- .../session/CredentialCleanupFailureTest.kt | 43 +++++++++++++++++++ 4 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 8981b99..4dd9133 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -80,7 +80,8 @@ fun LoginScreen( onSwitchProvider: () -> Unit, switchingProvider: Boolean = false, ) { - val navigationEnabled = !state.isSubmitting && !switchingProvider + val formEnabled = !state.isSubmitting && !state.isLoadingPreferences + val navigationEnabled = formEnabled && !switchingProvider BackHandler { if (navigationEnabled) onSwitchProvider() } @@ -140,7 +141,7 @@ fun LoginScreen( value = state.username, onValueChange = onUsernameChange, label = { Text(stringResource(R.string.login_username)) }, - enabled = !state.isSubmitting, + enabled = formEnabled, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -149,29 +150,31 @@ fun LoginScreen( onValueChange = onPasswordChange, label = { Text(stringResource(R.string.login_password)) }, visualTransformation = PasswordVisualTransformation(), - enabled = !state.isSubmitting, + enabled = formEnabled, singleLine = true, modifier = Modifier.fillMaxWidth(), ) ToggleRow( label = stringResource(R.string.login_remember_username), checked = state.rememberUsername, - enabled = !state.isSubmitting, + enabled = formEnabled, onCheckedChange = onRememberUsernameChange, ) ToggleRow( label = stringResource(R.string.login_auto_login), checked = state.autoLogin, - enabled = !state.isSubmitting, + enabled = formEnabled, onCheckedChange = onAutoLoginChange, ) state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } ABoxButton( onClick = onSubmit, - enabled = !state.isSubmitting && !switchingProvider, + enabled = formEnabled && !switchingProvider, modifier = Modifier.fillMaxWidth(), ) { - if (state.isSubmitting || switchingProvider) ABoxLoadingIndicator() + if (state.isSubmitting || state.isLoadingPreferences || switchingProvider) { + ABoxLoadingIndicator() + } else Text(stringResource(R.string.action_login)) } ABoxButton( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index 9ebef78..43f1b07 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -27,20 +27,39 @@ class LoginViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var profile: ProviderProfile? = null private var loadingJob: Job? = null + private var usernameEdited = false + private var rememberUsernameEdited = false + private var autoLoginEdited = false fun loadProfile(value: ProviderProfile) { val profileChanged = profile?.id != value.id if (!profileChanged && loadingJob != null) return loadingJob?.cancel() profile = value - _uiState.value = LoginUiState() + usernameEdited = false + rememberUsernameEdited = false + autoLoginEdited = false + _uiState.value = LoginUiState(isLoadingPreferences = true) loadingJob = viewModelScope.launch { val preferences = profileStore.loginPreferences(value.id) if (profile?.id != value.id) return@launch - _uiState.value = LoginUiState( - username = preferences.rememberedUsername?.value.orEmpty(), - rememberUsername = preferences.rememberUsername, - autoLogin = preferences.autoLoginEnabled, + _uiState.value = _uiState.value.copy( + username = if (usernameEdited) { + _uiState.value.username + } else { + preferences.rememberedUsername?.value.orEmpty() + }, + rememberUsername = if (rememberUsernameEdited) { + _uiState.value.rememberUsername + } else { + preferences.rememberUsername + }, + autoLogin = if (autoLoginEdited) { + _uiState.value.autoLogin + } else { + preferences.autoLoginEnabled + }, + isLoadingPreferences = false, ) } } @@ -49,10 +68,14 @@ class LoginViewModel @Inject constructor( loadingJob?.cancel() loadingJob = null profile = null + usernameEdited = false + rememberUsernameEdited = false + autoLoginEdited = false _uiState.value = LoginUiState() } fun setUsername(value: String) { + usernameEdited = true _uiState.value = _uiState.value.copy(username = value, errorMessage = null) } @@ -61,17 +84,23 @@ class LoginViewModel @Inject constructor( } fun setRememberUsername(value: Boolean) { + rememberUsernameEdited = true _uiState.value = _uiState.value.copy(rememberUsername = value) } fun setAutoLogin(value: Boolean) { + autoLoginEdited = true _uiState.value = _uiState.value.copy(autoLogin = value) } fun submit() { val activeProfile = profile ?: return val state = _uiState.value - if (state.username.isBlank() || state.password.isEmpty() || state.isSubmitting) { + if (state.username.isBlank() || + state.password.isEmpty() || + state.isSubmitting || + state.isLoadingPreferences + ) { _uiState.value = state.copy(errorMessage = "Username and password are required") return } @@ -104,6 +133,7 @@ data class LoginUiState( val password: String = "", val rememberUsername: Boolean = false, val autoLogin: Boolean = false, + val isLoadingPreferences: Boolean = false, val isSubmitting: Boolean = false, val errorMessage: String? = null, ) diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt index 02f801f..111915f 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -44,8 +44,16 @@ class KeystoreCredentialStore( DomainResult.Success(decode(cipher.decrypt(profileId, encrypted))) } catch (error: Exception) { if (error.isPermanentCredentialFailure()) { - dataStore.edit { it.remove(credentialKey(profileId)) } - DomainResult.Success(null) + try { + dataStore.edit { it.remove(credentialKey(profileId)) } + DomainResult.Success(null) + } catch (cleanupError: Exception) { + DomainResult.Failure( + DomainError.Unknown( + detail = cleanupError.message ?: cleanupError::class.java.simpleName, + ), + ) + } } else { DomainResult.Failure( DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt new file mode 100644 index 0000000..6373da6 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt @@ -0,0 +1,43 @@ +package com.flagship.abox.manager.session + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.session.credential.CredentialCipher +import com.flagship.abox.manager.session.credential.KeystoreCredentialStore +import java.io.IOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CredentialCleanupFailureTest { + @Test + fun cleanupFailureReturnsDomainFailureInsteadOfThrowing() = runTest { + val store = KeystoreCredentialStore(FailingCleanupDataStore(), UnusedCipher()) + + val result = store.loadCredentials(ProviderProfileId("profile")) + + assertTrue(result is DomainResult.Failure) + assertEquals("disk unavailable", (result as DomainResult.Failure).error.detail) + } + + private class FailingCleanupDataStore : DataStore { + override val data: Flow = flow { + throw IllegalArgumentException("corrupt credential") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = throw IOException("disk unavailable") + } + + private class UnusedCipher : CredentialCipher { + override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext + + override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted + } +} -- 2.54.0 From 7cb0480a7a75685c5b5aed9704ecaf7813a17219 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 23:30:11 +0800 Subject: [PATCH 04/26] fix: harden provider session restoration --- .../abox/manager/ui/login/LoginScreen.kt | 6 +-- .../abox/manager/ui/login/LoginViewModel.kt | 47 ++++++++++--------- .../profile/ProviderProfileStoreImpl.kt | 24 ++++++++-- .../session/profile/SessionDataStore.kt | 3 ++ .../ProviderProfileStoreFailureTest.kt | 33 +++++++++++++ 5 files changed, 83 insertions(+), 30 deletions(-) create mode 100644 android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 4dd9133..8cf7f47 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -48,10 +48,6 @@ fun LoginRoute( ) { LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } val state = viewModel.uiState.collectAsStateWithLifecycle().value - val switchProvider = { - viewModel.clearSensitiveState() - onSwitchProvider() - } LoginScreen( profile = profile, state = state.copy( @@ -62,7 +58,7 @@ fun LoginRoute( onRememberUsernameChange = viewModel::setRememberUsername, onAutoLoginChange = viewModel::setAutoLogin, onSubmit = viewModel::submit, - onSwitchProvider = switchProvider, + onSwitchProvider = onSwitchProvider, switchingProvider = actionInProgress, ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index 43f1b07..ab21e02 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -32,8 +32,7 @@ class LoginViewModel @Inject constructor( private var autoLoginEdited = false fun loadProfile(value: ProviderProfile) { - val profileChanged = profile?.id != value.id - if (!profileChanged && loadingJob != null) return + if (profile?.id == value.id && loadingJob?.isActive == true) return loadingJob?.cancel() profile = value usernameEdited = false @@ -41,26 +40,30 @@ class LoginViewModel @Inject constructor( autoLoginEdited = false _uiState.value = LoginUiState(isLoadingPreferences = true) loadingJob = viewModelScope.launch { - val preferences = profileStore.loginPreferences(value.id) - if (profile?.id != value.id) return@launch - _uiState.value = _uiState.value.copy( - username = if (usernameEdited) { - _uiState.value.username - } else { - preferences.rememberedUsername?.value.orEmpty() - }, - rememberUsername = if (rememberUsernameEdited) { - _uiState.value.rememberUsername - } else { - preferences.rememberUsername - }, - autoLogin = if (autoLoginEdited) { - _uiState.value.autoLogin - } else { - preferences.autoLoginEnabled - }, - isLoadingPreferences = false, - ) + try { + val preferences = profileStore.loginPreferences(value.id) + if (profile?.id != value.id) return@launch + _uiState.value = _uiState.value.copy( + username = if (usernameEdited) { + _uiState.value.username + } else { + preferences.rememberedUsername?.value.orEmpty() + }, + rememberUsername = if (rememberUsernameEdited) { + _uiState.value.rememberUsername + } else { + preferences.rememberUsername + }, + autoLogin = if (autoLoginEdited) { + _uiState.value.autoLogin + } else { + preferences.autoLoginEnabled + }, + isLoadingPreferences = false, + ) + } finally { + loadingJob = null + } } } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index 2ddadcc..c326082 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import com.flagship.abox.manager.domain.DomainResult @@ -13,19 +14,34 @@ import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.Username +import java.io.IOException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retryWhen class ProviderProfileStoreImpl( private val dataStore: DataStore, ) : ProviderProfileStore { - override fun observeProfiles(): Flow> = dataStore.data.map(::profilesFrom) + private val safeData: Flow + get() = dataStore.data + .retryWhen { cause, attempt -> + val retry = cause is IOException && attempt < MAX_READ_RETRIES + if (retry) delay(READ_RETRY_DELAY_MILLIS) + retry + } + .catch { error -> + if (error is IOException) emit(emptyPreferences()) else throw error + } - override fun observeActiveProfile(): Flow = dataStore.data.map(::activeProfileFrom) + override fun observeProfiles(): Flow> = safeData.map(::profilesFrom) + + override fun observeActiveProfile(): Flow = safeData.map(::activeProfileFrom) override fun observeLoginPreferences(profileId: ProviderProfileId): Flow = - dataStore.data.map { preferencesFrom(it, profileId) } + safeData.map { preferencesFrom(it, profileId) } override suspend fun profiles(): List = profilesFrom(dataStore.data.first()) @@ -99,6 +115,8 @@ class ProviderProfileStoreImpl( } companion object { + private const val MAX_READ_RETRIES = 2L + private const val READ_RETRY_DELAY_MILLIS = 50L private val PROFILE_IDS = stringSetPreferencesKey("profile_ids") private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt index 19d9c14..d656d83 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt @@ -2,11 +2,14 @@ package com.flagship.abox.manager.session.profile import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStore private const val SESSION_DATA_STORE_NAME = "provider_session" val Context.sessionDataStore: DataStore by preferencesDataStore( name = SESSION_DATA_STORE_NAME, + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, ) diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt new file mode 100644 index 0000000..011ca53 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt @@ -0,0 +1,33 @@ +package com.flagship.abox.manager.session + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl +import java.io.IOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProviderProfileStoreFailureTest { + @Test + fun ioFailureFallsBackToEmptyPreferences() = runTest { + val store = ProviderProfileStoreImpl(FailingReadDataStore()) + + assertEquals(emptyList(), store.observeProfiles().first()) + assertEquals(null, store.observeActiveProfile().first()) + } + + private class FailingReadDataStore : DataStore { + override val data: Flow = flow { + throw IOException("disk unavailable") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = transform(emptyPreferences()) + } +} -- 2.54.0 From 8788fe6ec5b6b408ff1d86e45270ba27928ab872 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 23:45:39 +0800 Subject: [PATCH 05/26] fix: secure login route lifecycle --- .../flagship/abox/manager/ui/login/LoginScreen.kt | 12 ++++++++++++ .../session/profile/ProviderProfileStoreImpl.kt | 6 +++--- .../session/ProviderProfileStoreFailureTest.kt | 3 +++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 8cf7f47..daae812 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -21,13 +21,17 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -47,6 +51,9 @@ fun LoginRoute( viewModel: LoginViewModel = hiltViewModel(), ) { LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } + DisposableEffect(profile.id) { + onDispose(viewModel::clearSensitiveState) + } val state = viewModel.uiState.collectAsStateWithLifecycle().value LoginScreen( profile = profile, @@ -146,6 +153,11 @@ fun LoginScreen( onValueChange = onPasswordChange, label = { Text(stringResource(R.string.login_password)) }, visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + autoCorrect = false, + ), enabled = formEnabled, singleLine = true, modifier = Modifier.fillMaxWidth(), diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index c326082..23ac9e9 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -43,12 +43,12 @@ class ProviderProfileStoreImpl( override fun observeLoginPreferences(profileId: ProviderProfileId): Flow = safeData.map { preferencesFrom(it, profileId) } - override suspend fun profiles(): List = profilesFrom(dataStore.data.first()) + override suspend fun profiles(): List = profilesFrom(safeData.first()) - override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(dataStore.data.first()) + override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(safeData.first()) override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences = - preferencesFrom(dataStore.data.first(), profileId) + preferencesFrom(safeData.first(), profileId) override suspend fun saveProfile(profile: ProviderProfile): DomainResult = operation { dataStore.edit { preferences -> diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt index 011ca53..779e572 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt @@ -19,6 +19,9 @@ class ProviderProfileStoreFailureTest { assertEquals(emptyList(), store.observeProfiles().first()) assertEquals(null, store.observeActiveProfile().first()) + assertEquals(emptyList(), store.profiles()) + assertEquals(null, store.activeProfile()) + assertEquals(false, store.loginPreferences(com.flagship.abox.manager.domain.ProviderProfileId("profile")).autoLoginEnabled) } private class FailingReadDataStore : DataStore { -- 2.54.0 From 03bf28afbc6aed8c11cf936bf1cb92fbd9d6a2da Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 00:06:59 +0800 Subject: [PATCH 06/26] fix: preserve session storage through transient failures --- .../credential/KeystoreCredentialStore.kt | 15 ++++---- .../profile/ProviderProfileStoreImpl.kt | 19 +++++----- .../ProviderProfileStoreFailureTest.kt | 36 ++++++++++++++++++- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt index 111915f..53f15b0 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -15,11 +15,10 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream -import java.security.InvalidKeyException -import java.security.KeyStoreException -import java.security.UnrecoverableKeyException +import android.security.keystore.KeyPermanentlyInvalidatedException import java.util.Base64 import javax.crypto.AEADBadTagException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first class KeystoreCredentialStore( @@ -42,11 +41,15 @@ class KeystoreCredentialStore( ?: return DomainResult.Success(null) val encrypted = Base64.getDecoder().decode(value) DomainResult.Success(decode(cipher.decrypt(profileId, encrypted))) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { if (error.isPermanentCredentialFailure()) { try { dataStore.edit { it.remove(credentialKey(profileId)) } DomainResult.Success(null) + } catch (cleanupError: CancellationException) { + throw cleanupError } catch (cleanupError: Exception) { DomainResult.Failure( DomainError.Unknown( @@ -86,6 +89,8 @@ class KeystoreCredentialStore( private suspend fun operation(block: suspend () -> Unit): DomainResult = try { block() DomainResult.Success(Unit) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { DomainResult.Failure( DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), @@ -100,9 +105,7 @@ class KeystoreCredentialStore( private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) { is AEADBadTagException, - is InvalidKeyException, - is KeyStoreException, - is UnrecoverableKeyException, + is KeyPermanentlyInvalidatedException, is IllegalArgumentException, -> true diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index 23ac9e9..e4fba9d 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -15,9 +15,9 @@ import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.Username import java.io.IOException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen @@ -26,15 +26,12 @@ class ProviderProfileStoreImpl( private val dataStore: DataStore, ) : ProviderProfileStore { private val safeData: Flow - get() = dataStore.data - .retryWhen { cause, attempt -> - val retry = cause is IOException && attempt < MAX_READ_RETRIES - if (retry) delay(READ_RETRY_DELAY_MILLIS) - retry - } - .catch { error -> - if (error is IOException) emit(emptyPreferences()) else throw error - } + get() = dataStore.data.retryWhen { cause, attempt -> + if (cause !is IOException) return@retryWhen false + if (attempt >= MAX_READ_RETRIES) emit(emptyPreferences()) + delay(READ_RETRY_DELAY_MILLIS) + true + } override fun observeProfiles(): Flow> = safeData.map(::profilesFrom) @@ -106,6 +103,8 @@ class ProviderProfileStoreImpl( private suspend fun operation(block: suspend () -> Unit): DomainResult = try { block() DomainResult.Success(Unit) + } catch (error: CancellationException) { + throw error } catch (error: Exception) { DomainResult.Failure( com.flagship.abox.manager.domain.DomainError.Unknown( diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt index 779e572..1aa30ad 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt @@ -1,13 +1,18 @@ package com.flagship.abox.manager.session import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import java.io.IOException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -21,7 +26,17 @@ class ProviderProfileStoreFailureTest { assertEquals(null, store.observeActiveProfile().first()) assertEquals(emptyList(), store.profiles()) assertEquals(null, store.activeProfile()) - assertEquals(false, store.loginPreferences(com.flagship.abox.manager.domain.ProviderProfileId("profile")).autoLoginEnabled) + assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled) + } + + @Test + fun observerContinuesAfterStorageRecovers() = runTest { + val store = ProviderProfileStoreImpl(RecoveringReadDataStore()) + + val emissions = store.observeActiveProfile().take(2).toList() + + assertEquals(null, emissions[0]) + assertEquals("recovered", emissions[1]?.id?.value) } private class FailingReadDataStore : DataStore { @@ -33,4 +48,23 @@ class ProviderProfileStoreFailureTest { transform: suspend (t: Preferences) -> Preferences, ): Preferences = transform(emptyPreferences()) } + + private class RecoveringReadDataStore : DataStore { + private var attempts = 0 + override val data: Flow + get() = flow { + if (attempts++ < 3) throw IOException("temporarily unavailable") + val values: MutablePreferences = emptyPreferences().toMutablePreferences() + values[stringPreferencesKey("active_profile_id")] = "recovered" + values[androidx.datastore.preferences.core.stringSetPreferencesKey("profile_ids")] = + setOf("recovered") + values[stringPreferencesKey("profile_recovered_type")] = "LOCAL_EXPERIENCE" + values[stringPreferencesKey("profile_recovered_name")] = "Recovered" + emit(values) + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = transform(emptyPreferences()) + } } -- 2.54.0 From 1c5caf5a6b4a71e01e05874ddd63e02c18023704 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 00:33:21 +0800 Subject: [PATCH 07/26] fix: enforce provider availability on restore --- .../abox/manager/ui/login/LoginViewModel.kt | 10 +++++ .../manager/ui/session/AppSessionViewModel.kt | 14 +++++- .../profile/ProviderProfileStoreImpl.kt | 31 ++++++++++--- .../ProviderProfileStoreFailureTest.kt | 45 +++++++++++++------ 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index ab21e02..7f9a0cd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.Username import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -61,6 +62,15 @@ class LoginViewModel @Inject constructor( }, isLoadingPreferences = false, ) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + if (profile?.id == value.id) { + _uiState.value = _uiState.value.copy( + isLoadingPreferences = false, + errorMessage = "Unable to load saved login settings", + ) + } } finally { loadingJob = null } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index a123d38..39d43f0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.ui.provider.Availability +import com.flagship.abox.manager.ui.provider.ProviderAvailability import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +21,8 @@ import kotlinx.coroutines.launch @HiltViewModel class AppSessionViewModel @Inject constructor( private val coordinator: SessionCoordinator, - profileStore: ProviderProfileStore, + private val profileStore: ProviderProfileStore, + private val availability: ProviderAvailability, ) : ViewModel() { private val bootstrapResult = MutableStateFlow?>(null) @@ -38,7 +41,13 @@ class AppSessionViewModel @Inject constructor( init { viewModelScope.launch { - bootstrapResult.value = coordinator.bootstrap() + val activeProfile = profileStore.activeProfile() + if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { + coordinator.switchProvider() + bootstrapResult.value = DomainResult.Success(null) + } else { + bootstrapResult.value = coordinator.bootstrap() + } } } @@ -48,6 +57,7 @@ class AppSessionViewModel @Inject constructor( bootstrapError: String?, ): AppSessionUiState = when { profile == null -> AppSessionUiState.ProviderSelection + availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection session != null -> AppSessionUiState.Authenticated(profile) else -> AppSessionUiState.Login(profile, bootstrapError) } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index e4fba9d..2baed17 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -18,20 +18,34 @@ import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.retryWhen +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class ProviderProfileStoreImpl( private val dataStore: DataStore, ) : ProviderProfileStore { + private val refresh = MutableSharedFlow(extraBufferCapacity = 1) private val safeData: Flow - get() = dataStore.data.retryWhen { cause, attempt -> - if (cause !is IOException) return@retryWhen false - if (attempt >= MAX_READ_RETRIES) emit(emptyPreferences()) - delay(READ_RETRY_DELAY_MILLIS) - true + get() = refresh + .onStart { emit(Unit) } + .flatMapLatest { readDataOnce() } + .distinctUntilChanged() + + private fun readDataOnce(): Flow = dataStore.data + .retryWhen { cause, attempt -> + val retry = cause is IOException && attempt < MAX_READ_RETRIES + if (retry) delay(READ_RETRY_DELAY_MILLIS) + retry } + .catchIOExceptionAsEmpty() override fun observeProfiles(): Flow> = safeData.map(::profilesFrom) @@ -53,6 +67,7 @@ class ProviderProfileStoreImpl( preferences[profileTypeKey(profile.id)] = profile.type.name preferences[profileNameKey(profile.id)] = profile.displayName } + refresh.tryEmit(Unit) } override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult = operation { @@ -60,6 +75,7 @@ class ProviderProfileStoreImpl( if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID) else preferences[ACTIVE_PROFILE_ID] = profileId.value } + refresh.tryEmit(Unit) } override suspend fun saveLoginPreferences( @@ -73,6 +89,7 @@ class ProviderProfileStoreImpl( if (username == null) values.remove(usernameKey(profileId)) else values[usernameKey(profileId)] = username } + refresh.tryEmit(Unit) } private fun profilesFrom(preferences: Preferences): List = @@ -135,3 +152,7 @@ class ProviderProfileStoreImpl( booleanPreferencesKey("profile_${id.value}_auto_login") } } + +private fun Flow.catchIOExceptionAsEmpty(): Flow = catch { error -> + if (error is IOException) emit(emptyPreferences()) else throw error +} diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt index 1aa30ad..a767f07 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt @@ -1,11 +1,11 @@ package com.flagship.abox.manager.session import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey +import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import java.io.IOException import kotlinx.coroutines.flow.Flow @@ -13,6 +13,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -29,14 +32,29 @@ class ProviderProfileStoreFailureTest { assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled) } + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Test - fun observerContinuesAfterStorageRecovers() = runTest { + fun observerResubscribesAfterSuccessfulWrite() = runTest { val store = ProviderProfileStoreImpl(RecoveringReadDataStore()) + val emissions = mutableListOf() + val collector = backgroundScope.launch { + store.observeActiveProfile().take(2).toList(emissions) + } + advanceTimeBy(200) + advanceUntilIdle() + assertEquals(listOf(null), emissions) - val emissions = store.observeActiveProfile().take(2).toList() + val profile = ProviderProfile( + ProviderProfileId("recovered"), + ProviderType.LOCAL_EXPERIENCE, + "Recovered", + ) + store.saveProfile(profile) + store.setActiveProfile(profile.id) + advanceUntilIdle() + collector.join() - assertEquals(null, emissions[0]) - assertEquals("recovered", emissions[1]?.id?.value) + assertEquals(listOf(null, profile), emissions) } private class FailingReadDataStore : DataStore { @@ -51,20 +69,19 @@ class ProviderProfileStoreFailureTest { private class RecoveringReadDataStore : DataStore { private var attempts = 0 + private var current: Preferences = emptyPreferences() + override val data: Flow get() = flow { if (attempts++ < 3) throw IOException("temporarily unavailable") - val values: MutablePreferences = emptyPreferences().toMutablePreferences() - values[stringPreferencesKey("active_profile_id")] = "recovered" - values[androidx.datastore.preferences.core.stringSetPreferencesKey("profile_ids")] = - setOf("recovered") - values[stringPreferencesKey("profile_recovered_type")] = "LOCAL_EXPERIENCE" - values[stringPreferencesKey("profile_recovered_name")] = "Recovered" - emit(values) + emit(current) } override suspend fun updateData( transform: suspend (t: Preferences) -> Preferences, - ): Preferences = transform(emptyPreferences()) + ): Preferences { + current = transform(current) + return current + } } } -- 2.54.0 From 547146792f31857122853d5053e734e96caaf130 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 00:49:42 +0800 Subject: [PATCH 08/26] fix: serialize login submission state --- .../abox/manager/ui/login/LoginViewModel.kt | 44 +++++++++++++------ .../credential/KeystoreCredentialStore.kt | 27 ++++++++++-- .../manager/session/CredentialStoreTest.kt | 15 +++++++ 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index 7f9a0cd..a96a46b 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -28,12 +28,15 @@ class LoginViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var profile: ProviderProfile? = null private var loadingJob: Job? = null + private var submitJob: Job? = null private var usernameEdited = false private var rememberUsernameEdited = false private var autoLoginEdited = false fun loadProfile(value: ProviderProfile) { - if (profile?.id == value.id && loadingJob?.isActive == true) return + if (profile?.id == value.id && + (loadingJob?.isActive == true || submitJob?.isActive == true) + ) return loadingJob?.cancel() profile = value usernameEdited = false @@ -80,6 +83,14 @@ class LoginViewModel @Inject constructor( fun clearSensitiveState() { loadingJob?.cancel() loadingJob = null + if (submitJob?.isActive == true) { + _uiState.value = _uiState.value.copy( + password = "", + errorMessage = null, + isSubmitting = true, + ) + return + } profile = null usernameEdited = false rememberUsernameEdited = false @@ -107,6 +118,7 @@ class LoginViewModel @Inject constructor( } fun submit() { + if (submitJob?.isActive == true) return val activeProfile = profile ?: return val state = _uiState.value if (state.username.isBlank() || @@ -117,19 +129,23 @@ class LoginViewModel @Inject constructor( _uiState.value = state.copy(errorMessage = "Username and password are required") return } - viewModelScope.launch { - _uiState.value = state.copy(isSubmitting = true, errorMessage = null) - val result = coordinator.login( - activeProfile.id, - LoginCredentials(Username(state.username.trim()), Password(state.password)), - rememberUsername = state.rememberUsername, - autoLogin = state.autoLogin, - ) - _uiState.value = _uiState.value.copy( - password = "", - isSubmitting = false, - errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(), - ) + submitJob = viewModelScope.launch { + try { + _uiState.value = state.copy(isSubmitting = true, errorMessage = null) + val result = coordinator.login( + activeProfile.id, + LoginCredentials(Username(state.username.trim()), Password(state.password)), + rememberUsername = state.rememberUsername, + autoLogin = state.autoLogin, + ) + _uiState.value = _uiState.value.copy( + password = "", + isSubmitting = false, + errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(), + ) + } finally { + submitJob = null + } } } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt index 53f15b0..a989ad0 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -71,8 +71,9 @@ class KeystoreCredentialStore( private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output -> DataOutputStream(output).use { data -> - data.writeUTF(credentials.username.value) - data.writeUTF(credentials.password.value) + data.writeByte(CREDENTIAL_FORMAT_VERSION) + data.writeByteArray(credentials.username.value.toByteArray(Charsets.UTF_8)) + data.writeByteArray(credentials.password.value.toByteArray(Charsets.UTF_8)) } output.toByteArray() } @@ -80,8 +81,11 @@ class KeystoreCredentialStore( private fun decode(value: ByteArray): LoginCredentials = DataInputStream( ByteArrayInputStream(value), ).use { data -> - val username = data.readUTF() - val password = data.readUTF() + require(data.readUnsignedByte() == CREDENTIAL_FORMAT_VERSION) { + "Unsupported credential data format" + } + val username = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8) + val password = data.readByteArray(MAX_CREDENTIAL_FIELD_BYTES).toString(Charsets.UTF_8) require(data.available() == 0) { "Unexpected credential data" } LoginCredentials(Username(username), Password(password)) } @@ -98,11 +102,26 @@ class KeystoreCredentialStore( } companion object { + private const val CREDENTIAL_FORMAT_VERSION = 1 + private const val MAX_CREDENTIAL_FIELD_BYTES = 1_048_576 + private fun credentialKey(profileId: ProviderProfileId) = stringPreferencesKey("profile_${profileId.value}_credential") } } +private fun DataOutputStream.writeByteArray(value: ByteArray) { + require(value.size <= 1_048_576) { "Credential field is too large" } + writeInt(value.size) + write(value) +} + +private fun DataInputStream.readByteArray(maxSize: Int): ByteArray { + val length = readInt() + require(length in 0..maxSize && length <= available()) { "Invalid credential field length" } + return ByteArray(length).also(::readFully) +} + private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) { is AEADBadTagException, is KeyPermanentlyInvalidatedException, diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt index dd9a204..c34e1c4 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -33,6 +33,21 @@ class CredentialStoreTest { assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value) } + @Test + fun credentialsLargerThanWriteUtfLimitRoundTrip() = runTest { + val file = File.createTempFile("large-credentials", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val store = KeystoreCredentialStore(dataStore, ReversingCipher()) + val profile = ProviderProfileId("large") + val credentials = LoginCredentials( + Username("u".repeat(70_000)), + Password("p".repeat(70_000)), + ) + + assertEquals(DomainResult.Success(Unit), store.saveCredentials(profile, credentials)) + assertEquals(credentials, (store.loadCredentials(profile) as DomainResult.Success).value) + } + @Test fun malformedCredentialIsRemoved() = runTest { val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() } -- 2.54.0 From 40bbc2f30b596252f3d84bccf06bc0ad3a050add Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 01:02:58 +0800 Subject: [PATCH 09/26] fix: scope session errors to active screen --- .../abox/manager/ui/ABoxManagerApp.kt | 17 +++++++-- .../manager/ui/session/AppSessionViewModel.kt | 35 +++++++++++++++---- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 0a1b959..5ced03b 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -26,11 +27,14 @@ fun ABoxManagerApp( sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), ) { val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value + val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value + val screenKey = sessionState.screenKey() + LaunchedEffect(screenKey) { + sessionActionsViewModel.dismissError() + } ABoxTheme { ABoxSurface(modifier = Modifier.fillMaxSize()) { - when ( - val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value - ) { + when (val state = sessionState) { AppSessionUiState.Loading -> Box( modifier = Modifier .fillMaxSize() @@ -58,3 +62,10 @@ fun ABoxManagerApp( } } } + +private fun AppSessionUiState.screenKey(): String = when (this) { + AppSessionUiState.Loading -> "loading" + AppSessionUiState.ProviderSelection -> "providers" + is AppSessionUiState.Login -> "login:${profile.id.value}" + is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}" +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index 39d43f0..7525220 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.SessionCoordinator @@ -24,15 +25,19 @@ class AppSessionViewModel @Inject constructor( private val profileStore: ProviderProfileStore, private val availability: ProviderAvailability, ) : ViewModel() { - private val bootstrapResult = MutableStateFlow?>(null) + private val bootstrapResult = MutableStateFlow(null) val uiState: StateFlow = combine( bootstrapResult, profileStore.observeActiveProfile(), coordinator.observeActiveSession(), - ) { result, profile, session -> - if (result == null) AppSessionUiState.Loading - else stateFor(profile, session, (result as? DomainResult.Failure)?.error?.detail) + ) { bootstrap, profile, session -> + if (bootstrap == null) AppSessionUiState.Loading + else stateFor( + profile = profile, + session = session, + bootstrapError = bootstrap.errorFor(profile?.id), + ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -44,9 +49,15 @@ class AppSessionViewModel @Inject constructor( val activeProfile = profileStore.activeProfile() if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { coordinator.switchProvider() - bootstrapResult.value = DomainResult.Success(null) + bootstrapResult.value = ProviderBootstrapResult( + profileId = null, + result = DomainResult.Success(null), + ) } else { - bootstrapResult.value = coordinator.bootstrap() + bootstrapResult.value = ProviderBootstrapResult( + profileId = activeProfile?.id, + result = coordinator.bootstrap(), + ) } } } @@ -63,6 +74,18 @@ class AppSessionViewModel @Inject constructor( } } +private data class ProviderBootstrapResult( + val profileId: ProviderProfileId?, + val result: DomainResult, +) { + fun errorFor(currentProfileId: ProviderProfileId?): String? = + if (profileId == currentProfileId) { + (result as? DomainResult.Failure)?.error?.detail + } else { + null + } +} + sealed interface AppSessionUiState { data object Loading : AppSessionUiState data object ProviderSelection : AppSessionUiState -- 2.54.0 From d893a74becad827debdc97b8cad16dce1cdeeba2 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 01:27:00 +0800 Subject: [PATCH 10/26] fix: expire and localize login errors --- .../abox/manager/ui/login/LoginScreen.kt | 20 ++++++++++--- .../abox/manager/ui/login/LoginViewModel.kt | 25 +++++++++++----- .../manager/ui/session/AppSessionViewModel.kt | 29 +++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 6 ++++ android/app/src/main/res/values/strings.xml | 6 ++++ 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index daae812..704bdbd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -55,11 +55,13 @@ fun LoginRoute( onDispose(viewModel::clearSensitiveState) } val state = viewModel.uiState.collectAsStateWithLifecycle().value + val errorText = state.errorMessage?.let { stringResource(it.stringResource()) } + ?: actionError + ?: bootstrapError LoginScreen( profile = profile, - state = state.copy( - errorMessage = state.errorMessage ?: actionError ?: bootstrapError, - ), + state = state, + errorText = errorText, onUsernameChange = viewModel::setUsername, onPasswordChange = viewModel::setPassword, onRememberUsernameChange = viewModel::setRememberUsername, @@ -75,6 +77,7 @@ fun LoginRoute( fun LoginScreen( profile: ProviderProfile, state: LoginUiState, + errorText: String? = null, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onRememberUsernameChange: (Boolean) -> Unit, @@ -174,7 +177,7 @@ fun LoginScreen( enabled = formEnabled, onCheckedChange = onAutoLoginChange, ) - state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } + errorText?.let { Text(it, color = MaterialTheme.colorScheme.error) } ABoxButton( onClick = onSubmit, enabled = formEnabled && !switchingProvider, @@ -210,6 +213,15 @@ private fun ToggleRow( } } +private fun LoginErrorMessage.stringResource(): Int = when (this) { + LoginErrorMessage.REQUIRED_FIELDS -> R.string.login_error_required + LoginErrorMessage.INVALID_CREDENTIALS -> R.string.login_error_invalid_credentials + LoginErrorMessage.NETWORK_UNAVAILABLE -> R.string.login_error_network + LoginErrorMessage.SESSION_EXPIRED -> R.string.login_error_session_expired + LoginErrorMessage.PREFERENCES_UNAVAILABLE -> R.string.login_error_preferences + LoginErrorMessage.LOGIN_FAILED -> R.string.login_error_failed +} + @Composable private fun providerName(type: ProviderType): String = stringResource( when (type) { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index a96a46b..5d96021 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -71,7 +71,7 @@ class LoginViewModel @Inject constructor( if (profile?.id == value.id) { _uiState.value = _uiState.value.copy( isLoadingPreferences = false, - errorMessage = "Unable to load saved login settings", + errorMessage = LoginErrorMessage.PREFERENCES_UNAVAILABLE, ) } } finally { @@ -126,7 +126,7 @@ class LoginViewModel @Inject constructor( state.isSubmitting || state.isLoadingPreferences ) { - _uiState.value = state.copy(errorMessage = "Username and password are required") + _uiState.value = state.copy(errorMessage = LoginErrorMessage.REQUIRED_FIELDS) return } submitJob = viewModelScope.launch { @@ -149,14 +149,23 @@ class LoginViewModel @Inject constructor( } } - private fun DomainError.toUiMessage(): String = when (this) { - is DomainError.Authentication -> "Invalid username or password" - is DomainError.Network -> "Network unavailable" - is DomainError.TokenInvalid -> "Session expired" - else -> detail ?: "Login failed" + private fun DomainError.toUiMessage(): LoginErrorMessage = when (this) { + is DomainError.Authentication -> LoginErrorMessage.INVALID_CREDENTIALS + is DomainError.Network -> LoginErrorMessage.NETWORK_UNAVAILABLE + is DomainError.TokenInvalid -> LoginErrorMessage.SESSION_EXPIRED + else -> LoginErrorMessage.LOGIN_FAILED } } +enum class LoginErrorMessage { + REQUIRED_FIELDS, + INVALID_CREDENTIALS, + NETWORK_UNAVAILABLE, + SESSION_EXPIRED, + PREFERENCES_UNAVAILABLE, + LOGIN_FAILED, +} + data class LoginUiState( val username: String = "", val password: String = "", @@ -164,5 +173,5 @@ data class LoginUiState( val autoLogin: Boolean = false, val isLoadingPreferences: Boolean = false, val isSubmitting: Boolean = false, - val errorMessage: String? = null, + val errorMessage: LoginErrorMessage? = null, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index 7525220..34da97a 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -60,6 +62,33 @@ class AppSessionViewModel @Inject constructor( ) } } + viewModelScope.launch { + coordinator.observeActiveSession() + .map { it?.profileId } + .distinctUntilChanged() + .collect { sessionProfileId -> + if (sessionProfileId != null && bootstrapResult.value?.profileId == sessionProfileId) { + bootstrapResult.value = ProviderBootstrapResult( + profileId = sessionProfileId, + result = DomainResult.Success(null), + ) + } + } + } + viewModelScope.launch { + profileStore.observeActiveProfile() + .map { it?.id } + .distinctUntilChanged() + .collect { activeProfileId -> + val currentBootstrap = bootstrapResult.value + if (currentBootstrap != null && currentBootstrap.profileId != activeProfileId) { + bootstrapResult.value = ProviderBootstrapResult( + profileId = activeProfileId, + result = DomainResult.Success(null), + ) + } + } + } } private fun stateFor( diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 3a812f4..90e8af8 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -19,6 +19,12 @@ 密码 记住用户名 自动登录 + 请输入用户名和密码。 + 用户名或密码不正确。 + 网络不可用,请重试。 + 会话已失效,请重新登录。 + 无法加载已保存的登录设置。 + 登录失败,请重试。 登录 退出登录 切换提供方 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0a63752..a2b1d2d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -19,6 +19,12 @@ Password Remember username Sign in automatically + Username and password are required. + Invalid username or password. + Network unavailable. Try again. + Session expired. Sign in again. + Unable to load saved login settings. + Sign-in failed. Try again. Sign in Sign out Switch provider -- 2.54.0 From a0f55b041a9e8e12e7a2a0630dde669be649d1bb Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 01:47:53 +0800 Subject: [PATCH 11/26] fix: align session state across transitions --- .../kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt | 6 ++++++ .../com/flagship/abox/manager/ui/login/LoginScreen.kt | 4 ---- .../flagship/abox/manager/ui/session/AppSessionViewModel.kt | 2 +- .../session/credential/AndroidKeystoreCredentialCipher.kt | 5 +++++ .../abox/manager/session/credential/CredentialCipher.kt | 2 ++ .../manager/session/credential/KeystoreCredentialStore.kt | 6 ++++++ .../abox/manager/session/CredentialCleanupFailureTest.kt | 2 ++ .../flagship/abox/manager/session/CredentialStoreTest.kt | 4 ++++ 8 files changed, 26 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 5ced03b..5f41001 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -16,6 +16,7 @@ import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen import com.flagship.abox.manager.ui.login.LoginRoute +import com.flagship.abox.manager.ui.login.LoginViewModel import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel @@ -25,12 +26,16 @@ import com.flagship.abox.manager.ui.session.SessionActionsViewModel fun ABoxManagerApp( appSessionViewModel: AppSessionViewModel = hiltViewModel(), sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), + loginViewModel: LoginViewModel = hiltViewModel(), ) { val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value val screenKey = sessionState.screenKey() LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() + if (sessionState !is AppSessionUiState.Login) { + loginViewModel.clearSensitiveState() + } } ABoxTheme { ABoxSurface(modifier = Modifier.fillMaxSize()) { @@ -50,6 +55,7 @@ fun ABoxManagerApp( actionError = actionState.errorMessage, actionInProgress = actionState.inProgress, onSwitchProvider = sessionActionsViewModel::switchProvider, + viewModel = loginViewModel, ) is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen( profile = state.profile, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 704bdbd..c0adf70 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,9 +50,6 @@ fun LoginRoute( viewModel: LoginViewModel = hiltViewModel(), ) { LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } - DisposableEffect(profile.id) { - onDispose(viewModel::clearSensitiveState) - } val state = viewModel.uiState.collectAsStateWithLifecycle().value val errorText = state.errorMessage?.let { stringResource(it.stringResource()) } ?: actionError diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index 34da97a..323c1c3 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -98,7 +98,7 @@ class AppSessionViewModel @Inject constructor( ): AppSessionUiState = when { profile == null -> AppSessionUiState.ProviderSelection availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection - session != null -> AppSessionUiState.Authenticated(profile) + session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile) else -> AppSessionUiState.Login(profile, bootstrapError) } } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt index 13b4f7c..bcb7234 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt @@ -40,6 +40,11 @@ class AndroidKeystoreCredentialCipher( return cipher.doFinal(ciphertext) } + override fun resetPermanentlyInvalidatedKey() { + val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } + if (keyStore.containsAlias(keyAlias)) keyStore.deleteEntry(keyAlias) + } + private fun secretKey(): SecretKey { val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } (keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt index ea3fbab..5101278 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt @@ -6,4 +6,6 @@ interface CredentialCipher { fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray + + fun resetPermanentlyInvalidatedKey() } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt index a989ad0..7f6366c 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -46,6 +46,9 @@ class KeystoreCredentialStore( } catch (error: Exception) { if (error.isPermanentCredentialFailure()) { try { + if (error.hasPermanentlyInvalidatedKey()) { + cipher.resetPermanentlyInvalidatedKey() + } dataStore.edit { it.remove(credentialKey(profileId)) } DomainResult.Success(null) } catch (cleanupError: CancellationException) { @@ -122,6 +125,9 @@ private fun DataInputStream.readByteArray(maxSize: Int): ByteArray { return ByteArray(length).also(::readFully) } +private fun Throwable.hasPermanentlyInvalidatedKey(): Boolean = + this is KeyPermanentlyInvalidatedException || cause?.hasPermanentlyInvalidatedKey() == true + private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) { is AEADBadTagException, is KeyPermanentlyInvalidatedException, diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt index 6373da6..50554ca 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialCleanupFailureTest.kt @@ -39,5 +39,7 @@ class CredentialCleanupFailureTest { override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = plaintext override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray = encrypted + + override fun resetPermanentlyInvalidatedKey() = Unit } } diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt index c34e1c4..b912d27 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -69,6 +69,8 @@ class CredentialStoreTest { override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray { throw IllegalArgumentException("Credential payload is truncated") } + + override fun resetPermanentlyInvalidatedKey() = Unit } private class ReversingCipher : CredentialCipher { @@ -81,5 +83,7 @@ class CredentialStoreTest { require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value) return decoded.copyOfRange(delimiter + 1, decoded.size) } + + override fun resetPermanentlyInvalidatedKey() = Unit } } -- 2.54.0 From cf2c59a49381e6235cc6725e2239773ce47fa79f Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 02:17:56 +0800 Subject: [PATCH 12/26] fix: keep provider state during storage outages --- .../ui/authenticated/AuthenticatedScreen.kt | 5 +- .../abox/manager/ui/login/LoginScreen.kt | 2 +- .../ui/provider/ProviderSelectionScreen.kt | 2 +- .../profile/ProviderProfileStoreImpl.kt | 40 ++++-------- .../ProviderProfileStoreFailureTest.kt | 64 +++---------------- 5 files changed, 28 insertions(+), 85 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt index 3015930..1df3284 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -45,8 +47,9 @@ fun AuthenticatedPlaceholderScreen( ) { Column( modifier = Modifier - .fillMaxWidth() .widthIn(max = 520.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index c0adf70..39b2055 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -118,8 +118,8 @@ fun LoginScreen( ) { Column( modifier = Modifier - .fillMaxWidth() .widthIn(max = 520.dp) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt index aaeabdb..28528d0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt @@ -65,8 +65,8 @@ fun ProviderSelectionScreen( ) { Column( modifier = Modifier - .fillMaxWidth() .widthIn(max = 640.dp) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index 2baed17..f3333b9 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -4,7 +4,6 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import com.flagship.abox.manager.domain.DomainResult @@ -18,35 +17,26 @@ import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.retryWhen -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class ProviderProfileStoreImpl( private val dataStore: DataStore, ) : ProviderProfileStore { - private val refresh = MutableSharedFlow(extraBufferCapacity = 1) private val safeData: Flow - get() = refresh - .onStart { emit(Unit) } - .flatMapLatest { readDataOnce() } + get() = dataStore.data + .retryWhen { cause, attempt -> + if (cause !is IOException) return@retryWhen false + val delayMillis = (READ_RETRY_DELAY_MILLIS shl + attempt.coerceAtMost(MAX_BACKOFF_SHIFT).toInt()) + .coerceAtMost(MAX_READ_RETRY_DELAY_MILLIS) + delay(delayMillis) + true + } .distinctUntilChanged() - private fun readDataOnce(): Flow = dataStore.data - .retryWhen { cause, attempt -> - val retry = cause is IOException && attempt < MAX_READ_RETRIES - if (retry) delay(READ_RETRY_DELAY_MILLIS) - retry - } - .catchIOExceptionAsEmpty() - override fun observeProfiles(): Flow> = safeData.map(::profilesFrom) override fun observeActiveProfile(): Flow = safeData.map(::activeProfileFrom) @@ -67,7 +57,6 @@ class ProviderProfileStoreImpl( preferences[profileTypeKey(profile.id)] = profile.type.name preferences[profileNameKey(profile.id)] = profile.displayName } - refresh.tryEmit(Unit) } override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult = operation { @@ -75,7 +64,6 @@ class ProviderProfileStoreImpl( if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID) else preferences[ACTIVE_PROFILE_ID] = profileId.value } - refresh.tryEmit(Unit) } override suspend fun saveLoginPreferences( @@ -89,7 +77,6 @@ class ProviderProfileStoreImpl( if (username == null) values.remove(usernameKey(profileId)) else values[usernameKey(profileId)] = username } - refresh.tryEmit(Unit) } private fun profilesFrom(preferences: Preferences): List = @@ -131,8 +118,9 @@ class ProviderProfileStoreImpl( } companion object { - private const val MAX_READ_RETRIES = 2L - private const val READ_RETRY_DELAY_MILLIS = 50L + private const val READ_RETRY_DELAY_MILLIS = 100L + private const val MAX_READ_RETRY_DELAY_MILLIS = 5_000L + private const val MAX_BACKOFF_SHIFT = 6L private val PROFILE_IDS = stringSetPreferencesKey("profile_ids") private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") @@ -152,7 +140,3 @@ class ProviderProfileStoreImpl( booleanPreferencesKey("profile_${id.value}_auto_login") } } - -private fun Flow.catchIOExceptionAsEmpty(): Flow = catch { error -> - if (error is IOException) emit(emptyPreferences()) else throw error -} diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt index a767f07..64e6a22 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt @@ -3,58 +3,32 @@ package com.flagship.abox.manager.session import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences -import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId -import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import java.io.IOException import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertFalse import org.junit.Test class ProviderProfileStoreFailureTest { - @Test - fun ioFailureFallsBackToEmptyPreferences() = runTest { - val store = ProviderProfileStoreImpl(FailingReadDataStore()) - - assertEquals(emptyList(), store.observeProfiles().first()) - assertEquals(null, store.observeActiveProfile().first()) - assertEquals(emptyList(), store.profiles()) - assertEquals(null, store.activeProfile()) - assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled) - } - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Test - fun observerResubscribesAfterSuccessfulWrite() = runTest { - val store = ProviderProfileStoreImpl(RecoveringReadDataStore()) - val emissions = mutableListOf() - val collector = backgroundScope.launch { - store.observeActiveProfile().take(2).toList(emissions) + fun persistentIoFailureDoesNotEmitFalseEmptyState() = runTest { + val store = ProviderProfileStoreImpl(FailingReadDataStore()) + var completed = false + + backgroundScope.launch { + runCatching { withTimeout(250) { store.activeProfile() } } + completed = true } advanceTimeBy(200) - advanceUntilIdle() - assertEquals(listOf(null), emissions) - val profile = ProviderProfile( - ProviderProfileId("recovered"), - ProviderType.LOCAL_EXPERIENCE, - "Recovered", - ) - store.saveProfile(profile) - store.setActiveProfile(profile.id) - advanceUntilIdle() - collector.join() - - assertEquals(listOf(null, profile), emissions) + assertFalse(completed) } private class FailingReadDataStore : DataStore { @@ -66,22 +40,4 @@ class ProviderProfileStoreFailureTest { transform: suspend (t: Preferences) -> Preferences, ): Preferences = transform(emptyPreferences()) } - - private class RecoveringReadDataStore : DataStore { - private var attempts = 0 - private var current: Preferences = emptyPreferences() - - override val data: Flow - get() = flow { - if (attempts++ < 3) throw IOException("temporarily unavailable") - emit(current) - } - - override suspend fun updateData( - transform: suspend (t: Preferences) -> Preferences, - ): Preferences { - current = transform(current) - return current - } - } } -- 2.54.0 From d0ad601efc9f75cbd43a36cca5a9f04424b73794 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 02:31:59 +0800 Subject: [PATCH 13/26] fix: bound provider storage retries --- .../flagship/abox/manager/ui/login/LoginViewModel.kt | 6 +++++- .../session/profile/ProviderProfileStoreImpl.kt | 12 ++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index 5d96021..e51b7bc 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -32,9 +32,11 @@ class LoginViewModel @Inject constructor( private var usernameEdited = false private var rememberUsernameEdited = false private var autoLoginEdited = false + private var loadedProfileId: com.flagship.abox.manager.domain.ProviderProfileId? = null fun loadProfile(value: ProviderProfile) { - if (profile?.id == value.id && + if (loadedProfileId == value.id || + profile?.id == value.id && (loadingJob?.isActive == true || submitJob?.isActive == true) ) return loadingJob?.cancel() @@ -65,6 +67,7 @@ class LoginViewModel @Inject constructor( }, isLoadingPreferences = false, ) + loadedProfileId = value.id } catch (error: CancellationException) { throw error } catch (_: Exception) { @@ -92,6 +95,7 @@ class LoginViewModel @Inject constructor( return } profile = null + loadedProfileId = null usernameEdited = false rememberUsernameEdited = false autoLoginEdited = false diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index f3333b9..248861a 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -28,12 +28,9 @@ class ProviderProfileStoreImpl( private val safeData: Flow get() = dataStore.data .retryWhen { cause, attempt -> - if (cause !is IOException) return@retryWhen false - val delayMillis = (READ_RETRY_DELAY_MILLIS shl - attempt.coerceAtMost(MAX_BACKOFF_SHIFT).toInt()) - .coerceAtMost(MAX_READ_RETRY_DELAY_MILLIS) - delay(delayMillis) - true + val retry = cause is IOException && attempt < MAX_READ_RETRIES + if (retry) delay(READ_RETRY_DELAY_MILLIS) + retry } .distinctUntilChanged() @@ -118,9 +115,8 @@ class ProviderProfileStoreImpl( } companion object { + private const val MAX_READ_RETRIES = 2L private const val READ_RETRY_DELAY_MILLIS = 100L - private const val MAX_READ_RETRY_DELAY_MILLIS = 5_000L - private const val MAX_BACKOFF_SHIFT = 6L private val PROFILE_IDS = stringSetPreferencesKey("profile_ids") private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") -- 2.54.0 From 6d8f8d30791193c40885a0b3ae6b04f6c2fb5ade Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 09:51:42 +0800 Subject: [PATCH 14/26] fix: surface provider bootstrap failures --- .../manager/ui/session/AppSessionViewModel.kt | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index 323c1c3..7e67424 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -48,17 +48,30 @@ class AppSessionViewModel @Inject constructor( init { viewModelScope.launch { - val activeProfile = profileStore.activeProfile() - if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { - coordinator.switchProvider() + try { + val activeProfile = profileStore.activeProfile() + if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { + coordinator.switchProvider() + bootstrapResult.value = ProviderBootstrapResult( + profileId = null, + result = DomainResult.Success(null), + ) + } else { + bootstrapResult.value = ProviderBootstrapResult( + profileId = activeProfile?.id, + result = coordinator.bootstrap(), + ) + } + } catch (error: kotlinx.coroutines.CancellationException) { + throw error + } catch (error: Exception) { bootstrapResult.value = ProviderBootstrapResult( profileId = null, - result = DomainResult.Success(null), - ) - } else { - bootstrapResult.value = ProviderBootstrapResult( - profileId = activeProfile?.id, - result = coordinator.bootstrap(), + result = DomainResult.Failure( + com.flagship.abox.manager.domain.DomainError.Unknown( + detail = error.message ?: "Unable to load provider settings", + ), + ), ) } } -- 2.54.0 From 7645f60b9c04c7ca70c04293c2bde4061284dd29 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 14:55:34 +0800 Subject: [PATCH 15/26] docs: define LAN ABSDK and local experience architecture --- ...4-lan-absdk-and-local-experience-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md diff --git a/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md b/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md new file mode 100644 index 0000000..63e82bb --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md @@ -0,0 +1,245 @@ +# LAN ABSDK and Local Experience Design + +## Status + +Approved on 2026-08-24 by the project owner. This specification replaces the three-provider model in `feat/provider-login`. + +## Goal + +ABox Manager exposes exactly two choices before authentication: + +1. **ABox** uses the real vendor ABSDK bridge to communicate with the smart-home gateway available on the current LAN. +2. **Local experience** uses production-safe simulation implementations, establishes a simulated session immediately, and enters the authenticated app without requesting credentials. + +The app no longer offers or plans a self-hosted ABSDK-compatible backend. + +## Confirmed Environment and Constraints + +- The observed gateway address is `192.168.100.1`. +- The gateway responds to the ABSDK-compatible route `POST /abox/index.php/abjhsdk/user/validate`. +- `libABSDK0630.jar` hardcodes `a-box.com.cn/abox/index.php/` and does not expose a supported base-URL setter. +- `setHostInfo(url, hotelName, roomNo, apikey)` submits `url` as an initialization form field and does not change the HTTP target. +- Hardware testing on `feat/moler-infrared-control` confirms that the current network environment allows the vendor ABSDK to reach the real gateway. +- Debug and release builds expose the same provider choices and routing behavior. Release is not treated as a special case. +- The vendor JAR uses cleartext HTTP. The existing `@CleartextAbsdkTransport` opt-in remains mandatory, and the Android application must explicitly permit the required LAN transport. +- Fake and real sessions, devices, state, and credentials must never be mixed. + +## Provider Model + +`ProviderType` contains only: + +```kotlin +enum class ProviderType { + ABOX, + LOCAL_EXPERIENCE, +} +``` + +The built-in profiles are: + +```text +abox-default -> ProviderType.ABOX +local-experience -> ProviderType.LOCAL_EXPERIENCE +``` + +The persisted profile store remains tolerant of removed enum values. A stored `COMPATIBLE_BACKEND` profile is ignored. If it was active, the app returns to provider selection rather than attempting migration to another provider. + +## User Flow + +### ABox + +```text +Provider selection + -> choose ABox + -> save and activate ABox profile + -> restore automatic-login preferences when available + -> otherwise show username/password login + -> SessionCoordinator delegates to the process-wide AbsdkBridge + -> enter authenticated app only after DomainResult.Success +``` + +Invalid credentials, network errors, and token failures remain on the login screen and display localized errors. + +### Local experience + +```text +Provider selection + -> choose Local experience + -> save and activate local profile + -> SimulationSessionGateway establishes Username("local") session + -> enter authenticated app immediately +``` + +Local experience: + +- never displays the credential form; +- never calls ABSDK; +- never reads or writes Keystore credentials; +- uses production-safe simulation device gateways; +- can later be removed as a single provider/module boundary if the team decides not to ship it. + +## Module Boundaries + +### `:absdk-bridge` + +Remains the anti-corruption layer around the vendor JAR and implements: + +- `SessionGateway`; +- `SocketGateway`; +- `TemperatureHumidityGateway`; +- `InfraredGateway`. + +A single process-wide `AbsdkBridge` instance is shared by session and device operations so the mutex and vendor singleton Token remain aligned. + +### New `:runtime:simulation` + +A production Kotlin/JVM module implementing: + +- `LocalSessionGateway` as `SimulationSessionGateway`; +- `DeviceCatalog` as `SimulationDeviceCatalog`; +- `SocketGateway` as `SimulationSocketGateway`; +- `TemperatureHumidityGateway` as `SimulationTemperatureHumidityGateway`; +- `InfraredGateway` as `SimulationInfraredGateway`. + +These implementations provide deterministic local experience behavior but do not expose test-only failure injection, mutable call-recording APIs, or assertion helpers. + +### `:testing:fakes` + +Remains test-only. It keeps scriptable errors, call recording, and other testing controls. It must not be added as an application runtime dependency. + +### `:android:core:session` + +Keeps provider/profile persistence, Keystore credential storage, and `LocalSessionGatewayImpl` only until the new simulation gateway replaces it. After migration, the old local gateway implementation is removed to prevent two production local-session implementations. + +### `:android:app` + +Owns the Hilt composition root and provider-aware routing. It injects: + +```text +SessionGateway -> AbsdkBridge +LocalSessionGateway -> SimulationSessionGateway +``` + +For device operations, the app exposes provider-routing adapters: + +```text +RoutingDeviceCatalog +RoutingSocketGateway +RoutingTemperatureHumidityGateway +RoutingInfraredGateway +``` + +Each adapter observes or reads the active `ProviderProfile` and delegates: + +```text +ABOX -> real AbsdkBridge implementation +LOCAL_EXPERIENCE -> simulation implementation +``` + +No feature UI selects Real or Fake directly. + +## Session and Credential Rules + +- `SessionCoordinator` continues to select the session gateway from `ProviderType`. +- ABox credentials can use remember-username and automatic-login settings. +- Automatic-login passwords are stored through Android Keystore. +- Local experience cannot persist credentials or login preferences requiring a password. +- Switching provider logs out the current gateway before activating another profile. +- Logging out of ABox clears credentials and the Bridge-local session but leaves the ABox profile active so the login page can be shown again. +- Logging out of local experience clears the local session and active profile, returning to provider selection. +- Only a session whose `profileId` matches the active profile can produce the authenticated UI state. + +## Provider Availability + +`ProviderAvailability` is simplified to the two supported options. Both are enabled in all build types: + +```kotlin +data class ProviderAvailability( + val abox: Availability = Availability.Enabled, + val local: Availability = Availability.Enabled, +) +``` + +`Availability.Simulation` and compatible-backend-specific disabled reasons are removed unless another current UI requirement uses them. + +## Android Transport + +The application declares: + +```xml + +``` + +and permits the vendor cleartext transport required by the current ABSDK. This is an explicit acceptance of the current LAN integration constraint, not a claim that the transport is encrypted. + +No password, Token, API key, raw vendor response, or infrared payload may be logged. + +## UI and Copy + +Provider selection presents exactly: + +- **ABox** — connects to the ABox smart-home gateway through ABSDK on the current LAN; +- **Local experience** — uses simulated local devices without network access. + +All compatibility-backend copy, status labels, and branches are removed from English and Simplified Chinese resources. + +## Error Handling + +- ABox authentication failure maps to the localized invalid-credentials message and does not create a session. +- Network failure maps to the localized network message and does not create a session. +- Token invalidation clears the Bridge-local session and returns the app to login. +- Selecting local experience fails visibly if profile persistence or simulated-session creation fails. +- Routing adapters return `DomainError.InvalidRequest` when no active profile exists or the profile type is unsupported. +- Cancellation is rethrown rather than converted to a normal failure. + +## Testing Strategy + +### Unit tests + +- `ProviderType` and provider selection expose exactly ABox and local experience. +- Removed persisted compatible profiles are ignored and cannot become active. +- `SessionCoordinator` routes ABox to `SessionGateway` and local experience to `LocalSessionGateway`. +- Arbitrary ABox credentials do not succeed unless the injected real/fake test gateway returns success. +- Local experience selection creates a session without calling the ABox gateway or credential store. +- Each routing adapter delegates ABox calls to the real test double and local calls to simulation. +- Switching providers logs out the previous session and isolates state. + +### Integration and build verification + +- Debug and release compile with the same real ABSDK and simulation modules. +- Hilt resolves one binding for each public gateway. +- Android lint validates Internet/cleartext configuration. +- Full command: `./gradlew check testDebugUnitTest test`. + +### Authorized hardware validation + +On a device connected to the gateway LAN: + +1. Choose ABox. +2. Enter a known-invalid credential pair and verify the app stays on login. +3. Enter an authorized valid credential pair and verify authenticated navigation. +4. Validate Token using the shared Bridge. +5. Perform one read-only device query before any control operation. +6. Choose local experience and verify no ABSDK call or network dependency is required. + +Sensitive credentials and Tokens are never captured in test reports. + +## Documentation Changes + +The implementation updates: + +- `docs/requirement_spec.md`; +- `docs/project_plan.md`; +- `docs/architecture.md`; +- `docs/protocol/absdk-0630.md`. + +The documents must no longer state that the product connects to an ABox cloud service or a self-hosted compatible backend. They must distinguish confirmed LAN behavior from the JAR's still-hardcoded host behavior. + +## Out of Scope + +- Reimplementing the ABSDK HTTP/signature protocol; +- patching or rewriting the vendor JAR; +- adding a self-hosted compatibility server; +- adding an in-app gateway IP field that the current JAR cannot consume; +- merging the authenticated app shell PR; +- deciding whether local experience remains in the final product beyond preserving it as a removable module boundary. -- 2.54.0 From 45601d94fadf8aa32f6ddaba5aa8eb39d92a68b4 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 14:58:14 +0800 Subject: [PATCH 16/26] docs: clarify provider-scoped device catalog boundary --- ...2026-08-24-lan-absdk-and-local-experience-design.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md b/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md index 63e82bb..91d68b5 100644 --- a/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md +++ b/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md @@ -120,22 +120,23 @@ SessionGateway -> AbsdkBridge LocalSessionGateway -> SimulationSessionGateway ``` -For device operations, the app exposes provider-routing adapters: +For ABSDK-backed device operations, the app exposes provider-routing adapters: ```text -RoutingDeviceCatalog RoutingSocketGateway RoutingTemperatureHumidityGateway RoutingInfraredGateway ``` -Each adapter observes or reads the active `ProviderProfile` and delegates: +Each adapter reads the active `ProviderProfile` and delegates: ```text ABOX -> real AbsdkBridge implementation LOCAL_EXPERIENCE -> simulation implementation ``` +`DeviceCatalog` is not implemented by ABSDK 0630. The ABox catalog remains a provider-scoped local persistence responsibility owned by the device-catalog module; `SimulationDeviceCatalog` supplies only the built-in local-experience catalog. This change does not invent a remote discovery API or take ownership of the final ABox catalog implementation from its module owner. + No feature UI selects Real or Fake directly. ## Session and Credential Rules @@ -201,7 +202,8 @@ All compatibility-backend copy, status labels, and branches are removed from Eng - `SessionCoordinator` routes ABox to `SessionGateway` and local experience to `LocalSessionGateway`. - Arbitrary ABox credentials do not succeed unless the injected real/fake test gateway returns success. - Local experience selection creates a session without calling the ABox gateway or credential store. -- Each routing adapter delegates ABox calls to the real test double and local calls to simulation. +- Each socket, temperature/humidity, and infrared routing adapter delegates ABox calls to the real test double and local calls to simulation. +- The local-experience device catalog is supplied by simulation without asserting that ABSDK provides ABox device discovery. - Switching providers logs out the previous session and isolates state. ### Integration and build verification -- 2.54.0 From 8931436338503ff3a498643dbc192ff6f3dbca48 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 15:14:53 +0800 Subject: [PATCH 17/26] docs: plan LAN ABSDK and local experience migration --- ...26-08-24-lan-absdk-and-local-experience.md | 1059 +++++++++++++++++ 1 file changed, 1059 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md diff --git a/docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md b/docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md new file mode 100644 index 0000000..625781c --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-lan-absdk-and-local-experience.md @@ -0,0 +1,1059 @@ +# LAN ABSDK and Local Experience Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the three-provider simulation login flow with two pre-login choices: real ABox ABSDK authentication on the LAN and a production-safe local experience that creates a simulated session immediately. + +**Architecture:** `SessionCoordinator` keeps routing by `ProviderType`: `ABOX` delegates to the process-wide real `AbsdkBridge`, while `LOCAL_EXPERIENCE` delegates to a new production `:runtime:simulation` module. Socket, temperature/humidity, and infrared calls use provider-aware routing adapters; ABSDK device discovery is not invented, so the ABox device catalog remains a provider-scoped local persistence responsibility owned by the device-catalog module. + +**Tech Stack:** Kotlin 1.9.24, Coroutines/Flow, Android API 26+, Jetpack Compose Material 3, Hilt, Preferences DataStore, Android Keystore, JUnit 4, AndroidX Compose UI tests, vendor `libABSDK0630.jar`. + +**Spec:** `docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md` + +## Global Constraints + +- The provider selection screen exposes exactly `ABOX` and `LOCAL_EXPERIENCE`; `COMPATIBLE_BACKEND` is removed. +- ABox uses the same process-wide `AbsdkBridge` instance for session, socket, temperature/humidity, and infrared operations in debug and release. +- Local experience creates `Username("local")` without a credential page, Keystore access, ABSDK call, or network dependency. +- `testing:fakes` remains test-only and is never an application runtime dependency. +- Production simulation implementations do not expose failure injection or mutable call-recording APIs. +- The Android app targets API 34, supports API 26+, and explicitly opts into the vendor JAR's required cleartext LAN transport. +- No password, Token, API key, raw vendor response, or infrared payload may be logged. +- `DeviceCatalog` is not implemented by ABSDK 0630; do not add a remote discovery assumption. +- Every task follows red-green-refactor TDD and ends with an independently reviewable commit. +- Do not merge or modify PR #6 while executing this plan. + +--- + +## File and Responsibility Map + +### New files + +- `runtime/simulation/build.gradle.kts` — production Kotlin/JVM simulation module configuration. +- `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt` — local-experience session lifecycle. +- `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt` — deterministic built-in local-experience devices. +- `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt` — production socket, temperature/humidity, and infrared simulation behavior. +- `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt` — session contract tests. +- `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt` — local device behavior and profile-isolation tests. +- `android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt` — active-provider routing for ABSDK-backed device operations. +- `android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt` — routing tests using test doubles. +- `android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt` — deterministic `Dispatchers.Main` replacement for ViewModel unit tests. +- `android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt` — exactly-two-provider behavior and local direct-entry tests. +- `android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt` — stale compatible-profile persistence behavior. + +### Modified files + +- `settings.gradle.kts` — includes `:runtime:simulation`. +- `domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt` — removes `COMPATIBLE_BACKEND`. +- `domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt` — retains only ABox/local routing and enforces local credential isolation. +- `testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt` — verifies real/local delegation and no credential use for local experience. +- `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt` — ignores removed persisted provider values and clears stale active selection behavior. +- `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt` — deleted after the simulation implementation replaces it. +- `android/app/build.gradle.kts` — adds runtime `:absdk-bridge` and `:runtime:simulation`, removes runtime `testing:fakes`, and adds packaging exclusions for legacy JAR metadata. +- `android/app/src/main/AndroidManifest.xml` — adds Internet permission and cleartext transport opt-in required by ABSDK. +- `android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt` — binds the real Bridge and simulation session gateway uniformly for every build type. +- `android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt` — deleted. +- `android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt` — deleted. +- `android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt` — removes runtime fake session/device bindings superseded by uniform routing. +- `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt` — reduces availability to two enabled providers. +- `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt` — exposes exactly ABox and local experience. +- `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt` — removes compatible/simulation/disabled branches. +- `android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt` — removes compatible-backend text branches. +- `android/app/src/main/res/values/strings.xml` — updates ABox LAN copy and removes compatible-provider strings. +- `android/app/src/main/res/values-zh-rCN/strings.xml` — matching Simplified Chinese copy. +- `android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt` — verifies both supported options are selectable and no third option renders. +- `docs/requirement_spec.md`, `docs/project_plan.md`, `docs/architecture.md`, `docs/protocol/absdk-0630.md` — align product and transport documentation with the approved scope. + +--- + +### Task 1: Remove the Compatible Provider from the Domain and Persisted Model + +**Files:** +- Modify: `domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt:26-30` +- Modify: `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt:79-92` +- Create: `android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt` + +**Interfaces:** +- Consumes: existing `ProviderProfileStore` and `ProviderProfileStoreImpl` APIs. +- Produces: `ProviderType` with only `ABOX` and `LOCAL_EXPERIENCE`; stale serialized provider names are ignored and cannot become active. + +- [ ] **Step 1: Write the failing stale-profile persistence test** + +```kotlin +package com.flagship.abox.manager.session + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemovedProviderProfileTest { + @Test + fun removedCompatibleProfileIsIgnoredAndCannotRemainActive() = runTest { + val file = File.createTempFile("removed-provider", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + dataStore.edit { values -> + values[stringSetPreferencesKey("profile_ids")] = setOf("compatible-default") + values[stringPreferencesKey("profile_compatible-default_type")] = "COMPATIBLE_BACKEND" + values[stringPreferencesKey("profile_compatible-default_name")] = "Compatible backend" + values[stringPreferencesKey("active_profile_id")] = "compatible-default" + } + val store = ProviderProfileStoreImpl(dataStore) + + assertTrue(store.profiles().isEmpty()) + assertNull(store.activeProfile()) + assertNull(store.observeActiveProfile().first()) + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the baseline behavior** + +Run: + +```powershell +.\gradlew :android:core:session:testDebugUnitTest --tests "*RemovedProviderProfileTest" +``` + +Expected before the enum removal: FAIL because the compatible profile is still parsed and becomes active. + +- [ ] **Step 3: Remove `COMPATIBLE_BACKEND` and preserve tolerant parsing** + +Change `ProviderType` to: + +```kotlin +enum class ProviderType { + ABOX, + LOCAL_EXPERIENCE, +} +``` + +Keep the existing tolerant parser: + +```kotlin +val type = preferences[profileTypeKey(id)] + ?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() } + ?: return@mapNotNull null +``` + +Do not map the removed value to ABox or local experience. + +- [ ] **Step 4: Run domain and session tests** + +Run: + +```powershell +.\gradlew :domain:test :android:core:session:testDebugUnitTest +``` + +Expected: PASS; stale compatible data is ignored, current ABox persistence tests remain green. + +- [ ] **Step 5: Commit** + +```powershell +git add domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt +git commit -m "refactor: remove compatible provider type" +``` + +--- + +### Task 2: Add the Production Simulation Runtime Module + +**Files:** +- Modify: `settings.gradle.kts:25-30` +- Create: `runtime/simulation/build.gradle.kts` +- Create: `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt` +- Create: `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt` +- Create: `runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt` +- Create: `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt` +- Create: `runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt` + +**Interfaces:** +- Consumes: `LocalSessionGateway`, `DeviceCatalog`, `SocketGateway`, `TemperatureHumidityGateway`, `InfraredGateway` from `:domain`. +- Produces: `SimulationSessionGateway`, `SimulationDeviceCatalog`, `SimulationSocketGateway`, `SimulationTemperatureHumidityGateway`, and `SimulationInfraredGateway` with public no-argument constructors. + +- [ ] **Step 1: Add the module skeleton and failing session test** + +Add to `settings.gradle.kts`: + +```kotlin +include(":runtime:simulation") +``` + +Create `runtime/simulation/build.gradle.kts`: + +```kotlin +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":domain")) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} +``` + +Create the test: + +```kotlin +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.domain.Username +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SimulationSessionGatewayTest { + @Test + fun loginObserveValidateAndLogoutAreProfileScoped() = runTest { + val gateway = SimulationSessionGateway() + val first = ProviderProfileId("local-one") + val second = ProviderProfileId("local-two") + + val result = gateway.login(first, LoginCredentials(Username("local"), Password(""))) + + assertEquals(Username("local"), (result as DomainResult.Success).value.username) + assertEquals(first, gateway.observeSession(first).first()?.profileId) + assertNull(gateway.observeSession(second).first()) + assertEquals(DomainResult.Success(SessionValidity.VALID), gateway.validateSession(first)) + gateway.logout(first) + assertEquals(DomainResult.Success(SessionValidity.INVALID), gateway.validateSession(first)) + } +} +``` + +- [ ] **Step 2: Run the session test and verify it fails to compile** + +Run: + +```powershell +.\gradlew :runtime:simulation:test --tests "*SimulationSessionGatewayTest" +``` + +Expected: FAIL because `SimulationSessionGateway` does not exist. + +- [ ] **Step 3: Implement `SimulationSessionGateway`** + +```kotlin +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LocalSessionGateway +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class SimulationSessionGateway : LocalSessionGateway { + private val sessions = ConcurrentHashMap>() + + override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = DomainResult.Success( + Session(profileId, credentials.username).also { flow(profileId).value = it }, + ) + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success( + if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, + ) + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + flow(profileId).value = null + return DomainResult.Success(Unit) + } + + private fun flow(profileId: ProviderProfileId): MutableStateFlow = + sessions.getOrPut(profileId) { MutableStateFlow(null) } +} +``` + +- [ ] **Step 4: Add failing deterministic device behavior tests** + +Create tests that assert: + +```kotlin +@Test +fun catalogProvidesSocketTemperatureHumidityAndInfraredDevicesForEachProfile() = runTest { + val profile = ProviderProfileId("local") + val devices = (SimulationDeviceCatalog().observeDevices(profile).first() as DomainResult.Success).value + assertEquals(setOf(DeviceType.SOCKET, DeviceType.TEMPERATURE_HUMIDITY, DeviceType.INFRARED), devices.map { it.type }.toSet()) + assertTrue(devices.all { it.profileId == profile }) +} + +@Test +fun socketStateIsMutableAndIsolatedByProfile() = runTest { + val gateway = SimulationSocketGateway() + val device = DeviceName("socket-1") + val first = ProviderProfileId("first") + val second = ProviderProfileId("second") + gateway.setPower(first, device, SocketPowerState.ON) + assertEquals(SocketPowerState.ON, (gateway.getStatus(first, device) as DomainResult.Success).value.powerState) + assertEquals(SocketPowerState.OFF, (gateway.getStatus(second, device) as DomainResult.Success).value.powerState) +} + +@Test +fun sensorAndInfraredOperationsSucceedWithoutNetworkConfiguration() = runTest { + val profile = ProviderProfileId("local") + val th = SimulationTemperatureHumidityGateway().getReading(profile, DeviceName("th-1")) + assertTrue(th is DomainResult.Success) + assertEquals(DomainResult.Success(Unit), SimulationInfraredGateway().sendKey(profile, DeviceName("ir-1"), "power")) +} +``` + +- [ ] **Step 5: Run device tests and verify they fail to compile** + +Run: + +```powershell +.\gradlew :runtime:simulation:test --tests "*SimulationDeviceGatewaysTest" +``` + +Expected: FAIL because the device simulation classes do not exist. + +- [ ] **Step 6: Implement the deterministic production simulations** + +Use the same deterministic defaults as the current test fakes, but expose only gateway interfaces. `SimulationInfraredGateway` returns success without public call-recording fields. `SimulationDeviceCatalog` creates IDs prefixed by the supplied profile ID. + +Core signatures: + +```kotlin +class SimulationDeviceCatalog : DeviceCatalog +class SimulationSocketGateway : SocketGateway +class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway +class SimulationInfraredGateway : InfraredGateway +``` + +Required default values: + +```text +socket-1 -> OFF +th-1 -> temperature "23.5", humidity "45" +ir-1 -> send/learn/download success +``` + +- [ ] **Step 7: Run all simulation tests** + +Run: + +```powershell +.\gradlew :runtime:simulation:test +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```powershell +git add settings.gradle.kts runtime/simulation +git commit -m "feat: add production local experience simulation" +``` + +--- + +### Task 3: Route Session Selection to Real ABSDK and Simulation + +**Files:** +- Modify: `android/app/build.gradle.kts:54-83` +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt:1-67` +- Delete: `android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt` +- Delete: `android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt` +- Delete: `android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt` +- Modify: `testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt` + +**Interfaces:** +- Consumes: `AbsdkBridge.create(): AbsdkBridge`, `SimulationSessionGateway`, and existing `SessionCoordinator` constructor. +- Produces: one all-build Hilt binding where `SessionGateway` is the process-wide `AbsdkBridge` and `LocalSessionGateway` is `SimulationSessionGateway`. + +- [ ] **Step 1: Extend the coordinator test to prove local selection never calls ABox or credentials** + +Add counters to test-local doubles or use existing fakes plus an explicit failing remote gateway. The test must assert: + +```kotlin +@Test +fun localSelectionCreatesSessionWithoutAboxOrCredentialAccess() = runTest { + val remote = CountingSessionGateway() + val credentials = CountingCredentialStore() + val local = FakeLocalSessionGateway() + val profiles = FakeProviderProfileStore() + val coordinator = SessionCoordinator(profiles, credentials, remote, local) + + val result = coordinator.selectProvider(localProfile) + + assertTrue(result is DomainResult.Success) + assertEquals(0, remote.loginCalls) + assertEquals(0, credentials.loadCalls + credentials.saveCalls) + assertEquals(localProfile.id, coordinator.observeActiveSession().first()?.profileId) +} +``` + +Also add: + +```kotlin +@Test +fun aboxAuthenticationFailureDoesNotCreateSession() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication()) + + val result = fixture.coordinator.login(remoteProfile.id, credentials, false, false) + + assertTrue(result is DomainResult.Failure) + assertNull(fixture.coordinator.observeActiveSession().first()) +} +``` + +- [ ] **Step 2: Run coordinator tests** + +Run: + +```powershell +.\gradlew :testing:fakes:test --tests "*SessionCoordinatorTest" +``` + +Expected: existing routing tests pass; the new counter-based test initially fails if it reveals any credential access during local selection. + +- [ ] **Step 3: Add runtime dependencies and legacy JAR packaging exclusions** + +In `android/app/build.gradle.kts`, add: + +```kotlin +implementation(project(":absdk-bridge")) +implementation(project(":runtime:simulation")) +testImplementation(project(":testing:fakes")) +``` + +Temporarily keep the existing `debugImplementation(project(":testing:fakes"))` until Task 5 removes the remaining debug device bindings; this keeps every intermediate commit compilable. Keep Compose tooling as `debugImplementation`. Add: + +```kotlin +packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "/META-INF/DEPENDENCIES" + excludes += "/META-INF/LICENSE*" + excludes += "/META-INF/NOTICE*" + } +} +``` + +- [ ] **Step 4: Replace build-type session bindings with uniform production bindings** + +In `SessionModule.kt`, bind: + +```kotlin +@Provides +@Singleton +@OptIn(CleartextAbsdkTransport::class) +fun provideAbsdkBridge(): AbsdkBridge = AbsdkBridge.create() + +@Provides +@Singleton +fun provideRemoteSessionGateway( + @OptIn(CleartextAbsdkTransport::class) bridge: AbsdkBridge, +): SessionGateway = bridge + +@Provides +@Singleton +fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway() +``` + +Delete `DebugSessionModule.kt`, `ReleaseSessionModule.kt`, and `LocalSessionGatewayImpl.kt`. Remove obsolete imports. + +- [ ] **Step 5: Compile both build types and run coordinator tests** + +Run: + +```powershell +.\gradlew :android:app:compileDebugKotlin :android:app:compileReleaseKotlin :testing:fakes:test --tests "*SessionCoordinatorTest" +``` + +Expected: PASS. Hilt reports no duplicate `SessionGateway` binding. + +- [ ] **Step 6: Commit** + +```powershell +git add android/app/build.gradle.kts android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt +git commit -m "feat: route provider sessions to ABSDK and simulation" +``` + +--- + +### Task 4: Reduce Provider Selection to ABox and Local Experience + +**Files:** +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt` +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt` +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt` +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt` +- Create: `android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt` +- Modify: `android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt` +- Modify: `android/app/src/main/res/values/strings.xml` +- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml` + +**Interfaces:** +- Consumes: `SessionCoordinator.selectProvider(profile)` and two-value `ProviderType`. +- Produces: provider selection with exactly two enabled options; ABox continues to login, local experience creates a session and transitions directly through `AppSessionViewModel`. + +- [ ] **Step 1: Add a Main dispatcher rule and write a failing ViewModel provider-list test** + +Create `android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt`: + +```kotlin +package com.flagship.abox.manager.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val dispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} +``` + +Create a test fixture with `FakeProviderProfileStore`, `FakeCredentialStore`, `FakeSessionGateway`, and `FakeLocalSessionGateway`. Add `@get:Rule val mainDispatcherRule = MainDispatcherRule()`. Test: + +```kotlin +@Test +fun exposesExactlyAboxAndLocalExperience() { + val viewModel = ProviderSelectionViewModel(coordinator, ProviderAvailability()) + + assertEquals( + listOf(ProviderType.ABOX, ProviderType.LOCAL_EXPERIENCE), + viewModel.uiState.value.providers.map { it.profile.type }, + ) + assertTrue(viewModel.uiState.value.providers.all { it.availability == Availability.Enabled }) +} +``` + +And: + +```kotlin +@Test +fun selectingLocalExperienceEstablishesSessionImmediately() = runTest { + val viewModel = ProviderSelectionViewModel(coordinator, ProviderAvailability()) + val local = viewModel.uiState.value.providers.single { it.profile.type == ProviderType.LOCAL_EXPERIENCE } + + viewModel.select(local) + advanceUntilIdle() + + assertEquals(local.profile.id, coordinator.observeActiveSession().first()?.profileId) +} +``` + +- [ ] **Step 2: Run the ViewModel test and verify failure** + +Run: + +```powershell +.\gradlew :android:app:testDebugUnitTest --tests "*ProviderSelectionViewModelTest" +``` + +Expected: FAIL because current state contains three providers and the availability constructor requires compatible-provider values. + +- [ ] **Step 3: Simplify availability and provider profiles** + +Use: + +```kotlin +data class ProviderAvailability( + val abox: Availability = Availability.Enabled, + val local: Availability = Availability.Enabled, +) { + fun forType(type: ProviderType): Availability = when (type) { + ProviderType.ABOX -> abox + ProviderType.LOCAL_EXPERIENCE -> local + } +} + +sealed interface Availability { + data object Enabled : Availability + data class Disabled(val reason: String) : Availability +} +``` + +If no supported provider is disabled, prefer deleting `Disabled` entirely and reduce `ProviderOption` to `ProviderProfile`. Keep it only if the current UI error-state contract still requires it. + +Set profiles to: + +```kotlin +private val PROFILES = listOf( + ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"), + ProviderProfile( + ProviderProfileId("local-experience"), + ProviderType.LOCAL_EXPERIENCE, + "Local experience", + ), +) +``` + +- [ ] **Step 4: Remove compatible-provider UI branches and update copy** + +English: + +```xml +Connect to the ABox smart-home gateway through ABSDK on the current local network. +Explore the app with simulated local devices and no network connection. +``` + +Simplified Chinese: + +```xml +通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。 +无需网络,通过本地模拟设备体验应用。 +``` + +Delete all `provider_compatible_*`, protocol-pending, transport-pending, and simulation-status resources that are no longer referenced. + +- [ ] **Step 5: Replace the Compose test with the two-option contract** + +```kotlin +@Test +fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() { + val abox = ProviderOption( + ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox"), + Availability.Enabled, + ) + val local = ProviderOption( + ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"), + Availability.Enabled, + ) + var selected: ProviderOption? = null + composeRule.setContent { + ABoxTheme { + ProviderSelectionScreen(ProviderSelectionUiState(listOf(abox, local))) { + selected = it + } + } + } + + composeRule.onNodeWithText("ABox").assertIsEnabled() + composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick() + assertEquals(local, selected) + composeRule.onNodeWithText("Compatible backend").assertDoesNotExist() +} +``` + +- [ ] **Step 6: Run provider unit and instrumentation compilation/tests** + +Run: + +```powershell +.\gradlew :android:app:testDebugUnitTest --tests "*ProviderSelectionViewModelTest" :android:app:compileDebugAndroidTestKotlin +``` + +If an emulator/device is available, also run: + +```powershell +.\gradlew :android:app:connectedDebugAndroidTest +``` + +Expected: unit tests pass; instrumentation source compiles; connected test passes when a device is available. + +- [ ] **Step 7: Commit** + +```powershell +git add android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt android/app/src/main/res android/app/src/test android/app/src/androidTest +git commit -m "refactor: offer ABox and local experience providers" +``` + +--- + +### Task 5: Add Provider-Aware Device Gateway Routing + +**Files:** +- Create: `android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt` +- Create: `android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt` +- Modify: `android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt` +- Create or modify: `android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt` + +**Interfaces:** +- Consumes: `ProviderProfileStore.activeProfile()`, real `AbsdkBridge`, simulation gateway implementations. +- Produces: `RoutingSocketGateway`, `RoutingTemperatureHumidityGateway`, and `RoutingInfraredGateway`, each implementing the public domain gateway interface. + +- [ ] **Step 1: Write failing routing tests** + +Define simple recording test doubles and test both provider types: + +```kotlin +@Test +fun socketRoutesAboxToRealAndLocalToSimulation() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingSocketGateway("real") + val simulation = RecordingSocketGateway("simulation") + val router = RoutingSocketGateway(store, real, simulation) + + activate(store, ProviderType.ABOX) + router.getStatus(activeId, DeviceName("socket-1")) + assertEquals(1, real.calls) + assertEquals(0, simulation.calls) + + activate(store, ProviderType.LOCAL_EXPERIENCE) + router.getStatus(activeId, DeviceName("socket-1")) + assertEquals(1, simulation.calls) +} +``` + +Add equivalent tests for temperature/humidity and infrared, plus: + +```kotlin +@Test +fun mismatchedOrMissingActiveProfileReturnsInvalidRequest() = runTest { + val result = router.getStatus(ProviderProfileId("other"), DeviceName("socket-1")) + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) +} +``` + +- [ ] **Step 2: Run the routing tests and verify failure** + +Run: + +```powershell +.\gradlew :android:app:testDebugUnitTest --tests "*ProviderRoutingGatewaysTest" +``` + +Expected: FAIL because routing adapters do not exist. + +- [ ] **Step 3: Implement focused routing adapters** + +Use a shared resolver: + +```kotlin +internal class ActiveProviderGatewayResolver( + private val profileStore: ProviderProfileStore, +) { + suspend fun resolve(profileId: ProviderProfileId): DomainResult { + val active = profileStore.activeProfile() + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No active provider")) + if (active.id != profileId) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")) + } + return DomainResult.Success(active.type) + } +} +``` + +Each router delegates only after a successful resolution. Rethrow `CancellationException`; map unexpected store exceptions to `DomainError.Unknown`. + +- [ ] **Step 4: Bind public device gateways to routers** + +Create an all-build Hilt module that receives distinct concrete types: + +```kotlin +@Provides +@Singleton +fun provideSocketGateway( + profileStore: ProviderProfileStore, + real: AbsdkBridge, + simulation: SimulationSocketGateway, +): SocketGateway = RoutingSocketGateway(profileStore, real, simulation) +``` + +Repeat for temperature/humidity and infrared. Provide simulation concrete instances as singletons. Remove the corresponding fake bindings from debug `FoundationModule`. + +Keep the existing debug-only `FakeDeviceCatalog` binding for now because ABSDK has no catalog and the provider-scoped production ABox catalog belongs to the device-catalog module owner. Do not route ABox catalog requests to `SimulationDeviceCatalog`. Document this temporary boundary in `FoundationModule`. Because `FakeDeviceCatalog` remains debug-only, keep `debugImplementation(project(":testing:fakes"))`; release compilation must not request a `DeviceCatalog` binding until a release consumer actually exists. If Hilt requires one during release compilation, add an explicit release `UnavailableDeviceCatalog` returning `DomainError.InvalidRequest("ABox device catalog is not implemented yet")`, not the simulation catalog. + +- [ ] **Step 5: Run routing and Hilt compilation tests** + +Run: + +```powershell +.\gradlew :android:app:testDebugUnitTest --tests "*ProviderRoutingGatewaysTest" :android:app:compileDebugKotlin :android:app:compileReleaseKotlin +``` + +Expected: PASS; no duplicate gateway bindings. + +- [ ] **Step 6: Commit** + +```powershell +git add android/app/src/main/kotlin/com/flagship/abox/manager/di android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt android/app/src/test/kotlin/com/flagship/abox/manager/di +git commit -m "feat: route device operations by active provider" +``` + +--- + +### Task 6: Configure Android LAN Transport and Verify Real Login Gating + +**Files:** +- Modify: `android/app/src/main/AndroidManifest.xml` +- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt` only if an additional regression assertion reveals a mismatch. +- Create: `android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt` + +**Interfaces:** +- Consumes: `AppSessionViewModel.stateFor` behavior via public `uiState`, real `SessionGateway` Hilt binding, Android manifest merge. +- Produces: authenticated UI only for a session matching the active ABox profile; manifest permits vendor LAN HTTP in every build type. + +- [ ] **Step 1: Add a regression test for failed ABox login and mismatched sessions** + +Use `MainDispatcherRule` from Task 4 plus `FakeProviderProfileStore`, `FakeCredentialStore`, `FakeSessionGateway`, `FakeLocalSessionGateway`, and a test `ProviderAvailability`. Assert: + +```kotlin +@Test +fun failedAboxLoginLeavesUiOnLogin() = runTest { + profiles.saveProfile(abox) + profiles.setActiveProfile(abox.id) + remote.failLogin(abox.id, DomainError.Authentication()) + val viewModel = AppSessionViewModel(coordinator, profiles, ProviderAvailability()) + + coordinator.login(abox.id, invalidCredentials, false, false) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is AppSessionUiState.Login) +} + +@Test +fun sessionForDifferentProfileCannotAuthenticateActiveProfile() = runTest { + // Activate ABox profile A while the gateway exposes a session for profile B. + // Assert uiState is Login, not Authenticated. +} +``` + +- [ ] **Step 2: Run the regression test** + +Run: + +```powershell +.\gradlew :android:app:testDebugUnitTest --tests "*AppSessionViewModelTest" +``` + +Expected: PASS for the current profile-ID guard; if the failed-login case exposes asynchronous setup issues, keep the test and make the smallest state-flow correction. + +- [ ] **Step 3: Add required application transport declarations** + +Update the main manifest: + +```xml + + + + ... + + +``` + +Do not add credentials or gateway IP addresses to the manifest. + +- [ ] **Step 4: Verify manifest merge and both build variants** + +Run: + +```powershell +.\gradlew :android:app:processDebugMainManifest :android:app:processReleaseMainManifest :android:app:testDebugUnitTest --tests "*AppSessionViewModelTest" +``` + +Expected: PASS; merged manifests contain `INTERNET` and `usesCleartextTraffic=true`. + +- [ ] **Step 5: Commit** + +```powershell +git add android/app/src/main/AndroidManifest.xml android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +git commit -m "fix: require real ABSDK success before authentication" +``` + +--- + +### Task 7: Align Product, Architecture, and Protocol Documentation + +**Files:** +- Modify: `docs/requirement_spec.md` +- Modify: `docs/project_plan.md` +- Modify: `docs/architecture.md` +- Modify: `docs/protocol/absdk-0630.md` + +**Interfaces:** +- Consumes: completed code behavior from Tasks 1-6. +- Produces: repository documentation that no longer describes cloud or compatible-backend product modes. + +- [ ] **Step 1: Update the requirement specification** + +State explicitly: + +```markdown +**连接方式** +1. ABox:通过 ABSDK 连接当前局域网中的智能家居网关,登录成功后进入应用。 +2. 本地体验:不访问网络或 ABSDK,直接建立模拟会话并进入应用。 +3. 首版不提供自有兼容后端入口。 +``` + +Retain remember-username and automatic-login requirements only for ABox. + +- [ ] **Step 2: Update the project plan** + +Replace the three-provider scope with two providers, remove `feat/compatible-server`, Ktor/Docker compatibility-server deliverables, and compatible-backend responsibility statements. Preserve the fixed five-person ownership model. + +- [ ] **Step 3: Update architecture documentation** + +Document: + +```text +ABOX -> AbsdkBridge singleton +LOCAL_EXPERIENCE -> :runtime:simulation +``` + +State that `testing:fakes` remains test-only and that ABSDK supplies no device catalog. + +- [ ] **Step 4: Update protocol documentation with observed LAN evidence** + +Record without overclaiming: + +- `192.168.100.1` was reachable on ports 80 and 443 on 2026-08-24; +- `POST http://192.168.100.1/abox/index.php/abjhsdk/user/validate` returned `code=00000`, `status=0` without a Token; +- hardware testing on `feat/moler-infrared-control` established that the vendor JAR reaches the real gateway in the current network environment; +- the JAR still hardcodes `a-box.com.cn`; the mechanism mapping it to the LAN gateway remains an environmental dependency and is not exposed by `setHostInfo`. + +- [ ] **Step 5: Search for obsolete product claims** + +Run: + +```powershell +git grep -n -E "COMPATIBLE_BACKEND|Compatible backend|自有兼容后端|feat/compatible-server|Ktor|PostgreSQL|Docker" -- docs domain android +``` + +Expected: only explicit historical/removed-value migration notes remain. Any product-scope references are removed. + +- [ ] **Step 6: Commit** + +```powershell +git add docs/requirement_spec.md docs/project_plan.md docs/architecture.md docs/protocol/absdk-0630.md +git commit -m "docs: align product scope with LAN ABSDK access" +``` + +--- + +### Task 8: Full Verification and Authorized Hardware Smoke Test + +**Files:** +- Modify only if verification reveals a specific defect. +- Update: `docs/protocol/absdk-0630.md` with the final authorized hardware result, excluding secrets. + +**Interfaces:** +- Consumes: all deliverables from Tasks 1-7. +- Produces: build evidence and a documented real-vs-local acceptance result. + +- [ ] **Step 1: Run the full repository verification** + +Run: + +```powershell +.\gradlew check testDebugUnitTest test +``` + +Expected: `BUILD SUCCESSFUL`; all domain, Bridge, simulation, session, app unit, and lint tasks pass. + +- [ ] **Step 2: Build both APK variants** + +Run: + +```powershell +.\gradlew :android:app:assembleDebug :android:app:assembleRelease +``` + +Expected: both APKs build successfully with the same two provider choices. + +- [ ] **Step 3: Run the authorized ABox hardware login smoke test** + +On a device connected to the gateway LAN: + +1. Install the debug APK. +2. Choose **ABox**. +3. Enter a deliberately invalid non-secret credential pair. +4. Verify the app remains on the login screen and displays the localized authentication error. +5. Enter an authorized valid credential pair without recording it. +6. Verify the app enters the authenticated screen only after ABSDK success. +7. Log out and verify the app returns to ABox login without exposing the password. + +Expected: invalid credentials cannot create a session; valid credentials can establish the real Bridge session. + +- [ ] **Step 4: Run the local-experience smoke test** + +1. Switch provider. +2. Choose **Local experience**. +3. Verify no credential page appears. +4. Verify the authenticated screen opens immediately. +5. Verify simulated socket, temperature/humidity, and infrared operations work without network access. + +Expected: local experience is independent from the ABSDK session and credentials. + +- [ ] **Step 5: Record the sanitized result** + +Add a dated table row to `docs/protocol/absdk-0630.md` containing: + +```text +App commit | build type | provider | invalid-login result | valid-login result | read-only device result | limitations +``` + +Do not record credentials, Token, API keys, or raw payloads. + +- [ ] **Step 6: Verify the final diff and working tree** + +Run: + +```powershell +git diff --check +git status --short +git log --oneline main..HEAD +``` + +Expected: no whitespace errors; only intentional uncommitted hardware-result documentation remains before the final commit. + +- [ ] **Step 7: Commit the hardware validation record** + +```powershell +git add docs/protocol/absdk-0630.md +git commit -m "test: record LAN ABSDK login validation" +``` + +- [ ] **Step 8: Push the branch and update PR #4** + +```powershell +git push origin feat/provider-login +``` + +Use `tea` to update the PR description so it states the new two-provider scope and removes cloud/compatible-backend claims. Do not merge; wait for review and required checks. -- 2.54.0 From 227120bc1121845237dea740a76d2723f18dd04a Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 15:57:42 +0800 Subject: [PATCH 18/26] refactor: remove compatible provider type --- .../session/RemovedProviderProfileTest.kt | 32 +++++++++++++++++++ .../flagship/abox/manager/domain/Models.kt | 1 - 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt new file mode 100644 index 0000000..b861242 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/RemovedProviderProfileTest.kt @@ -0,0 +1,32 @@ +package com.flagship.abox.manager.session + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemovedProviderProfileTest { + @Test + fun removedCompatibleProfileIsIgnoredAndCannotRemainActive() = runTest { + val file = File.createTempFile("removed-provider", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + dataStore.edit { values -> + values[stringSetPreferencesKey("profile_ids")] = setOf("compatible-default") + values[stringPreferencesKey("profile_compatible-default_type")] = "COMPATIBLE_BACKEND" + values[stringPreferencesKey("profile_compatible-default_name")] = "Compatible backend" + values[stringPreferencesKey("active_profile_id")] = "compatible-default" + } + val store = ProviderProfileStoreImpl(dataStore) + + assertTrue(store.profiles().isEmpty()) + assertNull(store.activeProfile()) + assertNull(store.observeActiveProfile().first()) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt index d27c801..b1e1cc4 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt @@ -25,7 +25,6 @@ value class Password(val value: String) { enum class ProviderType { ABOX, - COMPATIBLE_BACKEND, LOCAL_EXPERIENCE, } -- 2.54.0 From 8d5984fb3611bd2c5fc2ebf54ac0f8d40550afe8 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 17:56:11 +0800 Subject: [PATCH 19/26] fix: remove compatible provider consumers --- .../ui/provider/ProviderSelectionScreenTest.kt | 12 ++++-------- .../flagship/abox/manager/ui/login/LoginScreen.kt | 2 -- .../abox/manager/ui/provider/ProviderAvailability.kt | 1 - .../manager/ui/provider/ProviderSelectionScreen.kt | 3 --- .../ui/provider/ProviderSelectionViewModel.kt | 5 ----- 5 files changed, 4 insertions(+), 19 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt index b5fb6f9..b10ea08 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt @@ -27,19 +27,15 @@ class ProviderSelectionScreenTest { ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"), Availability.Enabled, ) - val compatible = ProviderOption( - ProviderProfile( - ProviderProfileId("compatible"), - ProviderType.COMPATIBLE_BACKEND, - "Compatible", - ), + val abox = ProviderOption( + ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox"), Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), ) var selected: ProviderOption? = null composeRule.setContent { ABoxTheme { ProviderSelectionScreen( - state = ProviderSelectionUiState(listOf(local, compatible)), + state = ProviderSelectionUiState(listOf(local, abox)), onSelect = { selected = it }, ) } @@ -47,6 +43,6 @@ class ProviderSelectionScreenTest { composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick() assertEquals(local, selected) - composeRule.onNodeWithText("Compatible backend").assertIsNotEnabled() + composeRule.onNodeWithText("ABox").assertIsNotEnabled() } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 39b2055..51b6ddb 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -222,7 +222,6 @@ private fun LoginErrorMessage.stringResource(): Int = when (this) { private fun providerName(type: ProviderType): String = stringResource( when (type) { ProviderType.ABOX -> R.string.provider_abox_name - ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_name ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name }, ) @@ -231,7 +230,6 @@ private fun providerName(type: ProviderType): String = stringResource( private fun providerDescription(type: ProviderType): String = stringResource( when (type) { ProviderType.ABOX -> R.string.provider_abox_description - ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_description ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description }, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt index c5e59a7..c4839ca 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt @@ -9,7 +9,6 @@ data class ProviderAvailability( ) { fun forType(type: ProviderType): Availability = when (type) { ProviderType.ABOX -> abox - ProviderType.COMPATIBLE_BACKEND -> compatible ProviderType.LOCAL_EXPERIENCE -> local } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt index 28528d0..1829e22 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt @@ -185,7 +185,6 @@ private fun ProviderOptionCard( private fun providerName(type: ProviderType): String = stringResource( when (type) { ProviderType.ABOX -> R.string.provider_abox_name - ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_name ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name }, ) @@ -194,14 +193,12 @@ private fun providerName(type: ProviderType): String = stringResource( private fun providerDescription(type: ProviderType): String = stringResource( when (type) { ProviderType.ABOX -> R.string.provider_abox_description - ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_description ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description }, ) private fun providerMark(type: ProviderType): String = when (type) { ProviderType.ABOX -> "A" - ProviderType.COMPATIBLE_BACKEND -> "C" ProviderType.LOCAL_EXPERIENCE -> "L" } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt index 1debcd7..8eb54f8 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt @@ -43,11 +43,6 @@ class ProviderSelectionViewModel @Inject constructor( companion object { private val PROFILES = listOf( ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"), - ProviderProfile( - ProviderProfileId("compatible-default"), - ProviderType.COMPATIBLE_BACKEND, - "Compatible backend", - ), ProviderProfile( ProviderProfileId("local-experience"), ProviderType.LOCAL_EXPERIENCE, -- 2.54.0 From d560a78b634d5f7100003903b7506043fb87b467 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 18:19:23 +0800 Subject: [PATCH 20/26] feat: add production local experience simulation --- runtime/simulation/build.gradle.kts | 14 ++++ .../simulation/SimulationDeviceCatalog.kt | 50 +++++++++++++ .../simulation/SimulationDeviceGateways.kt | 75 +++++++++++++++++++ .../simulation/SimulationSessionGateway.kt | 38 ++++++++++ .../SimulationDeviceGatewaysTest.kt | 54 +++++++++++++ .../SimulationSessionGatewayTest.kt | 31 ++++++++ settings.gradle.kts | 1 + 7 files changed, 263 insertions(+) create mode 100644 runtime/simulation/build.gradle.kts create mode 100644 runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt create mode 100644 runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt create mode 100644 runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt create mode 100644 runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt create mode 100644 runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt diff --git a/runtime/simulation/build.gradle.kts b/runtime/simulation/build.gradle.kts new file mode 100644 index 0000000..e8498a8 --- /dev/null +++ b/runtime/simulation/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":domain")) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt new file mode 100644 index 0000000..3bb677e --- /dev/null +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt @@ -0,0 +1,50 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceId +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class SimulationDeviceCatalog : DeviceCatalog { + private val devices = ConcurrentHashMap< + ProviderProfileId, + MutableStateFlow>>, + >() + + override fun observeDevices( + profileId: ProviderProfileId, + ): Flow>> = deviceFlow(profileId) + + private fun deviceFlow( + profileId: ProviderProfileId, + ): MutableStateFlow>> = devices.getOrPut(profileId) { + MutableStateFlow(DomainResult.Success(defaultDevices(profileId))) + } + + private fun defaultDevices(profileId: ProviderProfileId): List = listOf( + DeviceDescriptor( + id = DeviceId("${profileId.value}:socket-1"), + profileId = profileId, + name = DeviceName("socket-1"), + type = DeviceType.SOCKET, + ), + DeviceDescriptor( + id = DeviceId("${profileId.value}:th-1"), + profileId = profileId, + name = DeviceName("th-1"), + type = DeviceType.TEMPERATURE_HUMIDITY, + ), + DeviceDescriptor( + id = DeviceId("${profileId.value}:ir-1"), + profileId = profileId, + name = DeviceName("ir-1"), + type = DeviceType.INFRARED, + ), + ) +} diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt new file mode 100644 index 0000000..c45d265 --- /dev/null +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt @@ -0,0 +1,75 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import java.util.concurrent.ConcurrentHashMap + +class SimulationSocketGateway : SocketGateway { + private val states = ConcurrentHashMap() + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult { + val key = DeviceKey(profileId, deviceName) + return DomainResult.Success(SocketStatus(profileId, deviceName, states[key] ?: SocketPowerState.OFF)) + } + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + val key = DeviceKey(profileId, deviceName) + states[key] = powerState + return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + } +} + +class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway { + override suspend fun getReading( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = DomainResult.Success( + TemperatureHumidityReading( + profileId = profileId, + deviceName = deviceName, + temperature = "23.5", + humidity = "45", + ), + ) +} + +class SimulationInfraredGateway : InfraredGateway { + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = + DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) +} + +private data class DeviceKey( + val profileId: ProviderProfileId, + val deviceName: DeviceName, +) diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt new file mode 100644 index 0000000..fb34f42 --- /dev/null +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt @@ -0,0 +1,38 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LocalSessionGateway +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class SimulationSessionGateway : LocalSessionGateway { + private val sessions = ConcurrentHashMap>() + + override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = DomainResult.Success( + Session(profileId, credentials.username).also { flow(profileId).value = it }, + ) + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success( + if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, + ) + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + flow(profileId).value = null + return DomainResult.Success(Unit) + } + + private fun flow(profileId: ProviderProfileId): MutableStateFlow = + sessions.getOrPut(profileId) { MutableStateFlow(null) } +} diff --git a/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt new file mode 100644 index 0000000..0cafcc9 --- /dev/null +++ b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt @@ -0,0 +1,54 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketPowerState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SimulationDeviceGatewaysTest { + @Test + fun catalogProvidesSocketTemperatureHumidityAndInfraredDevicesForEachProfile() = runTest { + val profile = ProviderProfileId("local") + val devices = + (SimulationDeviceCatalog().observeDevices(profile).first() as DomainResult.Success).value + assertEquals( + setOf(DeviceType.SOCKET, DeviceType.TEMPERATURE_HUMIDITY, DeviceType.INFRARED), + devices.map { it.type }.toSet(), + ) + assertTrue(devices.all { it.profileId == profile }) + } + + @Test + fun socketStateIsMutableAndIsolatedByProfile() = runTest { + val gateway = SimulationSocketGateway() + val device = DeviceName("socket-1") + val first = ProviderProfileId("first") + val second = ProviderProfileId("second") + gateway.setPower(first, device, SocketPowerState.ON) + assertEquals( + SocketPowerState.ON, + (gateway.getStatus(first, device) as DomainResult.Success).value.powerState, + ) + assertEquals( + SocketPowerState.OFF, + (gateway.getStatus(second, device) as DomainResult.Success).value.powerState, + ) + } + + @Test + fun sensorAndInfraredOperationsSucceedWithoutNetworkConfiguration() = runTest { + val profile = ProviderProfileId("local") + val th = SimulationTemperatureHumidityGateway().getReading(profile, DeviceName("th-1")) + assertTrue(th is DomainResult.Success) + assertEquals( + DomainResult.Success(Unit), + SimulationInfraredGateway().sendKey(profile, DeviceName("ir-1"), "power"), + ) + } +} diff --git a/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt new file mode 100644 index 0000000..91c0289 --- /dev/null +++ b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.domain.Username +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SimulationSessionGatewayTest { + @Test + fun loginObserveValidateAndLogoutAreProfileScoped() = runTest { + val gateway = SimulationSessionGateway() + val first = ProviderProfileId("local-one") + val second = ProviderProfileId("local-two") + + val result = gateway.login(first, LoginCredentials(Username("local"), Password(""))) + + assertEquals(Username("local"), (result as DomainResult.Success).value.username) + assertEquals(first, gateway.observeSession(first).first()?.profileId) + assertNull(gateway.observeSession(second).first()) + assertEquals(DomainResult.Success(SessionValidity.VALID), gateway.validateSession(first)) + gateway.logout(first) + assertEquals(DomainResult.Success(SessionValidity.INVALID), gateway.validateSession(first)) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 398735c..4b9cad8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,7 @@ dependencyResolutionManagement { rootProject.name = "abox-manager" include(":domain") +include(":runtime:simulation") include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") -- 2.54.0 From 45f768208f53c7d1eb9cf1f901f66a420aa98fdf Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 19:02:06 +0800 Subject: [PATCH 21/26] feat: route provider sessions to ABSDK and simulation --- android/app/build.gradle.kts | 12 +++ .../abox/manager/di/DebugSessionModule.kt | 27 ------- .../flagship/abox/manager/di/SessionModule.kt | 27 ++++++- .../abox/manager/di/ReleaseSessionModule.kt | 27 ------- .../gateway/LocalSessionGatewayImpl.kt | 38 --------- .../testing/fakes/SessionCoordinatorTest.kt | 77 +++++++++++++++++++ 6 files changed, 114 insertions(+), 94 deletions(-) delete mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt delete mode 100644 android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt delete mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cac7fec..5cc61b1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -45,6 +45,15 @@ android { kotlinOptions { jvmTarget = "17" } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "/META-INF/DEPENDENCIES" + excludes += "/META-INF/LICENSE*" + excludes += "/META-INF/NOTICE*" + } + } } kapt { @@ -55,6 +64,8 @@ dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) implementation(project(":android:core:session")) + implementation(project(":absdk-bridge")) + implementation(project(":runtime:simulation")) debugImplementation(project(":testing:fakes")) implementation(platform(libs.compose.bom)) @@ -73,6 +84,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(project(":testing:fakes")) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.test.runner) diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt deleted file mode 100644 index ec992e9..0000000 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flagship.abox.manager.di - -import com.flagship.abox.manager.domain.SessionGateway -import com.flagship.abox.manager.testing.fakes.FakeSessionGateway -import com.flagship.abox.manager.ui.provider.Availability -import com.flagship.abox.manager.ui.provider.ProviderAvailability -import com.flagship.abox.manager.ui.provider.UnavailableReason -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object DebugSessionModule { - @Provides - @Singleton - fun provideRemoteSessionGateway(fake: FakeSessionGateway): SessionGateway = fake - - @Provides - @Singleton - fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability( - abox = Availability.Simulation, - compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), - ) -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt index 2c123f8..a2425ba 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt @@ -3,6 +3,8 @@ package com.flagship.abox.manager.di import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import com.flagship.abox.manager.absdk.bridge.AbsdkBridge +import com.flagship.abox.manager.absdk.bridge.CleartextAbsdkTransport import com.flagship.abox.manager.domain.CredentialStore import com.flagship.abox.manager.domain.LocalSessionGateway import com.flagship.abox.manager.domain.ProviderProfileStore @@ -11,9 +13,12 @@ import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.session.credential.AndroidKeystoreCredentialCipher import com.flagship.abox.manager.session.credential.CredentialCipher import com.flagship.abox.manager.session.credential.KeystoreCredentialStore -import com.flagship.abox.manager.session.gateway.LocalSessionGatewayImpl import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import com.flagship.abox.manager.session.profile.sessionDataStore +import com.flagship.abox.manager.simulation.SimulationSessionGateway +import com.flagship.abox.manager.ui.provider.Availability +import com.flagship.abox.manager.ui.provider.ProviderAvailability +import com.flagship.abox.manager.ui.provider.UnavailableReason import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -49,7 +54,25 @@ object SessionModule { @Provides @Singleton - fun provideLocalSessionGateway(): LocalSessionGateway = LocalSessionGatewayImpl() + @OptIn(CleartextAbsdkTransport::class) + fun provideAbsdkBridge(): AbsdkBridge = AbsdkBridge.create() + + @Provides + @Singleton + fun provideRemoteSessionGateway( + bridge: AbsdkBridge, + ): SessionGateway = bridge + + @Provides + @Singleton + fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway() + + @Provides + @Singleton + fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability( + abox = Availability.Enabled, + compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), + ) @Provides @Singleton diff --git a/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt b/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt deleted file mode 100644 index ff92f2a..0000000 --- a/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flagship.abox.manager.di - -import com.flagship.abox.manager.domain.SessionGateway -import com.flagship.abox.manager.session.gateway.UnavailableRemoteSessionGateway -import com.flagship.abox.manager.ui.provider.Availability -import com.flagship.abox.manager.ui.provider.ProviderAvailability -import com.flagship.abox.manager.ui.provider.UnavailableReason -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object ReleaseSessionModule { - @Provides - @Singleton - fun provideRemoteSessionGateway(): SessionGateway = UnavailableRemoteSessionGateway() - - @Provides - @Singleton - fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability( - abox = Availability.Disabled(UnavailableReason.TRANSPORT_SECURITY_PENDING), - compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), - ) -} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt deleted file mode 100644 index 1fbfb01..0000000 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.flagship.abox.manager.session.gateway - -import com.flagship.abox.manager.domain.DomainResult -import com.flagship.abox.manager.domain.LocalSessionGateway -import com.flagship.abox.manager.domain.LoginCredentials -import com.flagship.abox.manager.domain.ProviderProfileId -import com.flagship.abox.manager.domain.Session -import com.flagship.abox.manager.domain.SessionValidity -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -class LocalSessionGatewayImpl : LocalSessionGateway { - private val sessions = ConcurrentHashMap>() - - override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) - - override suspend fun login( - profileId: ProviderProfileId, - credentials: LoginCredentials, - ): DomainResult = DomainResult.Success( - Session(profileId, credentials.username).also { flow(profileId).value = it }, - ) - - override suspend fun validateSession( - profileId: ProviderProfileId, - ): DomainResult = DomainResult.Success( - if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, - ) - - override suspend fun logout(profileId: ProviderProfileId): DomainResult { - flow(profileId).value = null - return DomainResult.Success(Unit) - } - - private fun flow(profileId: ProviderProfileId): MutableStateFlow = - sessions.getOrPut(profileId) { MutableStateFlow(null) } -} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt index 89b3e94..29ea550 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt @@ -1,5 +1,6 @@ package com.flagship.abox.manager.testing.fakes +import com.flagship.abox.manager.domain.CredentialStore import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.LoginCredentials @@ -9,6 +10,8 @@ import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.Username import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -41,6 +44,37 @@ class SessionCoordinatorTest { assertNull(fixture.remote.observeSession(localProfile.id).first()) } + @Test + fun localSelectionCreatesSessionWithoutAboxOrCredentialAccess() = runTest { + val remote = CountingSessionGateway() + val credentialsStore = CountingCredentialStore() + val coordinator = SessionCoordinator( + FakeProviderProfileStore(), + credentialsStore, + remote, + FakeLocalSessionGateway(), + ) + + val result = coordinator.selectProvider(localProfile) + + assertTrue(result is DomainResult.Success) + assertEquals(0, remote.loginCalls) + assertEquals(0, credentialsStore.loadCalls + credentialsStore.saveCalls) + assertEquals(localProfile.id, coordinator.observeActiveSession().first()?.profileId) + } + + @Test + fun aboxAuthenticationFailureDoesNotCreateSession() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication()) + + val result = fixture.coordinator.login(remoteProfile.id, credentials, false, false) + + assertTrue(result is DomainResult.Failure) + assertNull(fixture.coordinator.observeActiveSession().first()) + } + @Test fun loginPersistsIndependentUsernameAndAutoLoginChoices() = runTest { val fixture = Fixture() @@ -126,4 +160,47 @@ class SessionCoordinatorTest { private val local = FakeLocalSessionGateway() val coordinator = SessionCoordinator(profiles, credentials, remote, local) } + + private class CountingSessionGateway( + private val delegate: FakeSessionGateway = FakeSessionGateway(), + ) : SessionGateway by delegate { + var loginCalls = 0 + private set + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult { + loginCalls++ + return delegate.login(profileId, credentials) + } + + fun failLogin(profileId: ProviderProfileId, error: DomainError) { + delegate.failLogin(profileId, error) + } + } + + private class CountingCredentialStore( + private val delegate: FakeCredentialStore = FakeCredentialStore(), + ) : CredentialStore by delegate { + var loadCalls = 0 + private set + var saveCalls = 0 + private set + + override suspend fun loadCredentials( + profileId: ProviderProfileId, + ): DomainResult { + loadCalls++ + return delegate.loadCredentials(profileId) + } + + override suspend fun saveCredentials( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult { + saveCalls++ + return delegate.saveCredentials(profileId, credentials) + } + } } -- 2.54.0 From 4121836947107351b8985eaa5cf2a754ed2b914d Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 20:03:24 +0800 Subject: [PATCH 22/26] refactor: offer ABox and local experience providers --- .../provider/ProviderSelectionScreenTest.kt | 22 +++--- .../flagship/abox/manager/di/SessionModule.kt | 10 --- .../ui/provider/ProviderAvailability.kt | 27 ------- .../ui/provider/ProviderSelectionScreen.kt | 79 +++++-------------- .../ui/provider/ProviderSelectionViewModel.kt | 26 ++---- .../manager/ui/session/AppSessionViewModel.kt | 20 +---- .../src/main/res/values-zh-rCN/strings.xml | 10 +-- android/app/src/main/res/values/strings.xml | 10 +-- .../manager/testing/MainDispatcherRule.kt | 23 ++++++ .../ProviderSelectionViewModelTest.kt | 49 ++++++++++++ 10 files changed, 117 insertions(+), 159 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt index b10ea08..10924e4 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt @@ -2,7 +2,6 @@ package com.flagship.abox.manager.ui.provider import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -22,27 +21,26 @@ class ProviderSelectionScreenTest { val composeRule = createAndroidComposeRule() @Test - fun enabledOptionCanBeSelectedAndDisabledOptionCannot() { - val local = ProviderOption( - ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"), - Availability.Enabled, + fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() { + val abox = ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox") + val local = ProviderProfile( + ProviderProfileId("local"), + ProviderType.LOCAL_EXPERIENCE, + "Local", ) - val abox = ProviderOption( - ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox"), - Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), - ) - var selected: ProviderOption? = null + var selected: ProviderProfile? = null composeRule.setContent { ABoxTheme { ProviderSelectionScreen( - state = ProviderSelectionUiState(listOf(local, abox)), + state = ProviderSelectionUiState(listOf(abox, local)), onSelect = { selected = it }, ) } } + composeRule.onNodeWithText("ABox").assertIsEnabled() composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick() assertEquals(local, selected) - composeRule.onNodeWithText("ABox").assertIsNotEnabled() + composeRule.onNodeWithText("Compatible backend").assertDoesNotExist() } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt index a2425ba..a2ca3ec 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt @@ -16,9 +16,6 @@ import com.flagship.abox.manager.session.credential.KeystoreCredentialStore import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import com.flagship.abox.manager.session.profile.sessionDataStore import com.flagship.abox.manager.simulation.SimulationSessionGateway -import com.flagship.abox.manager.ui.provider.Availability -import com.flagship.abox.manager.ui.provider.ProviderAvailability -import com.flagship.abox.manager.ui.provider.UnavailableReason import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -67,13 +64,6 @@ object SessionModule { @Singleton fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway() - @Provides - @Singleton - fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability( - abox = Availability.Enabled, - compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), - ) - @Provides @Singleton fun provideSessionCoordinator( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt deleted file mode 100644 index c4839ca..0000000 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flagship.abox.manager.ui.provider - -import com.flagship.abox.manager.domain.ProviderType - -data class ProviderAvailability( - val abox: Availability, - val compatible: Availability, - val local: Availability = Availability.Enabled, -) { - fun forType(type: ProviderType): Availability = when (type) { - ProviderType.ABOX -> abox - ProviderType.LOCAL_EXPERIENCE -> local - } -} - -sealed interface Availability { - data object Enabled : Availability - - data object Simulation : Availability - - data class Disabled(val reason: UnavailableReason) : Availability -} - -enum class UnavailableReason { - PROTOCOL_PENDING, - TRANSPORT_SECURITY_PENDING, -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt index 1829e22..6c486d5 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -36,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderType @Composable @@ -50,7 +50,7 @@ fun ProviderSelectionRoute( @Composable fun ProviderSelectionScreen( state: ProviderSelectionUiState, - onSelect: (ProviderOption) -> Unit, + onSelect: (ProviderProfile) -> Unit, ) { Scaffold( topBar = { @@ -77,9 +77,9 @@ fun ProviderSelectionScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp), ) - state.providers.forEach { option -> + state.providers.forEach { profile -> ProviderOptionCard( - option = option, + profile = profile, selecting = state.isSelecting, onSelect = onSelect, ) @@ -102,33 +102,25 @@ fun ProviderSelectionScreen( @Composable private fun ProviderOptionCard( - option: ProviderOption, + profile: ProviderProfile, selecting: Boolean, - onSelect: (ProviderOption) -> Unit, + onSelect: (ProviderProfile) -> Unit, ) { - val enabled = option.availability !is Availability.Disabled && !selecting - val displayName = providerName(option.profile.type) - val description = providerDescription(option.profile.type) - val status = availabilityLabel(option.availability) - val statusColor = availabilityColor(option.availability) + val displayName = providerName(profile.type) + val description = providerDescription(profile.type) OutlinedCard( - onClick = { onSelect(option) }, - enabled = enabled, + onClick = { onSelect(profile) }, + enabled = !selecting, shape = MaterialTheme.shapes.medium, colors = CardDefaults.outlinedCardColors( - containerColor = when (option.availability) { - Availability.Enabled -> MaterialTheme.colorScheme.surface - Availability.Simulation -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) - is Availability.Disabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) - }, + containerColor = MaterialTheme.colorScheme.surface, ), modifier = Modifier .fillMaxWidth() - .alpha(if (enabled || selecting) 1f else 0.72f) .semantics { role = Role.Button - contentDescription = "$displayName, $status" + contentDescription = displayName }, ) { Row( @@ -140,13 +132,13 @@ private fun ProviderOptionCard( modifier = Modifier .size(48.dp) .clip(CircleShape) - .background(statusColor.copy(alpha = 0.16f)), + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)), contentAlignment = Alignment.Center, ) { Text( - text = providerMark(option.profile.type), + text = providerMark(profile.type), style = MaterialTheme.typography.titleMedium, - color = statusColor, + color = MaterialTheme.colorScheme.primary, ) } Column( @@ -159,24 +151,12 @@ private fun ProviderOptionCard( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - Modifier - .size(8.dp) - .clip(CircleShape) - .background(statusColor), - ) - Text( - status, - style = MaterialTheme.typography.labelLarge, - color = statusColor, - ) - } } - Text("›", style = MaterialTheme.typography.headlineSmall, color = statusColor) + Text( + "›", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) } } } @@ -201,22 +181,3 @@ private fun providerMark(type: ProviderType): String = when (type) { ProviderType.ABOX -> "A" ProviderType.LOCAL_EXPERIENCE -> "L" } - -@Composable -private fun availabilityLabel(availability: Availability): String = stringResource( - when (availability) { - Availability.Enabled -> R.string.provider_status_available - Availability.Simulation -> R.string.provider_status_simulation - is Availability.Disabled -> when (availability.reason) { - UnavailableReason.PROTOCOL_PENDING -> R.string.provider_status_protocol_pending - UnavailableReason.TRANSPORT_SECURITY_PENDING -> R.string.provider_status_transport_pending - } - }, -) - -@Composable -private fun availabilityColor(availability: Availability) = when (availability) { - Availability.Enabled -> MaterialTheme.colorScheme.primary - Availability.Simulation -> MaterialTheme.colorScheme.tertiary - is Availability.Disabled -> MaterialTheme.colorScheme.error -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt index 8eb54f8..04e1ddd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt @@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.provider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType @@ -16,26 +17,18 @@ import kotlinx.coroutines.launch @HiltViewModel class ProviderSelectionViewModel @Inject constructor( private val coordinator: SessionCoordinator, - availability: ProviderAvailability, ) : ViewModel() { - private val _uiState = MutableStateFlow( - ProviderSelectionUiState( - providers = PROFILES.map { profile -> - ProviderOption(profile, availability.forType(profile.type)) - }, - ), - ) + private val _uiState = MutableStateFlow(ProviderSelectionUiState(providers = PROFILES)) val uiState: StateFlow = _uiState.asStateFlow() - fun select(option: ProviderOption) { - if (option.availability is Availability.Disabled || _uiState.value.isSelecting) return + fun select(profile: ProviderProfile) { + if (_uiState.value.isSelecting) return viewModelScope.launch { _uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null) - val result = coordinator.selectProvider(option.profile) + val result = coordinator.selectProvider(profile) _uiState.value = _uiState.value.copy( isSelecting = false, - errorMessage = (result as? com.flagship.abox.manager.domain.DomainResult.Failure) - ?.error?.detail, + errorMessage = (result as? DomainResult.Failure)?.error?.detail, ) } } @@ -53,12 +46,7 @@ class ProviderSelectionViewModel @Inject constructor( } data class ProviderSelectionUiState( - val providers: List, + val providers: List, val isSelecting: Boolean = false, val errorMessage: String? = null, ) - -data class ProviderOption( - val profile: ProviderProfile, - val availability: Availability, -) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index 7e67424..12f57aa 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -8,8 +8,6 @@ import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.SessionCoordinator -import com.flagship.abox.manager.ui.provider.Availability -import com.flagship.abox.manager.ui.provider.ProviderAvailability import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -25,7 +23,6 @@ import kotlinx.coroutines.launch class AppSessionViewModel @Inject constructor( private val coordinator: SessionCoordinator, private val profileStore: ProviderProfileStore, - private val availability: ProviderAvailability, ) : ViewModel() { private val bootstrapResult = MutableStateFlow(null) @@ -50,18 +47,10 @@ class AppSessionViewModel @Inject constructor( viewModelScope.launch { try { val activeProfile = profileStore.activeProfile() - if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { - coordinator.switchProvider() - bootstrapResult.value = ProviderBootstrapResult( - profileId = null, - result = DomainResult.Success(null), - ) - } else { - bootstrapResult.value = ProviderBootstrapResult( - profileId = activeProfile?.id, - result = coordinator.bootstrap(), - ) - } + bootstrapResult.value = ProviderBootstrapResult( + profileId = activeProfile?.id, + result = coordinator.bootstrap(), + ) } catch (error: kotlinx.coroutines.CancellationException) { throw error } catch (error: Exception) { @@ -110,7 +99,6 @@ class AppSessionViewModel @Inject constructor( bootstrapError: String?, ): AppSessionUiState = when { profile == null -> AppSessionUiState.ProviderSelection - availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile) else -> AppSessionUiState.Login(profile, bootstrapError) } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 90e8af8..28b7def 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -3,15 +3,9 @@ 选择提供方 选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。 ABox - 通过兼容 ABSDK 的 ABox 服务连接设备。 - 自有兼容后端 - 在协议准备完成后连接自行部署的 ABSDK 兼容服务。 + 通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。 本地体验 - 无需网络,通过本地模拟设备体验完整应用流程。 - 可用 - 开发模拟 - 协议待确认 - 等待传输安全方案批准 + 无需网络,通过本地模拟设备体验应用。 登录 %1$s 当前提供方:%1$s 返回提供方选择 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a2b1d2d..291774e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,15 +3,9 @@ Choose a provider Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers. ABox - Connect through the ABSDK-compatible ABox service. - Compatible backend - Use a self-hosted ABSDK-compatible service when its protocol is ready. + Connect to the ABox smart-home gateway through ABSDK on the current local network. Local experience - Explore the complete app flow with local simulated devices and no network. - Available - Development simulation - Protocol pending - Transport security approval required + Explore the app with simulated local devices and no network connection. Sign in to %1$s Selected provider: %1$s Back to providers diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt new file mode 100644 index 0000000..c485006 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package com.flagship.abox.manager.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val dispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt new file mode 100644 index 0000000..ca3ed31 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt @@ -0,0 +1,49 @@ +package com.flagship.abox.manager.ui.provider + +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.testing.MainDispatcherRule +import com.flagship.abox.manager.testing.fakes.FakeCredentialStore +import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeSessionGateway +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ProviderSelectionViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val coordinator = SessionCoordinator( + profileStore = FakeProviderProfileStore(), + credentialStore = FakeCredentialStore(), + remoteGateway = FakeSessionGateway(), + localGateway = FakeLocalSessionGateway(), + ) + + @Test + fun exposesExactlyAboxAndLocalExperience() { + val viewModel = ProviderSelectionViewModel(coordinator) + + assertEquals( + listOf(ProviderType.ABOX, ProviderType.LOCAL_EXPERIENCE), + viewModel.uiState.value.providers.map { it.type }, + ) + } + + @Test + fun selectingLocalExperienceEstablishesSessionImmediately() = runTest { + val viewModel = ProviderSelectionViewModel(coordinator) + val local = viewModel.uiState.value.providers + .single { it.type == ProviderType.LOCAL_EXPERIENCE } + + viewModel.select(local) + advanceUntilIdle() + + assertEquals(local.id, coordinator.observeActiveSession().first()?.profileId) + } +} -- 2.54.0 From 2ecca00371b70687fd40e91f54b763e58ca90e8e Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 20:53:54 +0800 Subject: [PATCH 23/26] feat: route device operations by active provider --- .../abox/manager/di/FoundationModule.kt | 29 +-- .../abox/manager/di/DeviceGatewayModule.kt | 62 +++++ .../manager/di/ProviderRoutingGateways.kt | 134 ++++++++++ .../manager/di/ProviderRoutingGatewaysTest.kt | 230 ++++++++++++++++++ .../UnavailableRemoteSessionGateway.kt | 31 --- 5 files changed, 431 insertions(+), 55 deletions(-) create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt delete mode 100644 android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt index 2052c9e..cc805b8 100644 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt @@ -1,14 +1,7 @@ package com.flagship.abox.manager.di import com.flagship.abox.manager.domain.DeviceCatalog -import com.flagship.abox.manager.domain.InfraredGateway -import com.flagship.abox.manager.domain.SocketGateway -import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog -import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway -import com.flagship.abox.manager.testing.fakes.FakeSessionGateway -import com.flagship.abox.manager.testing.fakes.FakeSocketGateway -import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,24 +11,12 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object FoundationModule { - @Provides - @Singleton - fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway() - + // TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production + // ABox catalog is owned by the device-catalog module. Until that exists, debug builds + // keep the fake ABox catalog here. Do NOT route ABox catalog requests to + // SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding + // until a real release consumer appears. @Provides @Singleton fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog() - - @Provides - @Singleton - fun provideSocketGateway(): SocketGateway = FakeSocketGateway() - - @Provides - @Singleton - fun provideTemperatureHumidityGateway(): TemperatureHumidityGateway = - FakeTemperatureHumidityGateway() - - @Provides - @Singleton - fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway() } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt new file mode 100644 index 0000000..88ee898 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt @@ -0,0 +1,62 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.absdk.bridge.AbsdkBridge +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.simulation.SimulationInfraredGateway +import com.flagship.abox.manager.simulation.SimulationSocketGateway +import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Binds each public device gateway to a router that dispatches on the active provider: + * ABox goes to the shared real [AbsdkBridge] and local experience goes to the shared + * production simulation gateway. + */ +@Module +@InstallIn(SingletonComponent::class) +object DeviceGatewayModule { + @Provides + @Singleton + fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway() + + @Provides + @Singleton + fun provideSimulationTemperatureHumidityGateway(): SimulationTemperatureHumidityGateway = + SimulationTemperatureHumidityGateway() + + @Provides + @Singleton + fun provideSimulationInfraredGateway(): SimulationInfraredGateway = SimulationInfraredGateway() + + @Provides + @Singleton + fun provideSocketGateway( + profileStore: ProviderProfileStore, + bridge: AbsdkBridge, + simulation: SimulationSocketGateway, + ): SocketGateway = RoutingSocketGateway(profileStore, bridge, simulation) + + @Provides + @Singleton + fun provideTemperatureHumidityGateway( + profileStore: ProviderProfileStore, + bridge: AbsdkBridge, + simulation: SimulationTemperatureHumidityGateway, + ): TemperatureHumidityGateway = + RoutingTemperatureHumidityGateway(profileStore, bridge, simulation) + + @Provides + @Singleton + fun provideInfraredGateway( + profileStore: ProviderProfileStore, + bridge: AbsdkBridge, + simulation: SimulationInfraredGateway, + ): InfraredGateway = RoutingInfraredGateway(profileStore, bridge, simulation) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt new file mode 100644 index 0000000..5d019c7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt @@ -0,0 +1,134 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import kotlinx.coroutines.CancellationException + +/** + * Resolves the provider type a device operation must be routed to. + * + * A device call is only valid for the currently active provider profile: a missing + * active profile or a mismatched [profileId] is rejected with [DomainError.InvalidRequest] + * before any gateway is invoked. + */ +internal class ActiveProviderGatewayResolver( + private val profileStore: ProviderProfileStore, +) { + suspend fun resolve(profileId: ProviderProfileId): DomainResult = try { + val active = profileStore.activeProfile() + when { + active == null -> + DomainResult.Failure(DomainError.InvalidRequest(detail = "No active provider")) + active.id != profileId -> + DomainResult.Failure( + DomainError.InvalidRequest(detail = "Provider profile is not active"), + ) + else -> DomainResult.Success(active.type) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + DomainResult.Failure( + DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), + ) + } +} + +/** Routes socket operations to the real ABSDK bridge or the simulation gateway by active provider. */ +internal class RoutingSocketGateway( + profileStore: ProviderProfileStore, + private val real: SocketGateway, + private val simulation: SocketGateway, +) : SocketGateway { + private val resolver = ActiveProviderGatewayResolver(profileStore) + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName) + } + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).setPower(profileId, deviceName, powerState) + } + + private fun delegate(type: ProviderType): SocketGateway = + if (type == ProviderType.ABOX) real else simulation +} + +/** Routes temperature/humidity operations by active provider, mirroring [RoutingSocketGateway]. */ +internal class RoutingTemperatureHumidityGateway( + profileStore: ProviderProfileStore, + private val real: TemperatureHumidityGateway, + private val simulation: TemperatureHumidityGateway, +) : TemperatureHumidityGateway { + private val resolver = ActiveProviderGatewayResolver(profileStore) + + override suspend fun getReading( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).getReading(profileId, deviceName) + } + + private fun delegate(type: ProviderType): TemperatureHumidityGateway = + if (type == ProviderType.ABOX) real else simulation +} + +/** Routes infrared operations by active provider, mirroring [RoutingSocketGateway]. */ +internal class RoutingInfraredGateway( + profileStore: ProviderProfileStore, + private val real: InfraredGateway, + private val simulation: InfraredGateway, +) : InfraredGateway { + private val resolver = ActiveProviderGatewayResolver(profileStore) + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).sendKey(profileId, deviceName, key) + } + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).learnKey(profileId, deviceName, key) + } + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).downloadCodes(profileId, codes) + } + + private fun delegate(type: ProviderType): InfraredGateway = + if (type == ProviderType.ABOX) real else simulation +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt new file mode 100644 index 0000000..1fa2587 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt @@ -0,0 +1,230 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProviderRoutingGatewaysTest { + @Test + fun socketRoutesAboxToRealAndLocalToSimulation() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingSocketGateway() + val simulation = RecordingSocketGateway() + val router = RoutingSocketGateway(store, real, simulation) + + val aboxId = activate(store, ProviderType.ABOX) + router.getStatus(aboxId, DeviceName("socket-1")) + assertEquals(1, real.calls) + assertEquals(0, simulation.calls) + + val localId = activate(store, ProviderType.LOCAL_EXPERIENCE) + router.getStatus(localId, DeviceName("socket-1")) + assertEquals(1, real.calls) + assertEquals(1, simulation.calls) + } + + @Test + fun temperatureHumidityRoutesAboxToRealAndLocalToSimulation() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingTemperatureHumidityGateway() + val simulation = RecordingTemperatureHumidityGateway() + val router = RoutingTemperatureHumidityGateway(store, real, simulation) + + val aboxId = activate(store, ProviderType.ABOX) + router.getReading(aboxId, DeviceName("th-1")) + assertEquals(1, real.calls) + assertEquals(0, simulation.calls) + + val localId = activate(store, ProviderType.LOCAL_EXPERIENCE) + router.getReading(localId, DeviceName("th-1")) + assertEquals(1, real.calls) + assertEquals(1, simulation.calls) + } + + @Test + fun infraredRoutesAboxToRealAndLocalToSimulation() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingInfraredGateway() + val simulation = RecordingInfraredGateway() + val router = RoutingInfraredGateway(store, real, simulation) + + val aboxId = activate(store, ProviderType.ABOX) + router.sendKey(aboxId, DeviceName("ir-1"), "power") + assertEquals(1, real.calls) + assertEquals(0, simulation.calls) + + val localId = activate(store, ProviderType.LOCAL_EXPERIENCE) + router.sendKey(localId, DeviceName("ir-1"), "power") + assertEquals(1, real.calls) + assertEquals(1, simulation.calls) + } + + @Test + fun missingActiveProfileReturnsInvalidRequest() = runTest { + val router = RoutingSocketGateway( + FakeProviderProfileStore(), + RecordingSocketGateway(), + RecordingSocketGateway(), + ) + + val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1")) + + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + } + + @Test + fun mismatchedActiveProfileReturnsInvalidRequest() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingSocketGateway() + val simulation = RecordingSocketGateway() + val router = RoutingSocketGateway(store, real, simulation) + activate(store, ProviderType.ABOX) + + val result = router.getStatus(ProviderProfileId("other"), DeviceName("socket-1")) + + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertEquals(0, real.calls) + assertEquals(0, simulation.calls) + } + + @Test + fun cancellationFromProfileStoreIsRethrown() = runTest { + val router = RoutingSocketGateway( + CancellingProviderProfileStore(), + RecordingSocketGateway(), + RecordingSocketGateway(), + ) + + try { + router.getStatus(ProviderProfileId("any"), DeviceName("socket-1")) + error("Expected CancellationException") + } catch (expected: CancellationException) { + // Rethrown so coroutine cancellation is never swallowed. + } + } + + @Test + fun unexpectedProfileStoreErrorReturnsUnknown() = runTest { + val router = RoutingSocketGateway( + FailingProviderProfileStore(), + RecordingSocketGateway(), + RecordingSocketGateway(), + ) + + val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1")) + + assertTrue(result is DomainResult.Failure) + val error = (result as DomainResult.Failure).error + assertTrue(error is DomainError.Unknown) + assertEquals("storage exploded", error.detail) + } + + private suspend fun activate( + store: FakeProviderProfileStore, + type: ProviderType, + ): ProviderProfileId { + val id = ProviderProfileId(type.name.lowercase()) + store.saveProfile(ProviderProfile(id, type, type.name)) + store.setActiveProfile(id) + return id + } +} + +private class RecordingSocketGateway : SocketGateway { + var calls: Int = 0 + private set + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult { + calls += 1 + return DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF)) + } + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + calls += 1 + return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + } +} + +private class RecordingTemperatureHumidityGateway : TemperatureHumidityGateway { + var calls: Int = 0 + private set + + override suspend fun getReading( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult { + calls += 1 + return DomainResult.Success(TemperatureHumidityReading(profileId, deviceName, "0", "0")) + } +} + +private class RecordingInfraredGateway : InfraredGateway { + var calls: Int = 0 + private set + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + calls += 1 + return DomainResult.Success(Unit) + } + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + calls += 1 + return DomainResult.Success(Unit) + } + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> { + calls += 1 + return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) + } +} + +private class CancellingProviderProfileStore( + private val delegate: ProviderProfileStore = FakeProviderProfileStore(), +) : ProviderProfileStore by delegate { + override suspend fun activeProfile(): Nothing = throw CancellationException("cancelled") +} + +private class FailingProviderProfileStore( + private val delegate: ProviderProfileStore = FakeProviderProfileStore(), +) : ProviderProfileStore by delegate { + override suspend fun activeProfile(): Nothing = + throw IllegalStateException("storage exploded") +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt deleted file mode 100644 index 5a8111c..0000000 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.flagship.abox.manager.session.gateway - -import com.flagship.abox.manager.domain.DomainError -import com.flagship.abox.manager.domain.DomainResult -import com.flagship.abox.manager.domain.LoginCredentials -import com.flagship.abox.manager.domain.ProviderProfileId -import com.flagship.abox.manager.domain.Session -import com.flagship.abox.manager.domain.SessionGateway -import com.flagship.abox.manager.domain.SessionValidity -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf - -class UnavailableRemoteSessionGateway : SessionGateway { - override fun observeSession(profileId: ProviderProfileId): Flow = flowOf(null) - - override suspend fun login( - profileId: ProviderProfileId, - credentials: LoginCredentials, - ): DomainResult = unavailable() - - override suspend fun validateSession( - profileId: ProviderProfileId, - ): DomainResult = unavailable() - - override suspend fun logout(profileId: ProviderProfileId): DomainResult = - DomainResult.Success(Unit) - - private fun unavailable(): DomainResult = DomainResult.Failure( - DomainError.InvalidRequest(detail = "Remote providers are not enabled in this build"), - ) -} -- 2.54.0 From 6535aeef1a72249bf8adffb1b356284ad09b465b Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 21:50:00 +0800 Subject: [PATCH 24/26] fix: require real ABSDK success before authentication --- android/app/src/main/AndroidManifest.xml | 2 + .../ui/session/AppSessionViewModelTest.kt | 126 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModelTest.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f3b7d57..5b9189a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,11 @@ + = + remote.observeSession(otherProfileId) + } + val viewModel = AppSessionViewModel(newCoordinator(crossProfileGateway), profiles) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is AppSessionUiState.Login) + } + + @Test + fun successfulAboxLoginAuthenticatesActiveProfile() = runTest { + saveAndActivateAboxProfile(aboxProfileId) + val coordinator = newCoordinator() + val viewModel = AppSessionViewModel(coordinator, profiles) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + advanceUntilIdle() + + coordinator.login( + profileId = aboxProfileId, + credentials = credentials, + rememberUsername = false, + autoLogin = false, + ) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is AppSessionUiState.Authenticated) + } + + private companion object { + val aboxProfileId = ProviderProfileId("abox-default") + val otherProfileId = ProviderProfileId("abox-other") + } +} -- 2.54.0 From d7f8f8c8e94359ce86e5355d7f1affb85ee2774c Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 22:25:57 +0800 Subject: [PATCH 25/26] docs: align product scope with LAN ABSDK access --- docs/architecture.md | 96 ++++++++++------ docs/project_plan.md | 108 ++++++++---------- docs/protocol/absdk-0630.md | 41 ++++--- docs/requirement_spec.md | 11 +- ...4-lan-absdk-and-local-experience-design.md | 11 +- 5 files changed, 151 insertions(+), 116 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1f01750..e2231f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## 1. 文档范围 -本文记录第一轮工程基础已经建立的模块边界、依赖方向和并行开发接口。ABSDK 0630 的初始化、协议、签名、连接重定向和会话清理等未确认内容不在本文中固化。 +本文记录已建立的模块边界、依赖方向、提供方路由和并行开发接口,覆盖 ABox(局域网 ABSDK)与本地体验两条连接路径。ABSDK 0630 的初始化、签名细节和会话清理等未确认内容不在本文中固化,协议观测证据见 `docs/protocol/absdk-0630.md`。 产品范围和固定分工以 `docs/project_plan.md` 为准,原始简要需求继续保留在 `docs/requirement_spec.md`。 @@ -11,10 +11,14 @@ ```text abox-mgr/ ├── android/ -│ ├── app/ # Android 应用与依赖注入组合根 -│ └── core/designsystem/ # Compose Material 3 设计系统基础 +│ ├── app/ # Android 应用、提供方路由与依赖注入组合根 +│ └── core/ +│ ├── designsystem/ # Compose Material 3 设计系统基础 +│ └── session/ # 提供方 profile、凭据 Keystore 持久化 ├── domain/ # 纯 Kotlin 领域模型和公共端口 -├── testing/fakes/ # 确定性的公共 Fake +├── absdk-bridge/ # ABSDK 0630 防腐层(生产) +├── runtime/simulation/ # 本地体验模拟运行时(生产) +├── testing/fakes/ # 测试专用 Fake(仅测试依赖) ├── docs/ └── samples/ # 历史示例,仅供参考 ``` @@ -23,10 +27,13 @@ abox-mgr/ ```text android/app ────────────────┬──> android/core/designsystem + ├──> android/core/session ──> domain ├──> domain - └──> testing/fakes ──> domain + ├──> absdk-bridge ──> domain + └──> runtime/simulation ──> domain android/core/designsystem ─────> Compose Material 3 +testing/fakes(仅测试)────────> domain domain ────────────────────────> Kotlin 与 Coroutines ``` @@ -34,21 +41,29 @@ domain ────────────────────────> ## 3. 提供方与数据隔离 -应用计划支持 ABox、自有兼容后端和本地体验三种提供方。每个连接配置由 `ProviderProfileId` 标识,所有会话、设备目录和设备操作都显式携带该标识。 +认证前应用只提供两种入口,两者在所有构建变体中始终可用: + +1. **ABox**:通过进程内唯一的真实 `AbsdkBridge` 连接当前局域网中的智能家居网关,登录成功后进入应用; +2. **本地体验**:由 `:runtime:simulation` 直接建立模拟会话并进入应用,不请求凭据、不访问网络、不调用 ABSDK。 + +每个提供方 profile 由 `ProviderProfileId` 标识,所有会话、设备目录和设备操作都显式携带该标识。 约束: - 任一时刻只启用一个提供方; -- 切换远端提供方前必须退出当前会话; -- 不同连接配置的数据、凭据和设备状态不得互相混用; +- 切换提供方前必须退出当前会话; +- 不同提供方的数据、凭据和设备状态不得互相混用; - 本地体验不调用 ABSDK,也不启动本地 HTTP 服务; -- Fake 使用相同标识隔离规则,便于在没有真实环境时验证边界。 +- Release 不是特殊情况:debug 与 release 暴露相同的提供方和路由行为。 ## 4. 公共领域端口 `domain` 当前提供以下最小端口: - `SessionGateway`:登录、观察会话、验证会话和退出; +- `LocalSessionGateway`:无凭据本地会话的标记端口,继承 `SessionGateway`; +- `ProviderProfileStore`:提供方 profile、活动 profile 和登录偏好持久化; +- `CredentialStore`:自动登录凭据的加密存取; - `DeviceCatalog`:按提供方观察设备目录; - `SocketGateway`:查询和设置插座状态; - `TemperatureHumidityGateway`:读取温度和湿度; @@ -63,9 +78,9 @@ domain ────────────────────────> - `Success` 携带类型化领域值; - `Failure` 携带 `DomainError`; - `DomainError.externalCode` 可以保留外部 SDK 错误码用于诊断; -- UI 不接触 `ABRet`、原始 `Map`、HTTP 表单或服务端数据库模型。 +- UI 不接触 `ABRet`、原始 `Map`、HTTP 表单或任何服务端数据库模型。 -真实 ABSDK 适配层必须在后续 `feat/absdk-bridge` 分支中完成同步阻塞调用的后台串行化、返回解析和错误映射。 +ABSDK 防腐层已在 `:absdk-bridge` 中完成同步阻塞调用的后台串行化、返回解析和错误映射。 ## 6. Fake 边界 @@ -77,58 +92,75 @@ domain ────────────────────────> - 记录红外学习、发送和下载调用; - 可直接用于其他模块的 ViewModel 与 UI 测试。 -Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。 +Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。`:testing:fakes` 是测试专用模块,不得加入应用生产依赖。 -## 7. Android 组合根 +一个例外是 `FakeDeviceCatalog`:ABSDK 不提供设备目录,而提供方作用域的生产 ABox 设备目录由设备目录模块负责,尚未落地。在该模块交付前,debug 构建的组合根暂时用 `FakeDeviceCatalog` 充当 ABox 目录占位。这是一个临时的目录所有权边界:不得把 ABox 目录请求路由到本地体验的模拟目录,release 构建在出现真实消费方之前不声明 `DeviceCatalog` 绑定。 + +## 7. 本地体验模拟运行时 + +`:runtime:simulation` 是生产 Kotlin/JVM 模块,为本地体验提供确定性实现: + +- `SimulationSessionGateway`:直接建立无凭据的本地会话; +- `SimulationDeviceCatalog`:内置的本地体验设备目录; +- `SimulationSocketGateway`、`SimulationTemperatureHumidityGateway`、`SimulationInfraredGateway`:固定行为的模拟设备操作。 + +模拟实现不暴露脚本化失败注入、调用记录或断言辅助;这些能力只存在于 `:testing:fakes`。 + +## 8. Android 组合根与提供方路由 `android/app` 是单 Activity Compose 应用,负责: - `ABoxManagerApplication` 与 Hilt 组合根; - `MainActivity`; -- 根 `NavHost`; -- 仅在 debug 构建中将领域端口绑定到当前开发阶段的 Fake,release 构建不包含 Fake; +- 根 `NavHost` 与提供方选择、登录、认证后路由; - 默认英文和简体中文资源; - 使用 `android/core/designsystem` 的主题和基础组件。 +组合根按端口绑定生产实现: + +```text +SessionGateway -> AbsdkBridge(进程内单例) +LocalSessionGateway -> SimulationSessionGateway +SocketGateway -> RoutingSocketGateway +TemperatureHumidityGateway -> RoutingTemperatureHumidityGateway +InfraredGateway -> RoutingInfraredGateway +``` + +`AbsdkBridge` 以 Hilt 单例提供并在所有构建变体中相同:会话与插座/温湿度/红外操作共享同一个 Bridge 实例,使互斥锁和 Bridge 本地会话与厂商 SDK 单例状态保持一致。三个 Routing 网关读取活动 profile 并按提供方分发——ABox 转发到共享的 AbsdkBridge 实现,本地体验转发到模拟实现。任何功能 UI 都不直接选择真实或 Fake 实现。 + 设备目录导航、插座与温湿度页面、红外页面和情景页面由固定模块负责人实现,不放入工程基础层。 -## 8. ABSDK Bridge +## 9. ABSDK Bridge `:absdk-bridge` 是纯 Kotlin/JVM 防腐层,依赖 `domain` 并实现会话、插座、温湿度和红外 Gateway。该模块: - 只在 `VendorAbsdkClient` 中接触 `ABSDK`、`ABRet` 和 `IrInfo`; - 将所有同步调用移到后台并串行进入全局 SDK; - 将返回值和错误码转换为 `DomainResult` 和领域模型; -- 不提供设备目录; - 不调用未确认的 `setHostInfo`; - logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter; - 生产工厂始终返回进程内同一个 Bridge,使互斥锁和本地会话与厂商单例保持一致; - 真实工厂标记为 `@CleartextAbsdkTransport`,调用方必须显式 opt-in 承认厂商 JAR 的硬编码 HTTP 风险; -- 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制。 +- 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制; +- ABSDK 0630 没有设备目录能力,Bridge 因此也不提供设备目录或设备发现。 -Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 +App 组合根已通过 Hilt 单例绑定该 Bridge,绑定处以 `@CleartextAbsdkTransport` 显式 opt-in 承认厂商 JAR 的硬编码明文 HTTP 风险;App 同时声明 `INTERNET` 权限并允许明文传输(`usesCleartextTraffic`),这是项目所有者对当前局域网集成约束的明确风险接受,不代表传输具备机密性或完整性。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 -## 9. Provider Session +## 10. Provider Session `:android:core:session` 负责提供方登录的持久化与 Android 安全边界: -- `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关; +- `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关;已移除的提供方类型(如历史兼容后端值)被忽略,不会成为活动 profile; - `CredentialStore` 使用 Android Keystore 中的 AES-256-GCM 密钥加密自动登录凭据,DataStore 只保存带随机 IV 的密文; - `ProviderProfileId` 作为 GCM AAD,防止不同 profile 的密文互换; -- `LocalSessionGateway` 只维护本地内存 Session,不调用 HTTP 或 ABSDK; -- `UnavailableRemoteSessionGateway` 在远端入口未获批准时明确拒绝登录。 +- 本地体验的会话由 `:runtime:simulation` 的 `SimulationSessionGateway` 提供,不调用 HTTP 或 ABSDK。 -纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。远端退出清除密码和 Session,但保留 profile 与 remembered username;本地体验退出后回到提供方选择。 +纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。ABox 登录成功必须来自注入的真实 Bridge 返回 `DomainResult.Success`;无效凭据和网络错误停留在登录页并显示本地化错误。ABox 远端退出清除密码和 Session,但保留 profile 与记住的用户名以便再次显示登录页;本地体验退出清除本地会话和活动 profile,回到提供方选择。 -App 按构建变体声明远端可用性:debug 的 ABox 入口明确是开发模拟,release 的 ABox 因传输安全待确认而禁用;兼容后端在协议明确前始终禁用;本地体验始终可用。App 没有依赖或创建 `AbsdkBridge`,也没有对 `@CleartextAbsdkTransport` opt-in。 +两个提供方在所有构建变体中始终可用,不存在按 debug/release 区分的可用性开关。 登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。 -## 10. 后续边界 +## 11. 后续边界 -后续 KremeCN 工作保持为独立短期分支: - -- 在获批传输控制明确后,单独评审真实 ABox Bridge 的 App 绑定; -- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。 - -Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 +Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。生产 ABox 设备目录由设备目录模块交付后替换 debug 构建中的临时 `FakeDeviceCatalog` 占位。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 diff --git a/docs/project_plan.md b/docs/project_plan.md index 138d1e5..6fa1ea0 100644 --- a/docs/project_plan.md +++ b/docs/project_plan.md @@ -13,18 +13,20 @@ 仓库目前主要包含: - 产品需求概要; -- ABSDK 0630 接口文档; -- AsyncTask 登录示例; +- ABSDK 0630 接口文档与协议观测记录; +- 正式 Android 应用工程(含提供方登录与本地体验运行时); +- ABSDK 防腐层、领域接口、模拟运行时和测试 Fake; - 清理归档后的 ABSDK Android Sample; - Git 和 AI 工具协作规则。 -当前还没有正式的 Android 应用工程、自有后端、自动化测试或 CI Runner。 +当前还没有设备目录、插座温湿度、红外和情景模式的正式实现,也没有 CI Runner。 重要参考资料: ```text docs/requirement_spec.md -docs/ABSDK接口部分_0630.md +docs/protocol/absdk-0630.md +docs/architecture.md docs/examples/ samples/abox-sdk-sample/ ``` @@ -49,42 +51,46 @@ ABox Manager 是面向 Android 手机和平板的智能家居管理应用,首 ## 4. MVP 范围 -### 4.1 登录 +### 4.1 ABox 登录 + +以下登录要求只适用于 ABox 提供方;本地体验不需要凭据,也不显示登录页面。 - 支持用户名和密码登录; - 支持记住用户名; -- 支持按连接配置独立开启自动登录; +- 支持独立开启自动登录; - 自动登录所需密码必须通过 Android Keystore 安全保存; - ABSDK Token 由 SDK 自身管理;进程重启或 Token 失效后,通过保存的凭据重新登录; -- 退出登录时保留非敏感连接信息和用户名,清除密码与会话; -- 切换后端提供方前必须退出当前会话。 +- 退出登录时保留用户名,清除密码与会话。 ### 4.2 提供方模式 -应用计划兼容三种相互独立的模式: +应用提供两种入口,认证前必须选择其一: -1. **ABox 后端**:使用现有 ABSDK 0630 连接 ABox 服务; -2. **自有兼容后端**:连接自行实现的 ABSDK 0630 协议兼容服务; -3. **本地体验**:不连接远端服务,通过本地模拟数据体验完整应用流程。 +1. **ABox**:通过 ABSDK 0630 连接当前局域网中的智能家居网关; +2. **本地体验**:不访问网络或 ABSDK,通过本地模拟数据直接进入完整应用流程。 + +首版不提供自有兼容后端入口,也不再规划兼容服务端。 约束: -- 任一时刻只使用一个提供方; -- 不要求同时连接两个后端; -- ABox 与自有后端的账号、会话和数据不互相绑定; -- 每个连接配置拥有独立的设备、遥控器、情景和凭据空间; +- 任一时刻只启用一个提供方; +- 切换提供方前必须退出当前会话; +- 每个提供方的会话、设备、遥控器、情景和凭据空间互不混用; - 本地体验不启动 HTTP 服务,也不调用 ABSDK。 ### 4.3 设备目录 -ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录采用: +ABSDK 0630 当前没有已确认的设备列表接口,也不提供设备目录。首版设备目录按提供方区分: + +- ABox:由设备目录模块负责的本地持久化目录(二维码批量导入,用户手工添加、编辑和删除); +- 本地体验:由模拟运行时内置的模拟设备目录。 + +共同约束: -- 二维码批量导入; -- 用户手工添加、编辑和删除; - 设备名称作为调用 ABSDK 的设备标识; - 设备类型首版只包含插座、温湿度和红外。 -不得假设存在尚未确认的新版 SDK 或设备发现接口。 +不得假设存在尚未确认的新版 SDK 或远程设备发现接口。 ### 4.4 插座 @@ -178,25 +184,15 @@ ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录 - 切换提供方前必须结束当前会话; - `docs/examples/` 和 `samples/abox-sdk-sample/` 只作为参考,不直接作为正式架构。 -### 5.3 自有兼容后端 +### 5.3 模拟运行时 -自有后端目标是兼容 ABSDK 0630 的必要服务端行为。 +本地体验由生产模块 `:runtime:simulation` 提供确定性模拟实现: -暂定方向: +- 不依赖网络、ABSDK 或 Android API; +- 内置固定的模拟设备目录和设备状态; +- 与 ABox 共用同一套领域端口,UI 不感知差异。 -- Kotlin 和 Ktor; -- 旧协议只存在于兼容适配边界; -- 内部使用类型化领域模型; -- 密码使用强哈希保存; -- 酒店与房间作为数据隔离边界; -- 管理操作通过内置 CLI 完成; -- 提供模拟设备驱动; -- 为未来真实设备驱动预留统一接口; -- SQLite 用于轻量部署; -- PostgreSQL 用于较大规模部署; -- Docker 作为标准交付方式。 - -具体协议、签名和连接方式必须先验证,不在本文档中提前固化。 +测试专用的脚本化 Fake 保留在 `:testing:fakes`,不进入生产依赖。 --- @@ -222,21 +218,20 @@ ABSDK 0630 当前没有已确认的设备列表接口,因此首版设备目录 - 建立 Compose、Navigation、Hilt、Room 和 DataStore 基础; - 建立公共设计系统基础; - 接入和封装 ABSDK 0630; -- 建立统一领域接口、错误模型和 Fake 实现; -- 实现 ABox、自有兼容后端和本地体验三个入口; +- 建立统一领域接口、错误模型和测试 Fake; +- 实现 ABox 和本地体验两个入口,以及按活动提供方的设备操作路由; - 实现记住用户名、自动登录、Keystore 凭据保存和退出; - 处理 Token 失效与重新登录; -- 研究 SDK 已确认行为和协议边界; -- 建立自有兼容后端基础、测试宿主、CLI 和 Docker 基础。 +- 研究 SDK 已确认行为和协议边界。 主要交付: ```text 工程基础 -SDK 适配层 +SDK 适配层(AbsdkBridge) +本地体验模拟运行时 登录与提供方选择 -公共领域接口与 Fake -兼容后端基础 +公共领域接口与测试 Fake ``` ### 6.2 alZerNest:设备列表导航 @@ -402,8 +397,7 @@ tzh: 退出条件: -- Android App 可以使用 Fake 完成主要页面导航; -- 服务端骨架可以独立启动; +- Android App 可以使用 Fake 或本地体验完成主要页面导航; - 所有人拥有可独立测试的模块。 ### 阶段 2:完整功能实现 @@ -412,8 +406,7 @@ tzh: - 完成登录和提供方隔离; - 完成设备目录导入和维护; - 完成插座、温湿度和红外; -- 完成情景模式; -- 完成自有兼容后端的已确认协议范围。 +- 完成情景模式。 退出条件: @@ -423,8 +416,7 @@ tzh: ### 阶段 3:真实集成 -- 连接真实 ABox 环境; -- 连接自有兼容后端; +- 在网关局域网内连接真实 ABox 环境; - 使用真实插座、温湿度和红外设备; - 验证 Token 失效、设备离线和错误码; - 验证手机和平板布局。 @@ -432,7 +424,7 @@ tzh: 退出条件: - 三类真实硬件核心流程通过; -- ABox 与自有后端连接行为明确; +- ABox 局域网连接行为明确; - 已知限制完整记录。 ### 阶段 4:发布验收 @@ -459,7 +451,6 @@ tzh: feat/project-foundation feat/absdk-bridge feat/provider-login -feat/compatible-server ``` ### alZerNest @@ -530,17 +521,18 @@ feat/app-integration - 领域模型和执行逻辑单元测试; - SDK 返回和错误映射测试; +- 提供方选择、会话协调和设备操作路由测试; +- 模拟运行时行为测试; - ViewModel 状态测试; - Room 持久化测试; - Compose 核心页面测试; -- 情景顺序执行和失败继续测试; -- 自有兼容后端认证、存储和模拟驱动测试; -- Docker 和 CLI 验证。 +- 情景顺序执行和失败继续测试。 ### 11.2 手工验收 -- 登录、自动登录和退出; -- 三种提供方入口; +- ABox 登录、自动登录和退出; +- 本地体验直接进入应用; +- 两种提供方入口; - 数据和凭据隔离; - 设备目录导入和维护; - 插座状态和控制; @@ -573,11 +565,11 @@ Runner 就绪后,再将相同命令配置为受保护 `main` 的必要检查 - ABSDK 0630 的完整初始化流程; - `setHostInfo` 的正式业务含义; - ABox 官方部署所需参数; -- 自有后端与原始 JAR 的最终连接方式; - SDK 的完整签名算法和参数规则; - SDK 线程安全与会话清理行为; - 真实测试账号、设备名称和硬件环境; -- 自有后端连接真实设备所使用的网关协议; +- 授权凭据下完整的 ABox 登录与设备操作冒烟验证; +- JAR 硬编码域名与局域网网关地址之间的映射机制(详见 `docs/protocol/absdk-0630.md`); - ABSDK 二进制的生产分发许可。 上述事项必须通过厂商资料、授权分析、实验或真实环境验证后,再更新本文档或对应技术文档。 diff --git a/docs/protocol/absdk-0630.md b/docs/protocol/absdk-0630.md index 6ae46c1..2d97349 100644 --- a/docs/protocol/absdk-0630.md +++ b/docs/protocol/absdk-0630.md @@ -11,7 +11,7 @@ - 历史 Sample:`samples/abox-sdk-sample/login-mac/`; - 公开 API 使用 `javap` 验证,协议细节通过授权字节码分析验证。 -本文不是自有兼容后端的最终规范。真实连接参数、部署行为和硬件结果仍须通过授权测试环境验证。 +本文只记录厂商 JAR 与局域网环境的已确认行为,不定义任何兼容服务端规范。尚未验证的内容见文末清单,不得作为产品实现承诺。 ## 2. JAR 公开类型 @@ -108,7 +108,7 @@ commons-logging-1.1.3.jar JAR 内含宽松的 TLS 信任实现。正式产品不得把这一历史传输行为扩展为 App 其他网络请求的安全策略,也不得记录密码、Token、API Key 或敏感红外载荷。 -真实 Bridge 工厂使用 `@CleartextAbsdkTransport` 作为编译期 opt-in 门禁,并且当前没有接入 Android App。该标记只防止误接入,不会为链路提供加密。生产集成前必须确定并批准 HTTPS、受控加密代理或其他能够提供机密性与完整性的传输控制;在此之前不得启用真实 ABox 入口。 +App 组合根绑定真实 Bridge 时使用 `@CleartextAbsdkTransport` 作为编译期 opt-in 门禁,并声明 `INTERNET` 权限与明文传输许可。该标记只防止误接入,不会为链路提供加密;允许明文是项目所有者对当前局域网集成约束的明确风险接受,不代表传输具备机密性或完整性。 ## 6. `setHostInfo` 的已确认行为 @@ -127,7 +127,7 @@ roomNo apikey ``` -其中第一个参数只写入请求字段 `url`,不会修改 SDK 的硬编码 HTTP 基础地址。因此原始 JAR 不能通过该公开 API 直接切换到任意自有后端。 +其中第一个参数只写入请求字段 `url`,不会修改 SDK 的硬编码 HTTP 基础地址(见第 5 节)。因此该公开 API 不能用于切换请求目标。 仍未确认: @@ -136,9 +136,23 @@ apikey - hotel、room 和 API Key 的正式取值; - 初始化失败对后续登录和设备操作的影响。 -所以 `feat/absdk-bridge` 不实现或调用 `setHostInfo`。 +Bridge 不实现或调用 `setHostInfo`。 -## 7. MVP 端点与数据 +## 7. 局域网网关观测证据(2026-08-24) + +以下为 2026-08-24 在网关所在局域网内获得的观测结果,仅描述当天环境,不构成通用承诺: + +- 网关地址 `192.168.100.1` 在端口 80 和 443 上可达; +- 未携带 Token 直接 `POST http://192.168.100.1/abox/index.php/abjhsdk/user/validate` 返回 `code=00000`、`status=0`; +- `feat/moler-infrared-control` 分支的真实硬件测试表明:当前网络环境下,厂商 JAR 能够到达真实网关并完成红外相关操作。 + +同时必须保留以下边界: + +- JAR 仍然硬编码基础地址 `a-box.com.cn`;它在该环境中被映射到局域网网关的机制属于外部环境依赖,机制未知,且不受 `setHostInfo` 影响; +- 尚未建立 JAR 可用的通用基础地址或主机配置能力,也没有完成携带授权凭据的登录冒烟验证; +- 观测结果不能外推为“任意网络下均可连通”,后续环境变化须重新验证。 + +## 8. MVP 端点与数据 | SDK 方法 | 端点 | 关键参数或返回 | |---|---|---| @@ -154,7 +168,7 @@ apikey `downloadIrCodes(List)` 并不是单次批量 HTTP 请求。JAR 按输入顺序逐项调用下载端点,并返回每项 `status`。只要有任一项失败,总体代码为 `20204`,同时 `dicDatas["ret"]` 仍包含逐项结果。 -## 8. 请求签名 +## 9. 请求签名 字节码显示 SDK 会: @@ -165,9 +179,9 @@ apikey 5. 按时间戳计算的位置插入时间片段并添加固定前缀; 6. 在请求的 `system` 数据中提交签名、时间戳和 Token。 -这些事实用于判断协议边界,不在本轮重新实现签名算法或对外宣称兼容性。正式兼容后端开始前,必须建立脱敏签名测试向量并复核每个字段和编码细节。 +这些事实用于判断协议边界,本轮不重新实现签名算法,也不对外宣称兼容性。任何需要复现该协议行为的工作开始前,必须先建立脱敏签名测试向量并复核每个字段和编码细节。 -## 9. 响应结构 +## 10. 响应结构 字节码解析的响应结构为: @@ -183,7 +197,7 @@ apikey 当前 Bridge 对成功数据进行防御性解析:缺失字段、未知状态或错误类型不会导致 `ClassCastException` 泄漏到调用方。 -## 10. 错误码来源 +## 11. 错误码来源 完整错误码表来自厂商文档和历史 `AboxConstants.java`。Bridge 保留原始 `externalCode`,并映射为以下领域分类: @@ -199,7 +213,7 @@ apikey 错误 `msg` 只作为诊断 detail,不直接作为最终 UI 文案。 -## 11. 历史 Sample 观察 +## 12. 历史 Sample 观察 历史 Sample: @@ -212,14 +226,15 @@ apikey 正式实现只复用已确认的 SDK 调用事实,不复制 AsyncTask、明文凭据或全局明文网络配置。 -## 12. 仍待确认 +## 13. 仍待确认 - ABox 官方初始化的完整流程和参数; - 文档所称 Token 两小时有效期在真实环境中的精确表现; - Token 失效时各设备接口实际返回的代码; - SDK 的厂商线程安全保证; -- 真实账号、设备名称和硬件环境; -- 原始 JAR 指向自有兼容后端的最终授权方案; +- 授权凭据下完整的 ABox 登录与设备操作冒烟验证; +- JAR 硬编码域名与局域网网关地址之间的映射机制(第 7 节); +- 网络环境变化(不同网段、DHCP、路由策略)下连通性是否保持; - ABSDK 和附属 JAR 的生产分发许可; - 所有端点的完整表单编码和可重复签名测试向量。 diff --git a/docs/requirement_spec.md b/docs/requirement_spec.md index cad3b73..5aff46d 100644 --- a/docs/requirement_spec.md +++ b/docs/requirement_spec.md @@ -1,8 +1,13 @@ ## 需求规格 -**登录** -1. 记住已登录用户信息功能:记住我 -2. 自动登录:选中自动登录之后 +**连接方式** +1. ABox:通过 ABSDK 连接当前局域网中的智能家居网关,登录成功后进入应用。 +2. 本地体验:不访问网络或 ABSDK,直接建立模拟会话并进入应用。 +3. 首版不提供自有兼容后端入口。 + +**ABox 登录** +1. 记住已登录用户信息功能:记住我(仅适用于 ABox) +2. 自动登录:选中自动登录之后(仅适用于 ABox) **设备列表** 1. 设备列表:显示设备名称(插座、温湿度、红外) diff --git a/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md b/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md index 91d68b5..a7b848d 100644 --- a/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md +++ b/docs/superpowers/specs/2026-08-24-lan-absdk-and-local-experience-design.md @@ -152,16 +152,7 @@ No feature UI selects Real or Fake directly. ## Provider Availability -`ProviderAvailability` is simplified to the two supported options. Both are enabled in all build types: - -```kotlin -data class ProviderAvailability( - val abox: Availability = Availability.Enabled, - val local: Availability = Availability.Enabled, -) -``` - -`Availability.Simulation` and compatible-backend-specific disabled reasons are removed unless another current UI requirement uses them. +As implemented, `ProviderAvailability` was deleted entirely: both remaining options are always enabled, so an availability model with disabled states has no caller. The provider selection screen lists exactly ABox and local experience in every build type, with no per-provider availability state or disabled reason. ## Android Transport -- 2.54.0 From ae5bc871e84f5af7640911121b90718c61bfa52a Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Mon, 24 Aug 2026 23:24:26 +0800 Subject: [PATCH 26/26] fix: address final review findings for provider migration - Add SessionCoordinator regression test covering switch from an authenticated ABox profile to Local experience: remote session is logged out and cleared, only Local is the active/observable session, credentials are cleared, auto-login disabled, and gateway state stays isolated (verified to fail when switch cleanup is removed). - Replace if/else provider dispatch with exhaustive when over ProviderType in all three routing gateways so future enum additions fail compilation. - Silence the known ExperimentalCoroutinesApi opt-in warning in ProviderSelectionViewModelTest with a class-level @OptIn. --- .../manager/di/ProviderRoutingGateways.kt | 18 ++++++--- .../ProviderSelectionViewModelTest.kt | 2 + .../testing/fakes/SessionCoordinatorTest.kt | 38 ++++++++++++++++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt index 5d019c7..b5576c0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt @@ -71,8 +71,10 @@ internal class RoutingSocketGateway( is DomainResult.Success -> delegate(provider.value).setPower(profileId, deviceName, powerState) } - private fun delegate(type: ProviderType): SocketGateway = - if (type == ProviderType.ABOX) real else simulation + private fun delegate(type: ProviderType): SocketGateway = when (type) { + ProviderType.ABOX -> real + ProviderType.LOCAL_EXPERIENCE -> simulation + } } /** Routes temperature/humidity operations by active provider, mirroring [RoutingSocketGateway]. */ @@ -91,8 +93,10 @@ internal class RoutingTemperatureHumidityGateway( is DomainResult.Success -> delegate(provider.value).getReading(profileId, deviceName) } - private fun delegate(type: ProviderType): TemperatureHumidityGateway = - if (type == ProviderType.ABOX) real else simulation + private fun delegate(type: ProviderType): TemperatureHumidityGateway = when (type) { + ProviderType.ABOX -> real + ProviderType.LOCAL_EXPERIENCE -> simulation + } } /** Routes infrared operations by active provider, mirroring [RoutingSocketGateway]. */ @@ -129,6 +133,8 @@ internal class RoutingInfraredGateway( is DomainResult.Success -> delegate(provider.value).downloadCodes(profileId, codes) } - private fun delegate(type: ProviderType): InfraredGateway = - if (type == ProviderType.ABOX) real else simulation + private fun delegate(type: ProviderType): InfraredGateway = when (type) { + ProviderType.ABOX -> real + ProviderType.LOCAL_EXPERIENCE -> simulation + } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt index ca3ed31..4aeca06 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt @@ -7,6 +7,7 @@ import com.flagship.abox.manager.testing.fakes.FakeCredentialStore import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore import com.flagship.abox.manager.testing.fakes.FakeSessionGateway +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -14,6 +15,7 @@ import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ProviderSelectionViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt index 29ea550..43f291a 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt @@ -153,11 +153,47 @@ class SessionCoordinatorTest { assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) } + @Test + fun switchingFromAuthenticatedAboxToLocalClearsRemoteSessionAndIsolatesState() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + val loginResult = fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + val switchResult = fixture.coordinator.selectProvider(localProfile) + + assertTrue(loginResult is DomainResult.Success) + assertTrue(switchResult is DomainResult.Success) + assertEquals( + localProfile.id, + (switchResult as DomainResult.Success).value?.profileId, + ) + assertEquals(localProfile.id, fixture.profiles.activeProfile()?.id) + assertEquals( + localProfile.id, + fixture.coordinator.observeActiveSession().first()?.profileId, + ) + assertNull(fixture.remote.observeSession(remoteProfile.id).first()) + assertNull(fixture.remote.observeSession(localProfile.id).first()) + assertEquals( + localProfile.id, + fixture.local.observeSession(localProfile.id).first()?.profileId, + ) + assertNull( + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) + } + private class Fixture { val profiles = FakeProviderProfileStore() val credentials = FakeCredentialStore() val remote = FakeSessionGateway() - private val local = FakeLocalSessionGateway() + val local = FakeLocalSessionGateway() val coordinator = SessionCoordinator(profiles, credentials, remote, local) } -- 2.54.0