diff --git a/src/components/ConduitActionsContext.test.tsx b/src/components/ConduitActionsContext.test.tsx index 244869b6..047f1f1f 100644 --- a/src/components/ConduitActionsContext.test.tsx +++ b/src/components/ConduitActionsContext.test.tsx @@ -18,6 +18,7 @@ */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; +import { Platform } from "react-native"; import { ReactTestRenderer, act, create } from "react-test-renderer"; import { @@ -59,6 +60,13 @@ const mockedUseHostedExperienceState = >; describe("ConduitActionsProvider", () => { + const originalPlatformOs = Platform.OS; + const originalPlatformConstants = Platform.constants; + + beforeEach(() => { + setPlatform("ios", { interfaceIdiom: "phone" }); + }); + afterEach(() => { jest.clearAllMocks(); mountedRenderers.splice(0).forEach((renderer) => { @@ -66,6 +74,7 @@ describe("ConduitActionsProvider", () => { renderer.unmount(); }); }); + setPlatform(originalPlatformOs, originalPlatformConstants); }); it("does not use the local Android personal compartment ID on iOS", () => { @@ -98,6 +107,43 @@ describe("ConduitActionsProvider", () => { expect(contextValue.isPersonalPairingPreparing).toBe(true); }); + it("uses the local personal compartment ID on Mac Catalyst", () => { + setPlatform("ios", { + ...originalPlatformConstants, + interfaceIdiom: "mac", + isMacCatalyst: true, + }); + mockedUseHostedExperienceState.mockReturnValue({ + authPhase: "authenticated", + session: { personalPairingWrapperBaseUrl: null }, + stationPhase: "active", + entitlementSnapshot: "active", + conduitsSnapshot: { + entitlement: { status: "active" }, + conduits: [ + { + conduit_id: "cond_1", + proxy_id: "st_1", + status: "active", + }, + ], + }, + } as never); + + const queryClient = createTestQueryClient(); + queryClient.setQueryData( + [QUERYKEY_ANDROID_PERSONAL_COMPARTMENT_ID], + "jgr+fj3yz6Wpn/vV7qlP4Sh+hBkThZCDEe6+OVJEm2g", + ); + + const contextValue = renderProvider(queryClient); + + expect(contextValue.personalCompartmentId).toBe( + "jgr+fj3yz6Wpn/vV7qlP4Sh+hBkThZCDEe6+OVJEm2g", + ); + expect(contextValue.isPersonalPairingPreparing).toBe(false); + }); + it("uses the hosted snapshot personal compartment ID on iOS once it is ready", () => { mockedUseHostedExperienceState.mockReturnValue({ authPhase: "authenticated", @@ -171,3 +217,14 @@ function createTestQueryClient(): QueryClient { }, }); } + +function setPlatform(os: typeof Platform.OS, constants: object) { + Object.defineProperty(Platform, "OS", { + configurable: true, + value: os, + }); + Object.defineProperty(Platform, "constants", { + configurable: true, + value: constants, + }); +} diff --git a/src/components/ConduitActionsContext.tsx b/src/components/ConduitActionsContext.tsx index 86a09b5c..dad00a8d 100644 --- a/src/components/ConduitActionsContext.tsx +++ b/src/components/ConduitActionsContext.tsx @@ -27,10 +27,11 @@ import { RyveClaimMaterial, resolvePreferredRyveName, } from "@/src/components/ryveClaim"; -import { useAndroidPersonalCompartmentId, useConduitName } from "@/src/hooks"; +import { useConduitName, useLocalPersonalCompartmentId } from "@/src/hooks"; import { useHostedExperienceState } from "@/src/hosted/experience/hooks"; import { isEntitlementAllowed } from "@/src/hosted/experience/stateMachine"; import { resolveHostedPersonalPairingState } from "@/src/hosted/personalPairing"; +import { isLocalPersonalPairingSupported } from "@/src/personalCompartmentId"; export interface ConduitActionsContextValue { /** Open the Ryve claim modal for a given claim material */ @@ -63,13 +64,16 @@ export function useConduitActions(): ConduitActionsContextValue { export function ConduitActionsProvider({ children }: React.PropsWithChildren) { const { openModal } = useModal(); - const { data: androidPersonalCompartmentId } = - useAndroidPersonalCompartmentId(); + const localPersonalCompartmentIdQuery = useLocalPersonalCompartmentId(); + const localPersonalCompartmentId = localPersonalCompartmentIdQuery.data; const { data: conduitName } = useConduitName(); const state = useHostedExperienceState(); const conduits = state.conduitsSnapshot?.conduits ?? []; const hasHostedSession = state.authPhase === "authenticated" && state.session != null; + const localPersonalPairingSupported = isLocalPersonalPairingSupported(); + const hostedPairingOnly = + Platform.OS === "ios" && !localPersonalPairingSupported; const hostedPersonalPairing = React.useMemo( () => @@ -88,16 +92,18 @@ export function ConduitActionsProvider({ children }: React.PropsWithChildren) { state.stationPhase, ], ); - const personalCompartmentId = - Platform.OS === "ios" - ? hostedPersonalPairing.ready - ? hostedPersonalPairing.hostedPersonalCompartmentId - : null - : (hostedPersonalPairing.hostedPersonalCompartmentId ?? - androidPersonalCompartmentId ?? - null); + const personalCompartmentId = hostedPairingOnly + ? hostedPersonalPairing.ready + ? hostedPersonalPairing.hostedPersonalCompartmentId + : null + : (hostedPersonalPairing.hostedPersonalCompartmentId ?? + localPersonalCompartmentId ?? + null); const isPersonalPairingPreparing = - Platform.OS === "ios" && hostedPersonalPairing.preparing; + (hostedPairingOnly && hostedPersonalPairing.preparing) || + (localPersonalPairingSupported && + localPersonalCompartmentId === undefined && + localPersonalCompartmentIdQuery.isLoading); const personalPairingWrapperBaseUrl = state.session?.personalPairingWrapperBaseUrl ?? null; diff --git a/src/hooks.tsx b/src/hooks.tsx index 6d15f5df..a42a9570 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -28,7 +28,11 @@ import { } from "@/src/constants"; import { loadCachedAlias } from "@/src/hosted/aliasCache"; import { PersonalCompartmentId } from "@/src/hosted/contracts"; -import { loadAndroidPersonalCompartmentId } from "@/src/personalCompartmentId"; +import { + isLocalPersonalPairingSupported, + loadAndroidPersonalCompartmentId, + loadLocalPersonalCompartmentId, +} from "@/src/personalCompartmentId"; const PermissionsStatusSchema = z.enum([ "GRANTED", @@ -66,6 +70,16 @@ export const useAndroidPersonalCompartmentId = queryFn: async () => loadAndroidPersonalCompartmentId(), }); +export const useLocalPersonalCompartmentId = + (): UseQueryResult => + useQuery({ + queryKey: [QUERYKEY_ANDROID_PERSONAL_COMPARTMENT_ID], + staleTime: Infinity, + gcTime: Infinity, + enabled: isLocalPersonalPairingSupported(), + queryFn: async () => loadLocalPersonalCompartmentId(), + }); + export const useConduitName = (): UseQueryResult => { return useQuery({ queryKey: [QUERYKEY_CONDUIT_NAME], diff --git a/src/personalCompartmentId.ts b/src/personalCompartmentId.ts index 5623acbb..fe2aaa85 100644 --- a/src/personalCompartmentId.ts +++ b/src/personalCompartmentId.ts @@ -30,11 +30,32 @@ import { PersonalCompartmentIdSchema, } from "@/src/hosted/contracts"; +interface ExtendedPlatformConstants { + interfaceIdiom?: string; + isMacCatalyst?: boolean; +} + export async function loadAndroidPersonalCompartmentId(): Promise { if (Platform.OS !== "android") { return null; } + return loadOrCreatePersistedPersonalCompartmentId(); +} + +export async function loadLocalPersonalCompartmentId(): Promise { + if (!isLocalPersonalPairingSupported()) { + return null; + } + + return loadOrCreatePersistedPersonalCompartmentId(); +} + +export function isLocalPersonalPairingSupported(): boolean { + return Platform.OS === "android" || isMacCatalyst(); +} + +async function loadOrCreatePersistedPersonalCompartmentId(): Promise { const storedPersonalCompartmentId = parsePersonalCompartmentId( await SecureStore.getItemAsync( SECURESTORE_ANDROID_PERSONAL_COMPARTMENT_ID_KEY, @@ -94,3 +115,16 @@ async function derivePersonalCompartmentId(): Promise