feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,41 @@
import { unregisterCurrentDevice } from '../features/devices/device-service'
import { detachPushRegistrationForLogout } from '../features/notifications/notification-service'
import { getMobileRuntimeConfig } from './native-config'
import { signOutWithLocalFallback, type LocalLogoutResult } from './logout'
export interface AccountLogoutInput {
userId: string | null
deviceId: string | null
purgeLocalSession: () => Promise<void>
}
export interface AccountLogoutOperations {
detachPush: (userId: string, deviceId: string | null) => Promise<void>
unregisterDevice: (userId: string, installationId: string) => Promise<unknown>
installationId: () => string
signOut: (purgeLocalSession: () => Promise<void>) => Promise<LocalLogoutResult>
}
const accountLogoutOperations: AccountLogoutOperations = {
detachPush: detachPushRegistrationForLogout,
unregisterDevice: unregisterCurrentDevice,
installationId: () => getMobileRuntimeConfig().installationId,
signOut: signOutWithLocalFallback,
}
/** Keeps authenticated server detach operations ahead of local session loss. */
export async function performAccountLogout(
input: AccountLogoutInput,
operations: AccountLogoutOperations = accountLogoutOperations,
): Promise<LocalLogoutResult> {
if (input.userId !== null) {
await operations.detachPush(input.userId, input.deviceId)
try {
await operations.unregisterDevice(input.userId, operations.installationId())
} catch {
// Device unregister is best effort after push detach; local privacy and
// refresh-token deletion must still proceed while offline.
}
}
return operations.signOut(input.purgeLocalSession)
}

View file

@ -0,0 +1,49 @@
import { clearAllActionHistories } from '../features/actions/action-service'
import { clearAllHistoryCaches } from '../features/history/history-cache'
import { deleteNativePushRegistration } from '../features/notifications/notification-native'
import { clearAllQueuedAudio } from '../features/recording/durable-processing-queue'
import { clearEveryGenerationIdempotencyKey } from '../features/templates'
import { audioRecorder } from './audio-recorder'
import { clearAllEntitlementCaches } from './entitlement-context'
import { clearAllUserPreferenceCaches } from './preferences-context'
export class AccountLocalDataPurgeError extends Error {
readonly code = 'account_local_data_purge_failed'
constructor() {
super('account_local_data_purge_failed')
this.name = 'AccountLocalDataPurgeError'
}
}
let purgeInFlight: Promise<void> | null = null
/**
* Removes every account-scoped local artifact before an auth boundary opens.
* All tasks are allowed to settle so an early failure cannot skip deletion of
* unrelated caches or raw audio; callers receive only a stable public code.
*/
export function purgeAllAccountLocalData(): Promise<void> {
if (purgeInFlight !== null) return purgeInFlight
const operation = (async (): Promise<void> => {
const results = await Promise.allSettled([
clearAllHistoryCaches(),
clearAllEntitlementCaches(),
clearAllUserPreferenceCaches(),
audioRecorder.cancel(),
clearAllQueuedAudio(),
clearAllActionHistories(),
clearEveryGenerationIdempotencyKey(),
deleteNativePushRegistration(),
])
if (results.some((result) => result.status === 'rejected')) {
throw new AccountLocalDataPurgeError()
}
})().finally(() => {
if (purgeInFlight === operation) purgeInFlight = null
})
purgeInFlight = operation
return operation
}

View file

@ -0,0 +1,677 @@
import { NativeModules, PermissionsAndroid, Platform } from 'react-native';
import { Dirs, FileSystem } from 'react-native-file-access';
import {
AudioEncoderAndroidType,
AudioSourceAndroidType,
AVEncoderAudioQualityIOSType,
OutputFormatAndroidType,
createSound,
type AudioSet,
type RecordBackType,
} from 'react-native-nitro-sound';
import { createUuidV4 } from './random-id';
const RECORDING_PREFIX = 'd3ro-recording-';
const RECORDING_EXTENSION = '.m4a';
const RECORDING_MIME_TYPE = 'audio/mp4';
const ANDROID_RECORDING_MIME_TYPE = 'audio/wav';
const AUDIO_SETTINGS: AudioSet = {
AudioSourceAndroid: AudioSourceAndroidType.MIC,
OutputFormatAndroid: OutputFormatAndroidType.MPEG_4,
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
AudioQuality: 'medium',
AudioChannels: 1,
AudioSamplingRate: 44100,
AudioEncodingBitRate: 128000,
AVFormatIDKeyIOS: 'aac',
AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,
AVNumberOfChannelsKeyIOS: 1,
AVSampleRateKeyIOS: 44100,
};
type NativeRecorderState =
| 'idle'
| 'starting'
| 'recording'
| 'pausing'
| 'paused'
| 'resuming'
| 'stopping'
| 'stopped';
export interface RecordingProgress {
durationMs: number;
meteringDb: number | null;
}
export interface RecordedAudio {
uri: string;
path: string;
fileName: string;
mimeType: string;
size: number;
durationMs: number;
}
export type RecordingRuntimeState =
| 'idle'
| 'starting'
| 'recording'
| 'paused'
| 'stopped'
| 'recoverable';
export interface RecordingRuntimeSnapshot {
state: RecordingRuntimeState;
recording: RecordedAudio | null;
meetingId: string | null;
interruptionReason: string | null;
startedAtMs: number;
}
export interface StartRecordingOptions {
meetingId?: string;
onStateChange?: (snapshot: RecordingRuntimeSnapshot) => void;
}
interface NativeRecordingSnapshot {
state: RecordingRuntimeState;
path: string | null;
uri: string | null;
fileName: string | null;
mimeType: string;
size: number;
durationMs: number;
meteringDb: number | null;
meetingId: string | null;
interruptionReason: string | null;
startedAtMs: number;
recoverable: boolean;
}
interface D3RORecordingNativeModule {
getStatus: () => Promise<NativeRecordingSnapshot>;
start: (options: { meetingId?: string }) => Promise<NativeRecordingSnapshot>;
pause: () => Promise<NativeRecordingSnapshot>;
resume: () => Promise<NativeRecordingSnapshot>;
stop: () => Promise<NativeRecordingSnapshot>;
cancel: () => Promise<NativeRecordingSnapshot>;
dispose: (path: string) => Promise<void>;
}
function androidRecordingModule(): D3RORecordingNativeModule {
const candidate = NativeModules.D3RORecording as
| Partial<D3RORecordingNativeModule>
| undefined;
if (
candidate === undefined ||
typeof candidate.getStatus !== 'function' ||
typeof candidate.start !== 'function' ||
typeof candidate.pause !== 'function' ||
typeof candidate.resume !== 'function' ||
typeof candidate.stop !== 'function' ||
typeof candidate.cancel !== 'function' ||
typeof candidate.dispose !== 'function'
) {
throw new Error('Android foreground recording module is unavailable');
}
return candidate as D3RORecordingNativeModule;
}
function assertFiniteNonNegative(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new Error(`Android recorder returned invalid ${field}`);
}
return value;
}
function parseNativeSnapshot(value: NativeRecordingSnapshot): RecordingRuntimeSnapshot {
const states: readonly RecordingRuntimeState[] = [
'idle',
'starting',
'recording',
'paused',
'stopped',
'recoverable',
];
if (!states.includes(value.state)) {
throw new Error('Android recorder returned an invalid state');
}
const durationMs = assertFiniteNonNegative(value.durationMs, 'duration');
const size = assertFiniteNonNegative(value.size, 'file size');
const startedAtMs = assertFiniteNonNegative(value.startedAtMs, 'start time');
const hasFile = value.path !== null || value.uri !== null || value.fileName !== null;
if (
hasFile &&
(typeof value.path !== 'string' ||
value.path.length === 0 ||
value.uri !== `file://${value.path}` ||
typeof value.fileName !== 'string' ||
!value.fileName.endsWith('.wav') ||
value.mimeType !== ANDROID_RECORDING_MIME_TYPE)
) {
throw new Error('Android recorder returned an invalid recording file');
}
if (value.state !== 'idle' && !hasFile) {
throw new Error('Android recorder state has no recording file');
}
const recording = hasFile
? {
uri: value.uri as string,
path: value.path as string,
fileName: value.fileName as string,
mimeType: ANDROID_RECORDING_MIME_TYPE,
size,
durationMs,
}
: null;
return {
state: value.state,
recording,
meetingId: typeof value.meetingId === 'string' ? value.meetingId : null,
interruptionReason:
typeof value.interruptionReason === 'string'
? value.interruptionReason
: null,
startedAtMs,
};
}
function createTemporaryPath(): string {
return `${Dirs.CacheDir}/${RECORDING_PREFIX}${createUuidV4()}${RECORDING_EXTENSION}`;
}
function pathFromFileUri(uri: string): string | null {
if (!uri.startsWith('file://')) return null;
try {
return decodeURIComponent(uri.slice('file://'.length));
} catch {
return null;
}
}
function isOwnedTemporaryPath(path: string): boolean {
const cachePrefix = `${Dirs.CacheDir}/`;
if (!path.startsWith(cachePrefix)) return false;
const fileName = path.slice(cachePrefix.length);
return (
!fileName.includes('/') &&
!fileName.includes('\\') &&
fileName.startsWith(RECORDING_PREFIX) &&
fileName.endsWith(RECORDING_EXTENSION)
);
}
async function deleteOwnedTemporaryFile(path: string | null): Promise<void> {
if (path === null) return;
if (!isOwnedTemporaryPath(path)) {
throw new Error('Refusing to delete a recording outside the app cache');
}
if (await FileSystem.exists(path)) {
await FileSystem.unlink(path);
}
}
async function deleteStaleTemporaryFiles(): Promise<void> {
const entries = await FileSystem.ls(Dirs.CacheDir);
const stalePaths = entries
.filter(
entry =>
entry.startsWith(RECORDING_PREFIX) &&
entry.endsWith(RECORDING_EXTENSION),
)
.map(entry => `${Dirs.CacheDir}/${entry}`);
await Promise.all(stalePaths.map(deleteOwnedTemporaryFile));
}
export class AudioRecorder {
private state: NativeRecorderState = 'idle';
private currentPath: string | null = null;
private progress: RecordingProgress = { durationMs: 0, meteringDb: null };
private pendingStart: Promise<void> | null = null;
private pendingTransition: Promise<void> | null = null;
private pendingStop: Promise<RecordedAudio> | null = null;
private sound: ReturnType<typeof createSound> | null = null;
private progressHandler: ((progress: RecordingProgress) => void) | null =
null;
private stateHandler: ((snapshot: RecordingRuntimeSnapshot) => void) | null =
null;
private androidPollGeneration = 0;
private getSound(): ReturnType<typeof createSound> {
if (this.sound !== null) return this.sound;
const sound = createSound();
sound.setSubscriptionDuration(0.1);
sound.addRecordBackListener((event: RecordBackType) => {
if (this.progressHandler === null) return;
const durationMs = Number.isFinite(event.currentPosition)
? Math.max(0, Math.round(event.currentPosition))
: this.progress.durationMs;
const meteringDb =
typeof event.currentMetering === 'number' &&
Number.isFinite(event.currentMetering)
? event.currentMetering
: null;
this.progress = { durationMs, meteringDb };
this.progressHandler(this.progress);
});
this.sound = sound;
return sound;
}
async requestPermission(): Promise<boolean> {
if (Platform.OS !== 'android') {
// Nitro Sound requests iOS microphone access through AVAudioSession.
return true;
}
const permission = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO;
if (await PermissionsAndroid.check(permission)) return true;
const result = await PermissionsAndroid.request(permission, {
title: 'Microphone access',
message:
'D3RO Voice needs microphone access to record and transcribe audio.',
buttonPositive: 'Allow',
buttonNegative: 'Cancel',
});
return result === PermissionsAndroid.RESULTS.GRANTED;
}
async start(
onProgress: (progress: RecordingProgress) => void,
options: StartRecordingOptions = {},
): Promise<void> {
if (this.state !== 'idle') {
throw new Error(`Cannot start recorder while it is ${this.state}`);
}
this.state = 'starting';
if (Platform.OS === 'android') {
this.progressHandler = onProgress;
this.stateHandler = options.onStateChange ?? null;
try {
const snapshot = parseNativeSnapshot(
await androidRecordingModule().start(
options.meetingId === undefined
? {}
: { meetingId: options.meetingId },
),
);
if (snapshot.state !== 'recording' || snapshot.recording === null) {
throw new Error('Android foreground recorder did not start');
}
this.currentPath = snapshot.recording.path;
this.progress = {
durationMs: snapshot.recording.durationMs,
meteringDb: null,
};
this.state = 'recording';
this.stateHandler?.(snapshot);
this.startAndroidPolling();
return;
} catch (error) {
this.progressHandler = null;
this.stateHandler = null;
this.currentPath = null;
this.state = 'idle';
throw error;
}
}
try {
await deleteStaleTemporaryFiles();
} catch (error) {
this.state = 'idle';
throw error;
}
const outputPath = createTemporaryPath();
this.currentPath = outputPath;
this.progress = { durationMs: 0, meteringDb: null };
this.progressHandler = onProgress;
const sound = this.getSound();
const pendingStart = (async () => {
try {
const startedUri = await sound.startRecorder(
outputPath,
AUDIO_SETTINGS,
true,
);
const startedPath = pathFromFileUri(startedUri);
if (startedPath !== outputPath) {
throw new Error('Recorder returned an unexpected output URI');
}
this.state = 'recording';
} catch (error) {
try {
await sound.stopRecorder();
} catch {
// stopRecorder also releases Android's MediaRecorder when stopping fails.
}
this.progressHandler = null;
if (this.currentPath === outputPath) this.currentPath = null;
this.state = 'idle';
await deleteOwnedTemporaryFile(outputPath);
throw error;
}
})();
this.pendingStart = pendingStart;
try {
await pendingStart;
} finally {
if (this.pendingStart === pendingStart) this.pendingStart = null;
}
}
async pause(): Promise<void> {
if (this.state !== 'recording') {
throw new Error(`Cannot pause recorder while it is ${this.state}`);
}
this.state = 'pausing';
if (Platform.OS === 'android') {
try {
const snapshot = parseNativeSnapshot(await androidRecordingModule().pause());
if (snapshot.state !== 'paused') throw new Error('Android recorder did not pause');
this.state = 'paused';
this.stateHandler?.(snapshot);
return;
} catch (error) {
this.state = 'recording';
throw error;
}
}
const pendingTransition = (async () => {
try {
await this.getSound().pauseRecorder();
this.state = 'paused';
} catch (error) {
this.state = 'recording';
throw error;
}
})();
this.pendingTransition = pendingTransition;
try {
await pendingTransition;
} finally {
if (this.pendingTransition === pendingTransition) {
this.pendingTransition = null;
}
}
}
async resume(): Promise<void> {
if (this.state !== 'paused') {
throw new Error(`Cannot resume recorder while it is ${this.state}`);
}
this.state = 'resuming';
if (Platform.OS === 'android') {
try {
const snapshot = parseNativeSnapshot(await androidRecordingModule().resume());
if (snapshot.state !== 'recording') throw new Error('Android recorder did not resume');
this.state = 'recording';
this.stateHandler?.(snapshot);
return;
} catch (error) {
this.state = 'paused';
throw error;
}
}
const pendingTransition = (async () => {
try {
await this.getSound().resumeRecorder();
this.state = 'recording';
} catch (error) {
this.state = 'paused';
throw error;
}
})();
this.pendingTransition = pendingTransition;
try {
await pendingTransition;
} finally {
if (this.pendingTransition === pendingTransition) {
this.pendingTransition = null;
}
}
}
async stop(): Promise<RecordedAudio> {
if (this.state !== 'recording' && this.state !== 'paused') {
throw new Error(`Cannot stop recorder while it is ${this.state}`);
}
const outputPath = this.currentPath;
if (outputPath === null) throw new Error('Recorder has no output path');
const pendingStop = (async (): Promise<RecordedAudio> => {
this.state = 'stopping';
try {
if (Platform.OS === 'android') {
const snapshot = parseNativeSnapshot(await androidRecordingModule().stop());
if (
(snapshot.state !== 'stopped' && snapshot.state !== 'recoverable') ||
snapshot.recording === null ||
snapshot.recording.size <= 44
) {
throw new Error('Android recorder produced no recoverable audio');
}
this.stopAndroidPolling();
this.currentPath = snapshot.recording.path;
this.progress = {
durationMs: snapshot.recording.durationMs,
meteringDb: null,
};
this.state = 'stopped';
this.stateHandler?.(snapshot);
return snapshot.recording;
}
const stoppedUri = await this.getSound().stopRecorder();
const stoppedPath = pathFromFileUri(stoppedUri);
if (stoppedPath !== outputPath) {
throw new Error('Recorder did not return the expected output file');
}
const stat = await FileSystem.stat(outputPath);
if (stat.type !== 'file' || stat.size <= 0) {
throw new Error('Recorder produced an empty audio file');
}
this.state = 'stopped';
return {
uri: stoppedUri,
path: outputPath,
fileName: outputPath.slice(outputPath.lastIndexOf('/') + 1),
mimeType: RECORDING_MIME_TYPE,
size: stat.size,
durationMs: this.progress.durationMs,
};
} catch (error) {
this.currentPath = null;
this.state = 'idle';
await deleteOwnedTemporaryFile(outputPath);
throw error;
} finally {
this.progressHandler = null;
this.stateHandler = null;
}
})();
this.pendingStop = pendingStop;
try {
return await pendingStop;
} finally {
if (this.pendingStop === pendingStop) this.pendingStop = null;
}
}
async cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void> {
const path = recording?.path ?? this.currentPath;
if (Platform.OS === 'android') {
if (path !== null) await androidRecordingModule().dispose(path);
this.stopAndroidPolling();
this.currentPath = null;
this.state = 'idle';
this.progress = { durationMs: 0, meteringDb: null };
this.progressHandler = null;
this.stateHandler = null;
return;
}
await deleteOwnedTemporaryFile(path);
if (path === this.currentPath) {
this.currentPath = null;
this.state = 'idle';
this.progress = { durationMs: 0, meteringDb: null };
}
}
async cancel(): Promise<void> {
if (Platform.OS === 'android') {
this.stopAndroidPolling();
await androidRecordingModule().cancel();
this.progressHandler = null;
this.stateHandler = null;
this.currentPath = null;
this.state = 'idle';
this.progress = { durationMs: 0, meteringDb: null };
return;
}
if (this.state === 'starting' && this.pendingStart !== null) {
try {
await this.pendingStart;
} catch {
return;
}
}
if (this.state === 'stopping' && this.pendingStop !== null) {
try {
await this.pendingStop;
} catch {
return;
}
}
if (this.pendingTransition !== null) {
try {
await this.pendingTransition;
} catch {
// Continue cleanup from the stable pre-transition recording state.
}
}
const outputPath = this.currentPath;
let stopError: unknown = null;
try {
if (this.state === 'recording' || this.state === 'paused') {
await this.getSound().stopRecorder();
}
} catch (error) {
stopError = error;
} finally {
this.progressHandler = null;
this.currentPath = null;
this.state = 'idle';
this.progress = { durationMs: 0, meteringDb: null };
await deleteOwnedTemporaryFile(outputPath);
}
if (stopError !== null) throw stopError;
}
async restore(
onProgress?: (progress: RecordingProgress) => void,
onStateChange?: (snapshot: RecordingRuntimeSnapshot) => void,
): Promise<RecordingRuntimeSnapshot> {
if (Platform.OS !== 'android') {
return {
state: 'idle',
recording: null,
meetingId: null,
interruptionReason: null,
startedAtMs: 0,
};
}
const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus());
this.stopAndroidPolling();
this.progressHandler = onProgress ?? null;
this.stateHandler = onStateChange ?? null;
this.currentPath = snapshot.recording?.path ?? null;
this.progress = {
durationMs: snapshot.recording?.durationMs ?? 0,
meteringDb: null,
};
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
this.state = snapshot.state;
this.startAndroidPolling();
} else if (snapshot.state === 'stopped' || snapshot.state === 'recoverable') {
this.state = 'stopped';
} else {
this.state = 'idle';
}
this.progressHandler?.(this.progress);
this.stateHandler?.(snapshot);
return snapshot;
}
private startAndroidPolling(): void {
const generation = ++this.androidPollGeneration;
const poll = async (): Promise<void> => {
if (generation !== this.androidPollGeneration) return;
try {
const native = await androidRecordingModule().getStatus();
if (generation !== this.androidPollGeneration) return;
const snapshot = parseNativeSnapshot(native);
const nextProgress = {
durationMs: snapshot.recording?.durationMs ?? 0,
meteringDb:
typeof native.meteringDb === 'number' && Number.isFinite(native.meteringDb)
? native.meteringDb
: null,
};
this.progress = nextProgress;
this.progressHandler?.(nextProgress);
this.stateHandler?.(snapshot);
if (snapshot.state === 'recording' || snapshot.state === 'paused') {
this.state = snapshot.state;
setTimeout(() => { void poll(); }, 250);
} else {
this.currentPath = snapshot.recording?.path ?? null;
this.state = snapshot.state === 'idle' ? 'idle' : 'stopped';
}
} catch {
// A transient React bridge failure must not stop the native foreground capture.
if (generation === this.androidPollGeneration) {
setTimeout(() => { void poll(); }, 500);
}
}
};
void poll();
}
private stopAndroidPolling(): void {
this.androidPollGeneration += 1;
}
}
export const audioRecorder = new AudioRecorder();

