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