app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 7++++++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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, + )