View file

@ -0,0 +1,44 @@
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '@d3ro/core/supabase-config'
export interface AuthCapabilities {
signUpEnabled: boolean
emailEnabled: boolean
googleEnabled: boolean
githubEnabled: boolean
appleEnabled: boolean
}
interface AuthSettingsResponse {
disable_signup?: boolean
external?: {
email?: boolean
google?: boolean
github?: boolean
apple?: boolean
}
}
let cachedCapabilities: AuthCapabilities | null = null
export async function getAuthCapabilities(forceRefresh = false): Promise<AuthCapabilities> {
if (!forceRefresh && cachedCapabilities !== null) return cachedCapabilities
const response = await fetch(`${SUPABASE_URL}/auth/v1/settings`, {
headers: { apikey: SUPABASE_ANON_KEY },
})
if (!response.ok) {
throw new Error(`Auth settings request failed with status ${response.status}`)
}
const settings = await response.json() as AuthSettingsResponse
const external = settings.external ?? {}
cachedCapabilities = {
signUpEnabled: settings.disable_signup !== true,
emailEnabled: external.email === true,
googleEnabled: external.google === true,
githubEnabled: external.github === true,
appleEnabled: external.apple === true,
}
return cachedCapabilities
}

View file

@ -1,63 +1,313 @@
// src/lib/auth-context.tsx — Auth provider using Supabase
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import type { ReactNode } from 'react'
import type { User, Session } from '@supabase/supabase-js'
import { Linking } from 'react-native'
import type { AuthChangeEvent, User, Session } from '@supabase/supabase-js'
import { supabase, isSupabaseConfigured } from './supabase'
import { completeAuthRedirect, isAuthRedirectUrl } from './auth-redirect'
import { clearAllSecureAuthStorage } from './secure-auth-storage'
import { purgeAllAccountLocalData } from './account-local-data'
export type AuthPrivacyCleanupState = 'ready' | 'purging' | 'failed'
export class AuthPrivacyBoundaryError extends Error {
readonly code = 'auth_privacy_boundary_failed'
constructor() {
super('auth_privacy_boundary_failed')
this.name = 'AuthPrivacyBoundaryError'
}
}
interface AuthContextValue {
user: User | null
session: Session | null
loading: boolean
devBypass: () => void
recoveryMode: boolean
authError: 'callback_failed' | 'bootstrap_failed' | null
privacyCleanupState: AuthPrivacyCleanupState
finishPasswordRecovery: () => void
purgeLocalSession: () => Promise<void>
retryPrivacyCleanup: () => Promise<void>
}
type AuthTransitionEvent = AuthChangeEvent | 'BOOTSTRAP' | 'EXPLICIT'
interface PendingAuthTransition {
session: Session | null
event: AuthTransitionEvent
forcePurge: boolean
clearSecureAuth: boolean
explicit: boolean
}
const AuthContext = createContext<AuthContextValue>({
user: null,
session: null,
loading: false,
devBypass: () => {},
loading: true,
recoveryMode: false,
authError: null,
privacyCleanupState: 'ready',
finishPasswordRecovery: () => undefined,
purgeLocalSession: async () => undefined,
retryPrivacyCleanup: async () => undefined,
})
export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement {
const [user, setUser] = useState<User | null>(null)
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(false)
const [committedUser, setCommittedUser] = useState<User | null>(null)
const [committedSession, setCommittedSession] = useState<Session | null>(null)
const [bootstrapLoading, setBootstrapLoading] = useState(true)
const [recoveryMode, setRecoveryMode] = useState(false)
const [authError, setAuthError] = useState<AuthContextValue['authError']>(null)
const [privacyCleanupState, setPrivacyCleanupState] = useState<AuthPrivacyCleanupState>('ready')
const mountedRef = useRef(true)
const committedSessionRef = useRef<Session | null>(null)
const privacyCleanupStateRef = useRef<AuthPrivacyCleanupState>('ready')
const transitionGenerationRef = useRef(0)
const pendingTransitionRef = useRef<PendingAuthTransition | null>(null)
useEffect(() => {
if (!isSupabaseConfigured()) {
setLoading(false)
return
const updatePrivacyCleanupState = useCallback((state: AuthPrivacyCleanupState): void => {
privacyCleanupStateRef.current = state
if (mountedRef.current) setPrivacyCleanupState(state)
}, [])
const commitSession = useCallback((
nextSession: Session | null,
event: AuthTransitionEvent,
): void => {
const previousUserId = committedSessionRef.current?.user.id ?? null
const nextUserId = nextSession?.user.id ?? null
committedSessionRef.current = nextSession
if (!mountedRef.current) return
setCommittedSession(nextSession)
setCommittedUser(nextSession?.user ?? null)
if (nextSession === null) {
setRecoveryMode(false)
} else if (event === 'PASSWORD_RECOVERY') {
setRecoveryMode(true)
} else if (previousUserId !== nextUserId) {
setRecoveryMode(false)
}
if (event === 'SIGNED_IN' || event === 'PASSWORD_RECOVERY' || event === 'EXPLICIT') {
setAuthError(null)
}
}, [])
const runAuthTransition = useCallback(async (
transition: PendingAuthTransition,
): Promise<boolean> => {
const generation = ++transitionGenerationRef.current
const previousUserId = committedSessionRef.current?.user.id ?? null
const nextUserId = transition.session?.user.id ?? null
const needsPurge = transition.forcePurge
|| privacyCleanupStateRef.current !== 'ready'
|| (previousUserId !== null && previousUserId !== nextUserId)
if (!needsPurge) {
pendingTransitionRef.current = null
commitSession(transition.session, transition.event)
updatePrivacyCleanupState('ready')
return true
}
pendingTransitionRef.current = transition
updatePrivacyCleanupState('purging')
try {
supabase.auth.getSession()
.then(({ data: { session: s } }) => {
setSession(s)
setUser(s?.user ?? null)
setLoading(false)
})
.catch(() => {
setLoading(false)
})
await purgeAllAccountLocalData()
if (generation !== transitionGenerationRef.current) return false
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s)
setUser(s?.user ?? null)
})
if (transition.clearSecureAuth) {
if (!transition.explicit) {
// A SIGNED_OUT notification can race with a direct A -> B account
// replacement. Preserve a newer different-account session that the
// auth client has already committed to secure storage.
try {
const { data } = await supabase.auth.getSession()
if (generation !== transitionGenerationRef.current) return false
const latestSession = data.session
const latestUserId = latestSession?.user.id ?? null
if (latestSession !== null && latestUserId !== previousUserId) {
pendingTransitionRef.current = null
commitSession(latestSession, 'SIGNED_IN')
updatePrivacyCleanupState('ready')
return true
}
} catch {
// If the auth client cannot prove that a newer session exists,
// fail closed by removing the signed-out account's secure data.
}
}
return () => subscription?.unsubscribe?.()
try {
await supabase.auth.stopAutoRefresh()
} catch {
// Secure deletion is still required when refresh shutdown fails.
}
try {
await clearAllSecureAuthStorage()
} finally {
void supabase.auth.startAutoRefresh()
}
}
if (generation !== transitionGenerationRef.current) return false
pendingTransitionRef.current = null
commitSession(transition.session, transition.event)
updatePrivacyCleanupState('ready')
return true
} catch {
setLoading(false)
if (generation !== transitionGenerationRef.current) return false
pendingTransitionRef.current = transition
updatePrivacyCleanupState('failed')
return false
}
}, [])
}, [commitSession, updatePrivacyCleanupState])
const devBypass = useCallback(() => {
setUser({ id: 'dev-user', email: 'dev@d3ro.local' } as User)
setLoading(false)
}, [])
useEffect(() => {
mountedRef.current = true
let authSubscription: { unsubscribe: () => void } | null = null
const handleUrl = async (url: string): Promise<void> => {
if (!isAuthRedirectUrl(url)) return
try {
const redirectType = await completeAuthRedirect(url)
if (!mountedRef.current) return
if (redirectType === 'recovery') setRecoveryMode(true)
if (redirectType === 'recovery' || redirectType === 'signed-in' || redirectType === 'cancelled') {
setAuthError(null)
}
} catch {
if (mountedRef.current) setAuthError('callback_failed')
}
}
const linkSubscription = Linking.addEventListener('url', ({ url }) => {
void handleUrl(url)
})
const subscribeToAuthChanges = (): void => {
if (!mountedRef.current || authSubscription !== null) return
authSubscription = supabase.auth.onAuthStateChange((event, nextSession) => {
if (!mountedRef.current) return
const previousUserId = committedSessionRef.current?.user.id ?? null
const nextUserId = nextSession?.user.id ?? null
void runAuthTransition({
session: nextSession,
event,
forcePurge: false,
clearSecureAuth: nextSession === null && previousUserId !== null,
explicit: false,
})
if (event === 'SIGNED_IN' || event === 'PASSWORD_RECOVERY') setAuthError(null)
if (event === 'PASSWORD_RECOVERY' && previousUserId === nextUserId) {
setRecoveryMode(true)
}
}).data.subscription
}
void (async () => {
if (!isSupabaseConfigured()) {
await runAuthTransition({
session: null,
event: 'BOOTSTRAP',
forcePurge: true,
clearSecureAuth: false,
explicit: false,
})
if (mountedRef.current) setBootstrapLoading(false)
return
}
try {
const initialUrl = await Linking.getInitialURL()
if (initialUrl !== null) await handleUrl(initialUrl)
const { data: { session: restoredSession }, error } = await supabase.auth.getSession()
if (error !== null) throw error
// A restored account owns its existing local caches. A cold boot with
// no session has no trustworthy owner and must purge before Login.
await runAuthTransition({
session: restoredSession,
event: 'BOOTSTRAP',
forcePurge: restoredSession === null,
clearSecureAuth: false,
explicit: false,
})
} catch {
if (mountedRef.current) setAuthError('bootstrap_failed')
await runAuthTransition({
session: null,
event: 'BOOTSTRAP',
forcePurge: true,
clearSecureAuth: false,
explicit: false,
})
} finally {
subscribeToAuthChanges()
if (mountedRef.current) setBootstrapLoading(false)
}
})()
return () => {
mountedRef.current = false
transitionGenerationRef.current += 1
linkSubscription.remove()
authSubscription?.unsubscribe()
}
}, [runAuthTransition])
const finishPasswordRecovery = (): void => {
setRecoveryMode(false)
}
const purgeLocalSession = useCallback(async (): Promise<void> => {
const succeeded = await runAuthTransition({
session: null,
event: 'EXPLICIT',
forcePurge: true,
clearSecureAuth: true,
explicit: true,
})
if (!succeeded && privacyCleanupStateRef.current === 'failed') {
throw new AuthPrivacyBoundaryError()
}
}, [runAuthTransition])
const retryPrivacyCleanup = useCallback(async (): Promise<void> => {
const pending = pendingTransitionRef.current
if (pending === null) {
updatePrivacyCleanupState('ready')
return
}
await runAuthTransition({ ...pending, forcePurge: true })
}, [runAuthTransition, updatePrivacyCleanupState])
const authBoundaryReady = privacyCleanupState === 'ready'
const loading = bootstrapLoading || !authBoundaryReady
return (
<AuthContext.Provider value={{ user, session, loading, devBypass }}>
<AuthContext.Provider
value={{
user: authBoundaryReady ? committedUser : null,
session: authBoundaryReady ? committedSession : null,
loading,
recoveryMode: authBoundaryReady ? recoveryMode : false,
authError,
privacyCleanupState,
finishPasswordRecovery,
purgeLocalSession,
retryPrivacyCleanup,
}}
>
{children}
</AuthContext.Provider>
)
@ -66,4 +316,3 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
export function useAuth(): AuthContextValue {
return useContext(AuthContext)
}

