commit 799cab96994574b09b0cdd10d3444088f51e71eb
parent d0cbcd614ab66f2afe01fbec96bc9380bc77bb46
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 21:08:22 +0000
application: keep degraded local state in the shell
- Classify ready and degraded snapshots through typed local usability.
- Admit only network and credential degradation without message matching.
- Keep active, read-only, chooser, and welcome states inside the shell.
- Preserve dashboard navigation while unusable degradation fails closed.
Diffstat:
4 files changed, 301 insertions(+), 22 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt
@@ -26,6 +26,7 @@ data class HarvestCircleShellState(
val identity: HarvestCirclePresenterState,
val buildInfo: BuildInfo,
val session: ShellSessionState = ShellSessionState(),
+ val localUsability: LocalUsability = deriveLocalUsability(identity.snapshot),
val root: ShellRoot = deriveShellRoot(identity, session),
val appearance: AppearanceState = AppearanceState(),
val overlays: OverlayState = OverlayState(),
@@ -101,7 +102,11 @@ class HarvestCircleShellPresenter(
derived.copy(navigation = root.navigation)
else -> derived
}
- copy(identity = identity, root = retained)
+ copy(
+ identity = identity,
+ localUsability = deriveLocalUsability(identity.snapshot),
+ root = retained,
+ )
}
}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt
@@ -13,6 +13,22 @@ data class ShellSessionState(
fun enterReadOnly(): ShellSessionState = copy(readOnly = true)
}
+enum class DegradationReason {
+ Network,
+ Credential,
+ NetworkAndCredential,
+}
+
+sealed interface LocalUsability {
+ data object Usable : LocalUsability
+
+ data class UsableDegraded(
+ val reason: DegradationReason,
+ ) : LocalUsability
+
+ data object Unusable : LocalUsability
+}
+
enum class ShellDestination { Today, Explore, Activity, Network, Settings, AddFarm }
data class ShellNavigationItem(
@@ -61,32 +77,72 @@ fun deriveShellRoot(
presenterState: HarvestCirclePresenterState,
session: ShellSessionState,
): ShellRoot {
+ if (
+ presenterState.route !in
+ setOf(
+ HarvestCircleRoute.IDENTITIES,
+ HarvestCircleRoute.ACTIVE_IDENTITY,
+ HarvestCircleRoute.DEGRADED,
+ )
+ ) {
+ return ShellRoot.LifecycleCanvas(presenterState.route)
+ }
+ if (deriveLocalUsability(presenterState.snapshot) is LocalUsability.Unusable) {
+ return ShellRoot.LifecycleCanvas(presenterState.route)
+ }
if (presenterState.generatedKeyBackup != null) {
return ShellRoot.BootstrapCanvas(BootstrapStep.GeneratedRecovery)
}
- return when (presenterState.route) {
- HarvestCircleRoute.ACTIVE_IDENTITY -> ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday))
- HarvestCircleRoute.IDENTITIES ->
- if (session.readOnly) {
- ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday))
- } else {
- val step =
- when (presenterState.identityEntryMode) {
- IdentityEntryMode.CREATE -> BootstrapStep.CreateIdentity
- IdentityEntryMode.IMPORT -> BootstrapStep.ImportIdentity
- IdentityEntryMode.CHOICE ->
- if (presenterState.snapshot.identities.isEmpty()) {
- BootstrapStep.Welcome
- } else {
- BootstrapStep.IdentityChooser
- }
- }
- ShellRoot.BootstrapCanvas(step)
- }
- else -> ShellRoot.LifecycleCanvas(presenterState.route)
+ if (presenterState.snapshot.activeIdentity != null || session.readOnly) {
+ return ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday))
}
+ val step =
+ when (presenterState.identityEntryMode) {
+ IdentityEntryMode.CREATE -> BootstrapStep.CreateIdentity
+ IdentityEntryMode.IMPORT -> BootstrapStep.ImportIdentity
+ IdentityEntryMode.CHOICE ->
+ if (presenterState.snapshot.identities.isEmpty()) {
+ BootstrapStep.Welcome
+ } else {
+ BootstrapStep.IdentityChooser
+ }
+ }
+ return ShellRoot.BootstrapCanvas(step)
}
+fun deriveLocalUsability(snapshot: ApplicationSnapshot): LocalUsability =
+ when (snapshot.lifecycle) {
+ ApplicationLifecycle.Ready -> LocalUsability.Usable
+ ApplicationLifecycle.Degraded -> deriveDegradedUsability(snapshot)
+ else -> LocalUsability.Unusable
+ }
+
+private fun deriveDegradedUsability(snapshot: ApplicationSnapshot): LocalUsability {
+ val categories =
+ listOfNotNull(
+ snapshot.lifecycleProblem,
+ snapshot.sessionProblem,
+ snapshot.recoverableProblem,
+ ).map(ApplicationProblem::category)
+ .toSet()
+ if (categories.isEmpty() || categories.any { it !in USABLE_DEGRADATION_CATEGORIES }) {
+ return LocalUsability.Unusable
+ }
+ val reason =
+ when (categories) {
+ setOf(ApplicationErrorCategory.Network) -> DegradationReason.Network
+ setOf(ApplicationErrorCategory.Credential) -> DegradationReason.Credential
+ else -> DegradationReason.NetworkAndCredential
+ }
+ return LocalUsability.UsableDegraded(reason)
+}
+
+private val USABLE_DEGRADATION_CATEGORIES =
+ setOf(
+ ApplicationErrorCategory.Network,
+ ApplicationErrorCategory.Credential,
+ )
+
fun activateShellDestination(
state: NavigationState,
destination: ShellDestination,
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt
@@ -47,6 +47,50 @@ class HarvestCircleShellPresenterTest {
assertTrue(identity.intents.single() === HarvestCircleIntent.ChooseCreateIdentity)
presenter.close()
}
+
+ @Test
+ fun usableDegradationPreservesDashboardNavigation() =
+ runTest {
+ val identity = FakeIdentityPresentation(activePresenterState(ApplicationLifecycle.Ready, null, 1UL))
+ val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this)
+ runCurrent()
+ presenter.dispatch(HarvestCircleShellIntent.Navigate(ShellDestination.Network))
+
+ identity.state.value =
+ activePresenterState(
+ ApplicationLifecycle.Degraded,
+ problem(ApplicationErrorCategory.Network),
+ 2UL,
+ )
+ runCurrent()
+
+ assertEquals(AppRoute.Network, presenter.state.value.currentRoute)
+ assertEquals(
+ LocalUsability.UsableDegraded(DegradationReason.Network),
+ presenter.state.value.localUsability,
+ )
+ presenter.close()
+ }
+
+ @Test
+ fun storageDegradationLeavesTheDashboardForALifecycleCanvas() =
+ runTest {
+ val identity = FakeIdentityPresentation(activePresenterState(ApplicationLifecycle.Ready, null, 1UL))
+ val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this)
+ runCurrent()
+
+ identity.state.value =
+ activePresenterState(
+ ApplicationLifecycle.Degraded,
+ problem(ApplicationErrorCategory.Storage),
+ 2UL,
+ )
+ runCurrent()
+
+ assertEquals(LocalUsability.Unusable, presenter.state.value.localUsability)
+ assertTrue(presenter.state.value.root is ShellRoot.LifecycleCanvas)
+ presenter.close()
+ }
}
private class FakeIdentityPresentation(
@@ -78,3 +122,55 @@ private fun presenterState(route: HarvestCircleRoute): HarvestCirclePresenterSta
),
route = route,
)
+
+private fun activePresenterState(
+ lifecycle: ApplicationLifecycle,
+ lifecycleProblem: ApplicationProblem?,
+ revision: ULong,
+): HarvestCirclePresenterState {
+ val identity =
+ IdentitySummary(
+ id = IdentityId.fromPublicKeyHex("02".repeat(32)),
+ npub = "npub1active",
+ displayLabel = "Active identity",
+ signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available),
+ createdAt = UnixSeconds(1),
+ lastUsedAt = null,
+ )
+ return HarvestCirclePresenterState(
+ ApplicationSnapshot(
+ revision = SnapshotRevision(revision),
+ lifecycle = lifecycle,
+ lifecycleProblem = lifecycleProblem,
+ configuredRelays = emptyList(),
+ identities = listOf(identity),
+ selectedIdentityId = identity.id,
+ session = SessionLifecycle.Active,
+ sessionSubjectIdentityId = identity.id,
+ sessionProblem = null,
+ activeIdentity =
+ ActiveIdentity(
+ identity = identity,
+ relays = RelaySummary(emptyList(), RelayConnectionState.Degraded),
+ profileState = ProfileLoadState.Cached,
+ profile = null,
+ ),
+ recoverableProblem = null,
+ ),
+ )
+}
+
+private fun problem(category: ApplicationErrorCategory): ApplicationProblem =
+ ApplicationProblem(
+ code =
+ if (category == ApplicationErrorCategory.Network) {
+ ApplicationErrorCode.RelayConnectionFailed
+ } else {
+ ApplicationErrorCode.StorageUnavailable
+ },
+ category = category,
+ retryable = true,
+ recoveryAction = RecoveryAction.Retry,
+ operationId = null,
+ safeMessage = "Typed degraded problem.",
+ )
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt
@@ -16,7 +16,7 @@ class ShellAvailabilityTest {
assertEquals(ShellRoot.BootstrapCanvas(BootstrapStep.Welcome), deriveShellRoot(signedOut, ShellSessionState()))
val readOnly = deriveShellRoot(signedOut, ShellSessionState().enterReadOnly()) as ShellRoot.Dashboard
assertEquals(AppRoute.PersonalToday, readOnly.navigation.current)
- val active = deriveShellRoot(presenterState(HarvestCircleRoute.ACTIVE_IDENTITY), ShellSessionState())
+ val active = deriveShellRoot(presenterState(readyActiveSnapshot()), ShellSessionState())
assertTrue(active is ShellRoot.Dashboard)
assertTrue(deriveShellRoot(presenterState(HarvestCircleRoute.FATAL), ShellSessionState()) is ShellRoot.LifecycleCanvas)
}
@@ -50,6 +50,52 @@ class ShellAvailabilityTest {
}
@Test
+ fun typedDegradationAllowsOnlyNetworkAndCredentialProblems() {
+ assertEquals(
+ LocalUsability.UsableDegraded(DegradationReason.Network),
+ deriveLocalUsability(degradedSnapshot(problem(ApplicationErrorCategory.Network))),
+ )
+ assertEquals(
+ LocalUsability.UsableDegraded(DegradationReason.Credential),
+ deriveLocalUsability(degradedSnapshot(problem(ApplicationErrorCategory.Credential))),
+ )
+ assertEquals(
+ LocalUsability.Unusable,
+ deriveLocalUsability(degradedSnapshot(problem(ApplicationErrorCategory.Storage))),
+ )
+ assertEquals(LocalUsability.Unusable, deriveLocalUsability(degradedSnapshot(null)))
+ }
+
+ @Test
+ fun usableDegradedSnapshotsStayInTheProductShell() {
+ val networkProblem = problem(ApplicationErrorCategory.Network)
+ val empty = presenterState(degradedSnapshot(networkProblem))
+ assertEquals(ShellRoot.BootstrapCanvas(BootstrapStep.Welcome), deriveShellRoot(empty, ShellSessionState()))
+
+ val savedIdentity = identity()
+ val signedOut = presenterState(degradedSnapshot(networkProblem, identities = listOf(savedIdentity)))
+ assertEquals(
+ ShellRoot.BootstrapCanvas(BootstrapStep.IdentityChooser),
+ deriveShellRoot(signedOut, ShellSessionState()),
+ )
+
+ val readOnly = deriveShellRoot(empty, ShellSessionState().enterReadOnly())
+ assertTrue(readOnly is ShellRoot.Dashboard)
+
+ val active = presenterState(degradedSnapshot(networkProblem, activeIdentity = activeIdentity(savedIdentity)))
+ assertTrue(deriveShellRoot(active, ShellSessionState()) is ShellRoot.Dashboard)
+ }
+
+ @Test
+ fun unusableDegradationFailsClosedWithoutInspectingMessageText() {
+ val storage = presenterState(degradedSnapshot(problem(ApplicationErrorCategory.Storage, "network unavailable")))
+ val compatibility =
+ presenterState(degradedSnapshot(problem(ApplicationErrorCategory.Compatibility, "credential unavailable")))
+ assertTrue(deriveShellRoot(storage, ShellSessionState()) is ShellRoot.LifecycleCanvas)
+ assertTrue(deriveShellRoot(compatibility, ShellSessionState()) is ShellRoot.LifecycleCanvas)
+ }
+
+ @Test
fun disabledFeaturesCannotDispatch() {
val state = NavigationState(AppRoute.PersonalToday)
assertSame(state, activateShellDestination(state, ShellDestination.Explore))
@@ -78,3 +124,79 @@ private fun presenterState(route: HarvestCircleRoute): HarvestCirclePresenterSta
),
route = route,
)
+
+private fun presenterState(snapshot: ApplicationSnapshot): HarvestCirclePresenterState = HarvestCirclePresenterState(snapshot)
+
+private fun degradedSnapshot(
+ problem: ApplicationProblem?,
+ activeIdentity: ActiveIdentity? = null,
+ identities: List<IdentitySummary> = activeIdentity?.identity?.let { listOf(it) } ?: emptyList(),
+): ApplicationSnapshot =
+ ApplicationSnapshot(
+ revision = SnapshotRevision(2UL),
+ lifecycle = ApplicationLifecycle.Degraded,
+ lifecycleProblem = problem,
+ configuredRelays = emptyList(),
+ identities = identities,
+ selectedIdentityId = activeIdentity?.identity?.id,
+ session = if (activeIdentity == null) SessionLifecycle.SignedOut else SessionLifecycle.Active,
+ sessionSubjectIdentityId = activeIdentity?.identity?.id,
+ sessionProblem = null,
+ activeIdentity = activeIdentity,
+ recoverableProblem = null,
+ )
+
+private fun readyActiveSnapshot(): ApplicationSnapshot {
+ val identity = identity()
+ return ApplicationSnapshot(
+ revision = SnapshotRevision(1UL),
+ lifecycle = ApplicationLifecycle.Ready,
+ lifecycleProblem = null,
+ configuredRelays = emptyList(),
+ identities = listOf(identity),
+ selectedIdentityId = identity.id,
+ session = SessionLifecycle.Active,
+ sessionSubjectIdentityId = identity.id,
+ sessionProblem = null,
+ activeIdentity = activeIdentity(identity),
+ recoverableProblem = null,
+ )
+}
+
+private fun problem(
+ category: ApplicationErrorCategory,
+ message: String = "The application is degraded.",
+): ApplicationProblem =
+ ApplicationProblem(
+ code =
+ when (category) {
+ ApplicationErrorCategory.Network -> ApplicationErrorCode.RelayConnectionFailed
+ ApplicationErrorCategory.Credential -> ApplicationErrorCode.CredentialMissing
+ ApplicationErrorCategory.Storage -> ApplicationErrorCode.StorageUnavailable
+ ApplicationErrorCategory.Compatibility -> ApplicationErrorCode.CompatibilityMismatch
+ else -> ApplicationErrorCode.Internal
+ },
+ category = category,
+ retryable = true,
+ recoveryAction = RecoveryAction.Retry,
+ operationId = null,
+ safeMessage = message,
+ )
+
+private fun identity(): IdentitySummary =
+ IdentitySummary(
+ id = IdentityId.fromPublicKeyHex("01".repeat(32)),
+ npub = "npub1degraded",
+ displayLabel = "Degraded identity",
+ signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available),
+ createdAt = UnixSeconds(1),
+ lastUsedAt = null,
+ )
+
+private fun activeIdentity(identity: IdentitySummary): ActiveIdentity =
+ ActiveIdentity(
+ identity = identity,
+ relays = RelaySummary(emptyList(), RelayConnectionState.Degraded),
+ profileState = ProfileLoadState.Cached,
+ profile = null,
+ )