feat: add provider login flow (#4)
## Summary - replace the former three-provider model with two pre-login choices: - **ABox**: authenticate through the vendor ABSDK on the current local network - **Local experience**: establish a production simulation session without credentials, network access, or ABSDK calls - remove the compatible-backend product/server scope and safely ignore legacy persisted compatible-provider values - bind one process-wide `AbsdkBridge` for ABox session, socket, temperature/humidity, and infrared operations - add production `:runtime:simulation` implementations and route device operations by the active provider - retain provider-scoped username preferences, Keystore-encrypted automatic-login credentials, token recovery, logout, and session isolation - add regression coverage for authentication gating, profile/session alignment, provider switching cleanup, routing, and simulation behavior - align requirement, plan, architecture, and protocol documentation with LAN ABSDK access ## Verification ```text ./gradlew check testDebugUnitTest test BUILD SUCCESSFUL (220 actionable tasks) ./gradlew :android:app:assembleDebug :android:app:assembleRelease BUILD SUCCESSFUL (217 actionable tasks) ./gradlew :android:app:compileDebugAndroidTestKotlin BUILD SUCCESSFUL git diff --check passed ``` Emulator smoke validation: - provider selection displays ABox and Local experience only - Local experience enters the authenticated route without credentials - Local experience also enters while emulator Wi-Fi/data are disabled - Local Sign out and Switch provider return to provider selection - ABox opens the credential form; empty submission stays on login with the required-fields error ## Known constraints and accepted risks - The vendor JAR uses cleartext HTTP and hardcodes `a-box.com.cn`; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision. `@CleartextAbsdkTransport`, `usesCleartextTraffic`, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity. - Real authenticated ABox and read-only hardware smoke validation is deferred because the gateway is currently unavailable locally. This PR does not claim that current-environment validation completed. - The mapping from the vendor JAR's hardcoded hostname to the LAN gateway remains an external network/deployment prerequisite. The ABSDK JAR and bridge path remain identical to the previously hardware-validated `feat/moler-infrared-control` branch. - `FakeDeviceCatalog` remains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation. Reviewed-on: #4 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #4.
This commit is contained in:
@@ -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 {
|
||||
@@ -54,12 +63,18 @@ kapt {
|
||||
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))
|
||||
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 +83,11 @@ dependencies {
|
||||
kapt(libs.hilt.compiler)
|
||||
|
||||
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)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
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<ComponentActivity>()
|
||||
|
||||
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 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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.flagship.abox.manager.ui.provider
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
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<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() {
|
||||
val abox = ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox")
|
||||
val local = ProviderProfile(
|
||||
ProviderProfileId("local"),
|
||||
ProviderType.LOCAL_EXPERIENCE,
|
||||
"Local",
|
||||
)
|
||||
var selected: ProviderProfile? = null
|
||||
composeRule.setContent {
|
||||
ABoxTheme {
|
||||
ProviderSelectionScreen(
|
||||
state = ProviderSelectionUiState(listOf(abox, local)),
|
||||
onSelect = { selected = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("ABox").assertIsEnabled()
|
||||
composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick()
|
||||
assertEquals(local, selected)
|
||||
composeRule.onNodeWithText("Compatible backend").assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -1,15 +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.SessionGateway
|
||||
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
|
||||
@@ -19,24 +11,12 @@ import javax.inject.Singleton
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object FoundationModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionGateway(): SessionGateway = 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()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:name=".ABoxManagerApplication"
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.ABoxManager">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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<ProviderType> = 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<SocketStatus> = 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<SocketStatus> = 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 = when (type) {
|
||||
ProviderType.ABOX -> real
|
||||
ProviderType.LOCAL_EXPERIENCE -> 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<TemperatureHumidityReading> = 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 = when (type) {
|
||||
ProviderType.ABOX -> real
|
||||
ProviderType.LOCAL_EXPERIENCE -> 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<Unit> = 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<Unit> = 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<InfraredCode>,
|
||||
): DomainResult<List<InfraredCodeDownloadResult>> = 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 = when (type) {
|
||||
ProviderType.ABOX -> real
|
||||
ProviderType.LOCAL_EXPERIENCE -> simulation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
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.profile.ProviderProfileStoreImpl
|
||||
import com.flagship.abox.manager.session.profile.sessionDataStore
|
||||
import com.flagship.abox.manager.simulation.SimulationSessionGateway
|
||||
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<Preferences> = context.sessionDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideProviderProfileStore(
|
||||
dataStore: DataStore<Preferences>,
|
||||
): ProviderProfileStore = ProviderProfileStoreImpl(dataStore)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialCipher(): CredentialCipher = AndroidKeystoreCredentialCipher()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialStore(
|
||||
dataStore: DataStore<Preferences>,
|
||||
cipher: CredentialCipher,
|
||||
): CredentialStore = KeystoreCredentialStore(dataStore, cipher)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@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 provideSessionCoordinator(
|
||||
profileStore: ProviderProfileStore,
|
||||
credentialStore: CredentialStore,
|
||||
remoteGateway: SessionGateway,
|
||||
localGateway: LocalSessionGateway,
|
||||
): SessionCoordinator = SessionCoordinator(
|
||||
profileStore,
|
||||
credentialStore,
|
||||
remoteGateway,
|
||||
localGateway,
|
||||
)
|
||||
}
|
||||
@@ -1,94 +1,77 @@
|
||||
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.runtime.LaunchedEffect
|
||||
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.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
|
||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||
|
||||
@Composable
|
||||
fun ABoxManagerApp() {
|
||||
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()) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = FOUNDATION_ROUTE,
|
||||
) {
|
||||
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),
|
||||
)
|
||||
when (val state = sessionState) {
|
||||
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,
|
||||
viewModel = loginViewModel,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
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}"
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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
|
||||
.widthIn(max = 520.dp)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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.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
|
||||
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
|
||||
val errorText = state.errorMessage?.let { stringResource(it.stringResource()) }
|
||||
?: actionError
|
||||
?: bootstrapError
|
||||
LoginScreen(
|
||||
profile = profile,
|
||||
state = state,
|
||||
errorText = errorText,
|
||||
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,
|
||||
errorText: String? = null,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onRememberUsernameChange: (Boolean) -> Unit,
|
||||
onAutoLoginChange: (Boolean) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
onSwitchProvider: () -> Unit,
|
||||
switchingProvider: Boolean = false,
|
||||
) {
|
||||
val formEnabled = !state.isSubmitting && !state.isLoadingPreferences
|
||||
val navigationEnabled = formEnabled && !switchingProvider
|
||||
BackHandler {
|
||||
if (navigationEnabled) 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
|
||||
.widthIn(max = 520.dp)
|
||||
.fillMaxWidth()
|
||||
.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 = formEnabled,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
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(),
|
||||
)
|
||||
ToggleRow(
|
||||
label = stringResource(R.string.login_remember_username),
|
||||
checked = state.rememberUsername,
|
||||
enabled = formEnabled,
|
||||
onCheckedChange = onRememberUsernameChange,
|
||||
)
|
||||
ToggleRow(
|
||||
label = stringResource(R.string.login_auto_login),
|
||||
checked = state.autoLogin,
|
||||
enabled = formEnabled,
|
||||
onCheckedChange = onAutoLoginChange,
|
||||
)
|
||||
errorText?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
ABoxButton(
|
||||
onClick = onSubmit,
|
||||
enabled = formEnabled && !switchingProvider,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isSubmitting || state.isLoadingPreferences || 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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
ProviderType.ABOX -> R.string.provider_abox_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.LOCAL_EXPERIENCE -> R.string.provider_local_description
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
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.CancellationException
|
||||
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<LoginUiState> = _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
|
||||
private var loadedProfileId: com.flagship.abox.manager.domain.ProviderProfileId? = null
|
||||
|
||||
fun loadProfile(value: ProviderProfile) {
|
||||
if (loadedProfileId == value.id ||
|
||||
profile?.id == value.id &&
|
||||
(loadingJob?.isActive == true || submitJob?.isActive == true)
|
||||
) return
|
||||
loadingJob?.cancel()
|
||||
profile = value
|
||||
usernameEdited = false
|
||||
rememberUsernameEdited = false
|
||||
autoLoginEdited = false
|
||||
_uiState.value = LoginUiState(isLoadingPreferences = true)
|
||||
loadingJob = viewModelScope.launch {
|
||||
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,
|
||||
)
|
||||
loadedProfileId = value.id
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (_: Exception) {
|
||||
if (profile?.id == value.id) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoadingPreferences = false,
|
||||
errorMessage = LoginErrorMessage.PREFERENCES_UNAVAILABLE,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
loadingJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSensitiveState() {
|
||||
loadingJob?.cancel()
|
||||
loadingJob = null
|
||||
if (submitJob?.isActive == true) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
password = "",
|
||||
errorMessage = null,
|
||||
isSubmitting = true,
|
||||
)
|
||||
return
|
||||
}
|
||||
profile = null
|
||||
loadedProfileId = 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)
|
||||
}
|
||||
|
||||
fun setPassword(value: String) {
|
||||
_uiState.value = _uiState.value.copy(password = value, errorMessage = null)
|
||||
}
|
||||
|
||||
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() {
|
||||
if (submitJob?.isActive == true) return
|
||||
val activeProfile = profile ?: return
|
||||
val state = _uiState.value
|
||||
if (state.username.isBlank() ||
|
||||
state.password.isEmpty() ||
|
||||
state.isSubmitting ||
|
||||
state.isLoadingPreferences
|
||||
) {
|
||||
_uiState.value = state.copy(errorMessage = LoginErrorMessage.REQUIRED_FIELDS)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = "",
|
||||
val rememberUsername: Boolean = false,
|
||||
val autoLogin: Boolean = false,
|
||||
val isLoadingPreferences: Boolean = false,
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: LoginErrorMessage? = null,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
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.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.ProviderProfile
|
||||
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: (ProviderProfile) -> 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
|
||||
.widthIn(max = 640.dp)
|
||||
.fillMaxWidth()
|
||||
.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 { profile ->
|
||||
ProviderOptionCard(
|
||||
profile = profile,
|
||||
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(
|
||||
profile: ProviderProfile,
|
||||
selecting: Boolean,
|
||||
onSelect: (ProviderProfile) -> Unit,
|
||||
) {
|
||||
val displayName = providerName(profile.type)
|
||||
val description = providerDescription(profile.type)
|
||||
|
||||
OutlinedCard(
|
||||
onClick = { onSelect(profile) },
|
||||
enabled = !selecting,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.outlinedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
contentDescription = displayName
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = providerMark(profile.type),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"›",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun providerName(type: ProviderType): String = stringResource(
|
||||
when (type) {
|
||||
ProviderType.ABOX -> R.string.provider_abox_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.LOCAL_EXPERIENCE -> R.string.provider_local_description
|
||||
},
|
||||
)
|
||||
|
||||
private fun providerMark(type: ProviderType): String = when (type) {
|
||||
ProviderType.ABOX -> "A"
|
||||
ProviderType.LOCAL_EXPERIENCE -> "L"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
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
|
||||
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,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ProviderSelectionUiState(providers = PROFILES))
|
||||
val uiState: StateFlow<ProviderSelectionUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun select(profile: ProviderProfile) {
|
||||
if (_uiState.value.isSelecting) return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null)
|
||||
val result = coordinator.selectProvider(profile)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isSelecting = false,
|
||||
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PROFILES = listOf(
|
||||
ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"),
|
||||
ProviderProfile(
|
||||
ProviderProfileId("local-experience"),
|
||||
ProviderType.LOCAL_EXPERIENCE,
|
||||
"Local experience",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ProviderSelectionUiState(
|
||||
val providers: List<ProviderProfile>,
|
||||
val isSelecting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
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.ProviderProfileId
|
||||
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.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class AppSessionViewModel @Inject constructor(
|
||||
private val coordinator: SessionCoordinator,
|
||||
private val profileStore: ProviderProfileStore,
|
||||
) : ViewModel() {
|
||||
private val bootstrapResult = MutableStateFlow<ProviderBootstrapResult?>(null)
|
||||
|
||||
val uiState: StateFlow<AppSessionUiState> = combine(
|
||||
bootstrapResult,
|
||||
profileStore.observeActiveProfile(),
|
||||
coordinator.observeActiveSession(),
|
||||
) { 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),
|
||||
initialValue = AppSessionUiState.Loading,
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val activeProfile = profileStore.activeProfile()
|
||||
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.Failure(
|
||||
com.flagship.abox.manager.domain.DomainError.Unknown(
|
||||
detail = error.message ?: "Unable to load provider settings",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
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(
|
||||
profile: ProviderProfile?,
|
||||
session: Session?,
|
||||
bootstrapError: String?,
|
||||
): AppSessionUiState = when {
|
||||
profile == null -> AppSessionUiState.ProviderSelection
|
||||
session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile)
|
||||
else -> AppSessionUiState.Login(profile, bootstrapError)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProviderBootstrapResult(
|
||||
val profileId: ProviderProfileId?,
|
||||
val result: DomainResult<Session?>,
|
||||
) {
|
||||
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
|
||||
data class Login(
|
||||
val profile: ProviderProfile,
|
||||
val bootstrapError: String? = null,
|
||||
) : AppSessionUiState
|
||||
data class Authenticated(val profile: ProviderProfile) : AppSessionUiState
|
||||
}
|
||||
+44
@@ -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<SessionActionsUiState> = _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<Unit>) {
|
||||
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,
|
||||
)
|
||||
@@ -1,8 +1,27 @@
|
||||
<resources>
|
||||
<string name="app_name">ABox Manager</string>
|
||||
<string name="foundation_title">工程基础已就绪</string>
|
||||
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string>
|
||||
<string name="continue_action">继续</string>
|
||||
<string name="placeholder_title">功能模块可以开始开发</string>
|
||||
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
|
||||
<string name="provider_selection_title">选择提供方</string>
|
||||
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
|
||||
<string name="provider_abox_name">ABox</string>
|
||||
<string name="provider_abox_description">通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。</string>
|
||||
<string name="provider_local_name">本地体验</string>
|
||||
<string name="provider_local_description">无需网络,通过本地模拟设备体验应用。</string>
|
||||
<string name="login_title">登录 %1$s</string>
|
||||
<string name="login_provider_context">当前提供方:%1$s</string>
|
||||
<string name="login_back_to_providers">返回提供方选择</string>
|
||||
<string name="login_username">用户名</string>
|
||||
<string name="login_password">密码</string>
|
||||
<string name="login_remember_username">记住用户名</string>
|
||||
<string name="login_auto_login">自动登录</string>
|
||||
<string name="login_error_required">请输入用户名和密码。</string>
|
||||
<string name="login_error_invalid_credentials">用户名或密码不正确。</string>
|
||||
<string name="login_error_network">网络不可用,请重试。</string>
|
||||
<string name="login_error_session_expired">会话已失效,请重新登录。</string>
|
||||
<string name="login_error_preferences">无法加载已保存的登录设置。</string>
|
||||
<string name="login_error_failed">登录失败,请重试。</string>
|
||||
<string name="action_login">登录</string>
|
||||
<string name="action_logout">退出登录</string>
|
||||
<string name="action_switch_provider">切换提供方</string>
|
||||
<string name="authenticated_title">已连接到 %1$s</string>
|
||||
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
<resources>
|
||||
<string name="app_name">ABox Manager</string>
|
||||
<string name="foundation_title">Project foundation ready</string>
|
||||
<string name="foundation_description">The shared domain contracts and local development providers are available.</string>
|
||||
<string name="continue_action">Continue</string>
|
||||
<string name="placeholder_title">Feature development can start</string>
|
||||
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
|
||||
<string name="provider_selection_title">Choose a provider</string>
|
||||
<string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
|
||||
<string name="provider_abox_name">ABox</string>
|
||||
<string name="provider_abox_description">Connect to the ABox smart-home gateway through ABSDK on the current local network.</string>
|
||||
<string name="provider_local_name">Local experience</string>
|
||||
<string name="provider_local_description">Explore the app with simulated local devices and no network connection.</string>
|
||||
<string name="login_title">Sign in to %1$s</string>
|
||||
<string name="login_provider_context">Selected provider: %1$s</string>
|
||||
<string name="login_back_to_providers">Back to providers</string>
|
||||
<string name="login_username">Username</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_remember_username">Remember username</string>
|
||||
<string name="login_auto_login">Sign in automatically</string>
|
||||
<string name="login_error_required">Username and password are required.</string>
|
||||
<string name="login_error_invalid_credentials">Invalid username or password.</string>
|
||||
<string name="login_error_network">Network unavailable. Try again.</string>
|
||||
<string name="login_error_session_expired">Session expired. Sign in again.</string>
|
||||
<string name="login_error_preferences">Unable to load saved login settings.</string>
|
||||
<string name="login_error_failed">Sign-in failed. Try again.</string>
|
||||
<string name="action_login">Sign in</string>
|
||||
<string name="action_logout">Sign out</string>
|
||||
<string name="action_switch_provider">Switch provider</string>
|
||||
<string name="authenticated_title">Connected to %1$s</string>
|
||||
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
|
||||
</resources>
|
||||
|
||||
+230
@@ -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<SocketStatus> {
|
||||
calls += 1
|
||||
return DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF))
|
||||
}
|
||||
|
||||
override suspend fun setPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
powerState: SocketPowerState,
|
||||
): DomainResult<SocketStatus> {
|
||||
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<TemperatureHumidityReading> {
|
||||
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<Unit> {
|
||||
calls += 1
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun learnKey(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
): DomainResult<Unit> {
|
||||
calls += 1
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun downloadCodes(
|
||||
profileId: ProviderProfileId,
|
||||
codes: List<InfraredCode>,
|
||||
): DomainResult<List<InfraredCodeDownloadResult>> {
|
||||
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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
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.ExperimentalCoroutinesApi
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.flagship.abox.manager.ui.session
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
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.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.Session
|
||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||
import com.flagship.abox.manager.domain.SessionGateway
|
||||
import com.flagship.abox.manager.domain.Username
|
||||
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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression tests for real ABox login gating: the authenticated UI may appear only when the
|
||||
* active provider profile owns a successful session, and a failed ABox login stays on Login.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppSessionViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val profiles = FakeProviderProfileStore()
|
||||
private val remote = FakeSessionGateway()
|
||||
private val local = FakeLocalSessionGateway()
|
||||
private val credentials = LoginCredentials(
|
||||
username = Username("user"),
|
||||
password = Password("password"),
|
||||
)
|
||||
|
||||
private fun newCoordinator(remoteGateway: SessionGateway = remote) = SessionCoordinator(
|
||||
profileStore = profiles,
|
||||
credentialStore = FakeCredentialStore(),
|
||||
remoteGateway = remoteGateway,
|
||||
localGateway = local,
|
||||
)
|
||||
|
||||
private suspend fun saveAndActivateAboxProfile(id: ProviderProfileId) {
|
||||
profiles.saveProfile(ProviderProfile(id, ProviderType.ABOX, id.value))
|
||||
profiles.setActiveProfile(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedAboxLoginLeavesUiOnLogin() = runTest {
|
||||
saveAndActivateAboxProfile(aboxProfileId)
|
||||
remote.failLogin(aboxProfileId, DomainError.Authentication())
|
||||
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()
|
||||
|
||||
assertTrue(viewModel.uiState.value is AppSessionUiState.Login)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionForDifferentProfileCannotAuthenticateActiveProfile() = runTest {
|
||||
profiles.saveProfile(ProviderProfile(otherProfileId, ProviderType.ABOX, otherProfileId.value))
|
||||
remote.login(otherProfileId, credentials)
|
||||
saveAndActivateAboxProfile(aboxProfileId)
|
||||
|
||||
// The gateway exposes the other profile's session no matter which profile is observed.
|
||||
val crossProfileGateway = object : SessionGateway by remote {
|
||||
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> =
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
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))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun rejectsTruncatedPayloadBeforeReadingHeader() {
|
||||
AndroidKeystoreCredentialCipher(
|
||||
keyAlias = "abox_manager_test_${System.nanoTime()}",
|
||||
).decrypt(ProviderProfileId("profile"), byteArrayOf(1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
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 {
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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 }
|
||||
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 HEADER_SIZE = 2
|
||||
private const val VERSION: Byte = 1
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
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
|
||||
|
||||
fun resetPermanentlyInvalidatedKey()
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
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 android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import java.util.Base64
|
||||
import javax.crypto.AEADBadTagException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class KeystoreCredentialStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val cipher: CredentialCipher,
|
||||
) : CredentialStore {
|
||||
override suspend fun saveCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Unit> = operation {
|
||||
val encrypted = cipher.encrypt(profileId, encode(credentials))
|
||||
dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) }
|
||||
}
|
||||
|
||||
override suspend fun loadCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
): DomainResult<LoginCredentials?> {
|
||||
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: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (error.isPermanentCredentialFailure()) {
|
||||
try {
|
||||
if (error.hasPermanentlyInvalidatedKey()) {
|
||||
cipher.resetPermanentlyInvalidatedKey()
|
||||
}
|
||||
dataStore.edit { it.remove(credentialKey(profileId)) }
|
||||
DomainResult.Success(null)
|
||||
} catch (cleanupError: CancellationException) {
|
||||
throw cleanupError
|
||||
} 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> = operation {
|
||||
dataStore.edit { it.remove(credentialKey(profileId)) }
|
||||
}
|
||||
|
||||
private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->
|
||||
DataOutputStream(output).use { data ->
|
||||
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()
|
||||
}
|
||||
|
||||
private fun decode(value: ByteArray): LoginCredentials = DataInputStream(
|
||||
ByteArrayInputStream(value),
|
||||
).use { data ->
|
||||
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))
|
||||
}
|
||||
|
||||
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
|
||||
block()
|
||||
DomainResult.Success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(
|
||||
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
|
||||
)
|
||||
}
|
||||
|
||||
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.hasPermanentlyInvalidatedKey(): Boolean =
|
||||
this is KeyPermanentlyInvalidatedException || cause?.hasPermanentlyInvalidatedKey() == true
|
||||
|
||||
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
|
||||
is AEADBadTagException,
|
||||
is KeyPermanentlyInvalidatedException,
|
||||
is IllegalArgumentException,
|
||||
-> true
|
||||
|
||||
else -> cause?.isPermanentCredentialFailure() == true
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
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 java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
|
||||
class ProviderProfileStoreImpl(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : ProviderProfileStore {
|
||||
private val safeData: Flow<Preferences>
|
||||
get() = dataStore.data
|
||||
.retryWhen { cause, attempt ->
|
||||
val retry = cause is IOException && attempt < MAX_READ_RETRIES
|
||||
if (retry) delay(READ_RETRY_DELAY_MILLIS)
|
||||
retry
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
||||
|
||||
override fun observeActiveProfile(): Flow<ProviderProfile?> = safeData.map(::activeProfileFrom)
|
||||
|
||||
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
|
||||
safeData.map { preferencesFrom(it, profileId) }
|
||||
|
||||
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(safeData.first())
|
||||
|
||||
override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(safeData.first())
|
||||
|
||||
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
|
||||
preferencesFrom(safeData.first(), profileId)
|
||||
|
||||
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = 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<Unit> = 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<Unit> = 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<ProviderProfile> =
|
||||
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<Unit> = try {
|
||||
block()
|
||||
DomainResult.Success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(
|
||||
com.flagship.abox.manager.domain.DomainError.Unknown(
|
||||
detail = error.message ?: error::class.java.simpleName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_READ_RETRIES = 2L
|
||||
private const val READ_RETRY_DELAY_MILLIS = 100L
|
||||
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")
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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<Preferences> by preferencesDataStore(
|
||||
name = SESSION_DATA_STORE_NAME,
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||
)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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<Preferences> {
|
||||
override val data: Flow<Preferences> = 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
|
||||
|
||||
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@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() }
|
||||
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")
|
||||
}
|
||||
|
||||
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override fun resetPermanentlyInvalidatedKey() = Unit
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
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.ProviderProfileId
|
||||
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class ProviderProfileStoreFailureTest {
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun persistentIoFailureDoesNotEmitFalseEmptyState() = runTest {
|
||||
val store = ProviderProfileStoreImpl(FailingReadDataStore())
|
||||
var completed = false
|
||||
|
||||
backgroundScope.launch {
|
||||
runCatching { withTimeout(250) { store.activeProfile() } }
|
||||
completed = true
|
||||
}
|
||||
advanceTimeBy(200)
|
||||
|
||||
assertFalse(completed)
|
||||
}
|
||||
|
||||
private class FailingReadDataStore : DataStore<Preferences> {
|
||||
override val data: Flow<Preferences> = flow {
|
||||
throw IOException("disk unavailable")
|
||||
}
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences = transform(emptyPreferences())
|
||||
}
|
||||
}
|
||||
+34
@@ -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())
|
||||
}
|
||||
}
|
||||
+32
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user