View file

@ -0,0 +1,177 @@
import { supabase } from './supabase'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'
export const AUTH_REDIRECT_URL = 'd3ro-voice://auth-callback'
export type AuthRedirectType = 'recovery' | 'signed-in' | 'cancelled' | 'ignored'
type AuthRedirectOperations = {
exchangeCodeForSession: typeof supabase.auth.exchangeCodeForSession
setSession: typeof supabase.auth.setSession
}
type ParsedAuthRedirect =
| { kind: 'ignored' }
| { kind: 'cancelled' }
| { kind: 'code'; code: string; recovery: boolean; replayKey: string }
| {
kind: 'tokens'
accessToken: string
refreshToken: string
recovery: boolean
replayKey: string
}
export class AuthRedirectError extends Error {
readonly code: 'invalid_callback' | 'provider_error'
constructor(code: AuthRedirectError['code']) {
super(code === 'invalid_callback'
? 'Invalid authentication callback.'
: 'Authentication provider returned an error.')
this.name = 'AuthRedirectError'
this.code = code
}
}
function hasAuthMaterial(parsedUrl: URL): boolean {
const fragment = new URLSearchParams(parsedUrl.hash.replace(/^#/, ''))
return parsedUrl.searchParams.has('code')
|| parsedUrl.searchParams.has('error')
|| parsedUrl.searchParams.has('error_description')
|| fragment.has('access_token')
|| fragment.has('refresh_token')
|| fragment.has('error')
|| fragment.has('error_description')
}
function replayFingerprint(kind: 'code' | 'tokens', secret: string): string {
return `${kind}:${bytesToHex(sha256(utf8ToBytes(secret)))}`
}
export function isAuthRedirectUrl(url: string): boolean {
try {
const parsedUrl = new URL(url)
return parsedUrl.protocol === 'd3ro-voice:'
&& parsedUrl.hostname === 'auth-callback'
&& parsedUrl.username === ''
&& parsedUrl.password === ''
&& parsedUrl.port === ''
&& (parsedUrl.pathname === '' || parsedUrl.pathname === '/')
} catch {
return false
}
}
function parseAuthRedirect(url: string): ParsedAuthRedirect {
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
throw new AuthRedirectError('invalid_callback')
}
if (!isAuthRedirectUrl(url)) {
if (hasAuthMaterial(parsedUrl)) throw new AuthRedirectError('invalid_callback')
return { kind: 'ignored' }
}
const query = parsedUrl.searchParams
const fragment = new URLSearchParams(parsedUrl.hash.replace(/^#/, ''))
const errorCode = query.get('error') ?? fragment.get('error')
const hasProviderError = errorCode !== null
|| query.has('error_description')
|| fragment.has('error_description')
if (hasProviderError) {
if (errorCode === 'access_denied') return { kind: 'cancelled' }
throw new AuthRedirectError('provider_error')
}
const code = query.get('code')
const accessToken = fragment.get('access_token')
const refreshToken = fragment.get('refresh_token')
const hasCode = typeof code === 'string' && code.length > 0
const hasAccessToken = typeof accessToken === 'string' && accessToken.length > 0
const hasRefreshToken = typeof refreshToken === 'string' && refreshToken.length > 0
if (hasCode && (hasAccessToken || hasRefreshToken)) {
throw new AuthRedirectError('invalid_callback')
}
if (hasAccessToken !== hasRefreshToken) {
throw new AuthRedirectError('invalid_callback')
}
if (hasCode) {
return {
kind: 'code',
code,
recovery: query.get('type') === 'recovery',
replayKey: replayFingerprint('code', code),
}
}
if (hasAccessToken && hasRefreshToken) {
return {
kind: 'tokens',
accessToken,
refreshToken,
recovery: fragment.get('type') === 'recovery',
replayKey: replayFingerprint('tokens', `${accessToken}\u0000${refreshToken}`),
}
}
return { kind: 'ignored' }
}
export function createAuthRedirectHandler(
auth: AuthRedirectOperations,
): (url: string) => Promise<AuthRedirectType> {
const inFlight = new Map<string, Promise<AuthRedirectType>>()
const completed = new Map<string, AuthRedirectType>()
const rememberCompletion = (key: string, result: AuthRedirectType): void => {
completed.set(key, result)
if (completed.size > 16) {
const oldestKey = completed.keys().next().value as string | undefined
if (oldestKey !== undefined) completed.delete(oldestKey)
}
}
return async (url: string): Promise<AuthRedirectType> => {
const parsed = parseAuthRedirect(url)
if (parsed.kind === 'ignored' || parsed.kind === 'cancelled') return parsed.kind
const replayed = completed.get(parsed.replayKey)
if (replayed !== undefined) return replayed
const existing = inFlight.get(parsed.replayKey)
if (existing !== undefined) return existing
const operation = (async (): Promise<AuthRedirectType> => {
if (parsed.kind === 'code') {
const { data, error } = await auth.exchangeCodeForSession(parsed.code)
if (error !== null) throw error
const redirectData = data as typeof data & { redirectType?: string | null }
return parsed.recovery || redirectData.redirectType === 'recovery'
? 'recovery'
: 'signed-in'
}
const { error } = await auth.setSession({
access_token: parsed.accessToken,
refresh_token: parsed.refreshToken,
})
if (error !== null) throw error
return parsed.recovery ? 'recovery' : 'signed-in'
})()
inFlight.set(parsed.replayKey, operation)
try {
const result = await operation
rememberCompletion(parsed.replayKey, result)
return result
} finally {
inFlight.delete(parsed.replayKey)
}
}
}
export const completeAuthRedirect = createAuthRedirectHandler(supabase.auth)

View file

@ -0,0 +1,583 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { Linking, Platform } from 'react-native'
import {
ErrorCode,
deepLinkToSubscriptions,
getAvailablePurchases,
useIAP,
type ProductSubscription,
type Purchase,
type PurchaseError,
} from 'react-native-iap'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'
import { useAuth } from './auth-context'
import {
useEntitlement,
type EntitlementSnapshot,
type MobileSubscriptionTier,
} from './entitlement-context'
import { supabase } from './supabase'
import { getMobileRuntimeConfig } from './native-config'
export const GOOGLE_PLAY_SUBSCRIPTION_IDS = [
'd3ro_voice_pro_monthly',
'd3ro_voice_pro_plus_monthly',
] as const
export type GooglePlaySubscriptionId = typeof GOOGLE_PLAY_SUBSCRIPTION_IDS[number]
export type BillingOperation =
| 'idle'
| 'loading-products'
| 'purchasing'
| 'pending'
| 'verifying'
| 'restoring'
| 'succeeded'
| 'cancelled'
| 'failed'
export interface StoreSubscription {
id: GooglePlaySubscriptionId
title: string
description: string
displayPrice: string
offerToken: string
basePlanId: string | null
}
interface BillingContextValue {
connected: boolean
storeAvailable: boolean
products: readonly StoreSubscription[]
operation: BillingOperation
errorCode: string | null
purchase: (productId: GooglePlaySubscriptionId) => Promise<void>
restore: () => Promise<void>
manage: () => Promise<void>
refreshProducts: () => Promise<void>
clearOperation: () => void
}
export interface IapVerifyResult {
purchase?: unknown
verification?: unknown
finish_transaction?: unknown
server_acknowledged?: unknown
}
const BillingContext = createContext<BillingContextValue | null>(null)
const PRODUCT_IDS = new Set<string>(GOOGLE_PLAY_SUBSCRIPTION_IDS)
const GOOGLE_PLAY_PACKAGE_NAME = 'com.d3ro.voice'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const PRODUCT_TIERS: Readonly<Record<GooglePlaySubscriptionId, MobileSubscriptionTier>> = {
d3ro_voice_pro_monthly: 'pro',
d3ro_voice_pro_plus_monthly: 'pro_plus',
}
export function googlePlayAccountHash(userId: string): string {
return bytesToHex(sha256(utf8ToBytes(`d3ro-google-play:${userId}`)))
}
function normalizeStoreSubscription(
product: ProductSubscription,
): StoreSubscription | null {
if (
product.platform !== 'android'
|| !PRODUCT_IDS.has(product.id)
|| product.productStatusAndroid === 'not-found'
|| product.productStatusAndroid === 'no-offers-available'
) return null
const offers = product.subscriptionOffers.filter(
(offer) => typeof offer.offerTokenAndroid === 'string'
&& offer.offerTokenAndroid.length > 0,
)
const offer = offers.find((candidate) => (
candidate.offerTagsAndroid?.includes('default') === true
)) ?? offers[0]
if (!offer?.offerTokenAndroid) return null
return {
id: product.id as GooglePlaySubscriptionId,
title: product.title,
description: product.description,
displayPrice: offer.displayPrice || product.displayPrice,
offerToken: offer.offerTokenAndroid,
basePlanId: offer.basePlanIdAndroid ?? null,
}
}
function purchaseIdentity(purchase: Purchase): string | null {
const token = purchase.purchaseToken
if (!token || token.length < 8 || token.length > 4096) return null
if (!PRODUCT_IDS.has(purchase.productId)) return null
return `${purchase.productId}:${token}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function isVerifiedActivePurchase(
result: IapVerifyResult | null | undefined,
expectedProductId: string,
): boolean {
if (!result || !isRecord(result.verification)) return false
const verification = result.verification
return verification.product_id === expectedProductId
&& PRODUCT_IDS.has(expectedProductId)
&& (verification.purchase_state === 'purchased' || verification.purchase_state === 'cancelled')
&& verification.entitled === true
&& verification.acknowledged === true
&& result.server_acknowledged === true
}
function restorePurchaseIdentity(purchase: Purchase): string | null {
if (!PRODUCT_IDS.has(purchase.productId)) return null
if (purchase.store !== 'google') throw new Error('restore_purchase_source_invalid')
const packageName = 'packageNameAndroid' in purchase ? purchase.packageNameAndroid : null
if (packageName !== null && packageName !== undefined && packageName !== GOOGLE_PLAY_PACKAGE_NAME) {
throw new Error('restore_purchase_package_mismatch')
}
const token = purchase.purchaseToken
if (!token || token.length < 8 || token.length > 4096 || /\s/.test(token)) {
throw new Error('restore_purchase_payload_invalid')
}
return `${purchase.productId}:${token}`
}
function verifiedRestorableProduct(
result: IapVerifyResult | null | undefined,
expectedProductId: string,
): GooglePlaySubscriptionId | null {
if (
!result
|| !isRecord(result.verification)
|| !isRecord(result.purchase)
|| typeof result.purchase.purchase_id !== 'string'
|| !UUID_PATTERN.test(result.purchase.purchase_id)
|| result.finish_transaction !== false
) throw new Error('restore_verification_response_invalid')
const verification = result.verification
if (verification.product_id !== expectedProductId || !PRODUCT_IDS.has(expectedProductId)) {
throw new Error('restore_product_mismatch')
}
if (typeof verification.purchase_state !== 'string') {
throw new Error('restore_verification_response_invalid')
}
if (verification.entitled !== true) return null
if (
verification.purchase_state !== 'purchased'
&& verification.purchase_state !== 'cancelled'
) return null
if (
verification.acknowledged !== true
|| result.server_acknowledged !== true
|| result.purchase.acknowledged !== true
) throw new Error('restore_acknowledgement_missing')
return expectedProductId as GooglePlaySubscriptionId
}
export function isEntitlementForRestoredProducts(
snapshot: EntitlementSnapshot,
restoredProductIds: ReadonlySet<GooglePlaySubscriptionId>,
): boolean {
const productId = snapshot.storeProductId
if (
productId === null
|| !PRODUCT_IDS.has(productId)
|| !restoredProductIds.has(productId as GooglePlaySubscriptionId)
) return false
const typedProductId = productId as GooglePlaySubscriptionId
return snapshot.provider === 'google_play'
&& snapshot.paymentProvider === 'google_play'
&& snapshot.tier === PRODUCT_TIERS[typedProductId]
&& snapshot.adFree
&& snapshot.purchases.some((purchase) => (
purchase.platform === 'google_play'
&& purchase.productId === typedProductId
&& (purchase.state === 'purchased' || purchase.state === 'cancelled')
))
}
export function googlePlayRestoreAvailabilityError(input: {
installedFromPlayStore: boolean
connected: boolean
platform: string
userId: string | null
}): string | null {
if (!input.installedFromPlayStore) return 'play_store_install_required'
if (input.platform !== 'android' || input.userId === null) return 'restore_not_supported'
if (!input.connected) return 'store_connection_failed'
return null
}
export async function restoreGooglePlayPurchaseSet(
purchases: readonly Purchase[],
verifyPurchase: (purchase: Purchase) => Promise<IapVerifyResult | null | undefined>,
refreshEntitlement: () => Promise<EntitlementSnapshot | null>,
): Promise<EntitlementSnapshot> {
const eligiblePurchases = new Map<string, Purchase>()
for (const purchase of purchases) {
const identity = restorePurchaseIdentity(purchase)
if (identity !== null) eligiblePurchases.set(identity, purchase)
}
if (eligiblePurchases.size === 0) throw new Error('no_restorable_purchases')
const restoredProductIds = new Set<GooglePlaySubscriptionId>()
for (const purchase of eligiblePurchases.values()) {
const verifiedProduct = verifiedRestorableProduct(
await verifyPurchase(purchase),
purchase.productId,
)
if (verifiedProduct !== null) restoredProductIds.add(verifiedProduct)
}
if (restoredProductIds.size === 0) {
throw new Error('no_active_subscription_to_restore')
}
const refreshed = await refreshEntitlement()
if (refreshed === null) throw new Error('entitlement_refresh_failed')
if (!isEntitlementForRestoredProducts(refreshed, restoredProductIds)) {
throw new Error('restored_entitlement_mismatch')
}
return refreshed
}
async function functionErrorCode(candidate: unknown): Promise<string> {
if (candidate && typeof candidate === 'object' && 'context' in candidate) {
const context = (candidate as { context?: unknown }).context
if (context instanceof Response) {
try {
const body = await context.clone().json() as { error?: unknown }
if (typeof body.error === 'string') return body.error
} catch {
return 'purchase_verification_failed'
}
}
}
return candidate instanceof Error && candidate.message
? candidate.message
: 'purchase_verification_failed'
}
export function BillingProvider({ children }: { children: ReactNode }): React.ReactElement {
const { user } = useAuth()
const entitlement = useEntitlement()
const [operation, setOperation] = useState<BillingOperation>('idle')
const [errorCode, setErrorCode] = useState<string | null>(null)
const inFlightPurchases = useRef(new Set<string>())
const userIdRef = useRef<string | null>(user?.id ?? null)
const installedFromPlayStore = useMemo(
() => Platform.OS === 'android' && getMobileRuntimeConfig().installedFromPlayStore,
[],
)
useEffect(() => {
userIdRef.current = user?.id ?? null
inFlightPurchases.current.clear()
setOperation('idle')
setErrorCode(null)
}, [user?.id])
const verifyStorePurchase = useCallback(async (purchase: Purchase): Promise<void> => {
const identity = purchaseIdentity(purchase)
const currentUserId = userIdRef.current
if (!identity || !currentUserId || Platform.OS !== 'android') {
setOperation('failed')
setErrorCode('purchase_payload_invalid')
return
}
if (inFlightPurchases.current.has(identity)) return
inFlightPurchases.current.add(identity)
setOperation(purchase.purchaseState === 'pending' ? 'pending' : 'verifying')
setErrorCode(null)
try {
const { data, error } = await supabase.functions.invoke<IapVerifyResult>('iap-verify', {
body: {
platform: 'google_play',
productId: purchase.productId,
purchaseToken: purchase.purchaseToken,
},
})
if (error) throw new Error(await functionErrorCode(error))
const verifiedProduct = verifiedRestorableProduct(data, purchase.productId)
const verification = data && isRecord(data.verification) ? data.verification : null
const refreshed = await entitlement.refresh()
if (verifiedProduct !== null) {
if (
refreshed === null
|| !isEntitlementForRestoredProducts(refreshed, new Set([verifiedProduct]))
) throw new Error('purchased_entitlement_mismatch')
setOperation('succeeded')
} else if (verification?.purchase_state === 'pending') {
setOperation('pending')
} else {
throw new Error('purchase_not_verified_active')
}
} catch (candidate) {
if (userIdRef.current !== currentUserId) return
setOperation('failed')
setErrorCode(await functionErrorCode(candidate))
} finally {
inFlightPurchases.current.delete(identity)
}
}, [entitlement])
const handlePurchaseError = useCallback((error: PurchaseError): void => {
if (error.code === ErrorCode.UserCancelled) {
setOperation('cancelled')
setErrorCode(null)
return
}
if (error.code === ErrorCode.Pending || error.code === ErrorCode.DeferredPayment) {
setOperation('pending')
setErrorCode(null)
return
}
setOperation('failed')
setErrorCode(error.code || 'purchase_failed')
}, [])
const {
connected,
subscriptions,
fetchProducts,
requestPurchase,
reconnect,
} = useIAP({
onPurchaseSuccess: (purchase) => { void verifyStorePurchase(purchase) },
onPurchaseError: handlePurchaseError,
onError: (error) => {
setErrorCode(error.message || 'store_connection_failed')
setOperation('failed')
},
onSubscriptionBillingIssue: () => {
setErrorCode('subscription_billing_issue')
setOperation('failed')
},
})
const products = useMemo(
() => subscriptions
.map(normalizeStoreSubscription)
.filter((product): product is StoreSubscription => product !== null)
.sort((left, right) => (
GOOGLE_PLAY_SUBSCRIPTION_IDS.indexOf(left.id)
- GOOGLE_PLAY_SUBSCRIPTION_IDS.indexOf(right.id)
)),
[subscriptions],
)
const refreshProducts = useCallback(async (): Promise<void> => {
if (!userIdRef.current || Platform.OS !== 'android') return
if (!installedFromPlayStore) {
setOperation('failed')
setErrorCode('play_store_install_required')
return
}
setOperation('loading-products')
setErrorCode(null)
try {
if (!connected) {
const didReconnect = await reconnect()
if (!didReconnect) throw new Error('store_connection_failed')
}
await fetchProducts({ skus: [...GOOGLE_PLAY_SUBSCRIPTION_IDS], type: 'subs' })
setOperation('idle')
} catch (candidate) {
setOperation('failed')
setErrorCode(candidate instanceof Error ? candidate.message : 'product_fetch_failed')
}
}, [connected, fetchProducts, installedFromPlayStore, reconnect])
useEffect(() => {
if (!user?.id || !connected || !installedFromPlayStore || Platform.OS !== 'android') return
void refreshProducts()
}, [connected, installedFromPlayStore, refreshProducts, user?.id])
const purchase = useCallback(async (productId: GooglePlaySubscriptionId): Promise<void> => {
const currentUserId = userIdRef.current
const product = products.find((candidate) => candidate.id === productId)
if (!installedFromPlayStore) {
setOperation('failed')
setErrorCode('play_store_install_required')
return
}
if (!currentUserId || !product || Platform.OS !== 'android') {
setOperation('failed')
setErrorCode('store_product_unavailable')
return
}
setOperation('purchasing')
setErrorCode(null)
try {
if (!connected) throw new Error('store_connection_failed')
const currentProvider = entitlement.snapshot.provider
const currentProductId = entitlement.snapshot.storeProductId
if (currentProvider !== 'none' && currentProvider !== 'google_play') {
throw new Error('active_subscription_other_provider')
}
if (currentProvider === 'google_play' && currentProductId === product.id) {
throw new Error('subscription_already_active')
}
const replacementPurchases = currentProvider === 'google_play'
? await getAvailablePurchases({ includeSuspendedAndroid: true })
: []
const replacementPurchase = currentProvider === 'google_play'
? replacementPurchases.find((candidate) => (
candidate.productId === currentProductId
&& PRODUCT_IDS.has(candidate.productId)
&& candidate.store === 'google'
&& (!('packageNameAndroid' in candidate)
|| candidate.packageNameAndroid === null
|| candidate.packageNameAndroid === GOOGLE_PLAY_PACKAGE_NAME)
&& typeof candidate.purchaseToken === 'string'
&& candidate.purchaseToken.length >= 8
&& candidate.purchaseToken.length <= 4096
&& !/\s/.test(candidate.purchaseToken)
))
: undefined
if (currentProvider === 'google_play' && !replacementPurchase?.purchaseToken) {
throw new Error('active_subscription_not_restored')
}
const isUpgrade = product.id === 'd3ro_voice_pro_plus_monthly'
await requestPurchase({
type: 'subs',
request: {
google: {
skus: [product.id],
subscriptionOffers: [{
sku: product.id,
offerToken: product.offerToken,
}],
obfuscatedAccountId: googlePlayAccountHash(currentUserId),
purchaseToken: replacementPurchase?.purchaseToken,
subscriptionProductReplacementParams: replacementPurchase ? {
oldProductId: replacementPurchase.productId,
replacementMode: isUpgrade ? 'charge-prorated-price' : 'deferred',
} : undefined,
},
},
})
} catch (candidate) {
setOperation('failed')
setErrorCode(candidate instanceof Error ? candidate.message : 'purchase_failed')
}
}, [
connected,
entitlement.snapshot.provider,
entitlement.snapshot.storeProductId,
installedFromPlayStore,
products,
requestPurchase,
])
const restore = useCallback(async (): Promise<void> => {
const currentUserId = userIdRef.current
const unavailable = googlePlayRestoreAvailabilityError({
installedFromPlayStore,
connected,
platform: Platform.OS,
userId: currentUserId,
})
if (unavailable !== null) {
setOperation('failed')
setErrorCode(unavailable)
return
}
setOperation('restoring')
setErrorCode(null)
try {
const restoredPurchases = await getAvailablePurchases({
includeSuspendedAndroid: true,
})
await restoreGooglePlayPurchaseSet(
restoredPurchases,
async (restoredPurchase) => {
if (userIdRef.current !== currentUserId) throw new Error('restore_session_changed')
const { data, error } = await supabase.functions.invoke<IapVerifyResult>('iap-verify', {
body: {
platform: 'google_play',
productId: restoredPurchase.productId,
purchaseToken: restoredPurchase.purchaseToken,
},
})
if (error) throw new Error(await functionErrorCode(error))
return data
},
async () => {
if (userIdRef.current !== currentUserId) throw new Error('restore_session_changed')
return entitlement.refresh()
},
)
if (userIdRef.current !== currentUserId) throw new Error('restore_session_changed')
setOperation('succeeded')
} catch (candidate) {
if (userIdRef.current !== currentUserId) return
setOperation('failed')
setErrorCode(candidate instanceof Error ? candidate.message : 'restore_failed')
}
}, [connected, entitlement, installedFromPlayStore])
const manage = useCallback(async (): Promise<void> => {
const provider = entitlement.snapshot.provider
if (provider === 'google_play' && Platform.OS === 'android') {
await deepLinkToSubscriptions({
packageNameAndroid: 'com.d3ro.voice',
skuAndroid: entitlement.snapshot.storeProductId ?? undefined,
})
return
}
await Linking.openURL('https://d3ro.chanpaca.net/billing')
}, [entitlement.snapshot.provider, entitlement.snapshot.storeProductId])
const clearOperation = useCallback(() => {
setOperation('idle')
setErrorCode(null)
}, [])
const value = useMemo<BillingContextValue>(() => ({
connected: connected && installedFromPlayStore,
storeAvailable: installedFromPlayStore,
products,
operation,
errorCode,
purchase,
restore,
manage,
refreshProducts,
clearOperation,
}), [
clearOperation,
connected,
errorCode,
installedFromPlayStore,
manage,
operation,
products,
purchase,
refreshProducts,
restore,
])
return <BillingContext.Provider value={value}>{children}</BillingContext.Provider>
}
export function useBilling(): BillingContextValue {
const value = useContext(BillingContext)
if (!value) throw new Error('useBilling must be used inside BillingProvider')
return value
}

View file

@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { AppState, type AppStateStatus } from 'react-native'
import { useAuth } from './auth-context'
import { getMobileRuntimeConfig } from './native-config'
import { supabase } from './supabase'
import {
registerCurrentDevice,
type RegisteredDevice,
} from '../features/devices/device-service'
interface DeviceContextValue {
currentDevice: RegisteredDevice | null
loading: boolean
error: string | null
refreshCurrentDevice: () => Promise<void>
}
const DeviceContext = createContext<DeviceContextValue | null>(null)
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000
export function DeviceProvider({ children }: { children: ReactNode }): React.ReactElement {
const { user, purgeLocalSession } = useAuth()
const [currentDevice, setCurrentDevice] = useState<RegisteredDevice | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const generationRef = useRef(0)
const lastHeartbeatRef = useRef(0)
const revocationHandledRef = useRef(false)
const handleRevocation = useCallback(async (): Promise<void> => {
if (revocationHandledRef.current) return
revocationHandledRef.current = true
await purgeLocalSession()
}, [purgeLocalSession])
const refreshCurrentDevice = useCallback(async (): Promise<void> => {
const userId = user?.id
const generation = ++generationRef.current
if (!userId) {
setCurrentDevice(null)
setLoading(false)
setError(null)
return
}
setLoading(true)
try {
const device = await registerCurrentDevice(userId, getMobileRuntimeConfig())
if (generation !== generationRef.current) return
setCurrentDevice(device)
setError(null)
lastHeartbeatRef.current = Date.now()
if (device.revokedAt !== null) await handleRevocation()
} catch {
if (generation !== generationRef.current) return
setError('device_registration_failed')
} finally {
if (generation === generationRef.current) setLoading(false)
}
}, [handleRevocation, user?.id])
useEffect(() => {
revocationHandledRef.current = false
void refreshCurrentDevice()
return () => { generationRef.current += 1 }
}, [refreshCurrentDevice, user?.id])
useEffect(() => {
if (!user?.id || !currentDevice?.id) return undefined
const channel = supabase
.channel(`mobile-current-device:${currentDevice.id}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: `id=eq.${currentDevice.id}`,
},
(payload) => {
const revokedAt = (payload.new as { revoked_at?: unknown }).revoked_at
if (typeof revokedAt === 'string' && Number.isFinite(Date.parse(revokedAt))) {
void handleRevocation().catch(() => undefined)
}
},
)
.subscribe()
return () => { void supabase.removeChannel(channel) }
}, [currentDevice?.id, handleRevocation, user?.id])
useEffect(() => {
const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
if (
state === 'active'
&& user?.id
&& Date.now() - lastHeartbeatRef.current >= HEARTBEAT_INTERVAL_MS
) void refreshCurrentDevice()
})
return () => subscription.remove()
}, [refreshCurrentDevice, user?.id])
const value = useMemo<DeviceContextValue>(() => ({
currentDevice,
loading,
error,
refreshCurrentDevice,
}), [currentDevice, error, loading, refreshCurrentDevice])
return <DeviceContext.Provider value={value}>{children}</DeviceContext.Provider>
}
export function useDevice(): DeviceContextValue {
const value = useContext(DeviceContext)
if (!value) throw new Error('useDevice must be used inside DeviceProvider')
return value
}

