feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
41
apps/mobile-rn/src/lib/account-exit.ts
Normal file
41
apps/mobile-rn/src/lib/account-exit.ts
Normal 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)
|
||||
}
|
||||
49
apps/mobile-rn/src/lib/account-local-data.ts
Normal file
49
apps/mobile-rn/src/lib/account-local-data.ts
Normal 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
|
||||
}
|
||||
677
apps/mobile-rn/src/lib/audio-recorder.ts
Normal file
677
apps/mobile-rn/src/lib/audio-recorder.ts
Normal 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();
|
||||
44
apps/mobile-rn/src/lib/auth-capabilities.ts
Normal file
44
apps/mobile-rn/src/lib/auth-capabilities.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
177
apps/mobile-rn/src/lib/auth-redirect.ts
Normal file
177
apps/mobile-rn/src/lib/auth-redirect.ts
Normal 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)
|
||||
583
apps/mobile-rn/src/lib/billing-context.tsx
Normal file
583
apps/mobile-rn/src/lib/billing-context.tsx
Normal 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
|
||||
}
|
||||
124
apps/mobile-rn/src/lib/device-context.tsx
Normal file
124
apps/mobile-rn/src/lib/device-context.tsx
Normal 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
|
||||
}
|
||||
44
apps/mobile-rn/src/lib/e2e-runtime-bootstrap.ts
Normal file
44
apps/mobile-rn/src/lib/e2e-runtime-bootstrap.ts
Normal 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
|
||||
}
|
||||
419
apps/mobile-rn/src/lib/entitlement-context.tsx
Normal file
419
apps/mobile-rn/src/lib/entitlement-context.tsx
Normal 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
|
||||
}
|
||||
27
apps/mobile-rn/src/lib/logout.ts
Normal file
27
apps/mobile-rn/src/lib/logout.ts
Normal 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 }
|
||||
}
|
||||
350
apps/mobile-rn/src/lib/mobile-ads-context.tsx
Normal file
350
apps/mobile-rn/src/lib/mobile-ads-context.tsx
Normal 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 }
|
||||
}
|
||||
106
apps/mobile-rn/src/lib/native-config.ts
Normal file
106
apps/mobile-rn/src/lib/native-config.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
55
apps/mobile-rn/src/lib/pkce-s256.ts
Normal file
55
apps/mobile-rn/src/lib/pkce-s256.ts
Normal 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)
|
||||
909
apps/mobile-rn/src/lib/preferences-context.tsx
Normal file
909
apps/mobile-rn/src/lib/preferences-context.tsx
Normal 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
|
||||
}
|
||||
20
apps/mobile-rn/src/lib/random-id.ts
Normal file
20
apps/mobile-rn/src/lib/random-id.ts
Normal 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))
|
||||
}
|
||||
141
apps/mobile-rn/src/lib/secure-auth-storage.ts
Normal file
141
apps/mobile-rn/src/lib/secure-auth-storage.ts
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue