diff --git a/packages/mobile/src/app/index.tsx b/packages/mobile/src/app/index.tsx index 3b85550e..e95f9fd7 100644 --- a/packages/mobile/src/app/index.tsx +++ b/packages/mobile/src/app/index.tsx @@ -219,6 +219,10 @@ export default function HomeScreen() { { + startMutation.continueWithoutRecovery(); + }} /> ); diff --git a/packages/mobile/src/app/start/index.tsx b/packages/mobile/src/app/start/index.tsx index 6a4834bf..c328f799 100644 --- a/packages/mobile/src/app/start/index.tsx +++ b/packages/mobile/src/app/start/index.tsx @@ -506,6 +506,10 @@ export default function StartEventScreen() { { + startMutation.continueWithoutRecovery(); + }} /> ); diff --git a/packages/mobile/src/components/start/start-mutation-attention.test.tsx b/packages/mobile/src/components/start/start-mutation-attention.test.tsx index 756d51ef..8fea1a06 100644 --- a/packages/mobile/src/components/start/start-mutation-attention.test.tsx +++ b/packages/mobile/src/components/start/start-mutation-attention.test.tsx @@ -672,4 +672,28 @@ describe('start mutation attention presentation', () => { expect(clear.props.accessibilityState).toEqual({ disabled: true }); expect(clear.props.disabled).toBe(true); }); + + test('gives the recovery-blocked screen a way out', () => { + let continued = 0; + const result = StartMutationRecoveryBlockedAttention({ + message: 'PSD EOC cannot safely read or retain the prior start request.', + onContinueWithoutRecovery: () => { + continued += 1; + }, + }) as Element; + const text = normalizedText(result); + const action = renderedElements(result).find( + (node) => + node.type === 'Pressable' && + node.props.accessibilityLabel === 'Continue without recovery', + ); + + if (action === undefined) { + throw new Error('The recovery-blocked screen still has no way out.'); + } + press(action); + expect(continued).toBe(1); + expect(text).toContain('nothing already stored is deleted'); + expect(text).toContain('will not be recoverable here'); + }); }); diff --git a/packages/mobile/src/components/start/start-mutation-attention.tsx b/packages/mobile/src/components/start/start-mutation-attention.tsx index cd7cd971..406177e3 100644 --- a/packages/mobile/src/components/start/start-mutation-attention.tsx +++ b/packages/mobile/src/components/start/start-mutation-attention.tsx @@ -482,6 +482,8 @@ export interface OtherSessionStartMutationAttentionProps { export interface StartMutationRecoveryBlockedAttentionProps { readonly message: string; + readonly online?: boolean; + readonly onContinueWithoutRecovery?: () => void; readonly testID?: string; } @@ -534,6 +536,8 @@ export function StartMutationRecoveryCheckingAttention({ /** Identity-free hard stop when durable recovery storage cannot be trusted. */ export function StartMutationRecoveryBlockedAttention({ message, + online = true, + onContinueWithoutRecovery, testID, }: StartMutationRecoveryBlockedAttentionProps) { return ( @@ -560,6 +564,35 @@ export function StartMutationRecoveryBlockedAttention({ No request will be sent or queued while recovery is unavailable. + + {onContinueWithoutRecovery === undefined ? null : ( + + + You can carry on without recovery on this device. Start and join + work again, and nothing already stored is deleted. If the app closes + while a request is in flight, its outcome will not be recoverable + here. + + [ + styles.acknowledgeAction, + styles.neutralAcknowledgeAction, + pressed && online && styles.pressed, + !online && styles.disabled, + ]} + > + + Continue without recovery + + + + )} ); } diff --git a/packages/mobile/src/lib/start/start-mutation-coordinator.test.ts b/packages/mobile/src/lib/start/start-mutation-coordinator.test.ts index 2b48a08b..34a3c2bd 100644 --- a/packages/mobile/src/lib/start/start-mutation-coordinator.test.ts +++ b/packages/mobile/src/lib/start/start-mutation-coordinator.test.ts @@ -1070,4 +1070,43 @@ describe('mapStartMutationError', () => { expect(signedOut.acknowledgeUnresolved(OWNER)).toBe(false); expect(persistence.record).not.toBeNull(); }); + + test('lets the operator carry on when the device store cannot be written', async () => { + const persistence = new MemoryPersistence(); + persistence.failWrite = true; + const coordinator = new StartMutationCoordinator(persistence); + online(coordinator); + const blocked = coordinator.submit( + activationSubmission(() => Promise.resolve(activationResult())), + ); + expect(blocked.accepted).toBe(false); + expect(coordinator.getSnapshot().phase).toBe('recovery-blocked'); + + expect(coordinator.continueWithoutRecovery(OWNER)).toBe(true); + expect(coordinator.getSnapshot().phase).toBe('idle'); + + // The broken store is no longer consulted, so a new emergency can be + // raised instead of the device refusing every start until it heals. + const next = coordinator.submit( + activationSubmission(() => Promise.resolve(activationResult())), + ); + expect(next.accepted).toBe(true); + if (next.accepted) await next.completion; + expect(coordinator.getSnapshot().phase).toBe('succeeded'); + }); + + test('refuses to carry on without recovery unless blocked and signed in', () => { + const persistence = new MemoryPersistence(); + persistence.failWrite = true; + const coordinator = new StartMutationCoordinator(persistence); + online(coordinator); + expect(coordinator.continueWithoutRecovery(OWNER)).toBe(false); + + coordinator.submit( + activationSubmission(() => Promise.resolve(activationResult())), + ); + expect(coordinator.getSnapshot().phase).toBe('recovery-blocked'); + expect(coordinator.continueWithoutRecovery(OTHER_OWNER)).toBe(false); + expect(coordinator.getSnapshot().phase).toBe('recovery-blocked'); + }); }); diff --git a/packages/mobile/src/lib/start/start-mutation-coordinator.ts b/packages/mobile/src/lib/start/start-mutation-coordinator.ts index 41f15b10..9ebf810f 100644 --- a/packages/mobile/src/lib/start/start-mutation-coordinator.ts +++ b/packages/mobile/src/lib/start/start-mutation-coordinator.ts @@ -491,6 +491,18 @@ export class StartMutationCoordinator { private readonly persistence: StartMutationPersistence | null = null, ) {} + private persistenceSuspended = false; + + /** + * The durable store, or nothing once the operator has chosen to carry on + * without device recovery. Suspension lasts for this process only and never + * rewrites or deletes what is already stored; it stops this coordinator + * depending on a store that has proven it cannot be read or written. + */ + private get store(): StartMutationPersistence | null { + return this.persistenceSuspended ? null : this.persistence; + } + public getSnapshot = (): StartMutationSnapshot => this.snapshot; public subscribe = (listener: StartMutationListener): (() => void) => { @@ -569,9 +581,9 @@ export class StartMutationCoordinator { private persistCurrentStateOrBlock(): boolean { const record = this.recoveryRecord(); - if (record === null || this.persistence === null) return true; + if (record === null || this.store === null) return true; try { - this.persistence.write(record); + this.store.write(record); return true; } catch { this.state = RECOVERY_BLOCKED_INTERNAL_STATE; @@ -581,9 +593,9 @@ export class StartMutationCoordinator { } private clearDurableRecordOrBlock(): boolean { - if (this.persistence === null) return true; + if (this.store === null) return true; try { - this.persistence.clear(); + this.store.clear(); return true; } catch { this.state = RECOVERY_BLOCKED_INTERNAL_STATE; @@ -594,10 +606,10 @@ export class StartMutationCoordinator { /** Hydrates retained mutation truth after local authentication succeeds. */ public hydrate(notify = true): boolean { - if (this.persistence === null || this.state.phase !== 'idle') return false; + if (this.store === null || this.state.phase !== 'idle') return false; let record: StartMutationRecoveryRecord | null; try { - record = this.persistence.read(); + record = this.store.read(); } catch { this.state = RECOVERY_BLOCKED_INTERNAL_STATE; this.refreshSnapshot(notify); @@ -719,9 +731,9 @@ export class StartMutationCoordinator { ? submission.activationEvidence : null; - if (this.persistence !== null) { + if (this.store !== null) { try { - this.persistence.write( + this.store.write( Object.freeze({ phase: 'unresolved', owner, @@ -900,6 +912,28 @@ export class StartMutationCoordinator { return true; } + /** + * Leaves the blocked state on an explicit human decision, carrying on with + * no durable recovery for the rest of this process. The store has already + * failed a read or a write here, so there is nothing trustworthy to keep; + * the alternative was a device that refused every start and join until the + * keychain healed, which for an emergency tool is the worse failure. What + * is already stored is left untouched rather than deleted. + */ + public continueWithoutRecovery(ownerInput: StartMutationOwner): boolean { + const owner = validatedOwner(ownerInput); + if ( + this.state.phase !== 'recovery-blocked' || + !sameOwner(this.onlineOwner, owner) + ) { + return false; + } + this.persistenceSuspended = true; + this.state = IDLE_INTERNAL_STATE; + this.refreshSnapshot(true); + return true; + } + /** Publishes a pre-transport denial without creating a key or running work. */ public reportDeniedSubmission( ownerInput: StartMutationOwner, diff --git a/packages/mobile/src/lib/start/start-mutation-provider.tsx b/packages/mobile/src/lib/start/start-mutation-provider.tsx index c117564d..ae2b8605 100644 --- a/packages/mobile/src/lib/start/start-mutation-provider.tsx +++ b/packages/mobile/src/lib/start/start-mutation-provider.tsx @@ -51,6 +51,7 @@ export interface StartMutationContextValue { readonly submitJoin: (input: SubmitStartJoinInput) => StartMutationAdmission; readonly acknowledge: () => boolean; readonly acknowledgeUnresolved: () => boolean; + readonly continueWithoutRecovery: () => boolean; readonly claimSuccessFeedback: () => StartMutationCompletion | null; } @@ -319,6 +320,13 @@ export class StartMutationProviderController { : this.coordinator.acknowledgeUnresolved(owner); }; + public continueWithoutRecovery = (): boolean => { + const owner = this.exactLiveOwner(); + return owner === null + ? false + : this.coordinator.continueWithoutRecovery(owner); + }; + public claimSuccessFeedback = (): StartMutationCompletion | null => { const owner = this.exactLiveOwner(); return owner === null ? null : this.coordinator.claimSuccessFeedback(owner); @@ -381,6 +389,9 @@ export function StartMutationProvider({ children }: PropsWithChildren) { acknowledgeUnresolved: () => snapshot.phase !== 'checking-recovery' && controller.acknowledgeUnresolved(), + continueWithoutRecovery: () => + snapshot.phase !== 'checking-recovery' && + controller.continueWithoutRecovery(), claimSuccessFeedback: () => snapshot.phase === 'checking-recovery' ? null