View file

@ -0,0 +1,44 @@
import { NativeModules, Platform } from 'react-native'
interface E2eRuntimeCandidate {
e2eTestBuild?: unknown
supabaseUrlOverride?: unknown
supabaseAnonKeyOverride?: unknown
}
export function resolveMobileE2eSupabaseOverride(
candidate: E2eRuntimeCandidate | undefined,
platform: string,
): { url: string; anonKey: string } | null {
const url = typeof candidate?.supabaseUrlOverride === 'string'
? candidate.supabaseUrlOverride
: ''
const anonKey = typeof candidate?.supabaseAnonKeyOverride === 'string'
? candidate.supabaseAnonKeyOverride
: ''
if (url === '' && anonKey === '') return null
if (
platform !== 'android'
|| candidate?.e2eTestBuild !== true
|| url !== 'http://10.0.2.2:55321'
|| !/^sb_publishable_[A-Za-z0-9_-]{20,}$/.test(anonKey)
) {
throw new Error('mobile_e2e_supabase_override_invalid')
}
return { url, anonKey }
}
const override = resolveMobileE2eSupabaseOverride(
NativeModules.D3ROConfig as E2eRuntimeCandidate | undefined,
Platform.OS,
)
if (override !== null) {
;(
globalThis as typeof globalThis & {
__D3RO_MOBILE_E2E_SUPABASE__?: { url: string; anonKey: string }
}
).__D3RO_MOBILE_E2E_SUPABASE__ = override
}

View file

