Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ public final class T3NativeControlsModule: Module {
return bounds.width > bounds.height ? "landscape" : "portrait"
}

@JS
@MainActor
func isProtectedDataAvailable() async -> Bool {
UIApplication.shared.isProtectedDataAvailable
}

private func launchArgument(_ flag: String) -> String? {
let arguments = ProcessInfo.processInfo.arguments
guard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Atom } from "effect/reactivity";
import * as SecureStore from "expo-secure-store";
import { useEffect, useMemo, useRef, useState } from "react";

import { whenProtectedDataAvailable } from "../../lib/protectedData";
import { useEnvironments } from "../../state/environments";
import { environmentSession } from "../../state/session";

Expand Down Expand Up @@ -35,7 +36,8 @@ export function PermissionUpdateNotice() {

useEffect(() => {
let active = true;
void SecureStore.getItemAsync(storageKey)
void whenProtectedDataAvailable()
.then(() => SecureStore.getItemAsync(storageKey))
.then((raw) => {
const value: unknown = raw === null ? [] : JSON.parse(raw);
if (active)
Expand Down
165 changes: 165 additions & 0 deletions apps/mobile/src/lib/protectedData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import * as Effect from "effect/Effect";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({
appState: "background" as string | null,
nativeCheckSupported: true,
platform: "ios",
subscribed: Promise.withResolvers<void>(),
listeners: new Set<(state: string) => void>(),
isProtectedDataAvailable: vi.fn<() => Promise<boolean>>(),
readStorage: vi.fn<() => Promise<void>>(),
}));

vi.mock("expo", () => ({
requireOptionalNativeModule: () => ({
...(mocks.nativeCheckSupported && {
isProtectedDataAvailable: mocks.isProtectedDataAvailable,
}),
}),
}));
vi.mock("react-native", () => ({
Platform: {
get OS() {
return mocks.platform;
},
},
AppState: {
get currentState() {
return mocks.appState;
},
addEventListener: (_event: string, listener: (state: string) => void) => {
mocks.listeners.add(listener);
mocks.subscribed.resolve();
return { remove: () => mocks.listeners.delete(listener) };
},
},
}));

vi.mock("../persistence/layer", async () => {
const Effect = await import("effect/Effect");
const Layer = await import("effect/Layer");
return { layer: Layer.effectDiscard(Effect.promise(mocks.readStorage)) };
});
vi.mock("../features/cloud/dpop", async () => ({
layer: (await import("effect/Layer")).empty,
}));
vi.mock("../features/cloud/managedRelayLayer", async () => {
const Layer = await import("effect/Layer");
return { layer: () => Layer.empty };
});
vi.mock("../features/cloud/publicConfig", () => ({
resolveCloudPublicConfig: () => ({ relay: { url: null } }),
}));
vi.mock("../features/observability/tracing", async () => ({
layer: (await import("effect/Layer")).empty,
}));
vi.mock("@t3tools/client-runtime/rpc", async () => {
const Layer = await import("effect/Layer");
return { layerRemoteHttpClient: () => Layer.empty };
});

let runtime: typeof import("./runtime").runtime | undefined;

beforeEach(() => {
vi.resetModules();
mocks.appState = "background";
mocks.nativeCheckSupported = true;
mocks.platform = "ios";
mocks.subscribed = Promise.withResolvers<void>();
mocks.listeners.clear();
mocks.isProtectedDataAvailable.mockReset();
mocks.readStorage.mockReset().mockResolvedValue(undefined);
});

afterEach(async () => {
await runtime?.dispose();
runtime = undefined;
});

describe("protected storage startup", () => {
it.each([
{ launch: "background", state: "background", checkRejects: false },
{ launch: "inactive", state: "inactive", checkRejects: false },
{ launch: "unknown", state: "unknown", checkRejects: false },
{ launch: "unset", state: null, checkRejects: false },
{ launch: "background with a failed native check", state: "background", checkRejects: true },
{
launch: "background without a native check",
state: "background",
nativeCheckSupported: false,
},
{ launch: "unknown without a native check", state: "unknown", nativeCheckSupported: false },
{ launch: "unset without a native check", state: null, nativeCheckSupported: false },
])(
"keeps a locked $launch launch usable after activation without rebuilding its runtime",
async (scenario) => {
mocks.appState = scenario.state;
mocks.nativeCheckSupported = scenario.nativeCheckSupported ?? true;
mocks.isProtectedDataAvailable.mockImplementation(async () => {
if (scenario.checkRejects) throw new Error("Protected data check unavailable.");
return false;
});
mocks.readStorage.mockImplementation(async () => {
if (mocks.appState !== "active") throw new Error("User interaction is not allowed.");
});
({ runtime } = await import("./runtime"));
const startup = runtime.runPromiseExit(Effect.succeed("ready"));

// A missing gate finishes startup with a cached storage failure instead.
await Promise.race([mocks.subscribed.promise, startup]);
expect(mocks.readStorage).not.toHaveBeenCalled();
mocks.appState = "inactive";
for (const listener of mocks.listeners) listener("inactive");
await Promise.resolve();
expect(mocks.readStorage).not.toHaveBeenCalled();

mocks.appState = "active";
for (const listener of mocks.listeners) listener("active");
expect(await startup).toMatchObject({ _tag: "Success", value: "ready" });
expect(await runtime.runPromise(Effect.succeed("still ready"))).toBe("still ready");
expect(mocks.readStorage).toHaveBeenCalledOnce();
expect(mocks.listeners.size).toBe(0);
mocks.appState = "background";
await (await import("./protectedData")).whenProtectedDataAvailable();
expect(mocks.isProtectedDataAvailable).toHaveBeenCalledTimes(
mocks.nativeCheckSupported ? 1 : 0,
);
},
);

it.each([
{
launch: "unlocked background",
state: "background",
platform: "ios",
nativeCheckSupported: true,
},
{ launch: "active foreground", state: "active", platform: "ios", nativeCheckSupported: true },
{
launch: "Android background",
state: "background",
platform: "android",
nativeCheckSupported: false,
},
{
launch: "Android with a native check",
state: "background",
platform: "android",
nativeCheckSupported: true,
},
])("starts storage on $launch launch without waiting for activation", async (scenario) => {
mocks.appState = scenario.state;
mocks.platform = scenario.platform;
mocks.nativeCheckSupported = scenario.nativeCheckSupported;
mocks.isProtectedDataAvailable.mockResolvedValue(scenario.platform === "ios");
({ runtime } = await import("./runtime"));

expect(await runtime.runPromise(Effect.succeed("ready"))).toBe("ready");
expect(mocks.isProtectedDataAvailable).toHaveBeenCalledTimes(
scenario.platform === "ios" && scenario.state === "background" ? 1 : 0,
);
expect(mocks.readStorage).toHaveBeenCalledOnce();
expect(mocks.listeners.size).toBe(0);
});
});
36 changes: 36 additions & 0 deletions apps/mobile/src/lib/protectedData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { requireOptionalNativeModule } from "expo";
import { AppState, Platform } from "react-native";

const NativeControls = requireOptionalNativeModule<{
readonly isProtectedDataAvailable?: () => Promise<boolean>;
}>("T3NativeControls");

let protectedDataAvailable: Promise<void> | undefined;

// A locked iOS background launch cannot read startup storage. Cache readiness
// for this launch only; later device locks do not pause an initialized runtime.
export function whenProtectedDataAvailable(): Promise<void> {
protectedDataAvailable ??= new Promise((resolve) => {
const isAvailable = NativeControls?.isProtectedDataAvailable;
if (Platform.OS !== "ios" || AppState.currentState === "active") {
resolve();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return;
}
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") done();
});
function done() {
subscription.remove();
resolve();
}
isAvailable?.().then(
(available) => {
if (available) done();
},
() => {
// A failed check does not establish readiness; activation still can.
},
);
});
return protectedDataAvailable;
}
5 changes: 5 additions & 0 deletions apps/mobile/src/lib/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Socket from "effect/socket/Socket";
Expand All @@ -10,6 +11,7 @@ import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
import * as Tracing from "../features/observability/tracing";
import * as Persistence from "../persistence/layer";
import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh";
import { whenProtectedDataAvailable } from "./protectedData";

declare const module: { readonly hot?: FoundationHotModule } | undefined;

Expand All @@ -35,6 +37,9 @@ const layerRuntime = Layer.merge(
Layer.provideMerge(layerHttpClient),
Layer.provideMerge(Tracing.layer.pipe(Layer.provide(layerHttpClient))),
Layer.provideMerge(Persistence.layer),
// These layers read the keychain and the database while they build, and a
// failed build is kept for the life of the process.
Layer.provide(Layer.effectDiscard(Effect.promise(whenProtectedDataAvailable))),
);

export const runtime: ManagedRuntime.ManagedRuntime<
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/lib/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ const mocks = vi.hoisted(() => {
};
});

vi.mock("expo", () => ({
requireOptionalNativeModule: () => null,
}));

vi.mock("expo-secure-store", () => ({
deleteItemAsync: mocks.deleteItemAsync,
getItemAsync: mocks.getItemAsync,
Expand All @@ -89,6 +93,7 @@ vi.mock("expo-constants", () => ({
}));

vi.mock("react-native", () => ({
AppState: { currentState: "active" },
Platform: {
OS: "ios",
},
Expand Down
Loading