@ -0,0 +1,419 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { AppState } from 'react-native'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { useAuth } from './auth-context'
import { supabase } from './supabase'
export type MobileSubscriptionTier = 'free' | 'pro' | 'pro_plus'
export type MobileBillingProvider =
| 'none'
| 'stripe'
| 'payple'
| 'google_play'
| 'app_store'
| 'admin'
export interface MobilePurchaseSummary {
id: string
platform: 'google_play' | 'app_store'
productId: string
state: 'pending' | 'purchased' | 'cancelled' | 'expired' | 'refunded' | 'on_hold' | 'paused'
purchaseAt: string | null
expiresAt: string | null
autoRenewing: boolean | null
verifiedAt: string
}
export interface EntitlementSnapshot {
tier: MobileSubscriptionTier
status: string
provider: MobileBillingProvider
paymentProvider: Exclude<MobileBillingProvider, 'admin'>
currentPeriodStart: string | null
currentPeriodEnd: string | null
cancelAt: string | null
autoRenewing: boolean | null
storeProductId: string | null
overageCredits: number
usageToday: Readonly<Record<string, number>>
purchases: readonly MobilePurchaseSummary[]
adFree: boolean
refreshedAt: string
}
interface EntitlementContextValue {
snapshot: EntitlementSnapshot
loading: boolean
stale: boolean
error: string | null
refresh: () => Promise<EntitlementSnapshot | null>
}
interface SubscriptionRow {
tier?: unknown
status?: unknown
provider?: unknown
payment_provider?: unknown
current_period_start?: unknown
current_period_end?: unknown
cancel_at?: unknown
auto_renewing?: unknown
store_product_id?: unknown
overage_credits?: unknown
}
interface UsageRow {
feature?: unknown
count?: unknown
}
interface PurchaseRow {
id?: unknown
platform?: unknown
product_id?: unknown
purchase_state?: unknown
purchase_at?: unknown
expires_at?: unknown
auto_renewing?: unknown
verified_at?: unknown
}
interface CacheEnvelope {
schemaVersion: 1
userId: string
savedAt: string
snapshot: EntitlementSnapshot
}
const ENTITLEMENT_CACHE_PREFIX = '@d3ro/mobile/entitlement-v1/'
const SUBSCRIPTION_COLUMNS = [
'tier',
'status',
'provider',
'payment_provider',
'current_period_start',
'current_period_end',
'cancel_at',
'auto_renewing',
'store_product_id',
'overage_credits',
].join(',')
const PURCHASE_COLUMNS = [
'id',
'platform',
'product_id',
'purchase_state',
'purchase_at',
'expires_at',
'auto_renewing',
'verified_at',
].join(',')
const EntitlementContext = createContext<EntitlementContextValue | null>(null)
function emptySnapshot(): EntitlementSnapshot {
return {
tier: 'free',
status: 'active',
provider: 'none',
paymentProvider: 'none',
currentPeriodStart: null,
currentPeriodEnd: null,
cancelAt: null,
autoRenewing: null,
storeProductId: null,
overageCredits: 0,
usageToday: {},
purchases: [],
adFree: false,
refreshedAt: new Date(0).toISOString(),
}
}
function cacheKey(userId: string): string {
return `${ENTITLEMENT_CACHE_PREFIX}${userId}`
}
function nullableIso(value: unknown): string | null {
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) return null
return new Date(value).toISOString()
}
function normalizeTier(value: unknown): MobileSubscriptionTier {
return value === 'pro' || value === 'pro_plus' ? value : 'free'
}
function normalizeProvider(value: unknown): MobileBillingProvider {
return value === 'stripe'
|| value === 'payple'
|| value === 'google_play'
|| value === 'app_store'
|| value === 'admin'
? value
: 'none'
}
function normalizePaymentProvider(value: unknown): Exclude<MobileBillingProvider, 'admin'> {
const provider = normalizeProvider(value)
return provider === 'admin' ? 'none' : provider
}
function normalizeNonNegative(value: unknown): number {
const numeric = Number(value)
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : 0
}
function normalizePurchase(row: PurchaseRow): MobilePurchaseSummary | null {
const validPlatform = row.platform === 'google_play' || row.platform === 'app_store'
const validState = row.purchase_state === 'pending'
|| row.purchase_state === 'purchased'
|| row.purchase_state === 'cancelled'
|| row.purchase_state === 'expired'
|| row.purchase_state === 'refunded'
|| row.purchase_state === 'on_hold'
|| row.purchase_state === 'paused'
if (
typeof row.id !== 'string'
|| !validPlatform
|| typeof row.product_id !== 'string'
|| !validState
|| typeof row.verified_at !== 'string'
|| !Number.isFinite(Date.parse(row.verified_at))
) return null
return {
id: row.id,
platform: row.platform as MobilePurchaseSummary['platform'],
productId: row.product_id,
state: row.purchase_state as MobilePurchaseSummary['state'],
purchaseAt: nullableIso(row.purchase_at),
expiresAt: nullableIso(row.expires_at),
autoRenewing: typeof row.auto_renewing === 'boolean' ? row.auto_renewing : null,
verifiedAt: new Date(row.verified_at).toISOString(),
}
}
export function normalizeEntitlement(
subscription: SubscriptionRow | null,
usageRows: readonly UsageRow[],
purchaseRows: readonly PurchaseRow[],
now = new Date(),
): EntitlementSnapshot {
const tier = normalizeTier(subscription?.tier)
const status = typeof subscription?.status === 'string' && subscription.status
? subscription.status
: 'active'
const currentPeriodEnd = nullableIso(subscription?.current_period_end)
const periodValid = currentPeriodEnd === null || Date.parse(currentPeriodEnd) > now.getTime()
const statusAllowsPaidAccess = ['active', 'trialing', 'canceled', 'past_due'].includes(status)
const usageToday: Record<string, number> = {}
for (const row of usageRows) {
if (typeof row.feature === 'string' && row.feature) {
usageToday[row.feature] = normalizeNonNegative(row.count)
}
}
return {
tier,
status,
provider: normalizeProvider(subscription?.provider),
paymentProvider: normalizePaymentProvider(subscription?.payment_provider),
currentPeriodStart: nullableIso(subscription?.current_period_start),
currentPeriodEnd,
cancelAt: nullableIso(subscription?.cancel_at),
autoRenewing: typeof subscription?.auto_renewing === 'boolean'
? subscription.auto_renewing
: null,
storeProductId: typeof subscription?.store_product_id === 'string'
? subscription.store_product_id
: null,
overageCredits: normalizeNonNegative(subscription?.overage_credits),
usageToday,
purchases: purchaseRows
.map(normalizePurchase)
.filter((purchase): purchase is MobilePurchaseSummary => purchase !== null),
adFree: tier !== 'free' && statusAllowsPaidAccess && periodValid,
refreshedAt: now.toISOString(),
}
}
function isCachedSnapshot(value: unknown, userId: string): value is CacheEnvelope {
if (typeof value !== 'object' || value === null) return false
const envelope = value as Partial<CacheEnvelope>
return envelope.schemaVersion === 1
&& envelope.userId === userId
&& typeof envelope.savedAt === 'string'
&& typeof envelope.snapshot === 'object'
&& envelope.snapshot !== null
&& normalizeTier(envelope.snapshot.tier) === envelope.snapshot.tier
}
async function readCache(userId: string): Promise<EntitlementSnapshot | null> {
const serialized = await AsyncStorage.getItem(cacheKey(userId))
if (!serialized) return null
try {
const value: unknown = JSON.parse(serialized)
return isCachedSnapshot(value, userId) ? value.snapshot : null
} catch {
return null
}
}
async function writeCache(userId: string, snapshot: EntitlementSnapshot): Promise<void> {
const envelope: CacheEnvelope = {
schemaVersion: 1,
userId,
savedAt: new Date().toISOString(),
snapshot,
}
await AsyncStorage.setItem(cacheKey(userId), JSON.stringify(envelope))
}
export async function clearAllEntitlementCaches(): Promise<void> {
const keys = await AsyncStorage.getAllKeys()
const entitlementKeys = keys.filter((key) => key.startsWith(ENTITLEMENT_CACHE_PREFIX))
if (entitlementKeys.length > 0) await AsyncStorage.multiRemove(entitlementKeys)
}
export async function fetchEntitlementSnapshot(userId: string): Promise<EntitlementSnapshot> {
const date = new Date().toISOString().slice(0, 10)
const [subscriptionResult, usageResult, purchasesResult] = await Promise.all([
supabase
.from('subscriptions')
.select(SUBSCRIPTION_COLUMNS)
.eq('user_id', userId)
.maybeSingle(),
supabase
.from('daily_usage')
.select('feature, count')
.eq('user_id', userId)
.eq('date', date),
supabase
.from('iap_purchases')
.select(PURCHASE_COLUMNS)
.eq('user_id', userId)
.order('verified_at', { ascending: false })
.limit(20),
])
if (subscriptionResult.error) throw subscriptionResult.error
if (usageResult.error) throw usageResult.error
if (purchasesResult.error) throw purchasesResult.error
return normalizeEntitlement(
subscriptionResult.data as SubscriptionRow | null,
(usageResult.data ?? []) as UsageRow[],
(purchasesResult.data ?? []) as PurchaseRow[],
)
}
export function EntitlementProvider({ children }: { children: ReactNode }): React.ReactElement {
const { user } = useAuth()
const [snapshot, setSnapshot] = useState<EntitlementSnapshot>(emptySnapshot)
const [loading, setLoading] = useState(true)
const [stale, setStale] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestGeneration = useRef(0)
const refresh = useCallback(async (): Promise<EntitlementSnapshot | null> => {
const currentUserId = user?.id
const generation = ++requestGeneration.current
if (!currentUserId) {
setSnapshot(emptySnapshot())
setLoading(false)
setStale(false)
setError(null)
return null
}
try {
const next = await fetchEntitlementSnapshot(currentUserId)
if (generation !== requestGeneration.current) return null
setSnapshot(next)
setStale(false)
setError(null)
setLoading(false)
await writeCache(currentUserId, next)
return next
} catch (candidate) {
if (generation !== requestGeneration.current) return null
setError(candidate instanceof Error ? candidate.message : 'entitlement_sync_failed')
setStale(true)
setLoading(false)
return null
}
}, [user?.id])
useEffect(() => {
const userId = user?.id
let cancelled = false
requestGeneration.current += 1
setLoading(true)
setError(null)
if (!userId) {
setSnapshot(emptySnapshot())
setStale(false)
setLoading(false)
return () => { cancelled = true }
}
void readCache(userId).then((cached) => {
if (cancelled || !cached) return
setSnapshot(cached)
setStale(true)
setLoading(false)
}).finally(() => {
if (!cancelled) void refresh()
})
const channel = supabase
.channel(`mobile-entitlement:${userId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'subscriptions', filter: `user_id=eq.${userId}` },
() => { void refresh() },
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'subscriptions', filter: `user_id=eq.${userId}` },
() => { void refresh() },
)
.subscribe()
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') void refresh()
})
return () => {
cancelled = true
requestGeneration.current += 1
appStateSubscription.remove()
void supabase.removeChannel(channel)
}
}, [refresh, user?.id])
const value = useMemo<EntitlementContextValue>(() => ({
snapshot,
loading,
stale,
error,
refresh,
}), [error, loading, refresh, snapshot, stale])
return <EntitlementContext.Provider value={value}>{children}</EntitlementContext.Provider>
}
export function useEntitlement(): EntitlementContextValue {
const value = useContext(EntitlementContext)
if (!value) throw new Error('useEntitlement must be used inside EntitlementProvider')
return value
}

View file

@ -0,0 +1,27 @@
import { supabase } from './supabase'
export interface LocalLogoutResult {
remoteRevocationConfirmed: boolean
}
/**
* Attempts server-side refresh-token revocation first, then always performs
* the caller's local secure purge. A network outage must not trap a user in a
* signed-in device or leave a reusable refresh token in local storage.
*/
export async function signOutWithLocalFallback(
purgeLocalSession: () => Promise<void>,
): Promise<LocalLogoutResult> {
let remoteRevocationConfirmed = false
try {
const { error } = await supabase.auth.signOut()
remoteRevocationConfirmed = error === null
} catch {
remoteRevocationConfirmed = false
}
// This remains fatal: the UI must never claim local logout if Keychain
// deletion itself failed.
await purgeLocalSession()
return { remoteRevocationConfirmed }
}

View file

@ -0,0 +1,350 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import {
AdsConsent,
AdsConsentPrivacyOptionsRequirementStatus,
MaxAdContentRating,
MobileAds,
useRewardedAd,
} from 'react-native-google-mobile-ads'
import { useAuth } from './auth-context'
import { useEntitlement } from './entitlement-context'
import { getMobileRuntimeConfig } from './native-config'
import { supabase } from './supabase'
export type MobileAdsStatus =
| 'disabled'
| 'checking-consent'
| 'consent-blocked'
| 'initializing'
| 'ready'
| 'error'
interface MobileAdsContextValue {
eligible: boolean
status: MobileAdsStatus
errorCode: string | null
bannerUnitId: string | null
rewardedUnitId: string | null
privacyOptionsRequired: boolean
showPrivacyOptions: () => Promise<boolean>
}
export type RewardedCreditStatus =
| 'unavailable'
| 'loading'
| 'ready'
| 'showing'
| 'server-verifying'
| 'credited'
| 'verification-pending'
| 'closed-without-reward'
| 'error'
interface RewardedCreditsValue {
status: RewardedCreditStatus
errorCode: string | null
show: () => void
reload: () => void
}
const MobileAdsContext = createContext<MobileAdsContextValue | null>(null)
function candidateMessage(candidate: unknown, fallback: string): string {
return candidate instanceof Error && candidate.message ? candidate.message : fallback
}
export function MobileAdsProvider({ children }: { children: ReactNode }): React.ReactElement {
const { user } = useAuth()
const entitlement = useEntitlement()
const [status, setStatus] = useState<MobileAdsStatus>('disabled')
const [errorCode, setErrorCode] = useState<string | null>(null)
const [privacyOptionsRequired, setPrivacyOptionsRequired] = useState(false)
const initialized = useRef(false)
const generation = useRef(0)
const eligible = user !== null
&& !entitlement.loading
&& !entitlement.stale
&& entitlement.error === null
&& entitlement.snapshot.tier === 'free'
&& !entitlement.snapshot.adFree
const runtimeConfig = useMemo(() => {
try {
return getMobileRuntimeConfig()
} catch {
return null
}
}, [])
const initializeAds = useCallback(async (): Promise<void> => {
if (initialized.current) {
setStatus('ready')
return
}
setStatus('initializing')
await MobileAds().setRequestConfiguration({
maxAdContentRating: MaxAdContentRating.T,
testDeviceIdentifiers: runtimeConfig?.debug ? ['EMULATOR'] : undefined,
})
await MobileAds().initialize()
initialized.current = true
setStatus('ready')
}, [runtimeConfig?.debug])
useEffect(() => {
const currentGeneration = ++generation.current
if (!eligible) {
setStatus('disabled')
setErrorCode(null)
return
}
if (!runtimeConfig) {
setStatus('error')
setErrorCode('mobile_native_config_invalid')
return
}
setStatus('checking-consent')
setErrorCode(null)
void AdsConsent.gatherConsent()
.then(async (consentInfo) => {
if (currentGeneration !== generation.current) return
setPrivacyOptionsRequired(
consentInfo.privacyOptionsRequirementStatus
=== AdsConsentPrivacyOptionsRequirementStatus.REQUIRED,
)
if (!consentInfo.canRequestAds) {
setStatus('consent-blocked')
return
}
await initializeAds()
})
.catch((candidate) => {
if (currentGeneration !== generation.current) return
setStatus('error')
setErrorCode(candidateMessage(candidate, 'ad_consent_failed'))
})
}, [eligible, initializeAds, runtimeConfig])
useEffect(() => {
void AdsConsent.getConsentInfo().then((info) => {
setPrivacyOptionsRequired(
info.privacyOptionsRequirementStatus
=== AdsConsentPrivacyOptionsRequirementStatus.REQUIRED,
)
}).catch(() => undefined)
}, [])
const showPrivacyOptions = useCallback(async (): Promise<boolean> => {
try {
const consentInfo = await AdsConsent.showPrivacyOptionsForm()
setPrivacyOptionsRequired(
consentInfo.privacyOptionsRequirementStatus
=== AdsConsentPrivacyOptionsRequirementStatus.REQUIRED,
)
if (eligible && consentInfo.canRequestAds) await initializeAds()
if (eligible && !consentInfo.canRequestAds) setStatus('consent-blocked')
return true
} catch (candidate) {
setErrorCode(candidateMessage(candidate, 'privacy_options_unavailable'))
return false
}
}, [eligible, initializeAds])
const value = useMemo<MobileAdsContextValue>(() => ({
eligible,
status,
errorCode,
bannerUnitId: eligible && status === 'ready'
? runtimeConfig?.adMobBannerUnitId ?? null
: null,
rewardedUnitId: eligible && status === 'ready'
? runtimeConfig?.adMobRewardedUnitId ?? null
: null,
privacyOptionsRequired,
showPrivacyOptions,
}), [
eligible,
errorCode,
privacyOptionsRequired,
runtimeConfig?.adMobBannerUnitId,
runtimeConfig?.adMobRewardedUnitId,
showPrivacyOptions,
status,
])
return <MobileAdsContext.Provider value={value}>{children}</MobileAdsContext.Provider>
}
export function useMobileAds(): MobileAdsContextValue {
const value = useContext(MobileAdsContext)
if (!value) throw new Error('useMobileAds must be used inside MobileAdsProvider')
return value
}
async function waitForVerifiedReward(
userId: string,
baselineCredits: number,
cancelled: () => boolean,
): Promise<boolean> {
const delays = [1200, 1800, 2500, 3500, 5000]
for (const delay of delays) {
await new Promise<void>((resolve) => setTimeout(resolve, delay))
if (cancelled()) return false
const { data, error } = await supabase
.from('subscriptions')
.select('overage_credits')
.eq('user_id', userId)
.maybeSingle()
if (error) continue
const credits = Number(data?.overage_credits ?? 0)
if (Number.isSafeInteger(credits) && credits > baselineCredits) return true
}
return false
}
export function useRewardedCredits(): RewardedCreditsValue {
const ads = useMobileAds()
const { user } = useAuth()
const entitlement = useEntitlement()
const [status, setStatus] = useState<RewardedCreditStatus>('unavailable')
const [errorCode, setErrorCode] = useState<string | null>(null)
const handledReward = useRef(false)
const loadRequested = useRef(false)
const verificationGeneration = useRef(0)
const requestOptions = useMemo(() => ({
requestNonPersonalizedAdsOnly: true,
serverSideVerificationOptions: user ? {
userId: user.id,
customData: 'd3ro-reward-v1',
} : undefined,
}), [user])
const {
error: rewardedError,
isClosed: rewardedIsClosed,
isEarnedReward: rewardedIsEarned,
isLoaded: rewardedIsLoaded,
isShowing: rewardedIsShowing,
load: loadRewarded,
show: showRewarded,
} = useRewardedAd(ads.rewardedUnitId, requestOptions)
const loadCallbackIdentity = useRef(loadRewarded)
useEffect(() => {
if (!ads.rewardedUnitId) {
loadRequested.current = false
setStatus('unavailable')
return
}
if (rewardedError) {
loadRequested.current = false
setStatus('error')
setErrorCode(rewardedError.message || 'rewarded_ad_failed')
return
}
if (rewardedIsShowing) {
setStatus('showing')
return
}
if (rewardedIsLoaded) {
loadRequested.current = false
setStatus('ready')
return
}
if (rewardedIsClosed && !rewardedIsEarned) {
setStatus('closed-without-reward')
return
}
setStatus('loading')
}, [
ads.rewardedUnitId,
rewardedError,
rewardedIsClosed,
rewardedIsEarned,
rewardedIsLoaded,
rewardedIsShowing,
])
useEffect(() => {
// useRewardedAd creates its native ad instance after the first render.
// The initial load callback can therefore target a null instance. When
// the hook replaces that callback with one bound to the real ad, release
// the single-load guard so the native request is actually made.
if (loadCallbackIdentity.current !== loadRewarded) {
loadCallbackIdentity.current = loadRewarded
loadRequested.current = false
}
if (rewardedIsClosed) loadRequested.current = false
if (
ads.rewardedUnitId
&& !rewardedIsLoaded
&& !rewardedIsShowing
&& !rewardedError
&& !loadRequested.current
) {
loadRequested.current = true
loadRewarded()
}
}, [
ads.rewardedUnitId,
loadRewarded,
rewardedError,
rewardedIsClosed,
rewardedIsLoaded,
rewardedIsShowing,
])
useEffect(() => {
if (!rewardedIsEarned || handledReward.current || !user) return
handledReward.current = true
const currentGeneration = ++verificationGeneration.current
const baselineCredits = entitlement.snapshot.overageCredits
setStatus('server-verifying')
setErrorCode(null)
void waitForVerifiedReward(
user.id,
baselineCredits,
() => currentGeneration !== verificationGeneration.current,
).then(async (credited) => {
if (currentGeneration !== verificationGeneration.current) return
await entitlement.refresh()
setStatus(credited ? 'credited' : 'verification-pending')
}).catch((candidate) => {
if (currentGeneration !== verificationGeneration.current) return
setStatus('error')
setErrorCode(candidateMessage(candidate, 'reward_verification_failed'))
})
}, [entitlement, rewardedIsEarned, user])
useEffect(() => () => {
verificationGeneration.current += 1
}, [])
const show = useCallback(() => {
if (!rewardedIsLoaded || rewardedIsShowing) return
handledReward.current = false
setErrorCode(null)
showRewarded()
}, [rewardedIsLoaded, rewardedIsShowing, showRewarded])
const reload = useCallback(() => {
handledReward.current = false
verificationGeneration.current += 1
setErrorCode(null)
setStatus('loading')
loadRequested.current = true
loadRewarded()
}, [loadRewarded])
return { status, errorCode, show, reload }
}

View file

@ -0,0 +1,106 @@
import { NativeModules, Platform } from 'react-native'
export interface MobileRuntimeConfig {
debug: boolean
packageName: string
adMobBannerUnitId: string
adMobRewardedUnitId: string
playStoreInstalled: boolean
installedFromPlayStore: boolean
installationId: string
deviceName: string
osVersion: string
appVersion: string
}
interface NativeConfigCandidate {
debug?: unknown
packageName?: unknown
adMobBannerUnitId?: unknown
adMobRewardedUnitId?: unknown
playStoreInstalled?: unknown
installedFromPlayStore?: unknown
installationId?: unknown
deviceName?: unknown
osVersion?: unknown
appVersion?: unknown
}
const ANDROID_PACKAGE_NAME = 'com.d3ro.voice'
const TEST_PUBLISHER_PREFIX = 'ca-app-pub-3940256099942544/'
const DEBUG_CONFIG: MobileRuntimeConfig = {
debug: true,
packageName: ANDROID_PACKAGE_NAME,
adMobBannerUnitId: `${TEST_PUBLISHER_PREFIX}6300978111`,
adMobRewardedUnitId: `${TEST_PUBLISHER_PREFIX}5224354917`,
playStoreInstalled: false,
installedFromPlayStore: false,
installationId: '00000000-0000-4000-8000-000000000000',
deviceName: 'Development device',
osVersion: Platform.OS,
appVersion: 'development',
}
function isAdUnitId(value: unknown): value is string {
return typeof value === 'string' && /^ca-app-pub-\d+\/\d+$/.test(value)
}
export function normalizeMobileRuntimeConfig(
candidate: NativeConfigCandidate | null | undefined,
): MobileRuntimeConfig {
if (
!candidate
|| typeof candidate.debug !== 'boolean'
|| candidate.packageName !== ANDROID_PACKAGE_NAME
|| !isAdUnitId(candidate.adMobBannerUnitId)
|| !isAdUnitId(candidate.adMobRewardedUnitId)
|| typeof candidate.playStoreInstalled !== 'boolean'
|| typeof candidate.installedFromPlayStore !== 'boolean'
|| typeof candidate.installationId !== 'string'
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(candidate.installationId)
|| typeof candidate.deviceName !== 'string'
|| candidate.deviceName.trim().length === 0
|| candidate.deviceName.length > 120
|| typeof candidate.osVersion !== 'string'
|| candidate.osVersion.trim().length === 0
|| candidate.osVersion.length > 120
|| typeof candidate.appVersion !== 'string'
|| candidate.appVersion.trim().length === 0
|| candidate.appVersion.length > 40
) {
throw new Error('mobile_native_config_invalid')
}
if (
!candidate.debug
&& (
candidate.adMobBannerUnitId.startsWith(TEST_PUBLISHER_PREFIX)
|| candidate.adMobRewardedUnitId.startsWith(TEST_PUBLISHER_PREFIX)
)
) {
throw new Error('production_ad_unit_uses_test_id')
}
return {
debug: candidate.debug,
packageName: candidate.packageName,
adMobBannerUnitId: candidate.adMobBannerUnitId,
adMobRewardedUnitId: candidate.adMobRewardedUnitId,
playStoreInstalled: candidate.playStoreInstalled,
installedFromPlayStore: candidate.installedFromPlayStore,
installationId: candidate.installationId,
deviceName: candidate.deviceName.trim(),
osVersion: candidate.osVersion.trim(),
appVersion: candidate.appVersion.trim(),
}
}
export function getMobileRuntimeConfig(): MobileRuntimeConfig {
if (Platform.OS !== 'android') {
if (__DEV__) return DEBUG_CONFIG
throw new Error('ios_mobile_native_config_unavailable')
}
return normalizeMobileRuntimeConfig(
NativeModules.D3ROConfig as NativeConfigCandidate | undefined,
)
}

View file

@ -0,0 +1,55 @@
import { sha256 } from '@noble/hashes/sha256'
type DigestAlgorithm = string | { readonly name?: unknown }
type DigestInput = ArrayBuffer | ArrayBufferView
interface PkceSubtleCrypto {
digest?: (algorithm: DigestAlgorithm, data: DigestInput) => Promise<ArrayBuffer>
}
export interface PkceCryptoTarget {
getRandomValues?: <T extends ArrayBufferView>(array: T) => T
subtle?: PkceSubtleCrypto
}
function normalizeAlgorithm(algorithm: DigestAlgorithm): string {
const name = typeof algorithm === 'string' ? algorithm : algorithm.name
return typeof name === 'string' ? name.trim().toUpperCase().replaceAll('_', '-') : ''
}
function inputBytes(data: DigestInput): Uint8Array {
if (data instanceof ArrayBuffer) return new Uint8Array(data)
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
}
/**
* Supabase Auth requires WebCrypto SHA-256 to generate an S256 PKCE challenge.
* React Native's native random-values shim supplies CSPRNG bytes but not
* `crypto.subtle`; without this narrow digest implementation auth-js silently
* downgrades to `code_challenge_method=plain`.
*/
export function installPkceS256(target: PkceCryptoTarget | undefined): void {
if (!target || typeof target.getRandomValues !== 'function') {
throw new Error('pkce_native_csprng_unavailable')
}
if (typeof target.subtle?.digest === 'function') return
const digest = async (algorithm: DigestAlgorithm, data: DigestInput): Promise<ArrayBuffer> => {
if (normalizeAlgorithm(algorithm) !== 'SHA-256') {
throw new Error('pkce_digest_algorithm_unsupported')
}
if (!(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
throw new Error('pkce_digest_input_invalid')
}
return new Uint8Array(sha256(inputBytes(data))).buffer
}
Object.defineProperty(target, 'subtle', {
value: Object.freeze({ digest }),
enumerable: true,
configurable: false,
writable: false,
})
}
installPkceS256(globalThis.crypto as unknown as PkceCryptoTarget | undefined)

View file

@ -0,0 +1,909 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
AppState,
useColorScheme,
} from 'react-native'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import type { ReactNode } from 'react'
import { useAuth } from './auth-context'
import { supabase } from './supabase'
import {
getMobileThemePalette,
isMobileThemeMode,
resolveEffectiveTheme,
type EffectiveMobileTheme,
type MobileThemeMode,
type MobileThemePalette,
} from '../theme/mobile-theme'
export const CURRENT_ONBOARDING_VERSION = 1
const CACHE_SCHEMA_VERSION = 1
const INSTALLATION_CACHE_KEY = '@d3ro/mobile/preferences/installation-v1'
const USER_CACHE_PREFIX = '@d3ro/mobile/preferences/user-v1/'
const USER_SETTINGS_COLUMNS = [
'user_id',
'theme_mode',
'locale',
'haptic_enabled',
'auto_polish_enabled',
'preferred_stt_model',
'preferred_llm_model',
'onboarding_version',
'tutorial_completed_at',
'revision',
'updated_at',
].join(',')
export type SupportedMobileLocale = 'ko' | 'en'
export type PreferencesSyncStatus =
| 'loading'
| 'local'
| 'saving'
| 'synced'
| 'offline'
| 'error'
export type PreferencesErrorCode =
| 'CACHE_READ_FAILED'
| 'CACHE_WRITE_FAILED'
| 'SYNC_FAILED'
| 'SYNC_CONFLICT'
export interface MobilePreferences {
themeMode: MobileThemeMode
locale: SupportedMobileLocale
hapticEnabled: boolean
autoPolishEnabled: boolean
preferredSttModel: string | null
preferredLlmModel: string | null
onboardingVersion: number
tutorialCompletedAt: string | null
revision: number
updatedAt: string | null
}
export type MobilePreferencesPatch = Partial<Pick<
MobilePreferences,
| 'themeMode'
| 'locale'
| 'hapticEnabled'
| 'autoPolishEnabled'
| 'preferredSttModel'
| 'preferredLlmModel'
| 'onboardingVersion'
| 'tutorialCompletedAt'
>>
export interface PreferenceMutationResult {
localSaved: boolean
serverSynced: boolean
}
interface PreferencesContextValue {
preferences: MobilePreferences
loading: boolean
syncStatus: PreferencesSyncStatus
errorCode: PreferencesErrorCode | null
lastSyncedAt: string | null
effectiveTheme: EffectiveMobileTheme
palette: MobileThemePalette
needsOnboarding: boolean
updatePreferences: (patch: MobilePreferencesPatch) => Promise<PreferenceMutationResult>
completeOnboarding: (
outcome: 'completed' | 'skipped',
) => Promise<PreferenceMutationResult>
retrySync: () => Promise<void>
}
interface CacheEnvelope {
schemaVersion: number
preferences: MobilePreferences
pendingPatch: MobilePreferencesPatch
lastSyncedAt: string | null
}
interface SettingsRow {
user_id: string
theme_mode: unknown
locale: unknown
haptic_enabled: unknown
auto_polish_enabled: unknown
preferred_stt_model: unknown
preferred_llm_model: unknown
onboarding_version: unknown
tutorial_completed_at: unknown
revision: unknown
updated_at: unknown
}
interface ProviderSnapshot {
ownerKey: string
preferences: MobilePreferences
loading: boolean
syncStatus: PreferencesSyncStatus
errorCode: PreferencesErrorCode | null
lastSyncedAt: string | null
}
const DEFAULT_PREFERENCES: MobilePreferences = Object.freeze({
themeMode: 'system',
locale: 'ko',
hapticEnabled: true,
autoPolishEnabled: true,
preferredSttModel: null,
preferredLlmModel: null,
onboardingVersion: 0,
tutorialCompletedAt: null,
revision: 1,
updatedAt: null,
})
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export async function clearAllUserPreferenceCaches(): Promise<void> {
const keys = await AsyncStorage.getAllKeys()
const userKeys = keys.filter((key) => key.startsWith(USER_CACHE_PREFIX))
if (userKeys.length > 0) await AsyncStorage.multiRemove(userKeys)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizeLocale(value: unknown): SupportedMobileLocale {
return value === 'en' ? 'en' : 'ko'
}
function normalizeNullableString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0
? value.trim()
: null
}
function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : fallback
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback
}
function normalizeIsoDate(value: unknown): string | null {
if (typeof value !== 'string') return null
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null
}
export function normalizePreferences(
value: unknown,
fallback: MobilePreferences = DEFAULT_PREFERENCES,
): MobilePreferences {
if (!isRecord(value)) return { ...fallback }
return {
themeMode: isMobileThemeMode(value.themeMode) ? value.themeMode : fallback.themeMode,
locale: normalizeLocale(value.locale ?? fallback.locale),
hapticEnabled: typeof value.hapticEnabled === 'boolean'
? value.hapticEnabled
: fallback.hapticEnabled,
autoPolishEnabled: typeof value.autoPolishEnabled === 'boolean'
? value.autoPolishEnabled
: fallback.autoPolishEnabled,
preferredSttModel: value.preferredSttModel === undefined
? fallback.preferredSttModel
: normalizeNullableString(value.preferredSttModel),
preferredLlmModel: value.preferredLlmModel === undefined
? fallback.preferredLlmModel
: normalizeNullableString(value.preferredLlmModel),
onboardingVersion: normalizeNonNegativeInteger(
value.onboardingVersion,
fallback.onboardingVersion,
),
tutorialCompletedAt: value.tutorialCompletedAt === undefined
? fallback.tutorialCompletedAt
: normalizeIsoDate(value.tutorialCompletedAt),
revision: normalizePositiveInteger(value.revision, fallback.revision),
updatedAt: value.updatedAt === undefined
? fallback.updatedAt
: normalizeIsoDate(value.updatedAt),
}
}
export function normalizePreferencesPatch(value: unknown): MobilePreferencesPatch {
if (!isRecord(value)) return {}
const patch: MobilePreferencesPatch = {}
if (isMobileThemeMode(value.themeMode)) patch.themeMode = value.themeMode
if (value.locale === 'ko' || value.locale === 'en') patch.locale = value.locale
if (typeof value.hapticEnabled === 'boolean') patch.hapticEnabled = value.hapticEnabled
if (typeof value.autoPolishEnabled === 'boolean') {
patch.autoPolishEnabled = value.autoPolishEnabled
}
if (value.preferredSttModel === null || typeof value.preferredSttModel === 'string') {
patch.preferredSttModel = normalizeNullableString(value.preferredSttModel)
}
if (value.preferredLlmModel === null || typeof value.preferredLlmModel === 'string') {
patch.preferredLlmModel = normalizeNullableString(value.preferredLlmModel)
}
if (value.onboardingVersion !== undefined) {
patch.onboardingVersion = normalizeNonNegativeInteger(value.onboardingVersion, 0)
}
if (value.tutorialCompletedAt === null || typeof value.tutorialCompletedAt === 'string') {
patch.tutorialCompletedAt = normalizeIsoDate(value.tutorialCompletedAt)
}
return patch
}
export function applyPreferencesPatch(
preferences: MobilePreferences,
patch: MobilePreferencesPatch,
): MobilePreferences {
return normalizePreferences({ ...preferences, ...normalizePreferencesPatch(patch) }, preferences)
}
function parseSettingsRow(value: unknown): MobilePreferences | null {
if (!isRecord(value) || typeof value.user_id !== 'string') return null
const row = value as unknown as SettingsRow
return normalizePreferences({
themeMode: row.theme_mode,
locale: row.locale,
hapticEnabled: row.haptic_enabled,
autoPolishEnabled: row.auto_polish_enabled,
preferredSttModel: row.preferred_stt_model,
preferredLlmModel: row.preferred_llm_model,
onboardingVersion: row.onboarding_version,
tutorialCompletedAt: row.tutorial_completed_at,
revision: row.revision,
updatedAt: row.updated_at,
})
}
function parseCacheEnvelope(raw: string | null): CacheEnvelope | null {
if (raw === null) return null
const parsed: unknown = JSON.parse(raw)
if (!isRecord(parsed) || parsed.schemaVersion !== CACHE_SCHEMA_VERSION) {
throw new Error('Unsupported mobile preferences cache schema')
}
return {
schemaVersion: CACHE_SCHEMA_VERSION,
preferences: normalizePreferences(parsed.preferences),
pendingPatch: normalizePreferencesPatch(parsed.pendingPatch),
lastSyncedAt: normalizeIsoDate(parsed.lastSyncedAt),
}
}
function createEnvelope(
preferences: MobilePreferences,
pendingPatch: MobilePreferencesPatch,
lastSyncedAt: string | null,
): CacheEnvelope {
return {
schemaVersion: CACHE_SCHEMA_VERSION,
preferences,
pendingPatch: normalizePreferencesPatch(pendingPatch),
lastSyncedAt,
}
}
function toInstallationPreferences(
preferences: MobilePreferences,
): MobilePreferences {
return {
...DEFAULT_PREFERENCES,
themeMode: preferences.themeMode,
locale: preferences.locale,
hapticEnabled: preferences.hapticEnabled,
onboardingVersion: preferences.onboardingVersion,
tutorialCompletedAt: preferences.tutorialCompletedAt,
}
}
function getUserCacheKey(userId: string): string {
return `${USER_CACHE_PREFIX}${userId}`
}
function serializeEnvelope(envelope: CacheEnvelope): string {
return JSON.stringify(envelope)
}
async function writeCaches(
userId: string | null,
preferences: MobilePreferences,
userPendingPatch: MobilePreferencesPatch,
lastSyncedAt: string | null,
installationPendingPatch: MobilePreferencesPatch = {},
): Promise<void> {
const installationEnvelope = createEnvelope(
toInstallationPreferences(preferences),
installationPendingPatch,
lastSyncedAt,
)
if (userId === null) {
await AsyncStorage.setItem(
INSTALLATION_CACHE_KEY,
serializeEnvelope(installationEnvelope),
)
return
}
const userEnvelope = createEnvelope(
preferences,
userPendingPatch,
lastSyncedAt,
)
await AsyncStorage.multiSet([
[INSTALLATION_CACHE_KEY, serializeEnvelope(installationEnvelope)],
[getUserCacheKey(userId), serializeEnvelope(userEnvelope)],
])
}
function toRowMutation(
userId: string,
preferences: MobilePreferences,
): Record<string, unknown> {
return {
user_id: userId,
theme_mode: preferences.themeMode,
locale: preferences.locale,
haptic_enabled: preferences.hapticEnabled,
auto_polish_enabled: preferences.autoPolishEnabled,
preferred_stt_model: preferences.preferredSttModel,
preferred_llm_model: preferences.preferredLlmModel,
onboarding_version: preferences.onboardingVersion,
tutorial_completed_at: preferences.tutorialCompletedAt,
revision: preferences.revision,
}
}
async function fetchServerPreferences(userId: string): Promise<MobilePreferences | null> {
const { data, error } = await supabase
.from('user_settings')
.select(USER_SETTINGS_COLUMNS)
.eq('user_id', userId)
.maybeSingle()
if (error) throw error
return parseSettingsRow(data)
}
async function writeServerPreferences(
userId: string,
fallback: MobilePreferences,
patch: MobilePreferencesPatch,
): Promise<MobilePreferences> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const remote = await fetchServerPreferences(userId)
if (remote === null) {
const desired = applyPreferencesPatch(fallback, patch)
const insertValue: MobilePreferences = {
...desired,
revision: Math.max(1, desired.revision),
}
const { data, error } = await supabase
.from('user_settings')
.insert(toRowMutation(userId, insertValue))
.select(USER_SETTINGS_COLUMNS)
.single()
if (error?.code === '23505') continue
if (error) throw error
const inserted = parseSettingsRow(data)
if (inserted === null) throw new Error('Invalid user settings insert response')
return inserted
}
const desired: MobilePreferences = {
...applyPreferencesPatch(remote, patch),
revision: remote.revision + 1,
}
const { data, error } = await supabase
.from('user_settings')
.update(toRowMutation(userId, desired))
.eq('user_id', userId)
.eq('revision', remote.revision)
.select(USER_SETTINGS_COLUMNS)
.maybeSingle()
if (error) throw error
const updated = parseSettingsRow(data)
if (updated !== null) return updated
}
const conflict = new Error('User settings changed repeatedly on another device')
conflict.name = 'PreferencesSyncConflictError'
throw conflict
}
function isPatchEmpty(patch: MobilePreferencesPatch): boolean {
return Object.keys(patch).length === 0
}
function removeCommittedPatch(
current: MobilePreferencesPatch,
committed: MobilePreferencesPatch,
): MobilePreferencesPatch {
const remaining: MobilePreferencesPatch = { ...current }
for (const key of Object.keys(committed) as Array<keyof MobilePreferencesPatch>) {
if (remaining[key] === committed[key]) {
delete remaining[key]
}
}
return remaining
}
function errorCodeForSync(error: unknown): PreferencesErrorCode {
return error instanceof Error && error.name === 'PreferencesSyncConflictError'
? 'SYNC_CONFLICT'
: 'SYNC_FAILED'
}
export function MobilePreferencesProvider({
children,
}: {
children: ReactNode
}): React.ReactElement {
const { user } = useAuth()
const systemScheme = useColorScheme()
const userId = user?.id ?? null
const ownerKey = userId === null ? 'installation' : `user:${userId}`
const [snapshot, setSnapshot] = useState<ProviderSnapshot>({
ownerKey: 'uninitialized',
preferences: { ...DEFAULT_PREFERENCES },
loading: true,
syncStatus: 'loading',
errorCode: null,
lastSyncedAt: null,
})
const ownerKeyRef = useRef(ownerKey)
const userIdRef = useRef<string | null>(userId)
const preferencesRef = useRef<MobilePreferences>({ ...DEFAULT_PREFERENCES })
const pendingPatchRef = useRef<MobilePreferencesPatch>({})
const lastSyncedAtRef = useRef<string | null>(null)
const loadGenerationRef = useRef(0)
const serverQueueRef = useRef<Promise<void>>(Promise.resolve())
const localQueueRef = useRef<Promise<void>>(Promise.resolve())
ownerKeyRef.current = ownerKey
userIdRef.current = userId
const flushPendingPatch = useCallback(async (targetUserId: string): Promise<boolean> => {
if (userIdRef.current !== targetUserId) return false
const committedPatch = { ...pendingPatchRef.current }
if (isPatchEmpty(committedPatch)) return true
setSnapshot((current) => current.ownerKey === `user:${targetUserId}`
? { ...current, syncStatus: 'saving', errorCode: null }
: current)
try {
const saved = await writeServerPreferences(
targetUserId,
preferencesRef.current,
committedPatch,
)
if (userIdRef.current !== targetUserId) return false
const remainingPatch = removeCommittedPatch(
pendingPatchRef.current,
committedPatch,
)
const nextPreferences = applyPreferencesPatch(saved, remainingPatch)
const syncedAt = new Date().toISOString()
preferencesRef.current = nextPreferences
pendingPatchRef.current = remainingPatch
lastSyncedAtRef.current = syncedAt
try {
await writeCaches(
targetUserId,
nextPreferences,
remainingPatch,
syncedAt,
)
setSnapshot({
ownerKey: `user:${targetUserId}`,
preferences: nextPreferences,
loading: false,
syncStatus: isPatchEmpty(remainingPatch) ? 'synced' : 'saving',
errorCode: null,
lastSyncedAt: syncedAt,
})
} catch {
setSnapshot({
ownerKey: `user:${targetUserId}`,
preferences: nextPreferences,
loading: false,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
lastSyncedAt: syncedAt,
})
}
return isPatchEmpty(remainingPatch)
} catch (error) {
if (userIdRef.current !== targetUserId) return false
setSnapshot((current) => current.ownerKey === `user:${targetUserId}`
? {
...current,
loading: false,
syncStatus: 'offline',
errorCode: errorCodeForSync(error),
}
: current)
return false
}
}, [])
const queueServerFlush = useCallback((targetUserId: string): Promise<boolean> => {
const run = serverQueueRef.current.then(
() => flushPendingPatch(targetUserId),
() => flushPendingPatch(targetUserId),
)
serverQueueRef.current = run.then(() => undefined, () => undefined)
return run
}, [flushPendingPatch])
const loadPreferences = useCallback(async (
targetOwnerKey: string,
targetUserId: string | null,
): Promise<void> => {
const generation = ++loadGenerationRef.current
setSnapshot((current) => ({
...current,
ownerKey: targetOwnerKey,
loading: true,
syncStatus: 'loading',
errorCode: null,
}))
let installationEnvelope: CacheEnvelope | null = null
let userEnvelope: CacheEnvelope | null = null
let cacheReadFailed = false
let cacheWriteFailed = false
try {
const keys = targetUserId === null
? [INSTALLATION_CACHE_KEY]
: [INSTALLATION_CACHE_KEY, getUserCacheKey(targetUserId)]
const entries = await AsyncStorage.multiGet(keys)
installationEnvelope = parseCacheEnvelope(entries[0]?.[1] ?? null)
if (targetUserId !== null) {
userEnvelope = parseCacheEnvelope(entries[1]?.[1] ?? null)
}
} catch {
cacheReadFailed = true
}
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
if (targetUserId === null) {
const localPreferences = installationEnvelope?.preferences ?? { ...DEFAULT_PREFERENCES }
const localPending = installationEnvelope?.pendingPatch ?? {}
preferencesRef.current = localPreferences
pendingPatchRef.current = localPending
lastSyncedAtRef.current = installationEnvelope?.lastSyncedAt ?? null
setSnapshot({
ownerKey: targetOwnerKey,
preferences: localPreferences,
loading: false,
syncStatus: cacheReadFailed ? 'error' : 'local',
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
lastSyncedAt: installationEnvelope?.lastSyncedAt ?? null,
})
return
}
const cachedPreferences = userEnvelope?.preferences
?? installationEnvelope?.preferences
?? { ...DEFAULT_PREFERENCES }
const stagedPatch: MobilePreferencesPatch = {
...(userEnvelope?.pendingPatch ?? {}),
...(installationEnvelope?.pendingPatch ?? {}),
}
const cachedWithPending = applyPreferencesPatch(cachedPreferences, stagedPatch)
preferencesRef.current = cachedWithPending
pendingPatchRef.current = stagedPatch
lastSyncedAtRef.current = userEnvelope?.lastSyncedAt
?? installationEnvelope?.lastSyncedAt
?? null
setSnapshot({
ownerKey: targetOwnerKey,
preferences: cachedWithPending,
loading: false,
syncStatus: 'loading',
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null,
})
try {
await writeCaches(
targetUserId,
cachedWithPending,
stagedPatch,
userEnvelope?.lastSyncedAt ?? null,
)
} catch {
cacheWriteFailed = true
}
let resolved: MobilePreferences
try {
const remote = await fetchServerPreferences(targetUserId)
if (remote === null || !isPatchEmpty(stagedPatch)) {
resolved = await writeServerPreferences(
targetUserId,
remote ?? cachedWithPending,
stagedPatch,
)
} else {
resolved = remote
}
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
} catch (error) {
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
setSnapshot({
ownerKey: targetOwnerKey,
preferences: cachedWithPending,
loading: false,
syncStatus: 'offline',
errorCode: cacheReadFailed
? 'CACHE_READ_FAILED'
: cacheWriteFailed
? 'CACHE_WRITE_FAILED'
: errorCodeForSync(error),
lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null,
})
return
}
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
const syncedAt = new Date().toISOString()
try {
await writeCaches(targetUserId, resolved, {}, syncedAt)
} catch {
cacheWriteFailed = true
}
preferencesRef.current = resolved
pendingPatchRef.current = {}
lastSyncedAtRef.current = syncedAt
setSnapshot({
ownerKey: targetOwnerKey,
preferences: resolved,
loading: false,
syncStatus: cacheReadFailed || cacheWriteFailed ? 'error' : 'synced',
errorCode: cacheReadFailed
? 'CACHE_READ_FAILED'
: cacheWriteFailed
? 'CACHE_WRITE_FAILED'
: null,
lastSyncedAt: syncedAt,
})
}, [])
useEffect(() => {
void loadPreferences(ownerKey, userId)
}, [loadPreferences, ownerKey, userId])
useEffect(() => {
if (userId === null) return undefined
const channel = supabase
.channel(`mobile-user-settings-${userId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'user_settings',
filter: `user_id=eq.${userId}`,
},
(payload) => {
if (userIdRef.current !== userId || !isPatchEmpty(pendingPatchRef.current)) return
const remote = parseSettingsRow(payload.new)
if (remote === null) return
const syncedAt = new Date().toISOString()
preferencesRef.current = remote
lastSyncedAtRef.current = syncedAt
setSnapshot({
ownerKey: `user:${userId}`,
preferences: remote,
loading: false,
syncStatus: 'synced',
errorCode: null,
lastSyncedAt: syncedAt,
})
void writeCaches(userId, remote, {}, syncedAt).catch(() => {
if (userIdRef.current !== userId) return
setSnapshot((current) => ({
...current,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
}))
})
},
)
.subscribe()
return () => {
void supabase.removeChannel(channel)
}
}, [userId])
const commitPatch = useCallback(async (
requestedPatch: MobilePreferencesPatch,
): Promise<PreferenceMutationResult> => {
const targetOwnerKey = ownerKeyRef.current
const targetUserId = userIdRef.current
const patch = normalizePreferencesPatch(requestedPatch)
if (isPatchEmpty(patch)) {
return {
localSaved: true,
serverSynced: targetUserId === null || isPatchEmpty(pendingPatchRef.current),
}
}
const nextPreferences = applyPreferencesPatch(preferencesRef.current, patch)
const nextPendingPatch = {
...pendingPatchRef.current,
...patch,
}
try {
await writeCaches(
targetUserId,
nextPreferences,
nextPendingPatch,
lastSyncedAtRef.current,
targetUserId === null ? nextPendingPatch : {},
)
} catch {
if (ownerKeyRef.current === targetOwnerKey) {
setSnapshot((current) => ({
...current,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
}))
}
return { localSaved: false, serverSynced: false }
}
if (ownerKeyRef.current !== targetOwnerKey) {
return { localSaved: true, serverSynced: false }
}
preferencesRef.current = nextPreferences
pendingPatchRef.current = nextPendingPatch
setSnapshot((current) => ({
...current,
ownerKey: targetOwnerKey,
preferences: nextPreferences,
loading: false,
syncStatus: targetUserId === null ? 'local' : 'saving',
errorCode: null,
}))
if (targetUserId === null) {
return { localSaved: true, serverSynced: false }
}
const serverSynced = await queueServerFlush(targetUserId)
return { localSaved: true, serverSynced }
}, [queueServerFlush])
const updatePreferences = useCallback((
patch: MobilePreferencesPatch,
): Promise<PreferenceMutationResult> => {
const run = localQueueRef.current.then(
() => commitPatch(patch),
() => commitPatch(patch),
)
localQueueRef.current = run.then(() => undefined, () => undefined)
return run
}, [commitPatch])
const completeOnboarding = useCallback((
outcome: 'completed' | 'skipped',
): Promise<PreferenceMutationResult> => updatePreferences({
onboardingVersion: CURRENT_ONBOARDING_VERSION,
tutorialCompletedAt: outcome === 'completed' ? new Date().toISOString() : null,
}), [updatePreferences])
const retrySync = useCallback(async (): Promise<void> => {
const targetOwnerKey = ownerKeyRef.current
const targetUserId = userIdRef.current
if (targetUserId !== null && !isPatchEmpty(pendingPatchRef.current)) {
await queueServerFlush(targetUserId)
return
}
await loadPreferences(targetOwnerKey, targetUserId)
}, [loadPreferences, queueServerFlush])
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state !== 'active' || userIdRef.current === null) return
if (isPatchEmpty(pendingPatchRef.current) && snapshot.syncStatus !== 'offline') return
void retrySync()
})
return () => subscription.remove()
}, [retrySync, snapshot.syncStatus])
const effectiveTheme = resolveEffectiveTheme(
snapshot.preferences.themeMode,
systemScheme,
)
const palette = useMemo(
() => getMobileThemePalette(effectiveTheme),
[effectiveTheme],
)
const loading = snapshot.loading || snapshot.ownerKey !== ownerKey
const contextValue = useMemo<PreferencesContextValue>(() => ({
preferences: snapshot.preferences,
loading,
syncStatus: snapshot.syncStatus,
errorCode: snapshot.errorCode,
lastSyncedAt: snapshot.lastSyncedAt,
effectiveTheme,
palette,
needsOnboarding:
snapshot.preferences.onboardingVersion < CURRENT_ONBOARDING_VERSION,
updatePreferences,
completeOnboarding,
retrySync,
}), [
completeOnboarding,
effectiveTheme,
loading,
palette,
retrySync,
snapshot.errorCode,
snapshot.lastSyncedAt,
snapshot.preferences,
snapshot.syncStatus,
updatePreferences,
])
return (
<PreferencesContext.Provider value={contextValue}>
{children}
</PreferencesContext.Provider>
)
}
export function useMobilePreferences(): PreferencesContextValue {
const context = useContext(PreferencesContext)
if (context === null) {
throw new Error('useMobilePreferences must be used inside MobilePreferencesProvider')
}
return context
}

View file

@ -0,0 +1,20 @@
import { randomBytes } from '@noble/hashes/utils'
export function uuidV4FromBytes(source: Uint8Array): string {
if (source.length !== 16) throw new Error('uuid_source_must_be_16_bytes')
const bytes = Uint8Array.from(source)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-')
}
export function createUuidV4(): string {
return uuidV4FromBytes(randomBytes(16))
}

View file

@ -0,0 +1,141 @@
import { Platform } from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as Keychain from 'react-native-keychain'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'
const AUTH_SERVICE_PREFIX = 'com.d3ro.voice.supabase-auth.v1.'
const LEGACY_SUPABASE_KEY_PREFIX = 'sb-'
export interface SecureAuthStorageAdapter {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void>
}
function normalizedStorageKey(key: string): string {
if (typeof key !== 'string' || key.length < 1 || key.length > 512 || key.includes('\u0000')) {
throw new Error('secure_auth_storage_invalid_key')
}
return key
}
function serviceForKey(key: string): string {
const digest = bytesToHex(sha256(utf8ToBytes(normalizedStorageKey(key))))
return `${AUTH_SERVICE_PREFIX}${digest}`
}
function setOptions(service: string): Keychain.SetOptions {
return {
service,
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
...(Platform.OS === 'android'
? {
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
storage: Keychain.STORAGE_TYPE.AES_GCM_NO_AUTH,
}
: {}),
}
}
async function removeLegacyValue(key: string): Promise<void> {
await AsyncStorage.removeItem(key)
}
function secureCredentials(value: unknown): false | { username: string; password: string } {
if (value === false) return false
if (
typeof value !== 'object' ||
value === null ||
typeof (value as { username?: unknown }).username !== 'string' ||
typeof (value as { password?: unknown }).password !== 'string'
) {
throw new Error('secure_auth_storage_invalid_response')
}
return value as { username: string; password: string }
}
function assertStored(value: unknown): void {
if (typeof value !== 'object' || value === null) {
throw new Error('secure_auth_storage_write_failed')
}
}
async function resetService(service: string): Promise<void> {
const reset = await Keychain.resetGenericPassword({ service })
if (reset !== true) throw new Error('secure_auth_storage_reset_failed')
}
export const secureAuthStorage: SecureAuthStorageAdapter = {
async getItem(keyValue: string): Promise<string | null> {
const key = normalizedStorageKey(keyValue)
const service = serviceForKey(key)
const credentials = secureCredentials(await Keychain.getGenericPassword({ service }))
if (credentials !== false) {
if (credentials.username !== key) {
await resetService(service)
throw new Error('secure_auth_storage_identity_mismatch')
}
// Old releases persisted the same Supabase session in AsyncStorage.
// Refuse to return a usable token until any plaintext duplicate is gone.
await removeLegacyValue(key)
return credentials.password
}
const legacyValue = await AsyncStorage.getItem(key)
if (legacyValue === null) return null
const stored = await Keychain.setGenericPassword(
key,
legacyValue,
setOptions(service),
)
assertStored(stored)
// Migration is complete only after the plaintext copy is removed. A
// removal failure is deliberately surfaced instead of silently falling
// back to an insecure session.
await removeLegacyValue(key)
return legacyValue
},
async setItem(keyValue: string, value: string): Promise<void> {
const key = normalizedStorageKey(keyValue)
if (typeof value !== 'string') throw new Error('secure_auth_storage_invalid_value')
const service = serviceForKey(key)
const stored = await Keychain.setGenericPassword(key, value, setOptions(service))
assertStored(stored)
await removeLegacyValue(key)
},
async removeItem(keyValue: string): Promise<void> {
const key = normalizedStorageKey(keyValue)
const service = serviceForKey(key)
await resetService(service)
await removeLegacyValue(key)
},
}
export async function clearAllSecureAuthStorage(): Promise<void> {
const [services, legacyKeys] = await Promise.all([
Keychain.getAllGenericPasswordServices(),
AsyncStorage.getAllKeys(),
])
if (!Array.isArray(services) || services.some((service) => typeof service !== 'string')) {
throw new Error('secure_auth_storage_invalid_response')
}
const secureServices = services.filter((service) => service.startsWith(AUTH_SERVICE_PREFIX))
const plaintextKeys = legacyKeys.filter((key) => key.startsWith(LEGACY_SUPABASE_KEY_PREFIX))
for (const service of secureServices) {
await resetService(service)
}
if (plaintextKeys.length > 0) await AsyncStorage.multiRemove(plaintextKeys)
}
export const secureAuthStorageInternals = {
AUTH_SERVICE_PREFIX,
serviceForKey,
}

View file

@ -1,29 +1,43 @@
// src/lib/supabase.ts — Supabase client for RN
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
// Fallback dummy credentials to prevent createClient crash when unconfigured
const FALLBACK_URL = 'https://auth.d3ro.chanpaca.net'
const FALLBACK_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.dummy_anon_key'
const SUPABASE_URL = ''
const SUPABASE_ANON_KEY = ''
import { AppState, Platform } from 'react-native'
import { createClient, processLock, type SupabaseClient } from '@supabase/supabase-js'
import {
SUPABASE_ANON_KEY,
SUPABASE_LOCAL_MOBILE_E2E,
SUPABASE_URL,
} from '@d3ro/core/supabase-config'
import { secureAuthStorage } from './secure-auth-storage'
export const supabase: SupabaseClient = createClient(
SUPABASE_URL || FALLBACK_URL,
SUPABASE_ANON_KEY || FALLBACK_ANON_KEY,
SUPABASE_URL,
SUPABASE_ANON_KEY,
{
auth: {
storage: AsyncStorage,
storage: secureAuthStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
flowType: 'pkce',
lock: processLock,
},
}
)
export function isSupabaseConfigured(): boolean {
return SUPABASE_URL.length > 0 && SUPABASE_ANON_KEY.length > 0
if (Platform.OS !== 'web') {
AppState.addEventListener('change', (state) => {
if (state === 'active') {
void supabase.auth.startAutoRefresh()
} else {
void supabase.auth.stopAutoRefresh()
}
})
}
export function isSupabaseConfigured(): boolean {
return (
/^https:\/\/[a-z0-9-]+\.supabase\.co$/i.test(SUPABASE_URL)
|| (SUPABASE_LOCAL_MOBILE_E2E && SUPABASE_URL === 'http://10.0.2.2:55321')
)
&& SUPABASE_ANON_KEY.trim().length > 20
}

View file

@ -1,92 +0,0 @@
// apps/mobile-rn/src/lib/update-manager.ts
// Cross-platform Mobile Update Manager (Android / iOS)
import { Platform, Linking, Alert } from 'react-native'
export interface VersionCheckResponse {
platform: 'android' | 'ios'
current_version: string
latest_version: string
min_supported_version: string
force_update: boolean
download_url: string
release_notes: {
ko: string
en: string
}
}
export const CURRENT_APP_VERSION = '1.0.0'
function compareSemver(v1: string, v2: string): number {
const p1 = v1.split('.').map((x) => parseInt(x, 10) || 0)
const p2 = v2.split('.').map((x) => parseInt(x, 10) || 0)
for (let i = 0; i < 3; i++) {
const a1 = p1[i] || 0
const a2 = p2[i] || 0
if (a1 > a2) return 1
if (a1 < a2) return -1
}
return 0
}
export async function checkMobileUpdate(isManualCheck = false): Promise<void> {
try {
const downloadUrl =
Platform.OS === 'android'
? 'https://d3ro.chanpaca.net/releases/1.0.0/d3ro-voice-v1.0.0-signed.zip'
: 'https://apps.apple.com/app/id6470000000'
// In production, queries /api/v1/version-check
const latestVersion = '1.0.0'
const minVersion = '1.0.0'
const isBelowMin = compareSemver(CURRENT_APP_VERSION, minVersion) < 0
const isBelowLatest = compareSemver(CURRENT_APP_VERSION, latestVersion) < 0
if (isBelowMin) {
// Mandatory Forced Update
Alert.alert(
'필수 업데이트 안내',
'보안 및 최신 API 호환성을 위해 최신 버전으로 업데이트해야 서비스를 계속 이용할 수 있습니다.',
[
{
text: '지금 업데이트',
onPress: () => {
Linking.openURL(downloadUrl).catch(() => {})
}
}
],
{ cancelable: false }
)
return
}
if (isBelowLatest) {
// Optional Update
Alert.alert(
'새 버전 안내',
`D3RO Voice v${latestVersion} 새 버전이 출시되었습니다. 최신 음성 AI 성능과 기능을 만나보세요.`,
[
{ text: '나중에', style: 'cancel' },
{
text: '지금 업데이트',
onPress: () => {
Linking.openURL(downloadUrl).catch(() => {})
}
}
]
)
return
}
if (isManualCheck) {
Alert.alert(
'최신 버전 사용 중',
`현재 최신 버전(v${CURRENT_APP_VERSION})을 사용하고 있습니다.`,
[{ text: '확인' }]
)
}
} catch (err) {
console.warn('Update check error:', err)
}
}