mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-28 08:37:42 +02:00
refactor: restructure
monorepo with apps/ and services/ directories
This commit is contained in:
parent
25824ed0ac
commit
ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions
|
|
@ -0,0 +1,16 @@
|
|||
import React, { useEffect, type ReactNode } from 'react';
|
||||
import { multiPlatformAnalytics } from './services/multiPlatformAnalytics';
|
||||
|
||||
interface AnalyticsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
multiPlatformAnalytics.initialize();
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AnalyticsProvider;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'expo-router';
|
||||
import { useAnalytics } from '../hooks/useAnalytics';
|
||||
|
||||
export const AnalyticsNavigationTracker = () => {
|
||||
const pathname = usePathname();
|
||||
const { screen } = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname) {
|
||||
// Convert path to screen name: /memo/123 -> memo_detail
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
let screenName = 'home';
|
||||
|
||||
if (segments.length > 0) {
|
||||
// Handle dynamic routes
|
||||
if (segments[0] === 'memo' && segments[1]) {
|
||||
screenName = 'memo_detail';
|
||||
} else if (segments[0] === 'space' && segments[1]) {
|
||||
screenName = 'space_detail';
|
||||
} else {
|
||||
// Convert path to screen name
|
||||
screenName = segments.join('_');
|
||||
}
|
||||
}
|
||||
|
||||
screen(screenName, {
|
||||
path: pathname,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}, [pathname, screen]);
|
||||
|
||||
return null;
|
||||
};
|
||||
85
apps/memoro/apps/mobile/features/analytics/events.ts
Normal file
85
apps/memoro/apps/mobile/features/analytics/events.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Authentication Events
|
||||
export const AUTH_EVENTS = {
|
||||
USER_SIGNED_IN: 'user_signed_in',
|
||||
USER_SIGNED_UP: 'user_signed_up',
|
||||
USER_SIGNED_OUT: 'user_signed_out',
|
||||
} as const;
|
||||
|
||||
// Recording Events
|
||||
export const RECORDING_EVENTS = {
|
||||
RECORDING_STARTED: 'recording_started',
|
||||
RECORDING_STOPPED: 'recording_stopped',
|
||||
RECORDING_PAUSED: 'recording_paused',
|
||||
RECORDING_RESUMED: 'recording_resumed',
|
||||
} as const;
|
||||
|
||||
// Memo Events
|
||||
export const MEMO_EVENTS = {
|
||||
MEMO_CREATED: 'memo_created',
|
||||
MEMO_VIEWED: 'memo_viewed',
|
||||
MEMO_EDITED: 'memo_edited',
|
||||
MEMO_DELETED: 'memo_deleted',
|
||||
MEMO_SHARED: 'memo_shared',
|
||||
MEMO_COMBINED: 'memo_combined',
|
||||
MEMO_TRANSLATED: 'memo_translated',
|
||||
} as const;
|
||||
|
||||
// AI Processing Events
|
||||
export const AI_EVENTS = {
|
||||
TRANSCRIPTION_STARTED: 'transcription_started',
|
||||
TRANSCRIPTION_COMPLETED: 'transcription_completed',
|
||||
TRANSCRIPTION_FAILED: 'transcription_failed',
|
||||
BLUEPRINT_APPLIED: 'blueprint_applied',
|
||||
PROMPT_EXECUTED: 'prompt_executed',
|
||||
QUESTION_ASKED: 'question_asked',
|
||||
} as const;
|
||||
|
||||
// Space Events
|
||||
export const SPACE_EVENTS = {
|
||||
SPACE_CREATED: 'space_created',
|
||||
SPACE_JOINED: 'space_joined',
|
||||
SPACE_LEFT: 'space_left',
|
||||
SPACE_INVITE_SENT: 'space_invite_sent',
|
||||
SPACE_INVITE_ACCEPTED: 'space_invite_accepted',
|
||||
} as const;
|
||||
|
||||
// Settings Events
|
||||
export const SETTINGS_EVENTS = {
|
||||
LANGUAGE_CHANGED: 'language_changed',
|
||||
THEME_CHANGED: 'theme_changed',
|
||||
NOTIFICATION_PERMISSION_GRANTED: 'notification_permission_granted',
|
||||
LOCATION_PERMISSION_GRANTED: 'location_permission_granted',
|
||||
} as const;
|
||||
|
||||
// Navigation Events
|
||||
export const NAVIGATION_EVENTS = {
|
||||
SCREEN_VIEWED: 'screen_viewed',
|
||||
TAB_CHANGED: 'tab_changed',
|
||||
} as const;
|
||||
|
||||
// Error Events
|
||||
export const ERROR_EVENTS = {
|
||||
ERROR_OCCURRED: 'error_occurred',
|
||||
CRASH_DETECTED: 'crash_detected',
|
||||
} as const;
|
||||
|
||||
// Rating Events
|
||||
export const RATING_EVENTS = {
|
||||
RATING_PROMPT_SHOWN: 'rating_prompt_shown',
|
||||
RATING_ACCEPTED: 'rating_accepted',
|
||||
RATING_DECLINED: 'rating_declined',
|
||||
RATING_NEVER_ASK: 'rating_never_ask',
|
||||
} as const;
|
||||
|
||||
// Export all events for convenience
|
||||
export const ANALYTICS_EVENTS = {
|
||||
...AUTH_EVENTS,
|
||||
...RECORDING_EVENTS,
|
||||
...MEMO_EVENTS,
|
||||
...AI_EVENTS,
|
||||
...SPACE_EVENTS,
|
||||
...SETTINGS_EVENTS,
|
||||
...NAVIGATION_EVENTS,
|
||||
...ERROR_EVENTS,
|
||||
...RATING_EVENTS,
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { useCallback } from 'react';
|
||||
import { multiPlatformAnalytics } from '../services/multiPlatformAnalytics';
|
||||
import type { AnalyticsProperties, UserProperties } from '../types';
|
||||
|
||||
export const useAnalytics = () => {
|
||||
const track = useCallback((event: string, properties?: AnalyticsProperties) => {
|
||||
multiPlatformAnalytics.track(event, properties);
|
||||
}, []);
|
||||
|
||||
const identify = useCallback((userId: string, properties?: UserProperties) => {
|
||||
multiPlatformAnalytics.identify(userId, properties);
|
||||
}, []);
|
||||
|
||||
const screen = useCallback((screenName: string, properties?: AnalyticsProperties) => {
|
||||
multiPlatformAnalytics.screen(screenName, properties);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
multiPlatformAnalytics.reset();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
track,
|
||||
identify,
|
||||
screen,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Hook to check if a feature flag is enabled
|
||||
* Note: PostHog has been removed, so this always returns false
|
||||
* This hook is kept for backward compatibility
|
||||
*
|
||||
* @param flagKey - The feature flag key to check
|
||||
* @returns boolean indicating if the feature is enabled (always false)
|
||||
*
|
||||
* @example
|
||||
* const showNewFeature = useFeatureFlag('new-recording-ui');
|
||||
* if (showNewFeature) {
|
||||
* return <NewRecordingUI />;
|
||||
* }
|
||||
*/
|
||||
export const useFeatureFlag = (_flagKey: string): boolean => {
|
||||
// PostHog removed - feature flags disabled
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get all feature flags
|
||||
* Note: PostHog has been removed, so this always returns empty object
|
||||
* This hook is kept for backward compatibility
|
||||
*
|
||||
* @returns Record of all feature flags (always empty)
|
||||
*/
|
||||
export const useFeatureFlags = (): Record<string, boolean> => {
|
||||
// PostHog removed - feature flags disabled
|
||||
return {};
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
|
||||
/**
|
||||
* Hook to track screen views when a screen comes into focus
|
||||
* Uses React Navigation's focus effect to detect when screen is visible
|
||||
*
|
||||
* @param screenName - Name of the screen to track
|
||||
* @param properties - Additional properties to include with the screen event
|
||||
*
|
||||
* @example
|
||||
* // In your screen component:
|
||||
* useScreenTracking('home_screen', { tab: 'home' });
|
||||
*/
|
||||
export const useScreenTracking = (screenName: string, properties?: Record<string, any>) => {
|
||||
const { screen } = useAnalytics();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Track screen view when screen comes into focus
|
||||
screen(screenName, {
|
||||
...properties,
|
||||
focused_at: new Date().toISOString(),
|
||||
});
|
||||
}, [screenName, screen]) // Remove properties from dependencies to avoid loops
|
||||
);
|
||||
};
|
||||
7
apps/memoro/apps/mobile/features/analytics/index.ts
Normal file
7
apps/memoro/apps/mobile/features/analytics/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { default as AnalyticsProvider } from './AnalyticsProvider';
|
||||
export { useAnalytics } from './hooks/useAnalytics';
|
||||
export { useScreenTracking } from './hooks/useScreenTracking';
|
||||
export { useFeatureFlag, useFeatureFlags } from './hooks/useFeatureFlag';
|
||||
export { ANALYTICS_EVENTS } from './events';
|
||||
export * from './utils/trackingHelpers';
|
||||
export type { AnalyticsEvent, AnalyticsProperties } from './types';
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Platform } from 'react-native';
|
||||
import type { AnalyticsService, AnalyticsProperties, UserProperties } from '../types';
|
||||
import { umamiService } from './umamiService';
|
||||
|
||||
class MultiPlatformAnalyticsService implements AnalyticsService {
|
||||
private umami = umamiService;
|
||||
|
||||
async initialize() {
|
||||
// Only initialize Umami for web
|
||||
if (Platform.OS === 'web') {
|
||||
await this.umami.initialize();
|
||||
}
|
||||
// Analytics disabled for mobile (PostHog removed)
|
||||
}
|
||||
|
||||
identify(userId: string, properties?: UserProperties) {
|
||||
if (Platform.OS === 'web') {
|
||||
this.umami.identify(userId, properties);
|
||||
}
|
||||
// No-op for mobile
|
||||
}
|
||||
|
||||
track(event: string, properties?: AnalyticsProperties) {
|
||||
if (Platform.OS === 'web') {
|
||||
this.umami.track(event, properties);
|
||||
}
|
||||
// No-op for mobile
|
||||
}
|
||||
|
||||
screen(screenName: string, properties?: AnalyticsProperties) {
|
||||
if (Platform.OS === 'web') {
|
||||
this.umami.screen(screenName, properties);
|
||||
}
|
||||
// No-op for mobile
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (Platform.OS === 'web') {
|
||||
this.umami.reset();
|
||||
}
|
||||
// No-op for mobile
|
||||
}
|
||||
}
|
||||
|
||||
export const multiPlatformAnalytics = new MultiPlatformAnalyticsService();
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { Platform } from 'react-native';
|
||||
import type { AnalyticsService, AnalyticsProperties, UserProperties } from '../types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string | ((props?: any) => void), properties?: any) => void;
|
||||
identify: (properties: any) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class UmamiAnalyticsService implements AnalyticsService {
|
||||
private initialized = false;
|
||||
private websiteId =
|
||||
process.env.EXPO_PUBLIC_UMAMI_WEBSITE_ID || 'fb5fc77a-ed24-4369-b9f5-7d8a01bc9fa4';
|
||||
private scriptUrl =
|
||||
process.env.EXPO_PUBLIC_UMAMI_SCRIPT_URL || 'https://umami.manacore.ai/script.js';
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Only initialize on web platform
|
||||
if (Platform.OS !== 'web') {
|
||||
console.log('[Umami] Skipping initialization - not on web platform');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if Umami is already loaded
|
||||
if (typeof window !== 'undefined' && window.umami) {
|
||||
this.initialized = true;
|
||||
console.log('[Umami] Already loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and append the script tag
|
||||
if (typeof document !== 'undefined') {
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = this.scriptUrl;
|
||||
script.setAttribute('data-website-id', this.websiteId);
|
||||
|
||||
script.onload = () => {
|
||||
this.initialized = true;
|
||||
console.log('[Umami] Loaded successfully');
|
||||
|
||||
// Track initialization
|
||||
this.track('analytics_initialized', {
|
||||
platform: 'web',
|
||||
service: 'umami',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('[Umami] Failed to load script:', error);
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Umami] Failed to initialize:', error);
|
||||
}
|
||||
}
|
||||
|
||||
identify(userId: string, properties?: UserProperties) {
|
||||
if (!this.initialized || Platform.OS !== 'web') return;
|
||||
|
||||
try {
|
||||
// Umami doesn't have native identify, but we can track it as a custom event
|
||||
if (window.umami) {
|
||||
window.umami.track('user_identified', {
|
||||
userId,
|
||||
...properties,
|
||||
});
|
||||
}
|
||||
console.log('[Umami] User identified:', userId, properties);
|
||||
} catch (error) {
|
||||
console.warn('[Umami] Failed to identify user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
track(event: string, properties?: AnalyticsProperties) {
|
||||
if (!this.initialized || Platform.OS !== 'web') return;
|
||||
|
||||
try {
|
||||
if (window.umami) {
|
||||
// Umami expects event name and optional data object
|
||||
window.umami.track(event, properties);
|
||||
}
|
||||
console.log('[Umami] Event tracked:', event, properties);
|
||||
} catch (error) {
|
||||
console.warn('[Umami] Failed to track event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
screen(screenName: string, properties?: AnalyticsProperties) {
|
||||
if (!this.initialized || Platform.OS !== 'web') return;
|
||||
|
||||
try {
|
||||
// Track screen views as page views with custom properties
|
||||
if (window.umami) {
|
||||
window.umami.track('screen_view', {
|
||||
screen: screenName,
|
||||
...properties,
|
||||
});
|
||||
}
|
||||
console.log('[Umami] Screen tracked:', screenName, properties);
|
||||
} catch (error) {
|
||||
console.warn('[Umami] Failed to track screen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.initialized || Platform.OS !== 'web') return;
|
||||
|
||||
try {
|
||||
// Umami doesn't have a reset method, but we can track it as an event
|
||||
if (window.umami) {
|
||||
window.umami.track('user_reset');
|
||||
}
|
||||
console.log('[Umami] User reset');
|
||||
} catch (error) {
|
||||
console.warn('[Umami] Failed to reset:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const umamiService = new UmamiAnalyticsService();
|
||||
24
apps/memoro/apps/mobile/features/analytics/types.ts
Normal file
24
apps/memoro/apps/mobile/features/analytics/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export interface AnalyticsProperties {
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
name: string;
|
||||
properties?: AnalyticsProperties;
|
||||
}
|
||||
|
||||
export interface UserProperties {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
plan?: string;
|
||||
createdAt?: string;
|
||||
language?: string;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface AnalyticsService {
|
||||
identify: (userId: string, properties?: UserProperties) => void;
|
||||
track: (event: string, properties?: AnalyticsProperties) => void;
|
||||
screen: (screenName: string, properties?: AnalyticsProperties) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Platform } from 'react-native';
|
||||
import * as Device from 'expo-device';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
/**
|
||||
* Get device information for analytics
|
||||
*/
|
||||
export const getDeviceInfo = () => ({
|
||||
platform: Platform.OS,
|
||||
platform_version: Platform.Version,
|
||||
device_type: Device.deviceType,
|
||||
device_name: Device.deviceName,
|
||||
device_brand: Device.brand,
|
||||
device_model: Device.modelName,
|
||||
is_device: Device.isDevice,
|
||||
app_version: Constants.expoConfig?.version,
|
||||
app_build: Constants.expoConfig?.ios?.buildNumber || Constants.expoConfig?.android?.versionCode,
|
||||
expo_version: Constants.expoVersion,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get common event properties
|
||||
*/
|
||||
export const getCommonEventProperties = () => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
...getDeviceInfo(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Track error with standardized properties
|
||||
*/
|
||||
export const trackError = (
|
||||
track: (event: string, properties?: any) => void,
|
||||
error: Error | unknown,
|
||||
context: {
|
||||
screen?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
) => {
|
||||
const errorData =
|
||||
error instanceof Error
|
||||
? {
|
||||
error_name: error.name,
|
||||
error_message: error.message,
|
||||
error_stack: __DEV__ ? error.stack : undefined,
|
||||
}
|
||||
: {
|
||||
error_type: 'unknown',
|
||||
error_details: String(error),
|
||||
};
|
||||
|
||||
track('error_occurred', {
|
||||
...errorData,
|
||||
...context,
|
||||
...getCommonEventProperties(),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track performance metric
|
||||
*/
|
||||
export const trackPerformance = (
|
||||
track: (event: string, properties?: any) => void,
|
||||
metricName: string,
|
||||
duration: number,
|
||||
metadata?: Record<string, any>
|
||||
) => {
|
||||
track('performance_metric', {
|
||||
metric_name: metricName,
|
||||
duration_ms: Math.round(duration),
|
||||
...metadata,
|
||||
...getCommonEventProperties(),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track user interaction
|
||||
*/
|
||||
export const trackInteraction = (
|
||||
track: (event: string, properties?: any) => void,
|
||||
element: string,
|
||||
action: string,
|
||||
metadata?: Record<string, any>
|
||||
) => {
|
||||
track('user_interaction', {
|
||||
element,
|
||||
action,
|
||||
...metadata,
|
||||
...getCommonEventProperties(),
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Status eines Audio-Players
|
||||
*/
|
||||
export enum AudioPlayerStatus {
|
||||
IDLE = 'idle',
|
||||
LOADING = 'loading',
|
||||
PLAYING = 'playing',
|
||||
PAUSED = 'paused',
|
||||
STOPPED = 'stopped',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { create } from 'zustand';
|
||||
import { AudioPlayer } from 'expo-audio';
|
||||
|
||||
interface AudioInstance {
|
||||
id: string;
|
||||
player: AudioPlayer;
|
||||
pause: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface AudioPlaybackStore {
|
||||
activeAudioInstances: Map<string, AudioInstance>;
|
||||
registerAudio: (id: string, player: AudioPlayer, pauseCallback: () => Promise<void>) => void;
|
||||
unregisterAudio: (id: string) => void;
|
||||
pauseAllAudio: () => Promise<void>;
|
||||
pauseAllExcept: (exceptId: string) => Promise<void>;
|
||||
getActiveCount: () => number;
|
||||
}
|
||||
|
||||
export const useAudioPlaybackStore = create<AudioPlaybackStore>((set, get) => ({
|
||||
activeAudioInstances: new Map(),
|
||||
|
||||
registerAudio: (id: string, player: AudioPlayer, pauseCallback: () => Promise<void>) => {
|
||||
set((state) => {
|
||||
const newInstances = new Map(state.activeAudioInstances);
|
||||
newInstances.set(id, { id, player, pause: pauseCallback });
|
||||
console.log(
|
||||
`[AudioPlaybackStore] Audio registered: ${id}, total active: ${newInstances.size}`
|
||||
);
|
||||
return { activeAudioInstances: newInstances };
|
||||
});
|
||||
},
|
||||
|
||||
unregisterAudio: (id: string) => {
|
||||
set((state) => {
|
||||
const newInstances = new Map(state.activeAudioInstances);
|
||||
newInstances.delete(id);
|
||||
console.log(
|
||||
`[AudioPlaybackStore] Audio unregistered: ${id}, total active: ${newInstances.size}`
|
||||
);
|
||||
return { activeAudioInstances: newInstances };
|
||||
});
|
||||
},
|
||||
|
||||
pauseAllAudio: async () => {
|
||||
const { activeAudioInstances } = get();
|
||||
console.log(`[AudioPlaybackStore] Pausing all audio, count: ${activeAudioInstances.size}`);
|
||||
|
||||
const pausePromises: Promise<void>[] = [];
|
||||
|
||||
activeAudioInstances.forEach((instance) => {
|
||||
pausePromises.push(
|
||||
instance.pause().catch((error) => {
|
||||
console.error(`[AudioPlaybackStore] Error pausing audio ${instance.id}:`, error);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(pausePromises);
|
||||
console.log('[AudioPlaybackStore] All audio paused');
|
||||
},
|
||||
|
||||
pauseAllExcept: async (exceptId: string) => {
|
||||
const { activeAudioInstances } = get();
|
||||
console.log(
|
||||
`[AudioPlaybackStore] Pausing all audio except ${exceptId}, count: ${activeAudioInstances.size}`
|
||||
);
|
||||
|
||||
const pausePromises: Promise<void>[] = [];
|
||||
|
||||
activeAudioInstances.forEach((instance) => {
|
||||
if (instance.id !== exceptId) {
|
||||
pausePromises.push(
|
||||
instance.pause().catch((error) => {
|
||||
console.error(`[AudioPlaybackStore] Error pausing audio ${instance.id}:`, error);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(pausePromises);
|
||||
console.log(`[AudioPlaybackStore] All audio paused except ${exceptId}`);
|
||||
},
|
||||
|
||||
getActiveCount: () => {
|
||||
return get().activeAudioInstances.size;
|
||||
},
|
||||
}));
|
||||
418
apps/memoro/apps/mobile/features/audioPlayer/useAudioPlayer.ts
Normal file
418
apps/memoro/apps/mobile/features/audioPlayer/useAudioPlayer.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { AudioPlayer, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
|
||||
import { NotificationChannel } from '~/features/notifications/types';
|
||||
import NotificationService from '~/features/notifications/NotificationService.native';
|
||||
import { formatDurationWithUnits, formatDurationFromMs } from '~/utils/formatters';
|
||||
import useTimer from '~/hooks/useTimer';
|
||||
import { AudioPlayerStatus } from './audioPlayer.types';
|
||||
import { useAudioPlaybackStore } from './store/audioPlaybackStore';
|
||||
|
||||
/**
|
||||
* Formatiert eine Zeitangabe in Sekunden als MM:SS
|
||||
*/
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
return formatDurationWithUnits(seconds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook zur Verwaltung eines Audio-Players
|
||||
*/
|
||||
export const useAudioPlayer = () => {
|
||||
const [player, setPlayer] = useState<AudioPlayer | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [status, setStatus] = useState<AudioPlayerStatus>(AudioPlayerStatus.IDLE);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const audioIdRef = useRef<string | null>(null);
|
||||
const { registerAudio, unregisterAudio, pauseAllExcept } = useAudioPlaybackStore();
|
||||
|
||||
// Verwende die zentralen Timer-Hooks mit externen Zeitaktualisierungen
|
||||
const positionTimer = useTimer(0, { useExternalTimeUpdates: true });
|
||||
const durationTimer = useTimer(0, { useExternalTimeUpdates: true });
|
||||
|
||||
const isWebEnvironment = Platform.OS === 'web';
|
||||
|
||||
const loadSound = useCallback(
|
||||
async (uri: string | undefined) => {
|
||||
try {
|
||||
setStatus(AudioPlayerStatus.LOADING);
|
||||
|
||||
if (player) {
|
||||
// Clear any existing intervals
|
||||
if ((player as any)._intervalId) {
|
||||
clearInterval((player as any)._intervalId);
|
||||
}
|
||||
if ((player as any)._checkDurationId) {
|
||||
clearInterval((player as any)._checkDurationId);
|
||||
}
|
||||
await player.pause();
|
||||
player.release();
|
||||
}
|
||||
|
||||
if (!uri) {
|
||||
setLoadError(true);
|
||||
setStatus(AudioPlayerStatus.ERROR);
|
||||
setError('Keine URI angegeben');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadError(false);
|
||||
await setAudioModeAsync({
|
||||
shouldPlayInBackground: true,
|
||||
playsInSilentMode: true,
|
||||
interruptionMode: 'duckOthers',
|
||||
allowsRecording: false,
|
||||
});
|
||||
|
||||
const newPlayer = createAudioPlayer(uri);
|
||||
|
||||
// Wait a moment for the player to load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if player loaded successfully
|
||||
if (newPlayer.duration === 0 && !newPlayer.playing) {
|
||||
// Try waiting a bit more
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
setPlayer(newPlayer);
|
||||
if (newPlayer.duration !== undefined && newPlayer.duration > 0) {
|
||||
durationTimer.setTime(newPlayer.duration);
|
||||
}
|
||||
|
||||
// Wiederholte Überprüfung der Dauer, falls sie nicht sofort verfügbar ist
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100;
|
||||
const checkDuration = setInterval(() => {
|
||||
if (
|
||||
newPlayer.duration &&
|
||||
newPlayer.duration > 0 &&
|
||||
newPlayer.duration !== Infinity
|
||||
) {
|
||||
durationTimer.setTime(newPlayer.duration);
|
||||
clearInterval(checkDuration);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(checkDuration);
|
||||
}
|
||||
attempts += 1;
|
||||
}, 100);
|
||||
|
||||
// Monitor playback state changes
|
||||
const intervalId = setInterval(() => {
|
||||
if (newPlayer) {
|
||||
positionTimer.updateTime(newPlayer.currentTime);
|
||||
setIsPlaying(newPlayer.playing);
|
||||
setIsBuffering(false); // expo-audio doesn't expose buffering state
|
||||
|
||||
if (newPlayer.duration !== undefined && newPlayer.duration > 0) {
|
||||
durationTimer.setTime(newPlayer.duration);
|
||||
}
|
||||
|
||||
// Status aktualisieren
|
||||
if (newPlayer.playing) {
|
||||
setStatus(AudioPlayerStatus.PLAYING);
|
||||
} else if (newPlayer.currentTime === 0 && !newPlayer.playing) {
|
||||
setStatus(AudioPlayerStatus.STOPPED);
|
||||
} else if (positionTimer.timer > 0) {
|
||||
setStatus(AudioPlayerStatus.PAUSED);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Store interval IDs for cleanup
|
||||
(newPlayer as any)._intervalId = intervalId;
|
||||
(newPlayer as any)._checkDurationId = checkDuration;
|
||||
|
||||
setStatus(AudioPlayerStatus.PAUSED);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Audio-Datei:', error);
|
||||
setLoadError(true);
|
||||
setPlayer(null);
|
||||
setStatus(AudioPlayerStatus.ERROR);
|
||||
setError(error instanceof Error ? error.message : 'Unbekannter Fehler');
|
||||
}
|
||||
},
|
||||
[player]
|
||||
);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
try {
|
||||
if (!player) return;
|
||||
|
||||
if (player.playing) {
|
||||
await player.pause();
|
||||
setStatus(AudioPlayerStatus.PAUSED);
|
||||
|
||||
if (audioIdRef.current) {
|
||||
unregisterAudio(audioIdRef.current);
|
||||
audioIdRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Pausieren:', error);
|
||||
}
|
||||
}, [player, unregisterAudio]);
|
||||
|
||||
const playPause = useCallback(async () => {
|
||||
try {
|
||||
if (!player) return;
|
||||
|
||||
if (player.playing) {
|
||||
// Pausieren
|
||||
await pause();
|
||||
|
||||
// Benachrichtigung aktualisieren (nur für native Plattformen)
|
||||
if (!isWebEnvironment) {
|
||||
await NotificationService.showNotification(
|
||||
'Audio-Wiedergabe pausiert',
|
||||
'Tippe, um zur Wiedergabe zurückzukehren',
|
||||
NotificationChannel.AUDIO_PLAYBACK,
|
||||
true
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Wenn Audio zu Ende ist (Position am Ende), von vorne starten
|
||||
if (
|
||||
player.currentTime >= player.duration && player.duration > 0
|
||||
) {
|
||||
player.seekTo(0);
|
||||
positionTimer.updateTime(0);
|
||||
}
|
||||
|
||||
// Generate audio ID if not exists
|
||||
if (!audioIdRef.current) {
|
||||
audioIdRef.current = `audio-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Pause all other audio before playing this one
|
||||
await pauseAllExcept(audioIdRef.current);
|
||||
|
||||
// Abspielen
|
||||
await player.play();
|
||||
setStatus(AudioPlayerStatus.PLAYING);
|
||||
|
||||
// Register in global store
|
||||
registerAudio(audioIdRef.current, player, pause);
|
||||
|
||||
// Benachrichtigung anzeigen (nur für native Plattformen)
|
||||
if (!isWebEnvironment) {
|
||||
await NotificationService.showNotification(
|
||||
'Audio-Wiedergabe läuft',
|
||||
'Tippe, um zur Wiedergabe zurückzukehren',
|
||||
NotificationChannel.AUDIO_PLAYBACK,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Play/Pause:', error);
|
||||
setLoadError(true);
|
||||
setStatus(AudioPlayerStatus.ERROR);
|
||||
setError(error instanceof Error ? error.message : 'Unbekannter Fehler beim Abspielen');
|
||||
}
|
||||
}, [player, isWebEnvironment, positionTimer, pause, registerAudio, pauseAllExcept]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
try {
|
||||
if (!player) return;
|
||||
|
||||
await player.pause();
|
||||
player.seekTo(0);
|
||||
setStatus(AudioPlayerStatus.STOPPED);
|
||||
positionTimer.updateTime(0);
|
||||
|
||||
// Unregister from global store
|
||||
if (audioIdRef.current) {
|
||||
unregisterAudio(audioIdRef.current);
|
||||
audioIdRef.current = null;
|
||||
}
|
||||
|
||||
// Benachrichtigung entfernen (nur für native Plattformen)
|
||||
if (!isWebEnvironment) {
|
||||
await NotificationService.stopForegroundService();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stop:', error);
|
||||
setLoadError(true);
|
||||
setStatus(AudioPlayerStatus.ERROR);
|
||||
setError(error instanceof Error ? error.message : 'Unbekannter Fehler beim Stoppen');
|
||||
}
|
||||
}, [player, isWebEnvironment, unregisterAudio]);
|
||||
|
||||
const seekAndPlay = useCallback(
|
||||
async (positionMillis: number) => {
|
||||
try {
|
||||
if (!player) {
|
||||
console.error('Kein Player geladen');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPosition = (player.duration || 0) * 1000;
|
||||
const clampedPosition = Math.min(Math.max(0, positionMillis), maxPosition);
|
||||
player.seekTo(clampedPosition / 1000);
|
||||
|
||||
// Generate audio ID if not exists
|
||||
if (!audioIdRef.current) {
|
||||
audioIdRef.current = `audio-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Pause all other audio before playing this one
|
||||
await pauseAllExcept(audioIdRef.current);
|
||||
|
||||
await player.play();
|
||||
setIsPlaying(true);
|
||||
positionTimer.updateTime(positionMillis / 1000);
|
||||
setStatus(AudioPlayerStatus.PLAYING);
|
||||
|
||||
// Register in global store
|
||||
registerAudio(audioIdRef.current, player, pause);
|
||||
|
||||
// Benachrichtigung aktualisieren (nur für native Plattformen)
|
||||
if (!isWebEnvironment) {
|
||||
await NotificationService.showNotification(
|
||||
'Audio-Wiedergabe läuft',
|
||||
'Tippe, um zur Wiedergabe zurückzukehren',
|
||||
NotificationChannel.AUDIO_PLAYBACK,
|
||||
true
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Scrubben und Abspielen:', error);
|
||||
setLoadError(true);
|
||||
setStatus(AudioPlayerStatus.ERROR);
|
||||
setError(error instanceof Error ? error.message : 'Unbekannter Fehler bei der Navigation');
|
||||
}
|
||||
},
|
||||
[player, isWebEnvironment, pause, registerAudio, pauseAllExcept]
|
||||
);
|
||||
|
||||
const seek = useCallback(
|
||||
async (positionMillis: number) => {
|
||||
try {
|
||||
if (!player) return;
|
||||
|
||||
const maxPosition = (player.duration || 0) * 1000;
|
||||
const clampedPosition = Math.min(Math.max(0, positionMillis), maxPosition);
|
||||
player.seekTo(clampedPosition / 1000);
|
||||
positionTimer.updateTime(clampedPosition / 1000);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Scrubben:', error);
|
||||
setLoadError(true);
|
||||
}
|
||||
},
|
||||
[player]
|
||||
);
|
||||
|
||||
const unload = useCallback(async () => {
|
||||
try {
|
||||
if (!player) return;
|
||||
|
||||
// Clear update intervals if they exist
|
||||
if ((player as any)._intervalId) {
|
||||
clearInterval((player as any)._intervalId);
|
||||
}
|
||||
if ((player as any)._checkDurationId) {
|
||||
clearInterval((player as any)._checkDurationId);
|
||||
}
|
||||
|
||||
await player.pause();
|
||||
player.release();
|
||||
|
||||
// Benachrichtigung entfernen (nur für native Plattformen)
|
||||
if (!isWebEnvironment) {
|
||||
await NotificationService.stopForegroundService();
|
||||
}
|
||||
|
||||
setPlayer(null);
|
||||
positionTimer.reset();
|
||||
durationTimer.reset();
|
||||
setIsPlaying(false);
|
||||
setLoadError(false);
|
||||
setStatus(AudioPlayerStatus.IDLE);
|
||||
setError(null);
|
||||
setIsBuffering(false);
|
||||
|
||||
// Unregister from global store
|
||||
if (audioIdRef.current) {
|
||||
unregisterAudio(audioIdRef.current);
|
||||
audioIdRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Unload:', error);
|
||||
|
||||
// Trotzdem Status zurücksetzen, um dem Nutzer zu ermöglichen, neu zu laden
|
||||
setPlayer(null);
|
||||
setStatus(AudioPlayerStatus.IDLE);
|
||||
|
||||
// Ensure unregistration even on error
|
||||
if (audioIdRef.current) {
|
||||
unregisterAudio(audioIdRef.current);
|
||||
audioIdRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [player, isWebEnvironment, unregisterAudio]);
|
||||
|
||||
// Ressourcen freigeben, wenn die Komponente unmounted wird
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (player) {
|
||||
// Clear update intervals if they exist
|
||||
if ((player as any)._intervalId) {
|
||||
clearInterval((player as any)._intervalId);
|
||||
}
|
||||
if ((player as any)._checkDurationId) {
|
||||
clearInterval((player as any)._checkDurationId);
|
||||
}
|
||||
// Check if pause method exists before calling it (Expo Audio API change)
|
||||
if (typeof player.pause === 'function') {
|
||||
try {
|
||||
player.pause();
|
||||
} catch (error) {
|
||||
console.error('Error pausing player:', error);
|
||||
}
|
||||
}
|
||||
player.release();
|
||||
}
|
||||
|
||||
// Benachrichtigung entfernen (nur für native Plattformen)
|
||||
if (!isWebEnvironment) {
|
||||
NotificationService.stopForegroundService().catch(console.error);
|
||||
}
|
||||
|
||||
// Unregister from global store
|
||||
if (audioIdRef.current) {
|
||||
unregisterAudio(audioIdRef.current);
|
||||
audioIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [player, isWebEnvironment, unregisterAudio]);
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
duration: durationTimer.timer,
|
||||
currentTime: positionTimer.timer,
|
||||
status,
|
||||
error,
|
||||
isBuffering,
|
||||
loadError,
|
||||
loadSound,
|
||||
playPause,
|
||||
stop,
|
||||
seekAndPlay,
|
||||
seek,
|
||||
unload,
|
||||
formattedPosition: positionTimer.formattedTime,
|
||||
formattedDuration: durationTimer.formattedTime,
|
||||
percentComplete:
|
||||
durationTimer.timer > 0 ? (positionTimer.timer / durationTimer.timer) * 100 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAudioPlayer;
|
||||
|
||||
// Hilfsfunktion zur Formatierung der Zeit
|
||||
export const formatTime = (milliseconds: number): string => {
|
||||
return formatDurationFromMs(milliseconds);
|
||||
};
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
# iOS Background Recording Fix - Summary
|
||||
|
||||
**Date Fixed:** October 28, 2025
|
||||
**Issue:** Recording stopped when app was backgrounded on iOS
|
||||
**Status:** ✅ RESOLVED
|
||||
|
||||
## Quick Summary
|
||||
|
||||
iOS background recording now works correctly. Users can:
|
||||
- Press home button while recording → Recording continues ✅
|
||||
- Switch to other apps → Recording continues ✅
|
||||
- Lock the device → Recording continues ✅
|
||||
- Full duration captured (foreground + background time) ✅
|
||||
|
||||
## What Was Broken
|
||||
|
||||
Two bugs prevented background recording:
|
||||
|
||||
1. **Wrong audio mode:** `interruptionMode: 'doNotMix'`
|
||||
- iOS revoked exclusive audio access when app backgrounded
|
||||
- Recording stopped immediately
|
||||
|
||||
2. **Manual pause logic:** App state handler paused on `inactive` state
|
||||
- Treated normal state transition as audio interruption
|
||||
- Manually paused recording when backgrounding
|
||||
|
||||
## The Fix
|
||||
|
||||
### Change 1: Audio Configuration
|
||||
**File:** `AudioEngineService.ts:114`
|
||||
|
||||
```typescript
|
||||
// BEFORE (WRONG):
|
||||
interruptionMode: 'doNotMix',
|
||||
|
||||
// AFTER (CORRECT):
|
||||
interruptionMode: 'mixWithOthers',
|
||||
```
|
||||
|
||||
**Why:** iOS allows background apps to share audio hardware (`mixWithOthers`) but not have exclusive access (`doNotMix`).
|
||||
|
||||
### Change 2: Remove Manual Pause
|
||||
**File:** `IOSRecordingService.ts:161-174`
|
||||
|
||||
```typescript
|
||||
// REMOVED the code that paused on 'inactive' state
|
||||
// Let iOS handle audio naturally
|
||||
```
|
||||
|
||||
**Why:** The `inactive` state is just a transient transition during backgrounding, not an interruption signal.
|
||||
|
||||
## How It Works Now
|
||||
|
||||
```
|
||||
User backgrounds app
|
||||
↓
|
||||
iOS: "This app is mixing with others"
|
||||
↓
|
||||
iOS: "OK, continue recording"
|
||||
↓
|
||||
Native recording continues ✅
|
||||
JavaScript timers suspended (normal)
|
||||
↓
|
||||
[User returns to foreground]
|
||||
↓
|
||||
Status polling restarts
|
||||
Duration syncs with native recorder
|
||||
UI updates with full duration
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
**Verified Scenarios:**
|
||||
- ✅ Home button → Recording continues
|
||||
- ✅ App switching → Recording continues
|
||||
- ✅ Lock screen → Recording continues
|
||||
- ✅ 30+ second background time captured
|
||||
- ✅ Full duration shown after stopping
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Audio Interruption Modes
|
||||
|
||||
| Mode | Background Recording | Use Case |
|
||||
|------|---------------------|----------|
|
||||
| `doNotMix` | ❌ NO | Professional audio apps (exclusive access) |
|
||||
| `mixWithOthers` | ✅ YES | Voice memos, podcasts (shared access) |
|
||||
| `duckOthers` | ✅ YES | Navigation (lowers other audio) |
|
||||
|
||||
### iOS App States
|
||||
|
||||
```
|
||||
active → inactive → background
|
||||
↑ ↑ ↑
|
||||
Foreground Transition Background
|
||||
```
|
||||
|
||||
**Key Insight:** `inactive` is a transition, not an interruption.
|
||||
|
||||
## Documentation Updated
|
||||
|
||||
1. ✅ `README.md` - Added background recording section
|
||||
2. ✅ `TROUBLESHOOTING.md` - Added Bug #1 with full analysis
|
||||
3. ✅ `KNOWLEDGE.md` - Added iOS background recording technical details
|
||||
4. ✅ `CLAUDE.md` - Updated audio recording system section
|
||||
5. ✅ This summary document
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **AudioEngineService.ts:114** - Changed to `mixWithOthers`
|
||||
2. **IOSRecordingService.ts:29** - Updated config to match
|
||||
3. **IOSRecordingService.ts:161-174** - Removed manual pause logic
|
||||
|
||||
## Trade-offs
|
||||
|
||||
✅ **Benefits:**
|
||||
- Background recording works
|
||||
- Standard voice memo behavior
|
||||
- Multi-app compatibility
|
||||
|
||||
⚠️ **Considerations:**
|
||||
- User's music won't auto-pause
|
||||
- Background audio might be faintly captured if music is loud
|
||||
- **Impact:** Minimal - same as Apple Voice Memos
|
||||
|
||||
## References
|
||||
|
||||
- [iOS Audio Session Programming Guide](https://developer.apple.com/documentation/avfoundation/audio_track_engineering)
|
||||
- [Expo Audio Documentation](https://docs.expo.dev/versions/latest/sdk/audio/)
|
||||
- `TROUBLESHOOTING.md` - Full technical analysis
|
||||
- `KNOWLEDGE.md` - iOS background recording best practices
|
||||
|
||||
## Need Help?
|
||||
|
||||
See the comprehensive documentation:
|
||||
- **Quick start:** `README.md`
|
||||
- **Bug fixes:** `TROUBLESHOOTING.md`
|
||||
- **Technical details:** `KNOWLEDGE.md`
|
||||
- **Main docs:** `CLAUDE.md`
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
# Audio Recording V2 - Code Changes Summary
|
||||
|
||||
## Quick Reference for Bug Fixes
|
||||
|
||||
This document provides a concise summary of all code changes made to fix the audio recording bugs.
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. `features/audioRecordingV2/core/AudioEngineService.ts`
|
||||
|
||||
#### Change 1: Use Platform-Optimized Recording Options (Line 204-209)
|
||||
**Problem:** Using `RecordingPresets.LOW_QUALITY` which caused FFmpeg 'chnl' box errors
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
const recordingOptions = {
|
||||
...RecordingPresets.LOW_QUALITY,
|
||||
isMeteringEnabled: true,
|
||||
};
|
||||
this.recorder = new AudioModule.AudioRecorder(recordingOptions);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
// Use the platform-specific options that prevent spatial audio metadata issues
|
||||
console.log('🎤 Creating new recorder instance with platform-optimized options...');
|
||||
this.recorder = new AudioModule.AudioRecorder(platformOptions);
|
||||
```
|
||||
|
||||
**Impact:** ✅ Fixes FFmpeg error, enables MONO recording, reduces file size by 50%
|
||||
|
||||
---
|
||||
|
||||
#### Change 2: Add Manual Status Polling (Lines 702-751)
|
||||
**Problem:** Event callbacks don't fire during active recording
|
||||
|
||||
**Added:**
|
||||
```typescript
|
||||
// FALLBACK: Add manual polling since events don't fire during recording
|
||||
console.log('🔄 [Polling] Starting manual status polling (100ms interval)');
|
||||
this.statusPollingInterval = setInterval(() => {
|
||||
if (!this.recorder) {
|
||||
this.stopStatusPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = this.recorder.getStatus();
|
||||
|
||||
if (!status.isRecording) {
|
||||
this.stopStatusPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const enhancedStatus = {
|
||||
...status,
|
||||
currentTime: status.durationMillis || status.currentTime || 0,
|
||||
isPaused: false,
|
||||
isRecording: status.isRecording,
|
||||
metering: status.metering,
|
||||
durationMillis: status.durationMillis || 0,
|
||||
};
|
||||
|
||||
if (this.onStatusUpdateCallback) {
|
||||
this.onStatusUpdateCallback(enhancedStatus);
|
||||
}
|
||||
|
||||
// Log every 2 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastPollLogTime > 2000) {
|
||||
console.log('🔄 [Polling] Status at', Math.floor(enhancedStatus.currentTime / 1000) + 's');
|
||||
lastPollLogTime = now;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔄 [Polling] Error:', error);
|
||||
}
|
||||
}, 100); // Poll every 100ms = 10 updates/second
|
||||
```
|
||||
|
||||
**Impact:** ✅ Real-time duration and metering updates, smooth UI progress
|
||||
|
||||
---
|
||||
|
||||
#### Change 3: Fix Listener Cleanup (Line 762)
|
||||
**Problem:** `recorder.removeListener()` doesn't exist
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
this.recorder.removeListener(event, listener);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
this.statusListener.remove(); // Subscription object has .remove() method
|
||||
```
|
||||
|
||||
**Impact:** ✅ Proper cleanup, no memory leaks
|
||||
|
||||
---
|
||||
|
||||
### 2. `features/audioRecordingV2/store/recordingStore.ts`
|
||||
|
||||
#### Change: Use Saved File Duration (Lines 308-330)
|
||||
**Problem:** Duration in store gets reset before upload can use it
|
||||
|
||||
**Modified:**
|
||||
```typescript
|
||||
// Save recording to local storage first to get accurate duration
|
||||
let savedFile = null;
|
||||
let finalDurationSeconds = session?.duration || 0;
|
||||
|
||||
if (uri) {
|
||||
console.log('[Store] Saving recording to local storage...');
|
||||
try {
|
||||
savedFile = await fileStorageService.saveRecording(
|
||||
uri,
|
||||
undefined,
|
||||
finalDurationSeconds
|
||||
);
|
||||
|
||||
// Use the accurate duration from the saved file
|
||||
if (savedFile && savedFile.duration) {
|
||||
finalDurationSeconds = savedFile.duration;
|
||||
console.log('[Store] Using accurate duration from saved file:', finalDurationSeconds, 'seconds');
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error('[Store] Failed to save recording locally:', saveError);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** ✅ Duration captured before store reset (partial fix, full fix requires callback parameter)
|
||||
|
||||
---
|
||||
|
||||
### 3. `features/storage/fileSystemUtils.ts`
|
||||
|
||||
#### Change: Always Return File Size (Lines 242-252)
|
||||
**Problem:** Size only returned conditionally based on `options?.size`
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
if (file.exists) {
|
||||
const size = options?.size ? file.size : undefined;
|
||||
return {
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
size: size, // Could be undefined
|
||||
uri: fileUri,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
if (file.exists) {
|
||||
console.log('[FileSystemUtils] File.size value:', file.size);
|
||||
return {
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
size: file.size, // ALWAYS return size when file exists
|
||||
uri: fileUri,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** ✅ File size always available, no undefined values
|
||||
|
||||
---
|
||||
|
||||
### 4. `components/atoms/RecordingButton.tsx`
|
||||
|
||||
#### Change 1: Update Callback Interface (Line 24)
|
||||
**Problem:** No duration parameter in callback
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
onRecordingComplete?: (
|
||||
result: string,
|
||||
title?: string,
|
||||
spaceId?: string | null,
|
||||
blueprintId?: string | null
|
||||
) => void;
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
onRecordingComplete?: (
|
||||
result: string,
|
||||
title?: string,
|
||||
spaceId?: string | null,
|
||||
blueprintId?: string | null,
|
||||
durationSeconds?: number // NEW: Pass duration explicitly
|
||||
) => void;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Change 2: Pass Duration in Callback (Line 369)
|
||||
**Problem:** Duration not passed to parent component
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
onRecordingComplete?.(savedFile.uri, title, spaceId, blueprintId);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
onRecordingComplete?.(
|
||||
savedFile.uri,
|
||||
title,
|
||||
spaceId,
|
||||
blueprintId,
|
||||
savedFile.duration // NEW: Pass duration from saved file
|
||||
);
|
||||
```
|
||||
|
||||
**Impact:** ✅ Duration reliably passed through callback chain
|
||||
|
||||
---
|
||||
|
||||
### 5. `app/(protected)/(tabs)/index.tsx`
|
||||
|
||||
#### Change 1: Update Upload Function Signature (Lines 954-960)
|
||||
**Problem:** Upload function doesn't accept duration parameter
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
const uploadAudioRecording = useCallback(async (
|
||||
filePath: string,
|
||||
title: string,
|
||||
spaceId: string | null,
|
||||
blueprintId: string | null
|
||||
) => {
|
||||
// ...
|
||||
}, [t]);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const uploadAudioRecording = useCallback(async (
|
||||
filePath: string,
|
||||
title: string,
|
||||
spaceId: string | null,
|
||||
blueprintId: string | null,
|
||||
durationSeconds?: number // NEW: Accept duration parameter
|
||||
) => {
|
||||
// ...
|
||||
}, [t]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Change 2: Use Duration Parameter (Line 1005)
|
||||
**Problem:** Duration not passed to transcription API
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
duration: recordingDuration || 0, // May be 0 if store reset
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
duration: durationSeconds || 0, // Use explicit parameter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Change 3: Pass Duration to Upload (Line 1037)
|
||||
**Problem:** handleRecordingComplete doesn't forward duration
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
(result: string, title?: string, spaceId?: string | null, blueprintId?: string | null) => {
|
||||
uploadAudioRecording(result, title || 'Memo', finalSpaceId, finalBlueprintId);
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
(result: string, title?: string, spaceId?: string | null, blueprintId?: string | null, durationSeconds?: number) => {
|
||||
const finalDuration = durationSeconds || recordingDuration;
|
||||
uploadAudioRecording(
|
||||
result,
|
||||
title || 'Memo',
|
||||
finalSpaceId,
|
||||
finalBlueprintId,
|
||||
finalDuration // NEW: Forward duration parameter
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** ✅ Duration reliably flows from recording to upload to transcription
|
||||
|
||||
---
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
### Platform Recording Options (AudioEngineService.ts lines 181-202)
|
||||
|
||||
**Key Settings:**
|
||||
```typescript
|
||||
const platformOptions = {
|
||||
extension: '.m4a',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1, // CRITICAL: MONO prevents iOS spatial audio
|
||||
bitRate: 64000, // Optimal for voice (reduced from 128000)
|
||||
android: {
|
||||
outputFormat: 'mpeg4',
|
||||
audioEncoder: 'aac',
|
||||
},
|
||||
ios: {
|
||||
outputFormat: 'mpeg4aac',
|
||||
audioQuality: 96, // HIGH quality, avoids MAX (127) that triggers spatial audio
|
||||
linearPCMBitDepth: 16,
|
||||
linearPCMIsBigEndian: false,
|
||||
linearPCMIsFloat: false,
|
||||
},
|
||||
web: {
|
||||
mimeType: 'audio/webm',
|
||||
bitsPerSecond: 64000,
|
||||
},
|
||||
isMeteringEnabled: true, // Required for waveform animation
|
||||
};
|
||||
```
|
||||
|
||||
**Why These Settings:**
|
||||
- `numberOfChannels: 1` (MONO) prevents iOS from adding spatial audio 'chnl' metadata
|
||||
- `audioQuality: 96` (HIGH) provides excellent voice quality without advanced features
|
||||
- `bitRate: 64000` optimal for voice, saves bandwidth (50% reduction from stereo)
|
||||
- `isMeteringEnabled: true` enables waveform visualization
|
||||
|
||||
---
|
||||
|
||||
## Testing Verification
|
||||
|
||||
### Expected Behavior After Fixes
|
||||
|
||||
1. **Start Recording:**
|
||||
- ✅ Immediate UI feedback
|
||||
- ✅ Time display starts at 0:00
|
||||
- ✅ Waveform shows green bars (metering working)
|
||||
|
||||
2. **During Recording:**
|
||||
- ✅ Time increments smoothly every 100ms
|
||||
- ✅ Waveform animates with audio levels
|
||||
- ✅ Console shows polling logs every 2 seconds
|
||||
```
|
||||
🔄 [Polling] Status at 2s: { durationMs: 2000, metering: -45.1 }
|
||||
🏪 [Store] Received status update: { durationMillis: 2000 }
|
||||
```
|
||||
|
||||
3. **Stop Recording:**
|
||||
- ✅ Final duration accurate
|
||||
- ✅ File saved with correct size
|
||||
- ✅ Duration passed to upload
|
||||
|
||||
4. **Upload:**
|
||||
- ✅ No FFmpeg 'chnl' box errors
|
||||
- ✅ Correct duration sent to transcription API
|
||||
- ✅ Transcription succeeds
|
||||
|
||||
### Log Verification
|
||||
|
||||
Expected console output:
|
||||
```
|
||||
🎤 Creating new recorder instance with platform-optimized options...
|
||||
🎤 Recording options: { numberOfChannels: 1, isMeteringEnabled: true }
|
||||
🎤 Starting recording...
|
||||
🔄 [Polling] Starting manual status polling (100ms interval)
|
||||
🔄 [Polling] Status at 2s: { isRecording: true, durationMs: 2000, metering: -42.3 }
|
||||
🏪 [Store] Received status update: { durationMillis: 2000, metering: -42.3 }
|
||||
[Store] Using accurate duration from saved file: 10.52 seconds
|
||||
[FileSystemUtils] File.size value: 842752
|
||||
✅ Upload successful with duration: 10.52s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Polling Overhead
|
||||
- **Frequency:** 100ms (10 checks/second)
|
||||
- **CPU:** ~0.1% (negligible)
|
||||
- **Memory:** <1KB constant
|
||||
- **Battery:** Minimal impact
|
||||
|
||||
### File Size Reduction
|
||||
- **Before:** STEREO 128kbps = ~16KB/second
|
||||
- **After:** MONO 64kbps = ~8KB/second
|
||||
- **Savings:** 50% smaller files, faster uploads
|
||||
|
||||
### UI Responsiveness
|
||||
- **Before:** No updates during recording (stuck at 0)
|
||||
- **After:** Smooth updates every 100ms (10 FPS)
|
||||
|
||||
---
|
||||
|
||||
## Rollback Instructions
|
||||
|
||||
If issues arise, revert these commits in order:
|
||||
|
||||
1. **Revert FFmpeg fix:**
|
||||
```typescript
|
||||
// AudioEngineService.ts line 209
|
||||
this.recorder = new AudioModule.AudioRecorder(RecordingPresets.LOW_QUALITY);
|
||||
```
|
||||
⚠️ Warning: Will cause FFmpeg errors again
|
||||
|
||||
2. **Disable polling:**
|
||||
```typescript
|
||||
// AudioEngineService.ts line 704
|
||||
// Comment out the setInterval block
|
||||
```
|
||||
⚠️ Warning: No real-time updates
|
||||
|
||||
3. **Revert duration parameter:**
|
||||
- Remove `durationSeconds` parameter from all callbacks
|
||||
- Use store duration instead
|
||||
⚠️ Warning: Duration may be 0 on upload
|
||||
|
||||
---
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
- **Full troubleshooting guide:** `TROUBLESHOOTING.md`
|
||||
- **Architecture overview:** `../../../docs/architecture/audio-recording-v2.md` (if exists)
|
||||
- **Type definitions:** `../types/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## Questions or Issues?
|
||||
|
||||
If you encounter problems:
|
||||
|
||||
1. **Check logs** - Look for 🎤, 🔄, 🏪 emoji markers
|
||||
2. **Verify polling** - Should see logs every 2 seconds during recording
|
||||
3. **Test on real device** - Simulator has known audio issues
|
||||
4. **Check file format** - Ensure MONO, not STEREO
|
||||
5. **Review TROUBLESHOOTING.md** - Detailed debugging guide
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** October 23, 2025
|
||||
**Author:** Claude Code
|
||||
**Status:** ✅ Production Ready
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
# iOS Permission Flow & Audio Session Fix
|
||||
|
||||
## Problem Summary
|
||||
|
||||
Users reported that during the **first recording attempt** on iOS, the recording circle animation would malfunction when the microphone permission dialog appeared. Additionally, after granting permission, recordings would fail with:
|
||||
|
||||
```
|
||||
RecordingDisabledException: Recording not allowed on iOS.
|
||||
Enable with Audio.setAudioModeAsync
|
||||
```
|
||||
|
||||
## Timeline of Investigation & Fixes
|
||||
|
||||
### Issue 1: Animation Starting Before Permission Dialog
|
||||
|
||||
**Initial Problem:**
|
||||
- User presses recording button for the first time
|
||||
- Animation starts immediately
|
||||
- Permission dialog appears
|
||||
- Animation behavior becomes inconsistent
|
||||
- User experience is confusing
|
||||
|
||||
**First Attempt - Fix Permission Check Timing ❌**
|
||||
|
||||
**File:** `RecordingButton.tsx`
|
||||
**Change:** Moved permission check to beginning of `handlePressIn` before animation starts
|
||||
**Result:** Permission dialog now appears first, but recording still failed after granting permission
|
||||
|
||||
**Why it didn't fully work:** While this fixed the animation timing issue, it revealed a deeper problem with audio session initialization.
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: Recording Fails After Permission Grant
|
||||
|
||||
**Problem After First Fix:**
|
||||
```
|
||||
🔐 Permission granted - reinitializing audio session...
|
||||
Recording store already initialized ← Returns early!
|
||||
🔐 Audio session reinitialized successfully ← False positive
|
||||
[Recording attempt fails with RecordingDisabledException]
|
||||
```
|
||||
|
||||
**Second Attempt - Manual Flag Reset ❌**
|
||||
|
||||
**File:** `RecordingButton.tsx` (lines 490-512)
|
||||
**Change:** Added manual reset of `isInitialized` flag:
|
||||
```typescript
|
||||
useRecordingStore.setState({ isInitialized: false });
|
||||
await initialize();
|
||||
```
|
||||
|
||||
**Result:** Still failed!
|
||||
|
||||
**Why it didn't work:** There were actually **TWO** `initialized` flags:
|
||||
1. `recordingStore.isInitialized` (store level) ✅ Reset
|
||||
2. `AudioEngineService.initialized` (service level) ❌ Still true
|
||||
|
||||
The `AudioEngineService.initialize()` method was returning early because its own flag was still `true`.
|
||||
|
||||
---
|
||||
|
||||
### Issue 3: Double Initialization Guards
|
||||
|
||||
**Root Cause Identified:**
|
||||
|
||||
Both the store and service had independent initialization guards:
|
||||
|
||||
**recordingStore.ts (lines 162-167):**
|
||||
```typescript
|
||||
initialize: async () => {
|
||||
if (isInitialized) {
|
||||
console.log('Recording store already initialized');
|
||||
return; // ❌ Returns early
|
||||
}
|
||||
// ... initialization code
|
||||
}
|
||||
```
|
||||
|
||||
**AudioEngineService.ts (lines 56-59):**
|
||||
```typescript
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
console.log('AudioEngineService already initialized');
|
||||
return; // ❌ Also returns early!
|
||||
}
|
||||
// ... calls setupAudioMode() which calls setAudioModeAsync()
|
||||
}
|
||||
```
|
||||
|
||||
**Third Attempt - Create Proper Reinitialize Method ✅**
|
||||
|
||||
**Files Modified:**
|
||||
- `recordingStore.ts` (added `reinitialize()` method)
|
||||
- `RecordingButton.tsx` (call `reinitialize()` instead of `initialize()`)
|
||||
|
||||
**Change:** Created dedicated `reinitialize()` method that:
|
||||
1. Calls `recordingService.cleanup()` to reset **both** flags
|
||||
2. Resets store's `isInitialized` flag
|
||||
3. Calls `initialize()` which now runs `setupAudioMode()`
|
||||
|
||||
**recordingStore.ts (lines 203-235):**
|
||||
```typescript
|
||||
reinitialize: async () => {
|
||||
// Reset both the service's and store's initialized flags
|
||||
recordingService.cleanup(); // Resets AudioEngineService.initialized
|
||||
set({ isInitialized: false }); // Resets store's isInitialized
|
||||
|
||||
// Now reinitialize
|
||||
await recordingService.initialize(); // Now runs setupAudioMode()!
|
||||
|
||||
const permissions = await recordingService.checkPermissions();
|
||||
set({ isInitialized: true, permissions, error: null });
|
||||
}
|
||||
```
|
||||
|
||||
**RecordingButton.tsx (lines 499-503):**
|
||||
```typescript
|
||||
await reinitialize(); // Use new method instead of initialize()
|
||||
```
|
||||
|
||||
**Result:** Recording now works after permission grant! 🎉
|
||||
|
||||
---
|
||||
|
||||
### Issue 4: Metering & Duration Not Displaying
|
||||
|
||||
**New Problem After Fix 3:**
|
||||
- Recording works
|
||||
- Audio is captured
|
||||
- But duration stays at 0:00
|
||||
- Metering visualization (colored ring) doesn't show
|
||||
|
||||
**Investigation:**
|
||||
|
||||
Checked logs and found callbacks weren't receiving updates:
|
||||
```
|
||||
🏪 [Store] Ignoring status update - no active session ← No updates!
|
||||
```
|
||||
|
||||
**Root Cause:** When `cleanup()` is called, it clears the callbacks:
|
||||
|
||||
**AudioEngineService.ts (lines 517-520):**
|
||||
```typescript
|
||||
cleanup(): void {
|
||||
// ...
|
||||
this.onStateChangeCallback = null; // ❌ Cleared!
|
||||
this.onStatusUpdateCallback = null; // ❌ Cleared!
|
||||
}
|
||||
```
|
||||
|
||||
But callbacks were only registered **once** on store creation and never re-registered after cleanup.
|
||||
|
||||
**Fourth Attempt - Re-register Callbacks ✅**
|
||||
|
||||
**File:** `recordingStore.ts`
|
||||
|
||||
**Change:** Extracted callback registration into reusable function and called it after cleanup:
|
||||
|
||||
**Step 1 - Extract callback registration (lines 51-120):**
|
||||
```typescript
|
||||
const registerCallbacks = (set: any, get: any) => {
|
||||
console.log('[Store] Registering callbacks...');
|
||||
|
||||
(recordingService as any).registerStateChangeCallback((partialState: any) => {
|
||||
set((state: any) => ({ ...state, ...partialState }));
|
||||
});
|
||||
|
||||
(recordingService as any).registerStatusUpdateCallback((status: RecorderState) => {
|
||||
// Updates duration and metering
|
||||
// ...
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2 - Register on store creation (line 124):**
|
||||
```typescript
|
||||
export const useRecordingStore = create<RecordingStoreState>((set, get) => {
|
||||
registerCallbacks(set, get); // Initial registration
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**Step 3 - Re-register in reinitialize() (line 223):**
|
||||
```typescript
|
||||
reinitialize: async () => {
|
||||
recordingService.cleanup(); // Clears callbacks
|
||||
set({ isInitialized: false });
|
||||
|
||||
registerCallbacks(set, get); // ✅ Re-register callbacks!
|
||||
|
||||
await recordingService.initialize();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Duration and metering now work perfectly! 🎉
|
||||
|
||||
---
|
||||
|
||||
## Final Working Solution
|
||||
|
||||
### Summary of Changes
|
||||
|
||||
**1. RecordingButton.tsx**
|
||||
- Added permission check BEFORE animation starts
|
||||
- Call `reinitialize()` after permission grant (before checking button state)
|
||||
- Added `reinitialize` to useCallback dependencies
|
||||
|
||||
**2. recordingStore.ts**
|
||||
- Extracted callback registration into `registerCallbacks()` helper function
|
||||
- Created new `reinitialize()` method that:
|
||||
- Calls `cleanup()` to reset both initialized flags
|
||||
- Re-registers callbacks
|
||||
- Reinitializes audio engine
|
||||
- Re-registers callbacks after cleanup in `reinitialize()`
|
||||
|
||||
### Complete Flow After Fix
|
||||
|
||||
**First Recording Attempt (No Permission):**
|
||||
```
|
||||
1. User presses recording button
|
||||
2. handlePressIn() called
|
||||
3. ✅ Permission check happens FIRST (before animation)
|
||||
4. Permission dialog shows → iOS app state: active → inactive
|
||||
5. iOS fires handlePressOut (button released during dialog)
|
||||
6. User grants permission
|
||||
7. ✅ reinitialize() called immediately:
|
||||
- cleanup() resets flags ✅
|
||||
- registerCallbacks() re-registers callbacks ✅
|
||||
- initialize() runs setupAudioMode() ✅
|
||||
- setAudioModeAsync() configures audio session ✅
|
||||
8. Check button state → Released, clean up animations
|
||||
9. Audio session is NOW READY for next attempt ✅
|
||||
```
|
||||
|
||||
**Second Recording Attempt (Has Permission):**
|
||||
```
|
||||
1. User presses recording button
|
||||
2. Permission check → Already granted ✅
|
||||
3. Animation starts smoothly
|
||||
4. Recording starts successfully ✅
|
||||
5. Duration updates in real-time ✅
|
||||
6. Metering shows colored ring ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### iOS Audio Session Lifecycle
|
||||
|
||||
**Key Insight:** iOS resets the audio session when the permission dialog appears. The app must call `Audio.setAudioModeAsync()` again after the dialog dismisses.
|
||||
|
||||
**Critical Requirements:**
|
||||
1. Audio session setup must be **decoupled** from button press state
|
||||
2. Callbacks must be **re-registered** after cleanup
|
||||
3. Both store and service flags must be **reset** before reinitialization
|
||||
|
||||
### Why Multiple Flags Existed
|
||||
|
||||
The architecture had two separate initialization flags for good reasons:
|
||||
- **Store flag** (`isInitialized`): Prevents redundant store setup
|
||||
- **Service flag** (`initialized`): Prevents redundant audio engine setup
|
||||
|
||||
However, this created a **hidden dependency** where resetting one wasn't enough.
|
||||
|
||||
### Why Cleanup Clears Callbacks
|
||||
|
||||
The `cleanup()` method is designed to fully reset the audio engine, including clearing callbacks. This is correct behavior for normal cleanup, but required special handling for the permission flow where we want to reinitialize without losing connection to the store.
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### 1. Multiple Initialization Guards
|
||||
**Problem:** Two separate `initialized` flags in different layers (store + service)
|
||||
**Lesson:** When debugging "already initialized" issues, check ALL layers for initialization guards
|
||||
|
||||
### 2. Callback Lifecycle
|
||||
**Problem:** Callbacks registered once but cleared by cleanup
|
||||
**Lesson:** If a service has lifecycle methods (cleanup), ensure all registrations are idempotent and can be re-applied
|
||||
|
||||
### 3. iOS Permission Dialog Side Effects
|
||||
**Problem:** iOS resets audio session when permission dialog appears
|
||||
**Lesson:** Always reinitialize audio-related systems after permission grant, not just check the permission state
|
||||
|
||||
### 4. Separation of Concerns
|
||||
**Problem:** Audio session setup was coupled to button press state
|
||||
**Lesson:** System-level initialization (audio session) should be independent of UI state (button pressed)
|
||||
|
||||
### 5. Early Returns Can Hide Issues
|
||||
**Problem:** `initialize()` returned early with success message, hiding the fact that setup didn't run
|
||||
**Lesson:** Add detailed logging at both entry and exit of guarded methods to catch early returns
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
To verify the fix works correctly:
|
||||
|
||||
- [ ] Delete app to reset permissions
|
||||
- [ ] Launch app
|
||||
- [ ] **First press:** Permission dialog appears immediately (no animation start)
|
||||
- [ ] Grant permission
|
||||
- [ ] Check logs: "Forcing reinitialization" → "Callbacks registered" → "AudioEngineService initialized"
|
||||
- [ ] **Second press:** Recording starts successfully
|
||||
- [ ] Duration updates in real-time
|
||||
- [ ] Colored ring pulses with audio level (green/orange/red)
|
||||
- [ ] Recording completes and saves successfully
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
### Modified Files
|
||||
- `components/atoms/RecordingButton.tsx` - Permission flow and reinitialize call
|
||||
- `features/audioRecordingV2/store/recordingStore.ts` - Reinitialize method and callback registration
|
||||
|
||||
### Related Files (Not Modified)
|
||||
- `features/audioRecordingV2/core/AudioEngineService.ts` - Contains cleanup() and initialize()
|
||||
- `features/audioRecordingV2/platforms/IOSRecordingService.ts` - Platform-specific iOS implementation
|
||||
- `features/audioRecordingV2/types/index.ts` - Type definitions including IAudioEngineService
|
||||
|
||||
---
|
||||
|
||||
## Prevention Strategies
|
||||
|
||||
To prevent similar issues in the future:
|
||||
|
||||
### 1. Document Initialization Dependencies
|
||||
```typescript
|
||||
/**
|
||||
* CRITICAL: This method requires callbacks to be registered first.
|
||||
* If cleanup() was called, callbacks must be re-registered via registerCallbacks()
|
||||
* before this method will function correctly.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Initialization State Enum
|
||||
Instead of boolean flags, use an enum:
|
||||
```typescript
|
||||
enum InitializationState {
|
||||
UNINITIALIZED,
|
||||
INITIALIZING,
|
||||
INITIALIZED,
|
||||
ERROR
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add Callback Health Check
|
||||
```typescript
|
||||
private ensureCallbacksRegistered(): void {
|
||||
if (!this.onStateChangeCallback || !this.onStatusUpdateCallback) {
|
||||
throw new Error('Callbacks not registered. Call registerCallbacks() first.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Integration Tests
|
||||
Add tests that specifically cover:
|
||||
- Permission grant flow
|
||||
- Audio session reinitialization
|
||||
- Callback persistence after cleanup/reinit
|
||||
|
||||
---
|
||||
|
||||
## Debug Commands
|
||||
|
||||
Useful commands for debugging similar issues:
|
||||
|
||||
```bash
|
||||
# Check iOS audio session state
|
||||
# Add to AudioEngineService.ts for debugging:
|
||||
import { Audio } from 'expo-audio';
|
||||
const mode = await Audio.getAudioModeAsync();
|
||||
console.log('Current audio mode:', mode);
|
||||
|
||||
# Check callback registration
|
||||
console.log('Callbacks registered:', {
|
||||
stateChange: !!this.onStateChangeCallback,
|
||||
statusUpdate: !!this.onStatusUpdateCallback
|
||||
});
|
||||
|
||||
# Check initialization state across layers
|
||||
console.log('Init state:', {
|
||||
store: useRecordingStore.getState().isInitialized,
|
||||
service: recordingService.isInitialized()
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [iOS Audio Session Programming Guide](https://developer.apple.com/library/archive/documentation/Audio/Conceptual/AudioSessionProgrammingGuide/)
|
||||
- [Expo Audio Documentation](https://docs.expo.dev/versions/latest/sdk/audio/)
|
||||
- [React Native Reanimated Worklets](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/glossary#worklet)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-08
|
||||
**Issue Fixed:** iOS recording permission flow with audio session reinitialization
|
||||
**Status:** ✅ Resolved
|
||||
217
apps/memoro/apps/mobile/features/audioRecordingV2/KNOWLEDGE.md
Normal file
217
apps/memoro/apps/mobile/features/audioRecordingV2/KNOWLEDGE.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Audio Recording V2 - Technical Knowledge
|
||||
|
||||
## iOS Background Recording
|
||||
|
||||
### Audio Session Modes
|
||||
|
||||
iOS provides three audio interruption modes for managing how your app's audio interacts with other apps:
|
||||
|
||||
#### Mode Comparison
|
||||
|
||||
| Mode | Background Recording | Other Apps' Audio | iOS Behavior | Use Case |
|
||||
|------|---------------------|-------------------|--------------|----------|
|
||||
| `doNotMix` | ❌ NO | Paused/Stopped | Requires exclusive access, revoked when backgrounded | Professional DAWs, music production |
|
||||
| `mixWithOthers` | ✅ YES | Continues normally | Shares audio hardware, continues in background | ✅ **Voice memos, podcasts** |
|
||||
| `duckOthers` | ✅ YES | Lowered (~30%) | Reduces other audio volume | Navigation, voice prompts |
|
||||
|
||||
#### Technical Details: mixWithOthers Mode
|
||||
|
||||
**Why It's Required for Background Recording:**
|
||||
|
||||
```typescript
|
||||
// AudioEngineService.ts:114
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: true,
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
interruptionMode: 'mixWithOthers', // ✅ CRITICAL for background recording
|
||||
});
|
||||
```
|
||||
|
||||
**How iOS Enforces Background Audio Rules:**
|
||||
|
||||
1. **Foreground Apps:**
|
||||
- Can request exclusive audio access (`doNotMix`)
|
||||
- iOS grants exclusive microphone/speaker control
|
||||
- Other apps' audio is paused
|
||||
|
||||
2. **Background Apps:**
|
||||
- **Cannot** have exclusive audio access
|
||||
- Must use `mixWithOthers` or `duckOthers`
|
||||
- iOS deactivates non-mixing sessions when app backgrounds
|
||||
|
||||
3. **State Transitions:**
|
||||
```
|
||||
User presses home button:
|
||||
active → inactive → background
|
||||
|
||||
doNotMix: iOS deactivates session → Recording stops ❌
|
||||
mixWithOthers: iOS keeps session active → Recording continues ✅
|
||||
```
|
||||
|
||||
### App State Handling
|
||||
|
||||
**iOS App State Lifecycle:**
|
||||
|
||||
```
|
||||
Foreground: active
|
||||
↓ (home button / app switch)
|
||||
Transition: inactive ← Brief transitional state
|
||||
↓
|
||||
Background: background
|
||||
```
|
||||
|
||||
**Important:** The `inactive` state is a **transient transition**, NOT an audio interruption signal.
|
||||
|
||||
**Correct Handling:**
|
||||
|
||||
```typescript
|
||||
// IOSRecordingService.ts
|
||||
handleAppStateChange(nextAppState) {
|
||||
if (lastState === 'active' && nextAppState === 'background') {
|
||||
// ✅ Recording continues naturally with mixWithOthers
|
||||
console.log('Backgrounded, recording continues');
|
||||
}
|
||||
|
||||
// ❌ WRONG: Don't treat 'inactive' as interruption
|
||||
// if (nextAppState === 'inactive') {
|
||||
// this.pauseRecording(); // This breaks background recording!
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
**Real Audio Interruptions:**
|
||||
|
||||
These are handled automatically by iOS:
|
||||
- **Phone calls** - iOS pauses recording
|
||||
- **Siri** - iOS pauses recording
|
||||
- **FaceTime calls** - iOS pauses recording
|
||||
- **Alarm/Timer** - iOS may pause (depends on user action)
|
||||
|
||||
Your app receives interruption notifications from iOS, not from app state changes.
|
||||
|
||||
### Background Recording Requirements
|
||||
|
||||
**Required Configuration:**
|
||||
|
||||
1. **Info.plist** (via app.json):
|
||||
```json
|
||||
{
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Audio Mode** (AudioEngineService.ts):
|
||||
```typescript
|
||||
interruptionMode: 'mixWithOthers'
|
||||
```
|
||||
|
||||
3. **No Manual Pause Logic** (IOSRecordingService.ts):
|
||||
- Don't pause on `inactive` state
|
||||
- Let iOS handle real interruptions
|
||||
|
||||
### JavaScript Timer Behavior
|
||||
|
||||
**Important:** JavaScript timers (`setInterval`, `setTimeout`) are **suspended** when app is backgrounded.
|
||||
|
||||
**Impact:**
|
||||
```typescript
|
||||
// Status polling interval (AudioEngineService.ts:709)
|
||||
this.statusPollingInterval = setInterval(() => {
|
||||
const status = this.recorder.getStatus();
|
||||
this.onStatusUpdateCallback(status);
|
||||
}, 100);
|
||||
```
|
||||
|
||||
- ⏸️ **Paused** when app backgrounds
|
||||
- ▶️ **Resumed** when app foregrounds
|
||||
- 🎤 **Native recording continues** regardless
|
||||
|
||||
**Solution:**
|
||||
|
||||
When app returns to foreground, restart polling:
|
||||
|
||||
```typescript
|
||||
// IOSRecordingService.ts:136
|
||||
if (lastState === 'background' && nextState === 'active') {
|
||||
this.startStatusPolling(); // Restart JavaScript polling
|
||||
// Native recording never stopped, just sync UI
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
**Status Polling Overhead:**
|
||||
- **Frequency:** 100ms (10 updates/second)
|
||||
- **CPU Impact:** ~0.1% (negligible)
|
||||
- **Memory Impact:** <1KB constant
|
||||
- **Battery Impact:** Minimal
|
||||
|
||||
**Native Recording:**
|
||||
- Handled by iOS Core Audio (separate process)
|
||||
- Not affected by JavaScript suspension
|
||||
- Continues in background independently
|
||||
- More efficient than JavaScript-based solutions
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Always use `mixWithOthers` for voice memos**
|
||||
2. **Don't manually pause on app state transitions**
|
||||
3. **Let iOS handle real audio interruptions**
|
||||
4. **Restart status polling when foregrounding**
|
||||
5. **Test on real devices, not just simulator**
|
||||
|
||||
## Audio Metering
|
||||
|
||||
### What is Audio Metering?
|
||||
Audio metering provides real-time feedback about the audio input level during recording. The metering value represents the audio input level in decibels (dB).
|
||||
|
||||
### Metering Value Range
|
||||
- **Range**: Typically -160 to 0 dB (negative values)
|
||||
- **0 dB**: Maximum level (loudest)
|
||||
- **-40 to -20 dB**: Normal speaking voice
|
||||
- **-60 dB and below**: Very quiet/silence
|
||||
- **undefined**: Recording not active or metering unavailable
|
||||
|
||||
### How Metering Works in Our Implementation
|
||||
|
||||
1. **Source**: Metering values come from Expo Audio's Recording API
|
||||
- Retrieved via `recorder.getStatus()` in `AudioEngineService.ts`
|
||||
- Native audio APIs provide these values based on microphone input
|
||||
|
||||
2. **Data Flow**:
|
||||
```
|
||||
Microphone → Expo Audio API → recorder.getStatus() → AudioEngineService → RecordingStore → UI Components
|
||||
```
|
||||
|
||||
3. **Update Frequency**:
|
||||
- Status polling occurs every 250ms during active recording
|
||||
- Metering values update in real-time with this frequency
|
||||
|
||||
4. **Usage in Components**:
|
||||
- Available in recording status for visual feedback
|
||||
- Can be used to create audio level indicators
|
||||
- Helps users verify microphone is working and picking up sound
|
||||
|
||||
### Implementation Details
|
||||
|
||||
The metering value is:
|
||||
- Retrieved in `AudioEngineService.ts:351` via `this.recorder.getStatus()`
|
||||
- Included in the enhanced status object returned to consumers
|
||||
- Logged during recording for debugging (see line 513)
|
||||
- Stored in the recording store state for UI access
|
||||
|
||||
### Platform Differences
|
||||
- **iOS**: Reliable metering values
|
||||
- **Android**: Reliable metering values
|
||||
- **iOS Simulator**: May have issues with metering updates (known Expo limitation)
|
||||
- **Web**: Metering availability depends on browser audio API support
|
||||
|
||||
### Troubleshooting
|
||||
- If metering shows `undefined`: Check recording permissions and ensure recording is active
|
||||
- If metering stuck at one value: May indicate audio subsystem issues or simulator limitations
|
||||
- If metering always shows very low values: Check microphone permissions and device audio settings
|
||||
302
apps/memoro/apps/mobile/features/audioRecordingV2/README.md
Normal file
302
apps/memoro/apps/mobile/features/audioRecordingV2/README.md
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
# AudioRecordingV2
|
||||
|
||||
A modern, production-ready audio recording solution for React Native using Expo SDK 54.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Correct Expo SDK 54 API Usage**
|
||||
- Uses `AudioModule.AudioRecorder` from internal API
|
||||
- Proper synchronous/asynchronous method handling
|
||||
- Status polling for real-time updates
|
||||
|
||||
✅ **Platform-Specific Optimizations**
|
||||
- Android: Foreground service, wake locks, Android 16 support
|
||||
- iOS: Audio session management, background recording
|
||||
- Web: Fallback support (basic implementation)
|
||||
|
||||
✅ **Robust Error Handling**
|
||||
- Comprehensive error classification
|
||||
- Automatic retry strategies
|
||||
- User-friendly error messages
|
||||
|
||||
✅ **State Management**
|
||||
- Zustand store integration
|
||||
- Separate timer management
|
||||
- Real-time status updates
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
import { useAudioRecordingV2 } from '@/features/audioRecordingV2';
|
||||
|
||||
function RecordingComponent() {
|
||||
const recording = useAudioRecordingV2();
|
||||
|
||||
const handleRecord = async () => {
|
||||
if (!recording.permissions.microphone.granted) {
|
||||
await recording.requestPermissions();
|
||||
}
|
||||
|
||||
if (recording.status === 'idle') {
|
||||
await recording.startRecording();
|
||||
} else if (recording.status === 'recording') {
|
||||
await recording.stopRecording();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>Status: {recording.status}</Text>
|
||||
<Text>Duration: {recording.session?.duration || 0}s</Text>
|
||||
<Button
|
||||
title={recording.status === 'recording' ? 'Stop' : 'Record'}
|
||||
onPress={handleRecord}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Usage with Error Handling
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useAudioRecordingV2,
|
||||
handleRecordingError,
|
||||
RecordingPreset
|
||||
} from '@/features/audioRecordingV2';
|
||||
|
||||
function AdvancedRecording() {
|
||||
const recording = useAudioRecordingV2();
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
await recording.startRecording({
|
||||
preset: RecordingPreset.HIGH_QUALITY,
|
||||
maxDuration: 300, // 5 minutes
|
||||
});
|
||||
} catch (error) {
|
||||
handleRecordingError(error, {
|
||||
showAlert: true,
|
||||
onRetry: startRecording,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Rest of component...
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Service Usage (Without Store)
|
||||
|
||||
```tsx
|
||||
import { createPlatformRecordingService } from '@/features/audioRecordingV2';
|
||||
|
||||
// Create service instance
|
||||
const recordingService = createPlatformRecordingService();
|
||||
|
||||
// Initialize
|
||||
await recordingService.initialize();
|
||||
|
||||
// Start recording
|
||||
await recordingService.startRecording();
|
||||
|
||||
// Stop and get URI
|
||||
const uri = await recordingService.stopRecording();
|
||||
|
||||
// Cleanup when done
|
||||
recordingService.cleanup();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Store State
|
||||
|
||||
```typescript
|
||||
interface RecordingStoreState {
|
||||
// State
|
||||
status: RecordingStatus;
|
||||
session: RecordingSession | null;
|
||||
error: RecordingError | null;
|
||||
permissions: PermissionState;
|
||||
isInitialized: boolean;
|
||||
|
||||
// Actions
|
||||
initialize(): Promise<void>;
|
||||
startRecording(options?: RecordingOptions): Promise<void>;
|
||||
stopRecording(): Promise<void>;
|
||||
pauseRecording(): void;
|
||||
resumeRecording(): void;
|
||||
requestPermissions(): Promise<PermissionState>;
|
||||
checkPermissions(): Promise<PermissionState>;
|
||||
reset(): void;
|
||||
setError(error: RecordingError | null): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Recording Options
|
||||
|
||||
```typescript
|
||||
interface RecordingOptions {
|
||||
preset?: RecordingPreset;
|
||||
format?: Partial<AudioFormat>;
|
||||
maxDuration?: number; // seconds
|
||||
sizeLimit?: number; // bytes
|
||||
}
|
||||
|
||||
enum RecordingPreset {
|
||||
HIGH_QUALITY = 'high_quality',
|
||||
MEDIUM_QUALITY = 'medium_quality',
|
||||
LOW_QUALITY = 'low_quality',
|
||||
VOICE_MEMO = 'voice_memo'
|
||||
}
|
||||
```
|
||||
|
||||
### Recording Status
|
||||
|
||||
```typescript
|
||||
enum RecordingStatus {
|
||||
IDLE = 'idle',
|
||||
PREPARING = 'preparing',
|
||||
RECORDING = 'recording',
|
||||
PAUSED = 'paused',
|
||||
STOPPING = 'stopping',
|
||||
STOPPED = 'stopped',
|
||||
ERROR = 'error'
|
||||
}
|
||||
```
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
### Android
|
||||
|
||||
- Requires foreground service for background recording
|
||||
- Android 16+ requires app to be in foreground to start recording
|
||||
- Wake lock prevents device sleep during recording
|
||||
- Notification shown during recording
|
||||
|
||||
### iOS
|
||||
|
||||
- Background audio capability required in Info.plist (`UIBackgroundModes: ["audio"]`)
|
||||
- Audio session configured with `mixWithOthers` mode for background recording
|
||||
- Recording continues when app is backgrounded or user switches apps
|
||||
- Handles real interruptions (phone calls, Siri) automatically
|
||||
- Audio session verification on cold start prevents first-recording failures
|
||||
|
||||
### Web
|
||||
|
||||
- Basic support using Web Audio API
|
||||
- Limited functionality compared to mobile
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system includes comprehensive error handling:
|
||||
|
||||
```typescript
|
||||
enum RecordingErrorType {
|
||||
PERMISSION_DENIED,
|
||||
HARDWARE_UNAVAILABLE,
|
||||
PLATFORM_RESTRICTION,
|
||||
STORAGE_ERROR,
|
||||
NETWORK_ERROR,
|
||||
AUDIO_ENGINE_ERROR,
|
||||
INITIALIZATION_ERROR,
|
||||
UNKNOWN_ERROR
|
||||
}
|
||||
```
|
||||
|
||||
Each error includes:
|
||||
- Type classification
|
||||
- Error code
|
||||
- User-friendly message
|
||||
- Recoverable flag
|
||||
- Retry strategy (if applicable)
|
||||
- Platform-specific details
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Android: Microphone not working
|
||||
|
||||
1. Check permissions in app settings
|
||||
2. Ensure app is in foreground (Android 16+)
|
||||
3. Check if another app is using microphone
|
||||
4. Restart the app
|
||||
|
||||
### iOS: Recording stops in background
|
||||
|
||||
**Fixed in latest version!** Background recording now works correctly.
|
||||
|
||||
**Root Cause:** Two bugs prevented background recording:
|
||||
1. `interruptionMode: 'doNotMix'` - iOS revoked exclusive audio access when backgrounded
|
||||
2. App state handler manually paused recording on `inactive` state transition
|
||||
|
||||
**Solution:**
|
||||
1. Changed to `interruptionMode: 'mixWithOthers'` - allows background recording
|
||||
2. Removed manual pause logic - let iOS handle audio naturally
|
||||
|
||||
**Verification:**
|
||||
- Recording continues when pressing home button
|
||||
- Recording continues when switching to other apps
|
||||
- Recording continues when device is locked
|
||||
- Full duration captured (foreground + background time)
|
||||
|
||||
See `TROUBLESHOOTING.md` for detailed technical explanation.
|
||||
|
||||
### Zero-byte recordings
|
||||
|
||||
This is a known issue with Expo SDK 54 on some Android devices. The implementation includes:
|
||||
- File validation after recording
|
||||
- Automatic retry mechanism
|
||||
- Error reporting for debugging
|
||||
|
||||
## Migration from V1
|
||||
|
||||
If migrating from the old recording system:
|
||||
|
||||
```typescript
|
||||
// Old
|
||||
import audioRecordingService from '@/features/audioRecording/audioRecording.service';
|
||||
await audioRecordingService.startRecording();
|
||||
|
||||
// New
|
||||
import { useAudioRecordingV2 } from '@/features/audioRecordingV2';
|
||||
const recording = useAudioRecordingV2();
|
||||
await recording.startRecording();
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
npm test features/audioRecordingV2
|
||||
|
||||
# Test on Android
|
||||
npx expo run:android
|
||||
|
||||
# Test on iOS
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logs:
|
||||
```typescript
|
||||
// In AudioEngineService.ts
|
||||
console.log('Debug:', status);
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When making changes:
|
||||
1. Test on both Android and iOS
|
||||
2. Verify Android 16 compatibility
|
||||
3. Check memory leaks with profiler
|
||||
4. Update types if API changes
|
||||
|
||||
## License
|
||||
|
||||
Internal use only
|
||||
1247
apps/memoro/apps/mobile/features/audioRecordingV2/TROUBLESHOOTING.md
Normal file
1247
apps/memoro/apps/mobile/features/audioRecordingV2/TROUBLESHOOTING.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,299 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { LANGUAGES } from '~/features/i18n';
|
||||
|
||||
// Definiere den Typ für den Kontext
|
||||
interface RecordingLanguageContextType {
|
||||
recordingLanguages: string[];
|
||||
addRecordingLanguage: (language: string) => Promise<void>;
|
||||
removeRecordingLanguage: (language: string) => Promise<void>;
|
||||
toggleRecordingLanguage: (language: string) => Promise<void>;
|
||||
supportedAzureLanguages: Record<string, { locale: string; nativeName: string; emoji: string }>;
|
||||
dialectChangeNotification: { oldDialect: string; newDialect: string } | null;
|
||||
clearDialectNotification: () => void;
|
||||
}
|
||||
|
||||
// Konstante für den AsyncStorage-Schlüssel
|
||||
const RECORDING_LANGUAGES_STORAGE_KEY = 'memoro_recording_languages';
|
||||
|
||||
// Azure unterstützte Sprachen mit ihren Locale-Codes
|
||||
// ⚡ = Fast transcription support / 🐢 = Standard transcription only
|
||||
export const AZURE_SUPPORTED_LANGUAGES = {
|
||||
auto: { locale: 'auto', nativeName: 'Auto', emoji: '🌐' },
|
||||
af: { locale: 'af-ZA', nativeName: LANGUAGES.af.nativeName + ' 🐢', emoji: LANGUAGES.af.emoji },
|
||||
ar: { locale: 'ar-SA', nativeName: LANGUAGES.ar.nativeName + ' ⚡', emoji: LANGUAGES.ar.emoji },
|
||||
'ar-AE': { locale: 'ar-AE', nativeName: 'العربية (الإمارات) 🐢', emoji: '🇦🇪' },
|
||||
'ar-EG': { locale: 'ar-EG', nativeName: 'العربية (مصر) 🐢', emoji: '🇪🇬' },
|
||||
at: { locale: 'de-AT', nativeName: LANGUAGES.at.nativeName + ' 🐢', emoji: LANGUAGES.at.emoji },
|
||||
bg: { locale: 'bg-BG', nativeName: LANGUAGES.bg.nativeName + ' 🐢', emoji: LANGUAGES.bg.emoji },
|
||||
bn: { locale: 'bn-IN', nativeName: LANGUAGES.bn.nativeName + ' 🐢', emoji: LANGUAGES.bn.emoji },
|
||||
ch: { locale: 'de-CH', nativeName: LANGUAGES.ch.nativeName + ' 🐢', emoji: LANGUAGES.ch.emoji },
|
||||
cs: { locale: 'cs-CZ', nativeName: LANGUAGES.cs.nativeName + ' 🐢', emoji: LANGUAGES.cs.emoji },
|
||||
da: { locale: 'da-DK', nativeName: LANGUAGES.da.nativeName + ' ⚡', emoji: LANGUAGES.da.emoji },
|
||||
de: { locale: 'de-DE', nativeName: LANGUAGES.de.nativeName + ' ⚡', emoji: LANGUAGES.de.emoji },
|
||||
el: { locale: 'el-GR', nativeName: LANGUAGES.el.nativeName + ' 🐢', emoji: LANGUAGES.el.emoji },
|
||||
en: { locale: 'en-US', nativeName: LANGUAGES.en.nativeName + ' ⚡', emoji: LANGUAGES.en.emoji },
|
||||
'en-GB': { locale: 'en-GB', nativeName: 'English (UK) ⚡', emoji: '🇬🇧' },
|
||||
'en-AU': { locale: 'en-AU', nativeName: 'English (Australia) 🐢', emoji: '🇦🇺' },
|
||||
'en-CA': { locale: 'en-CA', nativeName: 'English (Canada) 🐢', emoji: '🇨🇦' },
|
||||
'en-IN': { locale: 'en-IN', nativeName: 'English (India) ⚡', emoji: '🇮🇳' },
|
||||
'en-ZA': { locale: 'en-ZA', nativeName: 'English (South Africa) 🐢', emoji: '🇿🇦' },
|
||||
es: { locale: 'es-ES', nativeName: LANGUAGES.es.nativeName + ' ⚡', emoji: LANGUAGES.es.emoji },
|
||||
'es-MX': { locale: 'es-MX', nativeName: LANGUAGES['es-MX'].nativeName + ' ⚡', emoji: LANGUAGES['es-MX'].emoji },
|
||||
'es-AR': { locale: 'es-AR', nativeName: 'Español (Argentina) 🐢', emoji: '🇦🇷' },
|
||||
'es-CO': { locale: 'es-CO', nativeName: 'Español (Colombia) 🐢', emoji: '🇨🇴' },
|
||||
et: { locale: 'et-EE', nativeName: LANGUAGES.et.nativeName + ' 🐢', emoji: LANGUAGES.et.emoji },
|
||||
fa: { locale: 'fa-IR', nativeName: LANGUAGES.fa.nativeName + ' 🐢', emoji: LANGUAGES.fa.emoji },
|
||||
fi: { locale: 'fi-FI', nativeName: LANGUAGES.fi.nativeName + ' ⚡', emoji: LANGUAGES.fi.emoji },
|
||||
fr: { locale: 'fr-FR', nativeName: LANGUAGES.fr.nativeName + ' ⚡', emoji: LANGUAGES.fr.emoji },
|
||||
'fr-CA': { locale: 'fr-CA', nativeName: 'Français (Canada) 🐢', emoji: '🇨🇦' },
|
||||
'fr-CH': { locale: 'fr-CH', nativeName: 'Français (Suisse) 🐢', emoji: '🇨🇭' },
|
||||
ga: { locale: 'ga-IE', nativeName: LANGUAGES.ga.nativeName + ' 🐢', emoji: LANGUAGES.ga.emoji },
|
||||
he: { locale: 'he-IL', nativeName: LANGUAGES.he.nativeName + ' ⚡', emoji: LANGUAGES.he.emoji },
|
||||
hi: { locale: 'hi-IN', nativeName: LANGUAGES.hi.nativeName + ' ⚡', emoji: LANGUAGES.hi.emoji },
|
||||
hr: { locale: 'hr-HR', nativeName: LANGUAGES.hr.nativeName + ' 🐢', emoji: LANGUAGES.hr.emoji },
|
||||
hu: { locale: 'hu-HU', nativeName: LANGUAGES.hu.nativeName + ' 🐢', emoji: LANGUAGES.hu.emoji },
|
||||
id: { locale: 'id-ID', nativeName: LANGUAGES.id.nativeName + ' ⚡', emoji: LANGUAGES.id.emoji },
|
||||
it: { locale: 'it-IT', nativeName: LANGUAGES.it.nativeName + ' ⚡', emoji: LANGUAGES.it.emoji },
|
||||
ja: { locale: 'ja-JP', nativeName: LANGUAGES.ja.nativeName + ' ⚡', emoji: LANGUAGES.ja.emoji },
|
||||
ko: { locale: 'ko-KR', nativeName: LANGUAGES.ko.nativeName + ' ⚡', emoji: LANGUAGES.ko.emoji },
|
||||
lt: { locale: 'lt-LT', nativeName: LANGUAGES.lt.nativeName + ' 🐢', emoji: LANGUAGES.lt.emoji },
|
||||
lv: { locale: 'lv-LV', nativeName: LANGUAGES.lv.nativeName + ' 🐢', emoji: LANGUAGES.lv.emoji },
|
||||
ms: { locale: 'ms-MY', nativeName: LANGUAGES.ms.nativeName + ' 🐢', emoji: LANGUAGES.ms.emoji },
|
||||
mt: { locale: 'mt-MT', nativeName: LANGUAGES.mt.nativeName + ' 🐢', emoji: LANGUAGES.mt.emoji },
|
||||
nb: { locale: 'nb-NO', nativeName: LANGUAGES.nb.nativeName + ' 🐢', emoji: LANGUAGES.nb.emoji },
|
||||
nl: { locale: 'nl-NL', nativeName: LANGUAGES.nl.nativeName + ' ⚡', emoji: LANGUAGES.nl.emoji },
|
||||
pl: { locale: 'pl-PL', nativeName: LANGUAGES.pl.nativeName + ' ⚡', emoji: LANGUAGES.pl.emoji },
|
||||
pt: { locale: 'pt-PT', nativeName: LANGUAGES.pt.nativeName + ' ⚡', emoji: LANGUAGES.pt.emoji },
|
||||
'pt-BR': { locale: 'pt-BR', nativeName: LANGUAGES['pt-BR'].nativeName + ' ⚡', emoji: LANGUAGES['pt-BR'].emoji },
|
||||
ro: { locale: 'ro-RO', nativeName: LANGUAGES.ro.nativeName + ' 🐢', emoji: LANGUAGES.ro.emoji },
|
||||
ru: { locale: 'ru-RU', nativeName: LANGUAGES.ru.nativeName + ' ⚡', emoji: LANGUAGES.ru.emoji },
|
||||
sk: { locale: 'sk-SK', nativeName: LANGUAGES.sk.nativeName + ' 🐢', emoji: LANGUAGES.sk.emoji },
|
||||
sl: { locale: 'sl-SI', nativeName: LANGUAGES.sl.nativeName + ' 🐢', emoji: LANGUAGES.sl.emoji },
|
||||
sr: { locale: 'sr-RS', nativeName: LANGUAGES.sr.nativeName + ' 🐢', emoji: LANGUAGES.sr.emoji },
|
||||
sv: { locale: 'sv-SE', nativeName: LANGUAGES.sv.nativeName + ' ⚡', emoji: LANGUAGES.sv.emoji },
|
||||
th: { locale: 'th-TH', nativeName: LANGUAGES.th.nativeName + ' ⚡', emoji: LANGUAGES.th.emoji },
|
||||
tl: { locale: 'fil-PH', nativeName: LANGUAGES.tl.nativeName + ' 🐢', emoji: LANGUAGES.tl.emoji },
|
||||
tr: { locale: 'tr-TR', nativeName: LANGUAGES.tr.nativeName + ' 🐢', emoji: LANGUAGES.tr.emoji },
|
||||
uk: { locale: 'uk-UA', nativeName: LANGUAGES.uk.nativeName + ' 🐢', emoji: LANGUAGES.uk.emoji },
|
||||
ur: { locale: 'ur-IN', nativeName: LANGUAGES.ur.nativeName + ' 🐢', emoji: LANGUAGES.ur.emoji },
|
||||
vi: { locale: 'vi-VN', nativeName: LANGUAGES.vi.nativeName + ' 🐢', emoji: LANGUAGES.vi.emoji },
|
||||
zh: { locale: 'zh-CN', nativeName: LANGUAGES.zh.nativeName + ' ⚡', emoji: LANGUAGES.zh.emoji },
|
||||
'zh-HK': { locale: 'zh-HK', nativeName: '中文 (香港) 🐢', emoji: '🇭🇰' },
|
||||
'zh-TW': { locale: 'zh-TW', nativeName: '中文 (台灣) 🐢', emoji: '🇹🇼' },
|
||||
// Neue Sprachen
|
||||
'nl-BE': { locale: 'nl-BE', nativeName: 'Nederlands (België) 🐢', emoji: '🇧🇪' },
|
||||
'it-CH': { locale: 'it-CH', nativeName: 'Italiano (Svizzera) 🐢', emoji: '🇨🇭' },
|
||||
'ta-IN': { locale: 'ta-IN', nativeName: 'தமிழ் (இந்தியா) 🐢', emoji: '🇮🇳' },
|
||||
'te-IN': { locale: 'te-IN', nativeName: 'తెలుగు (భారతదేశం) 🐢', emoji: '🇮🇳' },
|
||||
'mr-IN': { locale: 'mr-IN', nativeName: 'मराठी (भारत) 🐢', emoji: '🇮🇳' },
|
||||
'gu-IN': { locale: 'gu-IN', nativeName: 'ગુજરાતી (ભારત) 🐢', emoji: '🇮🇳' },
|
||||
'kn-IN': { locale: 'kn-IN', nativeName: 'ಕನ್ನಡ (ಭಾರತ) 🐢', emoji: '🇮🇳' },
|
||||
'ml-IN': { locale: 'ml-IN', nativeName: 'മലയാളം (ഇന്ത്യ) 🐢', emoji: '🇮🇳' },
|
||||
'pa-IN': { locale: 'pa-IN', nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ) 🐢', emoji: '🇮🇳' },
|
||||
'kk-KZ': { locale: 'kk-KZ', nativeName: 'Қазақ (Қазақстан) 🐢', emoji: '🇰🇿' },
|
||||
'uz-UZ': { locale: 'uz-UZ', nativeName: 'Oʻzbek (Oʻzbekiston) 🐢', emoji: '🇺🇿' },
|
||||
'ka-GE': { locale: 'ka-GE', nativeName: 'ქართული (საქართველო) 🐢', emoji: '🇬🇪' },
|
||||
'hy-AM': { locale: 'hy-AM', nativeName: 'Հայերեն (Հայաստան) 🐢', emoji: '🇦🇲' },
|
||||
'is-IS': { locale: 'is-IS', nativeName: 'Íslenska (Ísland) 🐢', emoji: '🇮🇸' },
|
||||
'sq-AL': { locale: 'sq-AL', nativeName: 'Shqip (Shqipëri) 🐢', emoji: '🇦🇱' },
|
||||
'mk-MK': { locale: 'mk-MK', nativeName: 'Македонски (Македонија) 🐢', emoji: '🇲🇰' },
|
||||
'cy-GB': { locale: 'cy-GB', nativeName: 'Cymraeg (Cymru) 🐢', emoji: '🏴' },
|
||||
'ca-ES': { locale: 'ca-ES', nativeName: 'Català (Espanya) 🐢', emoji: '🇪🇸' },
|
||||
'eu-ES': { locale: 'eu-ES', nativeName: 'Euskara (Espainia) 🐢', emoji: '🇪🇸' },
|
||||
'gl-ES': { locale: 'gl-ES', nativeName: 'Galego (España) 🐢', emoji: '🇪🇸' }
|
||||
};
|
||||
|
||||
// Sprach-Familien Mapping für Single-Dialekt-Auswahl
|
||||
const LANGUAGE_FAMILIES = {
|
||||
german: ['de', 'at', 'ch'],
|
||||
english: ['en', 'en-GB', 'en-AU', 'en-CA', 'en-IN', 'en-ZA'],
|
||||
arabic: ['ar', 'ar-AE', 'ar-EG'],
|
||||
spanish: ['es', 'es-MX', 'es-AR', 'es-CO'],
|
||||
french: ['fr', 'fr-CA', 'fr-CH'],
|
||||
portuguese: ['pt', 'pt-BR'],
|
||||
chinese: ['zh', 'zh-HK', 'zh-TW'],
|
||||
dutch: ['nl', 'nl-BE'],
|
||||
italian: ['it', 'it-CH']
|
||||
};
|
||||
|
||||
// Helper-Funktion um die Sprachfamilie zu finden
|
||||
function getLanguageFamily(langCode: string): string[] | null {
|
||||
for (const family of Object.values(LANGUAGE_FAMILIES)) {
|
||||
if (family.includes(langCode)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Erstelle den Kontext
|
||||
const RecordingLanguageContext = createContext<RecordingLanguageContextType>({
|
||||
recordingLanguages: [],
|
||||
addRecordingLanguage: async () => {},
|
||||
removeRecordingLanguage: async () => {},
|
||||
toggleRecordingLanguage: async () => {},
|
||||
supportedAzureLanguages: AZURE_SUPPORTED_LANGUAGES,
|
||||
dialectChangeNotification: null,
|
||||
clearDialectNotification: () => {},
|
||||
});
|
||||
|
||||
// Hook für den Zugriff auf den Kontext
|
||||
export const useRecordingLanguage = () => useContext(RecordingLanguageContext);
|
||||
|
||||
// Provider-Komponente
|
||||
interface RecordingLanguageProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const RecordingLanguageProvider: React.FC<RecordingLanguageProviderProps> = ({ children }) => {
|
||||
const [recordingLanguages, setRecordingLanguages] = useState<string[]>([]);
|
||||
const [dialectChangeNotification, setDialectChangeNotification] = useState<{ oldDialect: string; newDialect: string } | null>(null);
|
||||
|
||||
// Lade die gespeicherten Aufnahmesprachen beim Start
|
||||
useEffect(() => {
|
||||
const loadStoredLanguages = async () => {
|
||||
try {
|
||||
const storedLanguages = await AsyncStorage.getItem(RECORDING_LANGUAGES_STORAGE_KEY);
|
||||
if (storedLanguages) {
|
||||
const parsedLanguages = JSON.parse(storedLanguages);
|
||||
// Wenn keine Sprachen ausgewählt sind, setze Auto als Standard
|
||||
if (parsedLanguages.length === 0) {
|
||||
setRecordingLanguages(['auto']);
|
||||
await AsyncStorage.setItem(RECORDING_LANGUAGES_STORAGE_KEY, JSON.stringify(['auto']));
|
||||
} else {
|
||||
setRecordingLanguages(parsedLanguages);
|
||||
}
|
||||
} else {
|
||||
// Standardmäßig Auto-Modus verwenden
|
||||
setRecordingLanguages(['auto']);
|
||||
await AsyncStorage.setItem(RECORDING_LANGUAGES_STORAGE_KEY, JSON.stringify(['auto']));
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error loading recording languages:', error);
|
||||
// Bei Fehler auch Auto-Modus als Fallback setzen
|
||||
setRecordingLanguages(['auto']);
|
||||
}
|
||||
};
|
||||
|
||||
loadStoredLanguages();
|
||||
}, []);
|
||||
|
||||
// Funktion zum Speichern der Aufnahmesprachen
|
||||
const storeRecordingLanguages = async (languages: string[]) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(RECORDING_LANGUAGES_STORAGE_KEY, JSON.stringify(languages));
|
||||
} catch (error) {
|
||||
console.debug('Error storing recording languages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Hinzufügen einer Sprache
|
||||
const addRecordingLanguage = async (language: string) => {
|
||||
if (!recordingLanguages.includes(language)) {
|
||||
const newLanguages = [...recordingLanguages, language];
|
||||
setRecordingLanguages(newLanguages);
|
||||
await storeRecordingLanguages(newLanguages);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Entfernen einer Sprache
|
||||
const removeRecordingLanguage = async (language: string) => {
|
||||
// Filtere die zu entfernende Sprache heraus
|
||||
const newLanguages = recordingLanguages.filter(lang => lang !== language);
|
||||
|
||||
// Wenn keine Sprachen mehr übrig sind, setze Auto als Standard
|
||||
if (newLanguages.length === 0) {
|
||||
setRecordingLanguages(['auto']);
|
||||
await storeRecordingLanguages(['auto']);
|
||||
} else {
|
||||
setRecordingLanguages(newLanguages);
|
||||
await storeRecordingLanguages(newLanguages);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Umschalten einer Sprache
|
||||
const toggleRecordingLanguage = async (language: string) => {
|
||||
// Wenn "Auto" ausgewählt wird, deselektiere alle anderen Sprachen
|
||||
if (language === 'auto') {
|
||||
if (recordingLanguages.includes('auto')) {
|
||||
// Wenn Auto bereits ausgewählt ist, einfach deselektieren
|
||||
await removeRecordingLanguage('auto');
|
||||
} else {
|
||||
// Alle anderen Sprachen entfernen und nur Auto hinzufügen
|
||||
const newLanguages = ['auto'];
|
||||
setRecordingLanguages(newLanguages);
|
||||
await storeRecordingLanguages(newLanguages);
|
||||
}
|
||||
} else {
|
||||
// Wenn eine andere Sprache ausgewählt wird
|
||||
let newLanguages = recordingLanguages.filter(lang => lang !== 'auto');
|
||||
|
||||
// Prüfe ob die Sprache zu einer Sprachfamilie gehört
|
||||
const languageFamily = getLanguageFamily(language);
|
||||
|
||||
if (languageFamily) {
|
||||
// Check if another dialect from the same family is already selected
|
||||
const existingDialect = recordingLanguages.find(lang =>
|
||||
languageFamily.includes(lang) && lang !== language
|
||||
);
|
||||
|
||||
if (existingDialect) {
|
||||
// Set notification for in-modal display
|
||||
const existingName = AZURE_SUPPORTED_LANGUAGES[existingDialect]?.nativeName.replace(' ⚡', '').replace(' 🐢', '') || existingDialect;
|
||||
const newName = AZURE_SUPPORTED_LANGUAGES[language]?.nativeName.replace(' ⚡', '').replace(' 🐢', '') || language;
|
||||
|
||||
setDialectChangeNotification({
|
||||
oldDialect: existingName,
|
||||
newDialect: newName
|
||||
});
|
||||
|
||||
// Auto-clear notification after 6 seconds
|
||||
setTimeout(() => {
|
||||
setDialectChangeNotification(null);
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
// Entferne alle anderen Dialekte der gleichen Sprachfamilie
|
||||
newLanguages = newLanguages.filter(lang => !languageFamily.includes(lang));
|
||||
}
|
||||
|
||||
// Toggle-Verhalten
|
||||
if (recordingLanguages.includes(language)) {
|
||||
// Deselektieren
|
||||
newLanguages = newLanguages.filter(lang => lang !== language);
|
||||
} else {
|
||||
// Selektieren
|
||||
newLanguages.push(language);
|
||||
}
|
||||
|
||||
// Wenn keine Sprachen mehr übrig sind, setze Auto als Standard
|
||||
if (newLanguages.length === 0) {
|
||||
newLanguages = ['auto'];
|
||||
}
|
||||
|
||||
setRecordingLanguages(newLanguages);
|
||||
await storeRecordingLanguages(newLanguages);
|
||||
}
|
||||
};
|
||||
|
||||
const clearDialectNotification = () => {
|
||||
setDialectChangeNotification(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecordingLanguageContext.Provider
|
||||
value={{
|
||||
recordingLanguages,
|
||||
addRecordingLanguage,
|
||||
removeRecordingLanguage,
|
||||
toggleRecordingLanguage,
|
||||
supportedAzureLanguages: AZURE_SUPPORTED_LANGUAGES,
|
||||
dialectChangeNotification,
|
||||
clearDialectNotification,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecordingLanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,902 @@
|
|||
/**
|
||||
* AudioEngineService
|
||||
* Core wrapper around AudioModule.AudioRecorder with correct API usage
|
||||
*/
|
||||
|
||||
import {
|
||||
setAudioModeAsync,
|
||||
getRecordingPermissionsAsync,
|
||||
requestRecordingPermissionsAsync,
|
||||
} from 'expo-audio';
|
||||
import { Platform } from 'react-native';
|
||||
// Import AudioModule for AudioRecorder class access
|
||||
import AudioModule from 'expo-audio/build/AudioModule';
|
||||
|
||||
import {
|
||||
IAudioEngineService,
|
||||
RecorderState,
|
||||
RecordingOptions,
|
||||
RecordingError,
|
||||
RecordingErrorType,
|
||||
StateChangeCallback,
|
||||
StatusUpdateCallback,
|
||||
PermissionState,
|
||||
} from '../types';
|
||||
|
||||
// Type for the AudioRecorder instance from AudioModule
|
||||
type AudioRecorderInstance = InstanceType<typeof AudioModule.AudioRecorder>;
|
||||
|
||||
export class AudioEngineService implements IAudioEngineService {
|
||||
protected recorder: AudioRecorderInstance | null = null;
|
||||
protected statusPollingInterval: NodeJS.Timeout | null = null;
|
||||
protected statusListener: any | null = null;
|
||||
protected initialized: boolean = false;
|
||||
protected onStateChangeCallback: StateChangeCallback | null = null;
|
||||
protected onStatusUpdateCallback: StatusUpdateCallback | null = null;
|
||||
protected lastKnownStatus: RecorderState | null = null;
|
||||
|
||||
// Event name constant for type-safe event subscription
|
||||
private readonly STATUS_UPDATE_EVENT = 'recordingStatusUpdate' as const;
|
||||
|
||||
// Configuration
|
||||
protected maxInitRetries: number = 3;
|
||||
protected initRetryDelayMs: number = 1000;
|
||||
|
||||
constructor() {
|
||||
// Service is initialized explicitly via initialize() method
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the audio engine service
|
||||
* Must be called before any recording operations
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
console.log('AudioEngineService already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
let retryCount = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (retryCount < this.maxInitRetries) {
|
||||
try {
|
||||
// Configure audio mode for recording
|
||||
await this.setupAudioMode();
|
||||
|
||||
// Just set up audio mode, don't create recorder yet
|
||||
// We'll create a fresh recorder for each recording session
|
||||
|
||||
this.initialized = true;
|
||||
console.log('AudioEngineService initialized successfully');
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
retryCount++;
|
||||
console.error(`Failed to initialize AudioEngineService (attempt ${retryCount}/${this.maxInitRetries}):`, error);
|
||||
|
||||
if (retryCount < this.maxInitRetries) {
|
||||
await this.delay(this.initRetryDelayMs * retryCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, initialization failed after all retries
|
||||
throw this.createError(
|
||||
RecordingErrorType.INITIALIZATION_ERROR,
|
||||
'init_failed',
|
||||
`Failed to initialize audio engine after ${this.maxInitRetries} attempts: ${lastError?.message}`,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the audio mode for recording
|
||||
*
|
||||
* IMPORTANT: Uses 'mixWithOthers' to enable background recording.
|
||||
* iOS will interrupt 'doNotMix' sessions when app backgrounds.
|
||||
*
|
||||
* Trade-offs:
|
||||
* - ✅ Recording continues when app is backgrounded
|
||||
* - ✅ No automatic interruptions from iOS
|
||||
* - ⚠️ Other apps' audio (music, podcasts) may play simultaneously
|
||||
* - ⚠️ User's music won't auto-pause when recording starts
|
||||
*
|
||||
* Alternative: Use 'duckOthers' if you want to lower other audio instead
|
||||
*/
|
||||
protected async setupAudioMode(): Promise<void> {
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: true,
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: true,
|
||||
interruptionMode: 'mixWithOthers', // REQUIRED for background recording
|
||||
});
|
||||
|
||||
// iOS Audio Session Initialization:
|
||||
// After setAudioModeAsync(), iOS needs time to:
|
||||
// 1. Configure the audio session category
|
||||
// 2. Initialize Core Audio device IDs
|
||||
// 3. Activate the audio session with recording permissions
|
||||
//
|
||||
// Real devices need 300-500ms on cold start (not just simulators!)
|
||||
// We use adaptive verification instead of fixed delays for optimal performance.
|
||||
// See: https://developer.apple.com/forums/thread/738346
|
||||
if (Platform.OS === 'ios') {
|
||||
await this.waitForAudioSessionReady(2000); // Max 2s, typically 50-500ms
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording with optional configuration
|
||||
*/
|
||||
async startRecording(options?: RecordingOptions): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
throw this.createError(
|
||||
RecordingErrorType.INITIALIZATION_ERROR,
|
||||
'not_initialized',
|
||||
'Audio engine must be initialized before recording',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Check and request permissions before starting recording
|
||||
let permissions = await this.checkPermissions();
|
||||
if (!permissions.microphone.granted) {
|
||||
console.log('[AudioEngine] Microphone permission not granted, requesting...');
|
||||
|
||||
// Try to request permissions
|
||||
permissions = await this.requestPermissions();
|
||||
|
||||
if (!permissions.microphone.granted) {
|
||||
console.error('[AudioEngine] Microphone permission denied by user');
|
||||
throw this.createError(
|
||||
RecordingErrorType.PERMISSION_DENIED,
|
||||
'microphone_permission_denied',
|
||||
'Microphone permission is required to start recording. Please grant permission in your device settings.',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[AudioEngine] Microphone permission granted');
|
||||
|
||||
// iOS: Wait for audio session to activate after permission grant
|
||||
// The permission dialog deactivates the audio session, and iOS needs time to:
|
||||
// 1. Reactivate audio session with new permissions
|
||||
// 2. Initialize microphone hardware
|
||||
// 3. Refresh Core Audio device IDs
|
||||
// This prevents the "first recording failure" bug on iOS
|
||||
if (Platform.OS === 'ios') {
|
||||
console.log('[AudioEngine] 🔄 Waiting for audio session activation after permission grant...');
|
||||
await this.waitForAudioSessionReady(2000);
|
||||
console.log('[AudioEngine] ✅ Audio session activated');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if already recording
|
||||
if (this.recorder) {
|
||||
const status = this.getStatus();
|
||||
if (status.isRecording) {
|
||||
console.warn('Recording already in progress');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// DEFENSIVE AUDIO MODE SETUP
|
||||
// iOS can reset the audio session between initialization and recording due to:
|
||||
// - Token refresh causing component remounts
|
||||
// - Audio hardware reconfiguration (CoreAudio events)
|
||||
// - Other apps or system audio interruptions
|
||||
// Therefore, we MUST reconfigure audio mode before EACH recording attempt
|
||||
// to ensure the session is in the correct state.
|
||||
console.log('[AudioEngine] 🔧 Reconfiguring audio mode before recording...');
|
||||
await this.setupAudioMode();
|
||||
console.log('[AudioEngine] ✅ Audio mode reconfigured successfully');
|
||||
|
||||
// OPTIMIZED FOR VOICE MEMOS AND FFMPEG COMPATIBILITY
|
||||
// Using MONO recording to:
|
||||
// 1. Prevent iOS from adding spatial audio metadata ('chnl' box) which causes FFprobe errors
|
||||
// 2. Reduce file size by 50% (mono vs stereo) for voice recordings
|
||||
// 3. Save bandwidth during uploads
|
||||
// 4. Improve battery efficiency
|
||||
//
|
||||
// Using HIGH quality (96) instead of MAX (127) to:
|
||||
// 1. Avoid triggering advanced iOS audio features that add problematic metadata
|
||||
// 2. Still provide excellent audio quality (indistinguishable for voice)
|
||||
// 3. Ensure maximum compatibility with FFmpeg/FFprobe
|
||||
const platformOptions = {
|
||||
extension: '.m4a',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1, // MONO: Prevents spatial audio metadata issues
|
||||
bitRate: 64000, // Optimal for voice (was 128000 for stereo music)
|
||||
android: {
|
||||
outputFormat: 'mpeg4',
|
||||
audioEncoder: 'aac',
|
||||
},
|
||||
ios: {
|
||||
outputFormat: 'mpeg4aac',
|
||||
audioQuality: 96, // HIGH: Avoids spatial audio, perfect for voice (was 127/MAX)
|
||||
linearPCMBitDepth: 16,
|
||||
linearPCMIsBigEndian: false,
|
||||
linearPCMIsFloat: false,
|
||||
},
|
||||
web: {
|
||||
mimeType: 'audio/webm',
|
||||
bitsPerSecond: 64000,
|
||||
},
|
||||
isMeteringEnabled: true,
|
||||
};
|
||||
|
||||
console.log('[AudioEngine] Using recording options:', JSON.stringify(platformOptions, null, 2));
|
||||
|
||||
// Use the platform-specific options that prevent spatial audio metadata issues
|
||||
// These options are specifically designed to avoid the 'chnl' box FFmpeg error
|
||||
console.log('🎤 Creating new recorder instance with platform-optimized options...');
|
||||
this.recorder = new AudioModule.AudioRecorder(platformOptions);
|
||||
|
||||
// Prepare the recorder for recording
|
||||
// CRITICAL: Do NOT pass options here to avoid duplicate recorder creation (Expo SDK 54 Issue #37241)
|
||||
console.log('🎤 Preparing recorder...');
|
||||
await this.recorder.prepareToRecordAsync();
|
||||
|
||||
// Start recording (SYNCHRONOUS method)
|
||||
console.log('🎤 Starting recording...');
|
||||
this.recorder.record();
|
||||
|
||||
// Check initial status immediately after starting
|
||||
try {
|
||||
const initialStatus = this.recorder.getStatus();
|
||||
console.log('🎤 Initial status after record():', JSON.stringify({
|
||||
isRecording: initialStatus.isRecording,
|
||||
durationMillis: initialStatus.durationMillis,
|
||||
metering: initialStatus.metering,
|
||||
uri: initialStatus.uri ? 'has-uri' : 'no-uri',
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
console.error('🎤 Error getting initial status:', error);
|
||||
}
|
||||
|
||||
// Start polling for status updates
|
||||
// Note: We don't call getStatus() immediately as the URL may not be set yet
|
||||
// Status polling will provide updates once recording is fully started
|
||||
this.startStatusPolling();
|
||||
|
||||
console.log('🎤 Recording started successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
this.stopStatusPolling();
|
||||
throw this.createError(
|
||||
RecordingErrorType.AUDIO_ENGINE_ERROR,
|
||||
'start_failed',
|
||||
`Failed to start recording: ${(error as Error).message}`,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and return the URI
|
||||
*/
|
||||
async stopRecording(): Promise<string> {
|
||||
console.log('[AudioEngineService] Stopping recording...');
|
||||
|
||||
if (!this.recorder) {
|
||||
// Make sure polling is stopped even if no recorder
|
||||
this.stopStatusPolling();
|
||||
throw this.createError(
|
||||
RecordingErrorType.AUDIO_ENGINE_ERROR,
|
||||
'no_recorder',
|
||||
'No recording instance available',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Stop polling immediately to prevent any further status checks
|
||||
this.stopStatusPolling();
|
||||
|
||||
try {
|
||||
// Check if actually recording (SYNCHRONOUS)
|
||||
const status = this.getStatus();
|
||||
console.log('[AudioEngineService] Current status before stop:', {
|
||||
isRecording: status.isRecording,
|
||||
currentTime: status.currentTime,
|
||||
uri: status.uri,
|
||||
recorderUri: this.recorder.uri,
|
||||
});
|
||||
|
||||
if (!status.isRecording) {
|
||||
console.warn('No active recording to stop');
|
||||
// Still clean up the recorder instance
|
||||
this.recorder = null;
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get the URI before stopping (it should be available during recording)
|
||||
const uri = this.recorder.uri;
|
||||
console.log('[AudioEngineService] URI before stop:', uri);
|
||||
|
||||
// Stop recording (ASYNCHRONOUS method) with timeout
|
||||
// Some Expo SDK 54 implementations may hang on stop
|
||||
const stopPromise = this.recorder.stop();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Stop recording timeout')), 5000)
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.race([stopPromise, timeoutPromise]);
|
||||
console.log('[AudioEngineService] Stop method completed');
|
||||
} catch (timeoutError) {
|
||||
console.warn('[AudioEngineService] Stop method timed out, continuing anyway');
|
||||
// Continue anyway - the recording is likely stopped
|
||||
}
|
||||
|
||||
// If URI wasn't available before stop, try to get it again
|
||||
const finalUri = uri || this.recorder.uri;
|
||||
console.log('[AudioEngineService] Final URI:', finalUri);
|
||||
|
||||
if (!finalUri) {
|
||||
throw this.createError(
|
||||
RecordingErrorType.AUDIO_ENGINE_ERROR,
|
||||
'no_uri',
|
||||
'Recording completed but no URI was returned',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Recording stopped successfully. URI:', finalUri);
|
||||
|
||||
// Clean up the recorder instance to ensure fresh instance next time
|
||||
this.recorder = null;
|
||||
console.log('Recorder instance cleaned up');
|
||||
|
||||
return finalUri;
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording:', error);
|
||||
throw this.createError(
|
||||
RecordingErrorType.AUDIO_ENGINE_ERROR,
|
||||
'stop_failed',
|
||||
`Failed to stop recording: ${(error as Error).message}`,
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
// ALWAYS ensure cleanup happens
|
||||
this.stopStatusPolling(); // Extra safety - stop polling again if needed
|
||||
this.recorder = null; // Always clean up recorder instance
|
||||
// Note: We do NOT set initialized = false here
|
||||
// The service remains initialized for future recordings
|
||||
console.log('[AudioEngineService] Cleanup in finally block completed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the current recording (SYNCHRONOUS)
|
||||
*/
|
||||
pauseRecording(): void {
|
||||
if (!this.recorder) {
|
||||
console.warn('No recorder instance to pause');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = this.getStatus();
|
||||
if (!status.isRecording) {
|
||||
console.warn('No active recording to pause');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause is SYNCHRONOUS
|
||||
this.recorder.pause();
|
||||
console.log('Recording paused');
|
||||
} catch (error) {
|
||||
console.error('Failed to pause recording:', error);
|
||||
throw this.createError(
|
||||
RecordingErrorType.AUDIO_ENGINE_ERROR,
|
||||
'pause_failed',
|
||||
`Failed to pause recording: ${(error as Error).message}`,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused recording (SYNCHRONOUS)
|
||||
*/
|
||||
resumeRecording(): void {
|
||||
if (!this.recorder) {
|
||||
console.warn('No recorder instance to resume');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resume by calling record() again (SYNCHRONOUS)
|
||||
this.recorder.record();
|
||||
console.log('Recording resumed');
|
||||
|
||||
// ✅ FIX: Restart status polling if it was stopped during pause
|
||||
if (!this.statusPollingInterval) {
|
||||
console.log('🔄 [Resume] Restarting status polling...');
|
||||
this.startStatusPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to resume recording:', error);
|
||||
throw this.createError(
|
||||
RecordingErrorType.AUDIO_ENGINE_ERROR,
|
||||
'resume_failed',
|
||||
`Failed to resume recording: ${(error as Error).message}`,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current recording status (SYNCHRONOUS)
|
||||
*/
|
||||
getStatus(): RecorderState {
|
||||
if (!this.recorder) {
|
||||
return {
|
||||
canRecord: false,
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
currentTime: 0,
|
||||
uri: null,
|
||||
durationMillis: 0,
|
||||
metering: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// getStatus() is SYNCHRONOUS
|
||||
// Note: This may fail if URL is not yet set (race condition on start)
|
||||
const status = this.recorder.getStatus();
|
||||
|
||||
// Use durationMillis as the primary time source (more reliable than currentTime)
|
||||
// currentTime doesn't update properly on iOS Simulator
|
||||
const timeMs = status.durationMillis || this.recorder.currentTime || 0;
|
||||
|
||||
const enhancedStatus: RecorderState = {
|
||||
...status,
|
||||
currentTime: timeMs,
|
||||
isPaused: false, // We track this separately in the store
|
||||
};
|
||||
|
||||
this.lastKnownStatus = enhancedStatus;
|
||||
return enhancedStatus;
|
||||
} catch (error) {
|
||||
console.warn('Failed to get recorder status (URL may not be ready yet):', error);
|
||||
// Return last known status or safe defaults
|
||||
// This can happen when URL is not yet set after starting recording
|
||||
return this.lastKnownStatus || {
|
||||
canRecord: false,
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
currentTime: 0,
|
||||
uri: null,
|
||||
durationMillis: 0,
|
||||
metering: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log('[AudioEngine] Starting cleanup...');
|
||||
|
||||
// Stop polling first to prevent any further status checks
|
||||
this.stopStatusPolling();
|
||||
console.log('[AudioEngine] Status polling stopped');
|
||||
|
||||
// Stop any active recording
|
||||
if (this.recorder) {
|
||||
console.log('[AudioEngine] Cleaning up recorder instance...');
|
||||
try {
|
||||
const status = this.getStatus();
|
||||
if (status.isRecording) {
|
||||
console.warn('[AudioEngine] Recorder still recording during cleanup - forcing stop');
|
||||
// Try to pause first for immediate effect
|
||||
try {
|
||||
this.recorder.pause();
|
||||
console.log('[AudioEngine] Recording paused');
|
||||
} catch (pauseError) {
|
||||
console.debug('[AudioEngine] Could not pause:', pauseError);
|
||||
}
|
||||
// Then stop async
|
||||
this.recorder.stop()
|
||||
.then(() => console.log('[AudioEngine] Recording stopped during cleanup'))
|
||||
.catch(error => console.debug('[AudioEngine] Stop error during cleanup:', error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AudioEngine] Error during cleanup:', error);
|
||||
}
|
||||
|
||||
// Always clear the recorder reference
|
||||
this.recorder = null;
|
||||
console.log('[AudioEngine] Recorder reference cleared');
|
||||
}
|
||||
|
||||
// Reset all state
|
||||
this.initialized = false;
|
||||
this.lastKnownStatus = null;
|
||||
this.onStateChangeCallback = null;
|
||||
this.onStatusUpdateCallback = null;
|
||||
|
||||
console.log('[AudioEngine] Cleanup completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the service is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for state changes
|
||||
*/
|
||||
registerStateChangeCallback(callback: StateChangeCallback): void {
|
||||
this.onStateChangeCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for status updates
|
||||
*/
|
||||
registerStatusUpdateCallback(callback: StatusUpdateCallback): void {
|
||||
this.onStatusUpdateCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request recording permissions
|
||||
*/
|
||||
async requestPermissions(): Promise<PermissionState> {
|
||||
try {
|
||||
const result = await requestRecordingPermissionsAsync();
|
||||
return {
|
||||
microphone: {
|
||||
granted: result.granted,
|
||||
canAskAgain: result.canAskAgain,
|
||||
},
|
||||
canAskAgain: result.canAskAgain ?? true,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to request permissions:', error);
|
||||
throw this.createError(
|
||||
RecordingErrorType.PERMISSION_DENIED,
|
||||
'permission_request_failed',
|
||||
'Failed to request recording permissions',
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current recording permissions
|
||||
*/
|
||||
async checkPermissions(): Promise<PermissionState> {
|
||||
try {
|
||||
const result = await getRecordingPermissionsAsync();
|
||||
return {
|
||||
microphone: {
|
||||
granted: result.granted,
|
||||
canAskAgain: result.canAskAgain,
|
||||
},
|
||||
canAskAgain: result.canAskAgain ?? true,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to check permissions:', error);
|
||||
return {
|
||||
microphone: {
|
||||
granted: false,
|
||||
canAskAgain: true,
|
||||
},
|
||||
canAskAgain: true,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Private Helper Methods ==========
|
||||
|
||||
/**
|
||||
* Start listening for status updates using RecordingEvents
|
||||
*/
|
||||
protected startStatusPolling(): void {
|
||||
if (this.statusListener) {
|
||||
return; // Already listening
|
||||
}
|
||||
|
||||
if (!this.recorder) {
|
||||
console.warn('[AudioEngine] No recorder instance to attach listener to');
|
||||
return;
|
||||
}
|
||||
|
||||
// Shared variables for both event listener and polling
|
||||
let previousTime = 0;
|
||||
let stuckCounter = 0;
|
||||
let lastLogTime = 0;
|
||||
let lastPollLogTime = 0; // Separate log timer for polling
|
||||
|
||||
// Use hybrid approach: events for updates + manual getStatus() for metering
|
||||
this.statusListener = this.recorder.addListener(
|
||||
this.STATUS_UPDATE_EVENT,
|
||||
(status: any) => {
|
||||
try {
|
||||
// Log what the event callback receives
|
||||
console.log('📡 [Event Callback] Raw status from event:', JSON.stringify({
|
||||
isRecording: status.isRecording,
|
||||
durationMillis: status.durationMillis,
|
||||
currentTime: status.currentTime,
|
||||
metering: status.metering,
|
||||
hasUri: !!status.uri,
|
||||
allKeys: Object.keys(status),
|
||||
}, null, 2));
|
||||
|
||||
// Get current status manually to ensure we have metering data
|
||||
const currentStatus = this.recorder?.getStatus();
|
||||
console.log('📡 [getStatus()] Manual status poll:', JSON.stringify({
|
||||
isRecording: currentStatus?.isRecording,
|
||||
durationMillis: currentStatus?.durationMillis,
|
||||
metering: currentStatus?.metering,
|
||||
hasUri: !!currentStatus?.uri,
|
||||
allKeys: currentStatus ? Object.keys(currentStatus) : [],
|
||||
}, null, 2));
|
||||
|
||||
// Enhance status with additional tracking and metering from getStatus
|
||||
const enhancedStatus = {
|
||||
...status,
|
||||
currentTime: currentStatus?.durationMillis || status.durationMillis || status.currentTime || 0,
|
||||
isPaused: false, // We track this separately in the store
|
||||
isRecording: currentStatus?.isRecording ?? status.isRecording,
|
||||
metering: currentStatus?.metering ?? status.metering,
|
||||
durationMillis: currentStatus?.durationMillis || status.durationMillis || 0,
|
||||
};
|
||||
|
||||
console.log('📡 [Enhanced] Final enhanced status:', JSON.stringify({
|
||||
currentTime: enhancedStatus.currentTime,
|
||||
currentTimeSeconds: Math.floor(enhancedStatus.currentTime / 1000),
|
||||
isRecording: enhancedStatus.isRecording,
|
||||
metering: enhancedStatus.metering,
|
||||
durationMillis: enhancedStatus.durationMillis,
|
||||
}, null, 2));
|
||||
|
||||
this.lastKnownStatus = enhancedStatus;
|
||||
|
||||
// Log status every 2 seconds for debugging
|
||||
const now = Date.now();
|
||||
if (now - lastLogTime > 2000) {
|
||||
console.log('[AudioEngine] ⏱️ Status summary at', Math.floor(enhancedStatus.currentTime / 1000) + 's:', {
|
||||
isRecording: enhancedStatus.isRecording,
|
||||
currentTimeMs: enhancedStatus.currentTime,
|
||||
metering: enhancedStatus.metering !== undefined ? enhancedStatus.metering : 'undefined',
|
||||
uri: enhancedStatus.uri?.substring(enhancedStatus.uri.lastIndexOf('/') + 1) || 'no-uri',
|
||||
});
|
||||
lastLogTime = now;
|
||||
}
|
||||
|
||||
// Detect if recording has stopped unexpectedly or is stuck
|
||||
if (enhancedStatus.isRecording) {
|
||||
// Only warn about stuck recording if we're not on simulator
|
||||
// iOS Simulator has known issues with currentTime not updating
|
||||
const isSimulator = Platform.OS === 'ios' && __DEV__;
|
||||
|
||||
if (enhancedStatus.currentTime === previousTime && !isSimulator) {
|
||||
stuckCounter++;
|
||||
if (stuckCounter > 10) {
|
||||
console.warn('[AudioEngine] Recording time not advancing at:', enhancedStatus.currentTime, 'ms');
|
||||
stuckCounter = 0; // Reset to avoid spamming
|
||||
}
|
||||
} else {
|
||||
stuckCounter = 0;
|
||||
}
|
||||
previousTime = enhancedStatus.currentTime;
|
||||
}
|
||||
|
||||
// Call status update callback if registered
|
||||
if (this.onStatusUpdateCallback) {
|
||||
console.log('🔔 [Callback] Sending enhanced status to store/UI:', JSON.stringify({
|
||||
currentTime: enhancedStatus.currentTime,
|
||||
isRecording: enhancedStatus.isRecording,
|
||||
metering: enhancedStatus.metering,
|
||||
durationMillis: enhancedStatus.durationMillis,
|
||||
}, null, 2));
|
||||
this.onStatusUpdateCallback(enhancedStatus);
|
||||
} else {
|
||||
console.warn('⚠️ [Callback] No onStatusUpdateCallback registered!');
|
||||
}
|
||||
|
||||
// If recording stopped, stop listening
|
||||
if (!enhancedStatus.isRecording) {
|
||||
if (previousTime > 0) {
|
||||
console.log('[AudioEngine] Recording stopped. Final duration:', previousTime, 'ms (', Math.floor(previousTime / 1000), 'seconds)');
|
||||
}
|
||||
this.stopStatusPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in status update listener:', error);
|
||||
this.stopStatusPolling();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[AudioEngine] Status update listener attached');
|
||||
|
||||
// FALLBACK: Add manual polling since events don't fire during recording
|
||||
// Poll every 500ms (Expo SDK 54 recommended default) for balance between
|
||||
// responsiveness and battery efficiency
|
||||
console.log('🔄 [Polling] Starting manual status polling (500ms interval)');
|
||||
|
||||
this.statusPollingInterval = setInterval(() => {
|
||||
if (!this.recorder) {
|
||||
console.warn('🔄 [Polling] No recorder, stopping poll');
|
||||
this.stopStatusPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = this.recorder.getStatus();
|
||||
|
||||
// ✅ FIX: Check if recorder is truly stopped (not just paused)
|
||||
// When paused, isRecording=false but we still need polling for UI updates
|
||||
// Only stop polling when there's no active recording at all
|
||||
if (!status.isRecording && !status.isPaused && status.durationMillis === 0) {
|
||||
console.log('🔄 [Polling] Recording fully stopped, ending poll');
|
||||
this.stopStatusPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling even when paused to maintain UI state
|
||||
if (!status.isRecording && status.durationMillis > 0) {
|
||||
console.log('🔄 [Polling] Recording paused at', status.durationMillis, 'ms, continuing poll for UI');
|
||||
}
|
||||
|
||||
// Create enhanced status from poll
|
||||
const enhancedStatus = {
|
||||
...status,
|
||||
currentTime: status.durationMillis || status.currentTime || 0,
|
||||
isPaused: false,
|
||||
isRecording: status.isRecording,
|
||||
metering: status.metering,
|
||||
durationMillis: status.durationMillis || 0,
|
||||
};
|
||||
|
||||
this.lastKnownStatus = enhancedStatus;
|
||||
|
||||
// Send to callback
|
||||
if (this.onStatusUpdateCallback) {
|
||||
this.onStatusUpdateCallback(enhancedStatus);
|
||||
}
|
||||
|
||||
// Log every 2 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastPollLogTime > 2000) {
|
||||
console.log('🔄 [Polling] Status at', Math.floor(enhancedStatus.currentTime / 1000) + 's:', {
|
||||
isRecording: enhancedStatus.isRecording,
|
||||
durationMs: enhancedStatus.durationMillis,
|
||||
metering: enhancedStatus.metering,
|
||||
});
|
||||
lastPollLogTime = now;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔄 [Polling] Error:', error);
|
||||
}
|
||||
}, 500); // 500ms = 2 updates per second (Expo SDK 54 recommended default)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for status updates
|
||||
*/
|
||||
protected stopStatusPolling(): void {
|
||||
// Remove event listener if it exists
|
||||
if (this.statusListener) {
|
||||
try {
|
||||
// The subscription returned by addListener has a remove() method
|
||||
this.statusListener.remove();
|
||||
this.statusListener = null;
|
||||
console.log('[AudioEngine] Status update listener removed');
|
||||
} catch (error) {
|
||||
console.error('[AudioEngine] Error removing status listener:', error);
|
||||
this.statusListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up legacy polling interval if it exists
|
||||
if (this.statusPollingInterval) {
|
||||
clearInterval(this.statusPollingInterval);
|
||||
this.statusPollingInterval = null;
|
||||
}
|
||||
|
||||
// Final status update
|
||||
if (this.recorder && this.onStatusUpdateCallback) {
|
||||
try {
|
||||
const finalStatus = this.getStatus();
|
||||
this.onStatusUpdateCallback(finalStatus);
|
||||
} catch (error) {
|
||||
console.error('Error getting final status:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for iOS audio session to be fully ready by attempting to create a test recorder.
|
||||
* This is more reliable than fixed delays as it only waits as long as needed.
|
||||
* Uses progressive retry with timeout to handle device variations.
|
||||
*
|
||||
* @param maxWaitMs Maximum time to wait in milliseconds (default 2000ms)
|
||||
*/
|
||||
private async waitForAudioSessionReady(maxWaitMs: number = 2000): Promise<void> {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return; // Only needed on iOS
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let attempt = 0;
|
||||
|
||||
console.log('[AudioEngine] 🔍 Verifying iOS audio session readiness...');
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
attempt++;
|
||||
|
||||
try {
|
||||
// Try to create a minimal test recorder
|
||||
// If this succeeds, audio session is ready for recording
|
||||
const testRecorder = new AudioModule.AudioRecorder({
|
||||
extension: '.m4a',
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1,
|
||||
ios: {
|
||||
outputFormat: 'mpeg4aac',
|
||||
audioQuality: 96,
|
||||
},
|
||||
});
|
||||
|
||||
// Success! Audio session is ready
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`✅ [AudioEngine] iOS audio session ready after ${elapsed}ms (attempt ${attempt})`);
|
||||
|
||||
// Note: Test recorder will be garbage collected
|
||||
// We'll create a new one with proper options for actual recording
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.debug(`⏳ [AudioEngine] Audio session not ready at ${elapsed}ms (attempt ${attempt}):`, (error as Error).message);
|
||||
|
||||
// Progressive backoff: 50ms, 100ms, 150ms, 200ms, then stay at 200ms
|
||||
const delay = Math.min(50 * attempt, 200);
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// If we timeout, log warning but don't throw
|
||||
// Let the actual recording attempt fail with a better error if needed
|
||||
const totalWait = Date.now() - startTime;
|
||||
console.warn(`⚠️ [AudioEngine] Audio session verification timed out after ${totalWait}ms - proceeding anyway`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a structured recording error
|
||||
*/
|
||||
protected createError(
|
||||
type: RecordingErrorType,
|
||||
code: string,
|
||||
message: string,
|
||||
recoverable: boolean
|
||||
): RecordingError {
|
||||
return {
|
||||
type,
|
||||
code,
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
recoverable,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility delay function
|
||||
*/
|
||||
protected delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
133
apps/memoro/apps/mobile/features/audioRecordingV2/index.ts
Normal file
133
apps/memoro/apps/mobile/features/audioRecordingV2/index.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* AudioRecordingV2 Public API
|
||||
* Main exports for the audio recording system
|
||||
*/
|
||||
|
||||
// Core exports
|
||||
export { AudioEngineService } from './core/AudioEngineService';
|
||||
|
||||
// Platform-specific services
|
||||
export { AndroidRecordingService } from './platforms/AndroidRecordingService';
|
||||
export { IOSRecordingService } from './platforms/IOSRecordingService';
|
||||
|
||||
// Store exports
|
||||
export { useRecordingStore, cleanupRecordingStore } from './store/recordingStore';
|
||||
|
||||
// Context exports
|
||||
export {
|
||||
RecordingLanguageProvider,
|
||||
useRecordingLanguage,
|
||||
AZURE_SUPPORTED_LANGUAGES,
|
||||
} from './context/RecordingLanguageContext';
|
||||
|
||||
// Services exports
|
||||
export { recordingSoundManager } from './services/recordingSoundManager';
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
// Core types
|
||||
RecordingState,
|
||||
RecordingSession,
|
||||
RecordingError,
|
||||
RecordingOptions,
|
||||
PermissionState,
|
||||
PermissionStatus,
|
||||
RecorderState,
|
||||
AudioFile,
|
||||
|
||||
// Configuration types
|
||||
AudioFormat,
|
||||
AndroidSpecificConfig,
|
||||
IOSSpecificConfig,
|
||||
WebSpecificConfig,
|
||||
PlatformConfig,
|
||||
|
||||
// Service interfaces
|
||||
IAudioEngineService,
|
||||
IPlatformRecordingService,
|
||||
|
||||
// Store types
|
||||
RecordingStoreState,
|
||||
TimerState,
|
||||
|
||||
// Utility types
|
||||
UploadProgress,
|
||||
RecordingMetrics,
|
||||
RetryStrategy,
|
||||
|
||||
// Callback types
|
||||
StatusUpdateCallback,
|
||||
ErrorCallback,
|
||||
StateChangeCallback,
|
||||
} from './types';
|
||||
|
||||
// Re-export enums as values
|
||||
export {
|
||||
RecordingStatus,
|
||||
RecordingErrorType,
|
||||
RecordingPreset,
|
||||
AudioEncoding,
|
||||
} from './types';
|
||||
|
||||
// Error handling utilities
|
||||
export {
|
||||
ERROR_MESSAGES,
|
||||
ERROR_CODES,
|
||||
classifyError,
|
||||
handleRecordingError,
|
||||
openAppSettings,
|
||||
retryWithBackoff,
|
||||
getErrorDescription,
|
||||
isAndroid16RestrictionError,
|
||||
logRecordingError,
|
||||
} from './utils/errors';
|
||||
|
||||
// Convenience hook for using the recording system
|
||||
import { useRecordingStore } from './store/recordingStore';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Main hook for using the audio recording system
|
||||
* Automatically initializes the service when mounted
|
||||
*/
|
||||
export function useAudioRecordingV2(autoInitialize: boolean = true) {
|
||||
const store = useRecordingStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (autoInitialize && !store.isInitialized) {
|
||||
store.initialize().catch((error) => {
|
||||
console.error('Failed to initialize audio recording:', error);
|
||||
});
|
||||
}
|
||||
}, [autoInitialize, store.isInitialized]);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
// Export a ready-to-use recording service factory
|
||||
import { Platform } from 'react-native';
|
||||
import { AndroidRecordingService } from './platforms/AndroidRecordingService';
|
||||
import { IOSRecordingService } from './platforms/IOSRecordingService';
|
||||
import { AudioEngineService as AudioEngine } from './core/AudioEngineService';
|
||||
|
||||
/**
|
||||
* Factory function to create a platform-specific recording service
|
||||
* Use this if you need direct service access without the store
|
||||
*/
|
||||
export function createPlatformRecordingService() {
|
||||
switch (Platform.OS) {
|
||||
case 'android':
|
||||
return new AndroidRecordingService();
|
||||
case 'ios':
|
||||
return new IOSRecordingService();
|
||||
default:
|
||||
return new AudioEngine();
|
||||
}
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
export default {
|
||||
useRecordingStore,
|
||||
useAudioRecordingV2,
|
||||
createPlatformRecordingService,
|
||||
};
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
/**
|
||||
* AndroidRecordingService
|
||||
* Android-specific implementation with foreground service and background recording
|
||||
*/
|
||||
|
||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||
import notifee from '@notifee/react-native';
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
||||
|
||||
import { AudioEngineService } from '../core/AudioEngineService';
|
||||
import {
|
||||
IPlatformRecordingService,
|
||||
RecordingOptions,
|
||||
RecordingErrorType,
|
||||
RecordingStatus,
|
||||
AndroidSpecificConfig,
|
||||
AppStateStatus as TypedAppStateStatus,
|
||||
} from '../types';
|
||||
|
||||
export class AndroidRecordingService extends AudioEngineService implements IPlatformRecordingService {
|
||||
private appStateSubscription: any = null;
|
||||
private lastAppState: AppStateStatus = 'active';
|
||||
private recordingActive: boolean = false;
|
||||
private wakeLockActive: boolean = false;
|
||||
private foregroundServiceActive: boolean = false;
|
||||
|
||||
// Android-specific configuration
|
||||
private androidConfig: AndroidSpecificConfig = {
|
||||
foregroundServiceEnabled: true,
|
||||
notificationTitle: 'Recording Audio',
|
||||
notificationBody: 'Your audio is being recorded...',
|
||||
notificationChannelId: 'audio_recording_channel',
|
||||
wakeLockEnabled: true,
|
||||
};
|
||||
|
||||
constructor(config?: Partial<AndroidSpecificConfig>) {
|
||||
super();
|
||||
|
||||
if (config) {
|
||||
this.androidConfig = { ...this.androidConfig, ...config };
|
||||
}
|
||||
|
||||
// Register foreground service for background recording
|
||||
this.registerForegroundService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with Android-specific setup
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await super.initialize();
|
||||
|
||||
// Set up Android-specific features
|
||||
await this.setupPlatformSpecific();
|
||||
|
||||
// Set up app state monitoring
|
||||
this.setupAppStateListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Android-specific features
|
||||
*/
|
||||
async setupPlatformSpecific(): Promise<void> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create notification channel for recording
|
||||
await notifee.createChannel({
|
||||
id: this.androidConfig.notificationChannelId,
|
||||
name: 'Audio Recording',
|
||||
lights: false,
|
||||
vibration: false,
|
||||
importance: 4, // HIGH importance
|
||||
sound: undefined, // No sound for recording notifications
|
||||
});
|
||||
|
||||
console.log('Android-specific setup completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to set up Android-specific features:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording with Android-specific handling
|
||||
*/
|
||||
async startRecording(options?: RecordingOptions): Promise<void> {
|
||||
// Android 16 compatibility: Ensure app is in foreground
|
||||
const currentAppState = AppState.currentState;
|
||||
if (currentAppState !== 'active') {
|
||||
console.warn('Android: Cannot start recording when app is not in foreground');
|
||||
throw this.createError(
|
||||
RecordingErrorType.PLATFORM_RESTRICTION,
|
||||
'android_foreground_required',
|
||||
'Recording can only be started when app is in foreground (Android 16+ restriction)',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check is handled by parent class (AudioEngineService)
|
||||
// If it fails, cleanup in catch block will handle resource cleanup
|
||||
|
||||
// Start foreground service if enabled
|
||||
if (this.androidConfig.foregroundServiceEnabled) {
|
||||
await this.startForegroundService();
|
||||
}
|
||||
|
||||
// Acquire wake lock if enabled
|
||||
if (this.androidConfig.wakeLockEnabled) {
|
||||
await this.acquireWakeLock();
|
||||
}
|
||||
|
||||
// Mark recording as active
|
||||
this.recordingActive = true;
|
||||
|
||||
try {
|
||||
// Call parent implementation (includes permission check)
|
||||
await super.startRecording(options);
|
||||
} catch (error) {
|
||||
// Clean up on failure (including permission denial)
|
||||
this.recordingActive = false;
|
||||
await this.releaseWakeLock();
|
||||
await this.stopForegroundService();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording with Android-specific cleanup
|
||||
*/
|
||||
async stopRecording(): Promise<string> {
|
||||
console.log('[Android] Stopping recording, ensuring complete cleanup...');
|
||||
|
||||
// Mark recording as inactive immediately to prevent re-entry
|
||||
this.recordingActive = false;
|
||||
|
||||
let uri = '';
|
||||
try {
|
||||
// Stop the actual recording
|
||||
uri = await super.stopRecording();
|
||||
console.log('Android: Recording stopped, URI:', uri);
|
||||
|
||||
// Verify the recording file (Android 16 workaround)
|
||||
console.log('Android 16: Verifying file integrity...');
|
||||
|
||||
// Check file size to detect empty recordings (emulator issue)
|
||||
try {
|
||||
const { getFileInfo } = await import('~/features/storage/fileSystemUtils');
|
||||
const fileInfo = await getFileInfo(uri);
|
||||
console.log('Recording file size:', fileInfo.size, 'bytes');
|
||||
if (fileInfo.size && fileInfo.size < 1000) {
|
||||
console.warn('⚠️ Recording file is very small - may be empty (common emulator issue)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not check file size:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Android] Error stopping recording:', error);
|
||||
// Continue with cleanup even if stop fails
|
||||
} finally {
|
||||
// ALWAYS perform cleanup, regardless of success or failure
|
||||
console.log('[Android] Performing cleanup...');
|
||||
|
||||
// Release wake lock
|
||||
try {
|
||||
await this.releaseWakeLock();
|
||||
} catch (error) {
|
||||
console.debug('[Android] Wake lock release error (non-critical):', error);
|
||||
}
|
||||
|
||||
// Stop foreground service
|
||||
try {
|
||||
await this.stopForegroundService();
|
||||
} catch (error) {
|
||||
console.debug('[Android] Foreground service stop error (non-critical):', error);
|
||||
}
|
||||
console.log('[Android] Cleanup completed');
|
||||
}
|
||||
|
||||
// If we didn't get a URI, throw an error
|
||||
if (!uri) {
|
||||
throw this.createError(
|
||||
RecordingErrorType.AUDIO_ENGINE_ERROR,
|
||||
'stop_failed_no_uri',
|
||||
'Failed to stop recording - no URI returned',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause recording with notification update
|
||||
*/
|
||||
pauseRecording(): void {
|
||||
super.pauseRecording();
|
||||
|
||||
if (this.foregroundServiceActive) {
|
||||
// Update notification to show paused state
|
||||
this.updateForegroundNotification('Recording Paused', 'Tap to return to the app');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume recording with notification update
|
||||
*/
|
||||
resumeRecording(): void {
|
||||
super.resumeRecording();
|
||||
|
||||
if (this.foregroundServiceActive) {
|
||||
// Update notification to show recording state
|
||||
this.updateForegroundNotification(
|
||||
this.androidConfig.notificationTitle!,
|
||||
this.androidConfig.notificationBody!
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app state changes for Android
|
||||
*/
|
||||
handleAppStateChange(nextAppState: TypedAppStateStatus): void {
|
||||
console.log(`[Android] App state changed from ${this.lastAppState} to ${nextAppState}`);
|
||||
|
||||
if (this.recordingActive) {
|
||||
if (this.lastAppState === 'active' && nextAppState.match(/inactive|background/)) {
|
||||
console.log('[Android] App going to background while recording...');
|
||||
|
||||
// On Android 16+, we may need to stop recording when backgrounded
|
||||
// Check if recording is actually still possible
|
||||
setTimeout(() => {
|
||||
if (this.recorder && this.recordingActive) {
|
||||
try {
|
||||
const status = this.getStatus();
|
||||
if (!status.isRecording && !status.isPaused) {
|
||||
console.error('[Android] Recording interrupted when app went to background');
|
||||
// Force stop and cleanup
|
||||
this.recordingActive = false;
|
||||
this.stopRecording()
|
||||
.catch(error => console.error('[Android] Error stopping interrupted recording:', error));
|
||||
} else {
|
||||
// Recording is still active, ensure services are running
|
||||
console.log('[Android] Recording continues in background');
|
||||
|
||||
// Re-acquire wake lock if needed
|
||||
if (this.androidConfig.wakeLockEnabled && !this.wakeLockActive) {
|
||||
this.acquireWakeLock().catch(console.error);
|
||||
}
|
||||
|
||||
// Ensure foreground service is active
|
||||
if (this.androidConfig.foregroundServiceEnabled && !this.foregroundServiceActive) {
|
||||
this.startForegroundService().catch(console.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Android] Error checking recording status:', error);
|
||||
// Force cleanup on error
|
||||
this.recordingActive = false;
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
}, 500); // Small delay to let the state settle
|
||||
} else if (this.lastAppState.match(/inactive|background/) && nextAppState === 'active') {
|
||||
console.log('[Android] App returning to foreground, validating recording status...');
|
||||
|
||||
// Check if recording is still active
|
||||
if (this.recorder && this.recordingActive) {
|
||||
try {
|
||||
const status = this.getStatus();
|
||||
console.log('[Android] Recording status after returning:', {
|
||||
isRecording: status.isRecording,
|
||||
isPaused: status.isPaused,
|
||||
currentTime: status.currentTime
|
||||
});
|
||||
|
||||
if (!status.isRecording && !status.isPaused) {
|
||||
console.error('[Android] Recording was terminated while backgrounded');
|
||||
// Clean up the broken recording state
|
||||
this.recordingActive = false;
|
||||
|
||||
// Notify the store about the failure
|
||||
if (this.onStateChangeCallback) {
|
||||
this.onStateChangeCallback({
|
||||
status: RecordingStatus.ERROR,
|
||||
error: this.createError(
|
||||
RecordingErrorType.PLATFORM_RESTRICTION,
|
||||
'recording_interrupted',
|
||||
'Recording was interrupted when app went to background',
|
||||
false
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Force cleanup
|
||||
this.cleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Android] Error checking recording status:', error);
|
||||
// Force cleanup on error
|
||||
this.recordingActive = false;
|
||||
this.cleanup();
|
||||
}
|
||||
} else if (this.recordingActive && !this.recorder) {
|
||||
// Recording flag is set but no recorder - inconsistent state
|
||||
console.error('[Android] Inconsistent state: recordingActive but no recorder');
|
||||
this.recordingActive = false;
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lastAppState = nextAppState as AppStateStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up with Android-specific resource release
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log('[Android] Starting comprehensive cleanup...');
|
||||
|
||||
// Mark recording as inactive immediately
|
||||
this.recordingActive = false;
|
||||
|
||||
// Remove app state listener first to prevent any callbacks
|
||||
try {
|
||||
this.removeAppStateListener();
|
||||
console.log('[Android] App state listener removed');
|
||||
} catch (error) {
|
||||
console.debug('[Android] Error removing app state listener:', error);
|
||||
}
|
||||
|
||||
// Release wake lock
|
||||
if (this.wakeLockActive) {
|
||||
this.releaseWakeLock()
|
||||
.then(() => console.log('[Android] Wake lock released during cleanup'))
|
||||
.catch(error => console.debug('[Android] Wake lock release error during cleanup:', error));
|
||||
}
|
||||
|
||||
// Stop foreground service
|
||||
if (this.foregroundServiceActive) {
|
||||
this.stopForegroundService()
|
||||
.then(() => console.log('[Android] Foreground service stopped during cleanup'))
|
||||
.catch(error => console.debug('[Android] Foreground service stop error during cleanup:', error));
|
||||
}
|
||||
|
||||
// Call parent cleanup to ensure recorder is released
|
||||
try {
|
||||
super.cleanup();
|
||||
console.log('[Android] Parent cleanup completed');
|
||||
} catch (error) {
|
||||
console.error('[Android] Error in parent cleanup:', error);
|
||||
}
|
||||
|
||||
console.log('[Android] Cleanup completed');
|
||||
}
|
||||
|
||||
// ========== Private Android-specific Methods ==========
|
||||
|
||||
/**
|
||||
* Register the foreground service for background recording
|
||||
*/
|
||||
private registerForegroundService(): void {
|
||||
if (Platform.OS !== 'android') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
notifee.registerForegroundService(() => {
|
||||
return new Promise(() => {
|
||||
// Long-lived task for audio recording
|
||||
// The promise never resolves to keep the service running
|
||||
});
|
||||
});
|
||||
console.log('Android: Foreground service registered');
|
||||
} catch (error) {
|
||||
console.error('Failed to register foreground service:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start foreground service with notification
|
||||
*/
|
||||
private async startForegroundService(): Promise<void> {
|
||||
if (Platform.OS !== 'android' || this.foregroundServiceActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if app is in foreground (Android 16 requirement)
|
||||
const currentAppState = AppState.currentState;
|
||||
if (currentAppState !== 'active') {
|
||||
console.warn('Android 16: Skipping foreground service start - app not active');
|
||||
return;
|
||||
}
|
||||
|
||||
await notifee.displayNotification({
|
||||
title: this.androidConfig.notificationTitle,
|
||||
body: this.androidConfig.notificationBody,
|
||||
android: {
|
||||
channelId: this.androidConfig.notificationChannelId,
|
||||
asForegroundService: true,
|
||||
ongoing: true,
|
||||
smallIcon: 'ic_notification',
|
||||
pressAction: {
|
||||
id: 'default',
|
||||
},
|
||||
colorized: true,
|
||||
onlyAlertOnce: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.foregroundServiceActive = true;
|
||||
console.log('Android: Foreground service started');
|
||||
} catch (error) {
|
||||
console.error('Failed to start foreground service:', error);
|
||||
// Don't throw - recording can continue without foreground service
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop foreground service
|
||||
*/
|
||||
private async stopForegroundService(): Promise<void> {
|
||||
if (!this.foregroundServiceActive) {
|
||||
console.log('[Android] Foreground service already inactive');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Android] Stopping foreground service...');
|
||||
|
||||
try {
|
||||
// First, cancel all notifications to ensure UI cleanup
|
||||
await Promise.race([
|
||||
notifee.cancelAllNotifications(),
|
||||
new Promise(resolve => setTimeout(resolve, 1000)),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.debug('[Android] Notification cancellation error (non-critical):', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Then stop the foreground service with a shorter timeout
|
||||
await Promise.race([
|
||||
notifee.stopForegroundService(),
|
||||
new Promise(resolve => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
console.log('[Android] Foreground service stop command completed');
|
||||
} catch (error) {
|
||||
console.warn('[Android] Foreground service stop timeout or error:', error);
|
||||
// Continue anyway - the service might have stopped
|
||||
}
|
||||
|
||||
// Always mark as inactive to prevent re-entry
|
||||
this.foregroundServiceActive = false;
|
||||
console.log('[Android] Foreground service marked as inactive');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update foreground service notification
|
||||
*/
|
||||
private async updateForegroundNotification(title: string, body: string): Promise<void> {
|
||||
if (!this.foregroundServiceActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId: this.androidConfig.notificationChannelId,
|
||||
asForegroundService: true,
|
||||
ongoing: true,
|
||||
smallIcon: 'ic_notification',
|
||||
pressAction: {
|
||||
id: 'default',
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update foreground notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Acquire wake lock to prevent device sleep during recording
|
||||
*/
|
||||
private async acquireWakeLock(): Promise<void> {
|
||||
if (this.wakeLockActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await activateKeepAwakeAsync('audio-recording');
|
||||
this.wakeLockActive = true;
|
||||
console.log('Android: Wake lock acquired');
|
||||
} catch (error) {
|
||||
console.warn('Could not acquire wake lock:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release wake lock
|
||||
*/
|
||||
private async releaseWakeLock(): Promise<void> {
|
||||
if (!this.wakeLockActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
deactivateKeepAwake('audio-recording');
|
||||
this.wakeLockActive = false;
|
||||
console.log('Android: Wake lock released');
|
||||
} catch (error) {
|
||||
console.warn('Could not release wake lock:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up app state listener
|
||||
*/
|
||||
private setupAppStateListener(): void {
|
||||
this.removeAppStateListener(); // Remove any existing listener
|
||||
|
||||
this.appStateSubscription = AppState.addEventListener(
|
||||
'change',
|
||||
(nextAppState) => this.handleAppStateChange(nextAppState as TypedAppStateStatus)
|
||||
);
|
||||
|
||||
console.log('Android: App state listener set up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app state listener
|
||||
*/
|
||||
private removeAppStateListener(): void {
|
||||
if (this.appStateSubscription) {
|
||||
this.appStateSubscription.remove();
|
||||
this.appStateSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
/**
|
||||
* IOSRecordingService
|
||||
* iOS-specific implementation with audio session management
|
||||
*/
|
||||
|
||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||
|
||||
import { AudioEngineService } from '../core/AudioEngineService';
|
||||
import {
|
||||
IPlatformRecordingService,
|
||||
RecordingOptions,
|
||||
IOSSpecificConfig,
|
||||
AppStateStatus as TypedAppStateStatus,
|
||||
} from '../types';
|
||||
|
||||
export class IOSRecordingService extends AudioEngineService implements IPlatformRecordingService {
|
||||
private appStateSubscription: any = null;
|
||||
private lastAppState: AppStateStatus = 'active';
|
||||
private recordingActive: boolean = false;
|
||||
private wasInterrupted: boolean = false;
|
||||
|
||||
// iOS-specific configuration
|
||||
// Note: Actual audio session is configured via AudioEngineService.setupAudioMode()
|
||||
// using setAudioModeAsync({ interruptionMode: 'mixWithOthers' })
|
||||
private iosConfig: IOSSpecificConfig = {
|
||||
audioSessionCategory: 'playAndRecord',
|
||||
audioSessionMode: 'spokenAudio',
|
||||
allowBluetooth: true,
|
||||
mixWithOthers: true, // ✅ FIXED: Matches setAudioModeAsync config for background recording
|
||||
defaultToSpeaker: false,
|
||||
};
|
||||
|
||||
constructor(config?: Partial<IOSSpecificConfig>) {
|
||||
super();
|
||||
|
||||
if (config) {
|
||||
this.iosConfig = { ...this.iosConfig, ...config };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with iOS-specific setup
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await super.initialize();
|
||||
|
||||
// Set up iOS-specific features
|
||||
await this.setupPlatformSpecific();
|
||||
|
||||
// Set up app state monitoring
|
||||
this.setupAppStateListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up iOS-specific features
|
||||
*/
|
||||
async setupPlatformSpecific(): Promise<void> {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// iOS audio session configuration is handled in setupAudioMode
|
||||
console.log('iOS-specific setup completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to set up iOS-specific features:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording with iOS-specific handling
|
||||
*/
|
||||
async startRecording(options?: RecordingOptions): Promise<void> {
|
||||
// Permission check is handled by parent class (AudioEngineService)
|
||||
|
||||
// Mark recording as active
|
||||
this.recordingActive = true;
|
||||
|
||||
try {
|
||||
// Call parent implementation (includes permission check)
|
||||
await super.startRecording(options);
|
||||
} catch (error) {
|
||||
// Clean up on failure
|
||||
this.recordingActive = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording with iOS-specific handling
|
||||
*/
|
||||
async stopRecording(): Promise<string> {
|
||||
try {
|
||||
const uri = await super.stopRecording();
|
||||
|
||||
// iOS-specific cleanup
|
||||
this.recordingActive = false;
|
||||
this.wasInterrupted = false;
|
||||
|
||||
console.log('iOS: Recording completed, URI:', uri);
|
||||
|
||||
return uri;
|
||||
} catch (error) {
|
||||
// Ensure cleanup happens even on error
|
||||
this.recordingActive = false;
|
||||
this.wasInterrupted = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app state changes for iOS
|
||||
*/
|
||||
handleAppStateChange(nextAppState: TypedAppStateStatus): void {
|
||||
console.log(`iOS: App state changed from ${this.lastAppState} to ${nextAppState}`);
|
||||
|
||||
// When app returns to foreground, restore audio session if needed
|
||||
// This handles cases where iOS deactivated the audio session (e.g., after phone calls,
|
||||
// when other apps played audio, or when the app was in background for a long time)
|
||||
if (this.lastAppState === 'background' && nextAppState === 'active') {
|
||||
console.log('iOS: App returning to foreground, restoring audio session...');
|
||||
|
||||
// ✅ FIX: Add timeout protection for audio session restoration
|
||||
// After extended background periods (1+ hours), iOS may take longer to restore
|
||||
// or fail completely. We use a 2-second timeout with fallback.
|
||||
const restoreAudioSession = async () => {
|
||||
try {
|
||||
// Attempt to restore audio session with 2-second timeout
|
||||
await Promise.race([
|
||||
this.setupAudioMode(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Audio session restoration timeout')), 2000)
|
||||
)
|
||||
]);
|
||||
|
||||
console.log('iOS: Audio session restored successfully');
|
||||
|
||||
// If we were recording, check if recording is still active and restart polling
|
||||
if (this.recordingActive && this.recorder) {
|
||||
try {
|
||||
const status = this.getStatus();
|
||||
console.log('Recording status after session restore:', status);
|
||||
|
||||
if (status.isRecording || status.isPaused) {
|
||||
// CRITICAL FIX: Restart status polling when returning from background
|
||||
// The JavaScript setInterval was suspended while backgrounded, so we need to restart it
|
||||
console.log('iOS: Restarting status polling after background...');
|
||||
this.startStatusPolling();
|
||||
|
||||
// Get current duration and update store to sync UI
|
||||
const currentDuration = status.durationMillis || 0;
|
||||
console.log(`iOS: Syncing duration after background: ${Math.floor(currentDuration / 1000)}s`);
|
||||
|
||||
// Trigger status update callback to sync UI with actual recording duration
|
||||
if (this.onStatusUpdateCallback) {
|
||||
this.onStatusUpdateCallback(status);
|
||||
}
|
||||
} else {
|
||||
console.warn('iOS: Recording was interrupted and could not be restored');
|
||||
// The app state change will notify the user
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Could not check recording status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('iOS: Audio session restoration failed/timeout, using fallback...', error);
|
||||
|
||||
// ✅ FALLBACK: Query native recorder directly without audio session
|
||||
// The native recorder may still have the correct duration even if audio session restore fails
|
||||
if (this.recordingActive && this.recorder) {
|
||||
try {
|
||||
console.log('iOS: Attempting direct recorder status query...');
|
||||
const status = this.getStatus();
|
||||
|
||||
if (status.durationMillis > 0) {
|
||||
console.log(`iOS: ✅ Fallback successful - duration: ${Math.floor(status.durationMillis / 1000)}s`);
|
||||
|
||||
// Restart polling even though audio session restoration failed
|
||||
// The native recorder is still valid
|
||||
this.startStatusPolling();
|
||||
|
||||
// Update UI with recovered duration
|
||||
if (this.onStatusUpdateCallback) {
|
||||
this.onStatusUpdateCallback(status);
|
||||
}
|
||||
} else {
|
||||
console.error('iOS: ❌ Fallback failed - duration is 0, recording may be corrupted');
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('iOS: ❌ Fallback recorder query also failed:', fallbackError);
|
||||
// At this point, duration sync has completely failed
|
||||
// User will see 00:00 and upload will be rejected
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute restoration asynchronously
|
||||
restoreAudioSession();
|
||||
} else if (this.recordingActive) {
|
||||
// Handle other state changes during recording
|
||||
if (this.lastAppState === 'active' && nextAppState === 'background') {
|
||||
console.log('iOS: App going to background, recording will continue thanks to mixWithOthers mode');
|
||||
// ✅ FIXED: With 'mixWithOthers' mode, iOS keeps audio session active in background
|
||||
// Native recording continues uninterrupted
|
||||
// Note: JavaScript timers will be suspended, but native recording continues
|
||||
// Status polling will restart when app returns to foreground (handled above)
|
||||
}
|
||||
// ✅ REMOVED: Don't treat 'inactive' state as interruption with mixWithOthers mode
|
||||
// The 'inactive' state is just a transient transition during backgrounding (active → inactive → background)
|
||||
// With mixWithOthers mode, backgrounding is NOT an interruption - recording continues
|
||||
// Real audio interruptions (phone calls, Siri) would be handled by iOS audio session notifications
|
||||
}
|
||||
|
||||
this.lastAppState = nextAppState as AppStateStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up with iOS-specific resource release
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log('Cleaning up IOSRecordingService');
|
||||
|
||||
// Remove app state listener
|
||||
this.removeAppStateListener();
|
||||
|
||||
// Call parent cleanup
|
||||
super.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure audio mode with iOS-specific settings
|
||||
*/
|
||||
protected async setupAudioMode(): Promise<void> {
|
||||
// Override parent method to include iOS-specific configuration
|
||||
await super.setupAudioMode();
|
||||
|
||||
// Additional iOS-specific configuration could go here
|
||||
// The base setupAudioMode already handles most iOS requirements
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle audio session interruptions (iOS-specific)
|
||||
*
|
||||
* NOTE: Currently unused with 'mixWithOthers' mode as backgrounding is not an interruption.
|
||||
* Kept for potential future use if we need to handle real interruptions (phone calls, etc.)
|
||||
* or switch to a different interruption mode.
|
||||
*/
|
||||
private handleAudioSessionInterruption(type: 'began' | 'ended'): void {
|
||||
if (type === 'began') {
|
||||
console.log('iOS: Audio session interruption began');
|
||||
|
||||
// Pause recording if active
|
||||
if (this.recordingActive && this.recorder) {
|
||||
try {
|
||||
const status = this.getStatus();
|
||||
if (status.isRecording) {
|
||||
this.pauseRecording();
|
||||
this.wasInterrupted = true;
|
||||
console.log('iOS: Recording paused due to interruption');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pause recording during interruption:', error);
|
||||
}
|
||||
}
|
||||
} else if (type === 'ended') {
|
||||
console.log('iOS: Audio session interruption ended');
|
||||
|
||||
// Log if recording was interrupted
|
||||
if (this.wasInterrupted) {
|
||||
console.log('iOS: Recording was interrupted and is now paused. User can resume manually.');
|
||||
}
|
||||
|
||||
// Note: User should explicitly resume, not automatic
|
||||
// The wasInterrupted flag will be reset when stopRecording is called
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Private iOS-specific Methods ==========
|
||||
|
||||
/**
|
||||
* Set up app state listener
|
||||
*/
|
||||
private setupAppStateListener(): void {
|
||||
this.removeAppStateListener(); // Remove any existing listener
|
||||
|
||||
this.appStateSubscription = AppState.addEventListener(
|
||||
'change',
|
||||
(nextAppState) => this.handleAppStateChange(nextAppState as TypedAppStateStatus)
|
||||
);
|
||||
|
||||
console.log('iOS: App state listener set up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app state listener
|
||||
*/
|
||||
private removeAppStateListener(): void {
|
||||
if (this.appStateSubscription) {
|
||||
this.appStateSubscription.remove();
|
||||
this.appStateSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { createAudioPlayer, AudioPlayer, setAudioModeAsync } from 'expo-audio';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
/**
|
||||
* Singleton manager for recording button sounds
|
||||
* Handles preloading and playback of UI sounds
|
||||
*/
|
||||
class RecordingSoundManager {
|
||||
private static instance: RecordingSoundManager;
|
||||
|
||||
private startSound: AudioPlayer | null = null;
|
||||
private stopSound: AudioPlayer | null = null;
|
||||
private cancelSound: AudioPlayer | null = null;
|
||||
private isLoaded = false;
|
||||
private isLoading = false;
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
static getInstance(): RecordingSoundManager {
|
||||
if (!RecordingSoundManager.instance) {
|
||||
RecordingSoundManager.instance = new RecordingSoundManager();
|
||||
}
|
||||
return RecordingSoundManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all sounds for instant playback
|
||||
* Should be called on app start or component mount
|
||||
*/
|
||||
async preloadSounds(): Promise<void> {
|
||||
// Skip on web platform for now (different audio API)
|
||||
if (Platform.OS === 'web') return;
|
||||
|
||||
// Prevent multiple loading attempts
|
||||
if (this.isLoaded || this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
console.debug('🔊 Preloading recording sounds...');
|
||||
|
||||
// Configure audio mode for UI sounds
|
||||
await setAudioModeAsync({
|
||||
allowsRecording: false, // UI sounds don't need recording
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: false,
|
||||
interruptionMode: 'duckOthers',
|
||||
});
|
||||
|
||||
// Load all sounds in parallel
|
||||
const [startResult, stopResult, cancelResult] = await Promise.all([
|
||||
this.loadSound(require('~/assets/sounds/TJS-RecordStart-Sound.mp3')),
|
||||
this.loadSound(require('~/assets/sounds/TJS-RecordStop-Sound.mp3')),
|
||||
this.loadSound(require('~/assets/sounds/TJS-RecordCancel-Sound.mp3')),
|
||||
].map(p => p.catch(e => {
|
||||
console.debug('Failed to load sound:', e);
|
||||
return null;
|
||||
})));
|
||||
|
||||
this.startSound = startResult;
|
||||
this.stopSound = stopResult;
|
||||
this.cancelSound = cancelResult;
|
||||
|
||||
this.isLoaded = true;
|
||||
console.debug('✅ Recording sounds loaded successfully');
|
||||
} catch (error) {
|
||||
console.debug('⚠️ Failed to preload sounds:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to load a single sound
|
||||
*/
|
||||
private async loadSound(source: any): Promise<AudioPlayer | null> {
|
||||
try {
|
||||
const player = createAudioPlayer(source);
|
||||
player.volume = 0.5; // 50% volume for UI sounds
|
||||
return player;
|
||||
} catch (error) {
|
||||
console.debug('Error loading sound:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the recording start sound (ding/chime)
|
||||
*/
|
||||
async playStartSound(): Promise<void> {
|
||||
if (!this.startSound) return;
|
||||
|
||||
try {
|
||||
// Seek to beginning and play
|
||||
await this.startSound.seekTo(0);
|
||||
await this.startSound.play();
|
||||
} catch (error) {
|
||||
console.debug('Error playing start sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the recording stop sound
|
||||
*/
|
||||
async playStopSound(): Promise<void> {
|
||||
if (!this.stopSound) return;
|
||||
|
||||
try {
|
||||
// Seek to beginning and play
|
||||
await this.stopSound.seekTo(0);
|
||||
await this.stopSound.play();
|
||||
} catch (error) {
|
||||
console.debug('Error playing stop sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the recording cancel sound
|
||||
*/
|
||||
async playCancelSound(): Promise<void> {
|
||||
if (!this.cancelSound) return;
|
||||
|
||||
try {
|
||||
// Seek to beginning and play
|
||||
await this.cancelSound.seekTo(0);
|
||||
await this.cancelSound.play();
|
||||
} catch (error) {
|
||||
console.debug('Error playing cancel sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup sounds when no longer needed
|
||||
*/
|
||||
async unloadSounds(): Promise<void> {
|
||||
try {
|
||||
// Stop and release audio players
|
||||
if (this.startSound) {
|
||||
await this.startSound.pause();
|
||||
this.startSound.release();
|
||||
}
|
||||
if (this.stopSound) {
|
||||
await this.stopSound.pause();
|
||||
this.stopSound.release();
|
||||
}
|
||||
if (this.cancelSound) {
|
||||
await this.cancelSound.pause();
|
||||
this.cancelSound.release();
|
||||
}
|
||||
|
||||
this.startSound = null;
|
||||
this.stopSound = null;
|
||||
this.cancelSound = null;
|
||||
this.isLoaded = false;
|
||||
} catch (error) {
|
||||
console.debug('Error unloading sounds:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const recordingSoundManager = RecordingSoundManager.getInstance();
|
||||
|
|
@ -0,0 +1,681 @@
|
|||
/**
|
||||
* recordingStore.ts
|
||||
* Zustand store for audio recording state management
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import {
|
||||
RecordingStoreState,
|
||||
RecordingStatus,
|
||||
RecordingSession,
|
||||
RecordingError,
|
||||
RecordingOptions,
|
||||
RecorderState,
|
||||
IPlatformRecordingService,
|
||||
RecordingErrorType,
|
||||
} from '../types';
|
||||
import { AudioEngineService } from '../core/AudioEngineService';
|
||||
import { AndroidRecordingService } from '../platforms/AndroidRecordingService';
|
||||
import { IOSRecordingService } from '../platforms/IOSRecordingService';
|
||||
import { fileStorageService } from '~/features/storage/fileStorage.service';
|
||||
|
||||
/**
|
||||
* Factory function to create the appropriate platform-specific recording service
|
||||
*
|
||||
* CRITICAL iOS Configuration Note:
|
||||
* `mixWithOthers: true` is MANDATORY for iOS recording to work reliably.
|
||||
* This MUST match the `interruptionMode: 'mixWithOthers'` setting in AudioEngineService.
|
||||
*
|
||||
* Why mixWithOthers is critical:
|
||||
* 1. Enables background recording (recording continues when app is backgrounded)
|
||||
* 2. Prevents iOS from resetting audio session during app state transitions
|
||||
* 3. Ensures consistent audio session configuration across the app
|
||||
* 4. Eliminates intermittent "RecordingDisabledException" errors
|
||||
*
|
||||
* See TROUBLESHOOTING.md Bug #7 for full debugging details.
|
||||
*/
|
||||
function createRecordingService(): IPlatformRecordingService {
|
||||
switch (Platform.OS) {
|
||||
case 'android':
|
||||
return new AndroidRecordingService({
|
||||
foregroundServiceEnabled: true,
|
||||
wakeLockEnabled: true,
|
||||
notificationTitle: 'Recording Audio',
|
||||
notificationBody: 'Your memo is being recorded...',
|
||||
});
|
||||
case 'ios':
|
||||
return new IOSRecordingService({
|
||||
allowBluetooth: true,
|
||||
mixWithOthers: true, // ⚠️ CRITICAL: Must be true for reliable recording. Do NOT change to false!
|
||||
defaultToSpeaker: false,
|
||||
});
|
||||
default:
|
||||
// Fallback to base service for web or unknown platforms
|
||||
return new AudioEngineService() as IPlatformRecordingService;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the recording service instance (exported for cleanup)
|
||||
const recordingService = createRecordingService();
|
||||
|
||||
// Create the recording store
|
||||
// Helper function to register callbacks (needed for both initialization and reinitialization)
|
||||
const registerCallbacks = (set: any, get: any) => {
|
||||
console.log('[Store] Registering callbacks...');
|
||||
|
||||
// Register state change callback
|
||||
(recordingService as any).registerStateChangeCallback((partialState: any) => {
|
||||
set((state: any) => ({ ...state, ...partialState }));
|
||||
});
|
||||
|
||||
// Single source of truth for duration: native recorder status updates
|
||||
// Remove JavaScript timer updates to prevent race conditions and battery drain
|
||||
(recordingService as any).registerStatusUpdateCallback((status: RecorderState) => {
|
||||
console.log('🏪 [Store] Received status update:', JSON.stringify({
|
||||
durationMillis: status.durationMillis,
|
||||
currentTime: status.currentTime,
|
||||
metering: status.metering,
|
||||
isRecording: status.isRecording,
|
||||
allKeys: Object.keys(status),
|
||||
}, null, 2));
|
||||
|
||||
set((state: any) => {
|
||||
// ✅ CRITICAL FIX: Only update state if we have an active recording session
|
||||
// This prevents status updates from overwriting reset() calls
|
||||
if (!state.session) {
|
||||
console.log('🏪 [Store] Ignoring status update - no active session');
|
||||
return state; // No changes
|
||||
}
|
||||
|
||||
// ✅ FIX: Only update if recording is actually active (not stopped/idle)
|
||||
if (state.status !== RecordingStatus.RECORDING && state.status !== RecordingStatus.PAUSED) {
|
||||
console.log('🏪 [Store] Ignoring status update - not in RECORDING or PAUSED state, current:', state.status);
|
||||
return state; // No changes
|
||||
}
|
||||
|
||||
// ✅ FIX: Don't update duration when paused (keeps duration frozen at pause point)
|
||||
// But DO update metering so we can show "paused" visualization
|
||||
if (state.status === RecordingStatus.PAUSED) {
|
||||
console.log('🏪 [Store] Paused - only updating metering, keeping duration frozen');
|
||||
return {
|
||||
...state,
|
||||
metering: status.metering || -120, // Silence when paused
|
||||
};
|
||||
}
|
||||
|
||||
// Use durationMillis as primary time source (more reliable than currentTime)
|
||||
const durationMs = status.durationMillis || status.currentTime || 0;
|
||||
const durationInSeconds = durationMs / 1000;
|
||||
|
||||
console.log('🏪 [Store] Updating state - duration:', durationMs, 'ms, metering:', status.metering);
|
||||
|
||||
// Update both duration and metering from native status
|
||||
const updatedState: any = {
|
||||
...state,
|
||||
duration: durationMs, // Store in milliseconds for compatibility
|
||||
metering: status.metering,
|
||||
};
|
||||
|
||||
if (state.session) {
|
||||
updatedState.session = {
|
||||
...state.session,
|
||||
duration: durationInSeconds, // Session duration in seconds
|
||||
};
|
||||
console.log('🏪 [Store] Updated session duration:', durationInSeconds, 's');
|
||||
}
|
||||
|
||||
return updatedState;
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[Store] Callbacks registered successfully');
|
||||
};
|
||||
|
||||
export const useRecordingStore = create<RecordingStoreState>((set, get) => {
|
||||
// Register callbacks on store creation
|
||||
registerCallbacks(set, get);
|
||||
|
||||
return {
|
||||
// Initial state
|
||||
status: RecordingStatus.IDLE,
|
||||
session: null,
|
||||
error: null,
|
||||
permissions: {
|
||||
microphone: { granted: false },
|
||||
canAskAgain: true,
|
||||
lastChecked: 0,
|
||||
},
|
||||
isInitialized: false,
|
||||
|
||||
// Convenience properties
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
isUploading: false,
|
||||
uploadProgress: 0,
|
||||
duration: 0,
|
||||
uri: null,
|
||||
savedFile: null,
|
||||
metering: undefined,
|
||||
|
||||
// Metadata
|
||||
title: undefined,
|
||||
spaceId: null,
|
||||
blueprintId: null,
|
||||
memoId: null,
|
||||
memo: null, // Added for backward compatibility
|
||||
|
||||
// Computed properties for backward compatibility
|
||||
get permissionDeniedError() {
|
||||
const state = get();
|
||||
return state.error?.type === RecordingErrorType.PERMISSION_DENIED;
|
||||
},
|
||||
|
||||
get canAskAgainForPermission() {
|
||||
const state = get();
|
||||
return state.permissions.canAskAgain;
|
||||
},
|
||||
|
||||
get insufficientCreditsError() {
|
||||
const state = get();
|
||||
return state.error?.type === RecordingErrorType.INSUFFICIENT_CREDITS;
|
||||
},
|
||||
|
||||
// Actions
|
||||
initialize: async () => {
|
||||
const { isInitialized } = get();
|
||||
if (isInitialized) {
|
||||
console.log('Recording store already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ status: RecordingStatus.IDLE });
|
||||
|
||||
try {
|
||||
// Initialize the recording service
|
||||
await recordingService.initialize();
|
||||
|
||||
// Check initial permissions
|
||||
const permissions = await recordingService.checkPermissions();
|
||||
|
||||
set({
|
||||
isInitialized: true,
|
||||
permissions,
|
||||
error: null,
|
||||
});
|
||||
|
||||
console.log('Recording store initialized successfully');
|
||||
} catch (error) {
|
||||
const recordingError = error as RecordingError;
|
||||
set({
|
||||
isInitialized: false,
|
||||
error: recordingError,
|
||||
status: RecordingStatus.ERROR,
|
||||
});
|
||||
|
||||
console.error('Failed to initialize recording store:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Force reinitialize the audio session.
|
||||
* This is needed when iOS resets the audio session (e.g., after permission dialog).
|
||||
* Calls cleanup() to reset flags, then initialize() to reconfigure audio mode.
|
||||
*/
|
||||
reinitialize: async () => {
|
||||
console.log('[Store] Forcing reinitialization...');
|
||||
|
||||
try {
|
||||
// Reset both the service's and store's initialized flags
|
||||
// This also clears callbacks, so we need to re-register them
|
||||
recordingService.cleanup();
|
||||
set({ isInitialized: false });
|
||||
|
||||
// Re-register callbacks (cleanup() cleared them)
|
||||
registerCallbacks(set, get);
|
||||
|
||||
// Now reinitialize the audio engine
|
||||
await recordingService.initialize();
|
||||
|
||||
// Check permissions again
|
||||
const permissions = await recordingService.checkPermissions();
|
||||
|
||||
set({
|
||||
isInitialized: true,
|
||||
permissions,
|
||||
error: null,
|
||||
});
|
||||
|
||||
console.log('[Store] Reinitialization successful');
|
||||
} catch (error) {
|
||||
const recordingError = error as RecordingError;
|
||||
set({
|
||||
isInitialized: false,
|
||||
error: recordingError,
|
||||
status: RecordingStatus.ERROR,
|
||||
});
|
||||
|
||||
console.error('[Store] Failed to reinitialize:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
startRecording: async (options?: RecordingOptions) => {
|
||||
console.log('[Store] startRecording called');
|
||||
const { isInitialized, permissions, status } = get();
|
||||
|
||||
console.log('[Store] Current status:', status, 'isInitialized:', isInitialized);
|
||||
|
||||
// Check if already recording or stopping
|
||||
if (status === RecordingStatus.RECORDING) {
|
||||
console.warn('[Store] Recording already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === RecordingStatus.STOPPING) {
|
||||
console.warn('[Store] Still stopping previous recording, please wait...');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure initialized
|
||||
if (!isInitialized) {
|
||||
await get().initialize();
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
console.log('[Store] Checking permissions:', permissions);
|
||||
if (!permissions.microphone.granted) {
|
||||
console.log('[Store] Requesting permissions...');
|
||||
const newPermissions = await get().requestPermissions();
|
||||
console.log('[Store] New permissions:', newPermissions);
|
||||
if (!newPermissions.microphone.granted) {
|
||||
const error: RecordingError = {
|
||||
type: RecordingErrorType.PERMISSION_DENIED,
|
||||
code: 'microphone_permission_denied',
|
||||
message: 'Microphone permission is required to start recording',
|
||||
timestamp: Date.now(),
|
||||
recoverable: true,
|
||||
};
|
||||
|
||||
set({
|
||||
error,
|
||||
status: RecordingStatus.ERROR,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
status: RecordingStatus.PREPARING,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
// Start recording
|
||||
console.log('[Store] Calling recordingService.startRecording...');
|
||||
await recordingService.startRecording(options);
|
||||
console.log('[Store] Recording started successfully');
|
||||
|
||||
// Create new session
|
||||
const session: RecordingSession = {
|
||||
id: `recording_${Date.now()}`,
|
||||
startTime: Date.now(),
|
||||
duration: 0,
|
||||
uri: null,
|
||||
};
|
||||
|
||||
set({
|
||||
status: RecordingStatus.RECORDING,
|
||||
session,
|
||||
error: null,
|
||||
isRecording: true,
|
||||
isPaused: false,
|
||||
duration: 0,
|
||||
uri: null,
|
||||
savedFile: null,
|
||||
});
|
||||
|
||||
console.log('Recording started successfully');
|
||||
} catch (error) {
|
||||
const recordingError = error as RecordingError;
|
||||
|
||||
set({
|
||||
status: RecordingStatus.ERROR,
|
||||
error: recordingError,
|
||||
session: null,
|
||||
});
|
||||
|
||||
console.error('Failed to start recording:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
stopRecording: async (memoId?: string) => {
|
||||
console.log('[Store] stopRecording called');
|
||||
const { status, session } = get();
|
||||
|
||||
console.log('[Store] Current recording status:', status);
|
||||
|
||||
// Allow stopping if already in STOPPING state (may be stuck from previous attempt)
|
||||
if (status === RecordingStatus.STOPPING) {
|
||||
console.warn('[Store] Already stopping, may be stuck. Attempting recovery...');
|
||||
// Don't force cleanup here as it would require re-initialization
|
||||
// Just reset the state and return
|
||||
set({
|
||||
status: RecordingStatus.IDLE,
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
});
|
||||
return;
|
||||
} else if (status !== RecordingStatus.RECORDING && status !== RecordingStatus.PAUSED) {
|
||||
console.warn('[Store] No active recording to stop. Current status:', status);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Store] Setting status to STOPPING...');
|
||||
set({ status: RecordingStatus.STOPPING });
|
||||
|
||||
let uri = '';
|
||||
let finalDurationSeconds = session?.duration || 0;
|
||||
|
||||
try {
|
||||
// ✅ CRITICAL FIX: Query native recorder BEFORE stopping to get latest duration
|
||||
// This ensures we capture the true recording duration even if audio session
|
||||
// restoration failed or JavaScript timers were suspended during backgrounding
|
||||
try {
|
||||
console.log('[Store] Querying native recorder for final duration before stop...');
|
||||
const preStopStatus = (recordingService as any).getStatus?.();
|
||||
|
||||
if (preStopStatus && preStopStatus.durationMillis > 0) {
|
||||
finalDurationSeconds = preStopStatus.durationMillis / 1000; // Convert to seconds
|
||||
console.log('[Store] ✅ Got duration from native recorder before stop:', finalDurationSeconds, 'seconds');
|
||||
} else {
|
||||
console.warn('[Store] ⚠️ Pre-stop query returned no duration, will use session duration:', finalDurationSeconds, 'seconds');
|
||||
}
|
||||
} catch (queryError) {
|
||||
console.warn('[Store] Could not query recorder before stop:', queryError);
|
||||
console.log('[Store] Will fall back to session duration:', finalDurationSeconds, 'seconds');
|
||||
}
|
||||
|
||||
// Stop recording and get URI with timeout
|
||||
const stopPromise = recordingService.stopRecording();
|
||||
const timeoutPromise = new Promise<string>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Stop recording timeout')), 10000)
|
||||
);
|
||||
|
||||
try {
|
||||
uri = await Promise.race([stopPromise, timeoutPromise]);
|
||||
} catch (timeoutError) {
|
||||
console.error('[Store] Recording stop timed out:', timeoutError);
|
||||
// Don't force cleanup as it would require re-initialization
|
||||
// The recording service should handle its own cleanup
|
||||
uri = ''; // Continue with empty URI
|
||||
}
|
||||
|
||||
// Save recording to local storage to get file info and verify duration
|
||||
let savedFile = null;
|
||||
|
||||
if (uri) {
|
||||
console.log('[Store] Saving recording to local storage...');
|
||||
try {
|
||||
savedFile = await fileStorageService.saveRecording(
|
||||
uri,
|
||||
undefined, // title
|
||||
finalDurationSeconds // initial duration estimate in seconds
|
||||
);
|
||||
console.log('[Store] Recording saved locally:', savedFile);
|
||||
|
||||
// Use the accurate duration from the saved file
|
||||
if (savedFile && savedFile.duration) {
|
||||
finalDurationSeconds = savedFile.duration;
|
||||
console.log('[Store] Using accurate duration from saved file:', finalDurationSeconds, 'seconds');
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error('[Store] Failed to save recording locally:', saveError);
|
||||
// Continue even if local save fails - the cloud upload is more important
|
||||
}
|
||||
}
|
||||
|
||||
// Update session with final data
|
||||
const updatedSession: RecordingSession = {
|
||||
...session!,
|
||||
endTime: Date.now(),
|
||||
duration: finalDurationSeconds, // Already in seconds (from saved file or session)
|
||||
uri,
|
||||
};
|
||||
|
||||
set({
|
||||
status: RecordingStatus.STOPPED,
|
||||
session: updatedSession,
|
||||
error: null,
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
uri: updatedSession.uri,
|
||||
savedFile: savedFile || (updatedSession.uri ? {
|
||||
uri: updatedSession.uri!,
|
||||
size: 0,
|
||||
duration: updatedSession.duration,
|
||||
format: {
|
||||
encoding: 'aac' as any,
|
||||
sampleRate: 44100,
|
||||
bitRate: 128000,
|
||||
channels: 1,
|
||||
extension: 'm4a',
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
} : null),
|
||||
});
|
||||
|
||||
console.log('Recording stopped successfully. URI:', uri);
|
||||
} catch (error) {
|
||||
const recordingError = error as RecordingError;
|
||||
|
||||
// Reset to idle state to allow recovery
|
||||
set({
|
||||
status: RecordingStatus.IDLE,
|
||||
error: recordingError,
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
});
|
||||
|
||||
console.error('Failed to stop recording:', error);
|
||||
// Don't throw - allow UI to recover
|
||||
}
|
||||
},
|
||||
|
||||
pauseRecording: () => {
|
||||
const { status } = get();
|
||||
|
||||
if (status !== RecordingStatus.RECORDING) {
|
||||
console.warn('No active recording to pause');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
recordingService.pauseRecording();
|
||||
|
||||
set({
|
||||
status: RecordingStatus.PAUSED,
|
||||
isPaused: true,
|
||||
});
|
||||
|
||||
console.log('Recording paused');
|
||||
} catch (error) {
|
||||
console.error('Failed to pause recording:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
resumeRecording: () => {
|
||||
const { status } = get();
|
||||
|
||||
if (status !== RecordingStatus.PAUSED) {
|
||||
console.warn('No paused recording to resume');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
recordingService.resumeRecording();
|
||||
|
||||
set({
|
||||
status: RecordingStatus.RECORDING,
|
||||
isPaused: false,
|
||||
});
|
||||
|
||||
console.log('Recording resumed');
|
||||
} catch (error) {
|
||||
console.error('Failed to resume recording:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
requestPermissions: async () => {
|
||||
try {
|
||||
const permissions = await recordingService.requestPermissions();
|
||||
|
||||
set({ permissions });
|
||||
|
||||
return permissions;
|
||||
} catch (error) {
|
||||
console.error('Failed to request permissions:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
checkPermissions: async () => {
|
||||
try {
|
||||
const permissions = await recordingService.checkPermissions();
|
||||
|
||||
set({ permissions });
|
||||
|
||||
return permissions;
|
||||
} catch (error) {
|
||||
console.error('Failed to check permissions:', error);
|
||||
|
||||
// Return default permissions on error
|
||||
return get().permissions;
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
console.log('[Store] Resetting recording store...');
|
||||
|
||||
// Note: We do NOT cleanup the recording service here anymore
|
||||
// because cleanup() sets initialized = false, which requires re-initialization
|
||||
// Instead, the service cleanup only happens when truly needed (app unmount)
|
||||
//
|
||||
// IMPORTANT: We do NOT reset isInitialized to false here!
|
||||
// The recording service remains initialized and ready for the next recording.
|
||||
// This prevents unnecessary re-initialization delays and audio session resets.
|
||||
|
||||
set({
|
||||
status: RecordingStatus.IDLE,
|
||||
session: null,
|
||||
error: null,
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
duration: 0,
|
||||
uri: null,
|
||||
savedFile: null,
|
||||
memo: null,
|
||||
metering: undefined,
|
||||
// isInitialized stays true - service remains ready for next recording
|
||||
});
|
||||
|
||||
console.log('[Store] Recording store reset completed');
|
||||
},
|
||||
|
||||
resetRecording: () => {
|
||||
// Alias for reset
|
||||
get().reset();
|
||||
},
|
||||
|
||||
setError: (error: RecordingError | null) => {
|
||||
set({
|
||||
error,
|
||||
status: error ? RecordingStatus.ERROR : get().status,
|
||||
});
|
||||
},
|
||||
|
||||
setRecordingInfo: (info: {
|
||||
title?: string;
|
||||
spaceId?: string | null;
|
||||
blueprintId?: string | null;
|
||||
memoId?: string | null;
|
||||
}) => {
|
||||
set({
|
||||
title: info.title,
|
||||
spaceId: info.spaceId ?? get().spaceId,
|
||||
blueprintId: info.blueprintId ?? get().blueprintId,
|
||||
memoId: info.memoId ?? get().memoId,
|
||||
});
|
||||
},
|
||||
|
||||
clearPermissionDeniedError: () => {
|
||||
const { error } = get();
|
||||
if (error?.type === RecordingErrorType.PERMISSION_DENIED) {
|
||||
set({ error: null });
|
||||
}
|
||||
},
|
||||
|
||||
clearInsufficientCreditsError: () => {
|
||||
const { error } = get();
|
||||
if (error?.type === RecordingErrorType.INSUFFICIENT_CREDITS) {
|
||||
set({ error: null });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Export the store instance for direct access if needed
|
||||
export default useRecordingStore;
|
||||
|
||||
// Cleanup function to be called when unmounting
|
||||
export const cleanupRecordingStore = () => {
|
||||
console.log('[Store] Starting recording store cleanup...');
|
||||
const state = useRecordingStore.getState();
|
||||
const service = (recordingService as any);
|
||||
|
||||
// Stop any active recording
|
||||
if (state.status === RecordingStatus.RECORDING ||
|
||||
state.status === RecordingStatus.PAUSED ||
|
||||
state.status === RecordingStatus.STOPPING) {
|
||||
console.log('[Store] Active recording detected during cleanup, stopping...');
|
||||
|
||||
// Try to stop normally first
|
||||
state.stopRecording()
|
||||
.catch(error => {
|
||||
console.error('[Store] Error stopping recording during cleanup:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Only cleanup service on final unmount
|
||||
// This ensures microphone is released but allows re-initialization
|
||||
if (service && typeof service.cleanup === 'function') {
|
||||
try {
|
||||
service.cleanup();
|
||||
console.log('[Store] Recording service cleaned up during final unmount');
|
||||
} catch (cleanupError) {
|
||||
console.error('[Store] Error during service cleanup:', cleanupError);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Only cleanup service on final unmount
|
||||
if (service && typeof service.cleanup === 'function') {
|
||||
try {
|
||||
service.cleanup();
|
||||
console.log('[Store] Recording service cleaned up during final unmount');
|
||||
} catch (cleanupError) {
|
||||
console.debug('[Store] Error during service cleanup:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the store state (but don't cleanup service in reset anymore)
|
||||
state.reset();
|
||||
|
||||
console.log('[Store] Recording store cleanup completed');
|
||||
};
|
||||
286
apps/memoro/apps/mobile/features/audioRecordingV2/types/index.ts
Normal file
286
apps/memoro/apps/mobile/features/audioRecordingV2/types/index.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* AudioRecordingV2 Type Definitions
|
||||
* Complete TypeScript interfaces and types for the recording system
|
||||
*/
|
||||
|
||||
// ============= Core Types =============
|
||||
|
||||
export interface RecordingState {
|
||||
status: RecordingStatus;
|
||||
session: RecordingSession | null;
|
||||
error: RecordingError | null;
|
||||
permissions: PermissionState;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
export enum RecordingStatus {
|
||||
IDLE = 'idle',
|
||||
PREPARING = 'preparing',
|
||||
RECORDING = 'recording',
|
||||
PAUSED = 'paused',
|
||||
STOPPING = 'stopping',
|
||||
STOPPED = 'stopped',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
id: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration: number;
|
||||
uri?: string | null;
|
||||
size?: number;
|
||||
format?: AudioFormat;
|
||||
}
|
||||
|
||||
export interface PermissionState {
|
||||
microphone: PermissionStatus;
|
||||
notifications?: PermissionStatus; // Android only
|
||||
canAskAgain: boolean;
|
||||
lastChecked: number;
|
||||
}
|
||||
|
||||
export interface PermissionStatus {
|
||||
granted: boolean;
|
||||
canAskAgain?: boolean;
|
||||
}
|
||||
|
||||
// ============= Audio Configuration =============
|
||||
|
||||
export interface AudioFormat {
|
||||
encoding: AudioEncoding;
|
||||
sampleRate: number;
|
||||
bitRate: number;
|
||||
channels: number;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export enum AudioEncoding {
|
||||
AAC = 'aac',
|
||||
AMR_NB = 'amr_nb',
|
||||
AMR_WB = 'amr_wb',
|
||||
HE_AAC = 'he_aac',
|
||||
AAC_ELD = 'aac_eld'
|
||||
}
|
||||
|
||||
export interface RecordingOptions {
|
||||
preset?: RecordingPreset;
|
||||
format?: Partial<AudioFormat>;
|
||||
maxDuration?: number; // in seconds
|
||||
sizeLimit?: number; // in bytes
|
||||
}
|
||||
|
||||
export enum RecordingPreset {
|
||||
HIGH_QUALITY = 'high_quality',
|
||||
MEDIUM_QUALITY = 'medium_quality',
|
||||
LOW_QUALITY = 'low_quality',
|
||||
VOICE_MEMO = 'voice_memo'
|
||||
}
|
||||
|
||||
// ============= Error Types =============
|
||||
|
||||
export enum RecordingErrorType {
|
||||
PERMISSION_DENIED = 'permission_denied',
|
||||
HARDWARE_UNAVAILABLE = 'hardware_unavailable',
|
||||
PLATFORM_RESTRICTION = 'platform_restriction',
|
||||
STORAGE_ERROR = 'storage_error',
|
||||
NETWORK_ERROR = 'network_error',
|
||||
AUDIO_ENGINE_ERROR = 'audio_engine_error',
|
||||
INITIALIZATION_ERROR = 'initialization_error',
|
||||
INSUFFICIENT_CREDITS = 'insufficient_credits',
|
||||
UNKNOWN_ERROR = 'unknown_error'
|
||||
}
|
||||
|
||||
export interface RecordingError {
|
||||
type: RecordingErrorType;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
timestamp: number;
|
||||
recoverable: boolean;
|
||||
retryStrategy?: RetryStrategy;
|
||||
platformSpecific?: PlatformErrorDetails;
|
||||
}
|
||||
|
||||
export interface RetryStrategy {
|
||||
maxAttempts: number;
|
||||
delayMs: number;
|
||||
backoffMultiplier?: number;
|
||||
shouldRetry: (attempt: number, error: RecordingError) => boolean;
|
||||
}
|
||||
|
||||
export interface PlatformErrorDetails {
|
||||
platform: 'android' | 'ios' | 'web';
|
||||
osVersion?: string;
|
||||
deviceModel?: string;
|
||||
additionalInfo?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ============= Platform-Specific Types =============
|
||||
|
||||
export interface AndroidSpecificConfig {
|
||||
foregroundServiceEnabled: boolean;
|
||||
notificationTitle?: string;
|
||||
notificationBody?: string;
|
||||
notificationChannelId?: string;
|
||||
wakeLockEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface IOSSpecificConfig {
|
||||
audioSessionCategory?: string;
|
||||
audioSessionMode?: string;
|
||||
allowBluetooth: boolean;
|
||||
mixWithOthers: boolean;
|
||||
defaultToSpeaker: boolean;
|
||||
}
|
||||
|
||||
export interface WebSpecificConfig {
|
||||
mimeType?: string;
|
||||
audioBitsPerSecond?: number;
|
||||
videoBitsPerSecond?: number;
|
||||
}
|
||||
|
||||
// ============= Service Interfaces =============
|
||||
|
||||
export interface IAudioEngineService {
|
||||
initialize(): Promise<void>;
|
||||
startRecording(options?: RecordingOptions): Promise<void>;
|
||||
stopRecording(): Promise<string>; // Returns URI
|
||||
pauseRecording(): void;
|
||||
resumeRecording(): void;
|
||||
getStatus(): RecorderState;
|
||||
cleanup(): void;
|
||||
isInitialized(): boolean;
|
||||
}
|
||||
|
||||
export interface IPlatformRecordingService extends IAudioEngineService {
|
||||
requestPermissions(): Promise<PermissionState>;
|
||||
checkPermissions(): Promise<PermissionState>;
|
||||
setupPlatformSpecific(): Promise<void>;
|
||||
handleAppStateChange(state: AppStateStatus): void;
|
||||
}
|
||||
|
||||
// ============= Internal Types (from expo-audio) =============
|
||||
|
||||
export interface RecorderState {
|
||||
canRecord: boolean;
|
||||
isRecording: boolean;
|
||||
isPaused?: boolean;
|
||||
currentTime: number; // in seconds
|
||||
uri?: string | null;
|
||||
metering?: number;
|
||||
durationMillis: number;
|
||||
mediaServicesDidReset?: boolean;
|
||||
}
|
||||
|
||||
export type AppStateStatus = 'active' | 'background' | 'inactive' | 'unknown' | 'extension';
|
||||
|
||||
// ============= Store Types =============
|
||||
|
||||
export interface RecordingStoreState extends RecordingState {
|
||||
// Convenience properties for backward compatibility
|
||||
isRecording: boolean;
|
||||
isPaused: boolean;
|
||||
isUploading: boolean;
|
||||
uploadProgress: number;
|
||||
duration: number;
|
||||
uri: string | null;
|
||||
savedFile: AudioFile | null;
|
||||
metering: number | undefined;
|
||||
|
||||
// Metadata for recordings
|
||||
title?: string;
|
||||
spaceId: string | null;
|
||||
blueprintId: string | null;
|
||||
memoId: string | null;
|
||||
memo: any | null; // Added for backward compatibility
|
||||
|
||||
// Computed properties for backward compatibility
|
||||
permissionDeniedError: boolean;
|
||||
canAskAgainForPermission: boolean;
|
||||
insufficientCreditsError: boolean;
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
startRecording: (options?: RecordingOptions) => Promise<void>;
|
||||
stopRecording: (memoId?: string) => Promise<void>;
|
||||
pauseRecording: () => void;
|
||||
resumeRecording: () => void;
|
||||
requestPermissions: () => Promise<PermissionState>;
|
||||
checkPermissions: () => Promise<PermissionState>;
|
||||
reset: () => void;
|
||||
resetRecording: () => void;
|
||||
setError: (error: RecordingError | null) => void;
|
||||
setRecordingInfo: (info: {
|
||||
title?: string;
|
||||
spaceId?: string | null;
|
||||
blueprintId?: string | null;
|
||||
memoId?: string | null;
|
||||
}) => void;
|
||||
clearPermissionDeniedError: () => void;
|
||||
clearInsufficientCreditsError: () => void;
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
duration: number; // in milliseconds
|
||||
isActive: boolean;
|
||||
startTime: number | null;
|
||||
pausedTime: number;
|
||||
// Actions
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
reset: () => void;
|
||||
getDuration: () => number;
|
||||
}
|
||||
|
||||
// ============= Callback Types =============
|
||||
|
||||
export type StatusUpdateCallback = (status: RecorderState) => void;
|
||||
export type ErrorCallback = (error: RecordingError) => void;
|
||||
export type StateChangeCallback = (state: Partial<RecordingState>) => void;
|
||||
|
||||
// ============= Utility Types =============
|
||||
|
||||
export interface AudioFile {
|
||||
uri: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
format: AudioFormat;
|
||||
createdAt: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
bytesUploaded: number;
|
||||
totalBytes: number;
|
||||
percentage: number;
|
||||
timeRemaining?: number;
|
||||
}
|
||||
|
||||
export interface RecordingMetrics {
|
||||
averageLevel: number;
|
||||
peakLevel: number;
|
||||
noiseLevel: number;
|
||||
silencePercentage: number;
|
||||
}
|
||||
|
||||
// ============= Factory Types =============
|
||||
|
||||
export type PlatformType = 'android' | 'ios' | 'web';
|
||||
|
||||
export interface PlatformConfig {
|
||||
android?: AndroidSpecificConfig;
|
||||
ios?: IOSSpecificConfig;
|
||||
web?: WebSpecificConfig;
|
||||
}
|
||||
|
||||
// ============= Export helper for better imports =============
|
||||
|
||||
export type {
|
||||
RecordingState as RS,
|
||||
RecordingSession as Session,
|
||||
RecordingError as RError,
|
||||
PermissionState as Permissions
|
||||
};
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* errors.ts
|
||||
* Error handling utilities and recovery strategies
|
||||
*/
|
||||
|
||||
import { Alert, Linking, Platform } from 'react-native';
|
||||
import {
|
||||
RecordingError,
|
||||
RecordingErrorType,
|
||||
RetryStrategy,
|
||||
PlatformErrorDetails,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Error messages for user display
|
||||
*/
|
||||
export const ERROR_MESSAGES: Record<RecordingErrorType, string> = {
|
||||
[RecordingErrorType.PERMISSION_DENIED]: 'Microphone access is required to record audio. Please grant permission in your device settings.',
|
||||
[RecordingErrorType.HARDWARE_UNAVAILABLE]: 'The microphone is currently unavailable. Please check if another app is using it.',
|
||||
[RecordingErrorType.PLATFORM_RESTRICTION]: 'Recording cannot be started due to system restrictions. Please ensure the app is in the foreground.',
|
||||
[RecordingErrorType.STORAGE_ERROR]: 'Unable to save the recording. Please check your device storage.',
|
||||
[RecordingErrorType.NETWORK_ERROR]: 'Network connection is required for this operation. Please check your internet connection.',
|
||||
[RecordingErrorType.AUDIO_ENGINE_ERROR]: 'An error occurred with the audio recording system. Please try again.',
|
||||
[RecordingErrorType.INITIALIZATION_ERROR]: 'Failed to initialize the recording system. Please restart the app.',
|
||||
[RecordingErrorType.UNKNOWN_ERROR]: 'An unexpected error occurred. Please try again.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a retry strategy for an error
|
||||
*/
|
||||
export function createRetryStrategy(
|
||||
errorType: RecordingErrorType,
|
||||
maxAttempts: number = 3
|
||||
): RetryStrategy | undefined {
|
||||
switch (errorType) {
|
||||
case RecordingErrorType.HARDWARE_UNAVAILABLE:
|
||||
case RecordingErrorType.AUDIO_ENGINE_ERROR:
|
||||
return {
|
||||
maxAttempts,
|
||||
delayMs: 1000,
|
||||
backoffMultiplier: 2,
|
||||
shouldRetry: (attempt, error) => {
|
||||
// Retry if recoverable and under max attempts
|
||||
return error.recoverable && attempt < maxAttempts;
|
||||
},
|
||||
};
|
||||
|
||||
case RecordingErrorType.NETWORK_ERROR:
|
||||
return {
|
||||
maxAttempts: 5,
|
||||
delayMs: 2000,
|
||||
backoffMultiplier: 1.5,
|
||||
shouldRetry: (attempt) => attempt < 5,
|
||||
};
|
||||
|
||||
case RecordingErrorType.PERMISSION_DENIED:
|
||||
case RecordingErrorType.PLATFORM_RESTRICTION:
|
||||
case RecordingErrorType.STORAGE_ERROR:
|
||||
case RecordingErrorType.INITIALIZATION_ERROR:
|
||||
// These errors typically require user action, not automatic retry
|
||||
return undefined;
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an error from a generic Error object
|
||||
*/
|
||||
export function classifyError(error: Error): RecordingError {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
let type = RecordingErrorType.UNKNOWN_ERROR;
|
||||
let code = 'unknown';
|
||||
let recoverable = false;
|
||||
|
||||
// Try to classify based on error message
|
||||
if (message.includes('permission')) {
|
||||
type = RecordingErrorType.PERMISSION_DENIED;
|
||||
code = 'permission_error';
|
||||
recoverable = true;
|
||||
} else if (message.includes('microphone') || message.includes('hardware')) {
|
||||
type = RecordingErrorType.HARDWARE_UNAVAILABLE;
|
||||
code = 'hardware_error';
|
||||
recoverable = true;
|
||||
} else if (message.includes('foreground') || message.includes('background')) {
|
||||
type = RecordingErrorType.PLATFORM_RESTRICTION;
|
||||
code = 'platform_restriction';
|
||||
recoverable = false;
|
||||
} else if (message.includes('storage') || message.includes('disk')) {
|
||||
type = RecordingErrorType.STORAGE_ERROR;
|
||||
code = 'storage_error';
|
||||
recoverable = false;
|
||||
} else if (message.includes('network') || message.includes('internet')) {
|
||||
type = RecordingErrorType.NETWORK_ERROR;
|
||||
code = 'network_error';
|
||||
recoverable = true;
|
||||
} else if (message.includes('audio') || message.includes('recording')) {
|
||||
type = RecordingErrorType.AUDIO_ENGINE_ERROR;
|
||||
code = 'audio_error';
|
||||
recoverable = true;
|
||||
} else if (message.includes('initialize') || message.includes('init')) {
|
||||
type = RecordingErrorType.INITIALIZATION_ERROR;
|
||||
code = 'init_error';
|
||||
recoverable = false;
|
||||
}
|
||||
|
||||
// Get platform details
|
||||
const platformDetails: PlatformErrorDetails = {
|
||||
platform: Platform.OS as 'android' | 'ios' | 'web',
|
||||
osVersion: Platform.Version?.toString(),
|
||||
};
|
||||
|
||||
return {
|
||||
type,
|
||||
code,
|
||||
message: error.message,
|
||||
details: error,
|
||||
timestamp: Date.now(),
|
||||
recoverable,
|
||||
retryStrategy: createRetryStrategy(type),
|
||||
platformSpecific: platformDetails,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error with user-friendly display
|
||||
*/
|
||||
export async function handleRecordingError(
|
||||
error: RecordingError,
|
||||
options?: {
|
||||
showAlert?: boolean;
|
||||
onRetry?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { showAlert = true, onRetry, onCancel } = options || {};
|
||||
|
||||
console.error('Recording error:', error);
|
||||
|
||||
if (!showAlert) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = 'Recording Error';
|
||||
const message = ERROR_MESSAGES[error.type] || error.message;
|
||||
|
||||
// Build alert buttons
|
||||
const buttons: any[] = [];
|
||||
|
||||
// Add retry button if error is recoverable
|
||||
if (error.recoverable && onRetry) {
|
||||
buttons.push({
|
||||
text: 'Retry',
|
||||
onPress: onRetry,
|
||||
style: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
// Add settings button for permission errors
|
||||
if (error.type === RecordingErrorType.PERMISSION_DENIED) {
|
||||
buttons.push({
|
||||
text: 'Open Settings',
|
||||
onPress: () => openAppSettings(),
|
||||
style: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
// Always add cancel/OK button
|
||||
buttons.push({
|
||||
text: error.recoverable ? 'Cancel' : 'OK',
|
||||
onPress: onCancel,
|
||||
style: 'cancel',
|
||||
});
|
||||
|
||||
Alert.alert(title, message, buttons);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open app settings for permission management
|
||||
*/
|
||||
export async function openAppSettings(): Promise<void> {
|
||||
try {
|
||||
if (Platform.OS === 'ios') {
|
||||
await Linking.openURL('app-settings:');
|
||||
} else if (Platform.OS === 'android') {
|
||||
await Linking.openSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open app settings:', error);
|
||||
Alert.alert(
|
||||
'Unable to Open Settings',
|
||||
'Please manually open your device settings and grant microphone permission to this app.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an operation with exponential backoff
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
operation: () => Promise<T>,
|
||||
strategy: RetryStrategy,
|
||||
onRetry?: (attempt: number) => void
|
||||
): Promise<T> {
|
||||
let lastError: RecordingError | undefined;
|
||||
let delay = strategy.delayMs;
|
||||
|
||||
for (let attempt = 1; attempt <= strategy.maxAttempts; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error as RecordingError;
|
||||
|
||||
// Check if we should retry
|
||||
if (!strategy.shouldRetry(attempt, lastError)) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Don't retry on last attempt
|
||||
if (attempt === strategy.maxAttempts) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Call retry callback
|
||||
if (onRetry) {
|
||||
onRetry(attempt);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
// Apply backoff multiplier
|
||||
if (strategy.backoffMultiplier) {
|
||||
delay *= strategy.backoffMultiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This should never be reached, but TypeScript doesn't know that
|
||||
throw lastError || new Error('Retry failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error description
|
||||
*/
|
||||
export function getErrorDescription(error: RecordingError): string {
|
||||
switch (error.type) {
|
||||
case RecordingErrorType.PERMISSION_DENIED:
|
||||
return 'Microphone permission denied';
|
||||
case RecordingErrorType.HARDWARE_UNAVAILABLE:
|
||||
return 'Microphone unavailable';
|
||||
case RecordingErrorType.PLATFORM_RESTRICTION:
|
||||
return 'System restriction';
|
||||
case RecordingErrorType.STORAGE_ERROR:
|
||||
return 'Storage error';
|
||||
case RecordingErrorType.NETWORK_ERROR:
|
||||
return 'Network error';
|
||||
case RecordingErrorType.AUDIO_ENGINE_ERROR:
|
||||
return 'Audio system error';
|
||||
case RecordingErrorType.INITIALIZATION_ERROR:
|
||||
return 'Initialization failed';
|
||||
case RecordingErrorType.UNKNOWN_ERROR:
|
||||
default:
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is related to Android 16 restrictions
|
||||
*/
|
||||
export function isAndroid16RestrictionError(error: RecordingError): boolean {
|
||||
return (
|
||||
Platform.OS === 'android' &&
|
||||
Platform.Version >= 16 &&
|
||||
(error.type === RecordingErrorType.PLATFORM_RESTRICTION ||
|
||||
error.message.toLowerCase().includes('foreground'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error for debugging/telemetry
|
||||
*/
|
||||
export function logRecordingError(
|
||||
error: RecordingError,
|
||||
context?: Record<string, any>
|
||||
): void {
|
||||
const errorLog = {
|
||||
timestamp: error.timestamp,
|
||||
type: error.type,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
recoverable: error.recoverable,
|
||||
platform: error.platformSpecific,
|
||||
context,
|
||||
};
|
||||
|
||||
// Log to console in development
|
||||
if (__DEV__) {
|
||||
console.error('Recording Error Log:', errorLog);
|
||||
}
|
||||
|
||||
// Here you would send to your telemetry service
|
||||
// Example: crashlytics.recordError(error.details || error);
|
||||
}
|
||||
|
||||
// Export error codes for easy reference
|
||||
export const ERROR_CODES = {
|
||||
PERMISSION_DENIED: 'permission_denied',
|
||||
MICROPHONE_UNAVAILABLE: 'microphone_unavailable',
|
||||
RECORDING_IN_PROGRESS: 'recording_in_progress',
|
||||
NO_ACTIVE_RECORDING: 'no_active_recording',
|
||||
INITIALIZATION_FAILED: 'initialization_failed',
|
||||
START_FAILED: 'start_failed',
|
||||
STOP_FAILED: 'stop_failed',
|
||||
PAUSE_FAILED: 'pause_failed',
|
||||
RESUME_FAILED: 'resume_failed',
|
||||
FOREGROUND_REQUIRED: 'foreground_required',
|
||||
STORAGE_FULL: 'storage_full',
|
||||
NETWORK_ERROR: 'network_error',
|
||||
UNKNOWN_ERROR: 'unknown_error',
|
||||
} as const;
|
||||
118
apps/memoro/apps/mobile/features/auth/README-GOOGLE-AUTH.md
Normal file
118
apps/memoro/apps/mobile/features/auth/README-GOOGLE-AUTH.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Google Sign-In with Supabase in Memoro App
|
||||
|
||||
This guide explains how to set up and use Google Sign-In with Supabase in the Memoro app.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Google Cloud project
|
||||
2. A Supabase project
|
||||
3. Access to the Memoro app codebase
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Configure Google Cloud Project
|
||||
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Navigate to "APIs & Services" > "OAuth consent screen"
|
||||
4. Set up the consent screen with the required information
|
||||
5. Add the following scopes:
|
||||
- `.../auth/userinfo.email`
|
||||
- `.../auth/userinfo.profile`
|
||||
- `openid`
|
||||
6. Navigate to "APIs & Services" > "Credentials"
|
||||
7. Create OAuth client IDs for each platform:
|
||||
- Web application
|
||||
- iOS
|
||||
- Android
|
||||
|
||||
#### For iOS:
|
||||
- Provide your app's Bundle ID
|
||||
- Add App Store ID and Team ID if the app is already published
|
||||
|
||||
#### For Android:
|
||||
- Provide the package name
|
||||
- Generate the SHA-1 certificate fingerprint using:
|
||||
```
|
||||
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
|
||||
```
|
||||
- For production, generate the SHA-1 using your release keystore
|
||||
|
||||
### 2. Configure Supabase
|
||||
|
||||
1. Go to the [Supabase Dashboard](https://supabase.com/dashboard)
|
||||
2. Select your project
|
||||
3. Navigate to "Authentication" > "Providers"
|
||||
4. Enable Google provider
|
||||
5. Add the Client IDs from Google Cloud Console
|
||||
- Web Client ID
|
||||
- iOS Client ID
|
||||
- Android Client ID
|
||||
6. Save the changes
|
||||
|
||||
### 3. Update Environment Variables
|
||||
|
||||
Add the following variables to your `.env` file:
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=your_web_client_id
|
||||
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=your_ios_client_id
|
||||
EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID=your_android_client_id
|
||||
```
|
||||
|
||||
### 4. iOS Configuration
|
||||
|
||||
Update your `Info.plist` file to include:
|
||||
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<!-- Replace with your reversed client ID -->
|
||||
<string>com.googleusercontent.apps.YOUR_CLIENT_ID</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
### 5. Android Configuration
|
||||
|
||||
Ensure your `AndroidManifest.xml` includes:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
android:value="@integer/google_play_services_version" />
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The Google Sign-In button is now available on the login and registration screens. When users click this button:
|
||||
|
||||
1. They will be prompted to select a Google account
|
||||
2. After selecting an account and granting permissions, they will be authenticated
|
||||
3. The app will receive their profile information and create or authenticate their account
|
||||
4. They will be redirected to the home screen
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues:
|
||||
|
||||
1. **"No Google ID token found"**: Ensure the Google Sign-In configuration is correct and the required scopes are enabled.
|
||||
|
||||
2. **"Invalid client ID"**: Double-check that the client IDs in your `.env` file match those in the Google Cloud Console.
|
||||
|
||||
3. **"Sign-in was canceled"**: The user canceled the sign-in process. No action needed.
|
||||
|
||||
4. **"Play Services not available"**: The user's device doesn't have Google Play Services installed or updated. Prompt them to update Google Play Services.
|
||||
|
||||
5. **"Error in authentication flow"**: Check the Supabase dashboard logs for more details on the error.
|
||||
|
||||
For more information, refer to:
|
||||
- [Supabase Auth Documentation](https://supabase.com/docs/guides/auth/social-login/auth-google)
|
||||
- [React Native Google Sign-In Documentation](https://github.com/react-native-google-signin/google-signin)
|
||||
347
apps/memoro/apps/mobile/features/auth/README.md
Normal file
347
apps/memoro/apps/mobile/features/auth/README.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# Memoro Authentication Integration
|
||||
|
||||
This document provides an overview of the authentication integration implemented in the Memoro app. It covers how the app integrates with the middleware authentication system and Supabase.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Authentication Flow](#authentication-flow)
|
||||
3. [File Structure](#file-structure)
|
||||
4. [Key Components](#key-components)
|
||||
5. [Platform-Specific Implementations](#platform-specific-implementations)
|
||||
6. [Token Management](#token-management)
|
||||
7. [Middleware API Endpoints](#middleware-api-endpoints)
|
||||
8. [Row Level Security (RLS)](#row-level-security-rls)
|
||||
9. [Security Considerations](#security-considerations)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Overview
|
||||
|
||||
The Memoro app uses a middleware authentication system as a bridge between the frontend and Supabase, providing a secure and centralized authentication solution. The middleware issues compatible JWT tokens that work with Supabase's Row Level Security (RLS) policies.
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌────────────────┐ ┌─────────────┐
|
||||
│ Memoro │────▶│ Middleware │────▶│ Supabase │
|
||||
│ App │◀────│ Authentication │◀────│ Backend │
|
||||
└─────────────┘ └────────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Application Tokens**: The middleware issues app-specific JWT tokens (appTokens) that are compatible with Supabase's auth system
|
||||
- **Token Refresh**: Implements secure token refresh mechanism to maintain session persistence
|
||||
- **Role-Based Access**: Supports role-based access control in the JWT claims
|
||||
- **Secure Storage**: Uses secure storage for all authentication tokens
|
||||
- **Email Persistence**: Maintains email data even when tokens don't contain it
|
||||
- **Cross-Platform**: Supports both web and native mobile platforms
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
The standard authentication flow works as follows:
|
||||
|
||||
1. User enters credentials in the app (login.tsx or register.tsx)
|
||||
2. The app sends credentials to the middleware with the appId
|
||||
3. Middleware validates credentials and returns three tokens:
|
||||
- `manaToken`: Core authentication token for the middleware
|
||||
- `appToken`: Supabase-compatible JWT with appropriate claims
|
||||
- `refreshToken`: Used to get new tokens when the appToken expires
|
||||
4. The app stores these tokens securely using the safeStorage utility (platform-specific)
|
||||
5. User information is extracted from the token and stored in the AuthContext
|
||||
6. The Supabase client is configured to use the appToken for all requests
|
||||
7. All subsequent database operations are subject to RLS policies based on the JWT claims
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
features/
|
||||
auth/
|
||||
components/
|
||||
AuthErrorDisplay.tsx - Component for displaying authentication errors
|
||||
index.ts - Export file for components
|
||||
contexts/
|
||||
AuthContext.tsx - React Context for global auth state management
|
||||
index.ts - Export file for contexts
|
||||
index.ts - Main export file for auth feature
|
||||
lib/
|
||||
index.ts - Export file for libraries
|
||||
supabaseClient.ts - Supabase client configuration with JWT integration
|
||||
services/
|
||||
authService.ts - Core authentication service with API integrations
|
||||
index.ts - Export file for services
|
||||
utils/
|
||||
index.ts - Export file for utilities
|
||||
safeStorage.ts - Secure storage utility for tokens (React Native)
|
||||
safeStorage.web.ts - Web-specific storage utility using localStorage
|
||||
README.md - This documentation file
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### AuthContext
|
||||
|
||||
The `AuthContext` provides a global authentication state and methods for authentication:
|
||||
|
||||
```typescript
|
||||
// Key properties and methods
|
||||
{
|
||||
isAuthenticated: boolean; // Whether the user is authenticated
|
||||
user: User | null; // Current user information
|
||||
loading: boolean; // Loading state for async operations
|
||||
signIn: (email, password) => Promise<Result>; // Sign in with email/password
|
||||
signUp: (email, password) => Promise<Result>; // Register a new user
|
||||
signOut: () => Promise<void>; // Sign out the current user
|
||||
}
|
||||
```
|
||||
|
||||
### authService
|
||||
|
||||
The `authService` provides core authentication functionality:
|
||||
|
||||
```typescript
|
||||
// Key methods
|
||||
{
|
||||
signIn: (email, password) => Promise<Result>; // Sign in with credentials
|
||||
signUp: (email, password) => Promise<Result>; // Register a new user
|
||||
signOut: () => Promise<void>; // Sign out
|
||||
refreshTokens: (refreshToken) => Promise<Tokens>; // Refresh expired tokens
|
||||
validateToken: () => Promise<boolean>; // Validate current token
|
||||
getUserFromToken: () => Promise<UserData>; // Extract user data from token
|
||||
isAuthenticated: () => Promise<boolean>; // Check authentication status
|
||||
clearAuthStorage: () => Promise<void>; // Clear all auth data
|
||||
}
|
||||
```
|
||||
|
||||
### Supabase Integration
|
||||
|
||||
The `supabaseClient.ts` file configures Supabase to work with our authentication system:
|
||||
|
||||
- Creates a Supabase client with custom auth handling
|
||||
- Adds JWT to all API requests
|
||||
- Handles token refresh and session persistence
|
||||
- Configures realtime subscriptions with authentication
|
||||
|
||||
## Platform-Specific Implementations
|
||||
|
||||
The authentication system uses platform-specific implementations for certain components to support both web and native mobile platforms.
|
||||
|
||||
### Web Platform
|
||||
|
||||
For web platforms, the system uses:
|
||||
|
||||
- `safeStorage.web.ts`: Storage implementation using browser's localStorage
|
||||
- Synchronous token retrieval
|
||||
|
||||
Example web storage implementation:
|
||||
|
||||
```typescript
|
||||
// Web storage implementation (safeStorage.web.ts)
|
||||
export const safeStorage = {
|
||||
setItem: async <T>(key: string, value: T): Promise<void> => {
|
||||
try {
|
||||
const jsonValue = JSON.stringify(value);
|
||||
localStorage.setItem(key, jsonValue);
|
||||
} catch (e) {
|
||||
console.error('Error saving data', e);
|
||||
}
|
||||
},
|
||||
getItem: async <T>(key: string): Promise<T | null> => {
|
||||
try {
|
||||
const jsonValue = localStorage.getItem(key);
|
||||
return jsonValue != null ? JSON.parse(jsonValue) : null;
|
||||
} catch (e) {
|
||||
console.error('Error reading data', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// ... other methods
|
||||
};
|
||||
```
|
||||
|
||||
### React Native Platform
|
||||
|
||||
For native mobile platforms, the system uses:
|
||||
|
||||
- `safeStorage.ts`: Storage implementation using React Native's AsyncStorage
|
||||
- Asynchronous token retrieval and storage
|
||||
|
||||
Example native storage implementation:
|
||||
|
||||
```typescript
|
||||
// React Native storage implementation (safeStorage.ts)
|
||||
export const safeStorage = {
|
||||
setItem: async <T>(key: string, value: T): Promise<void> => {
|
||||
try {
|
||||
const jsonValue = JSON.stringify(value);
|
||||
await AsyncStorage.setItem(key, jsonValue);
|
||||
} catch (e) {
|
||||
console.error('Error saving data', e);
|
||||
}
|
||||
},
|
||||
getItem: async <T>(key: string): Promise<T | null> => {
|
||||
try {
|
||||
const jsonValue = await AsyncStorage.getItem(key);
|
||||
return jsonValue != null ? JSON.parse(jsonValue) : null;
|
||||
} catch (e) {
|
||||
console.error('Error reading data', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// ... other methods
|
||||
};
|
||||
```
|
||||
|
||||
## Token Management
|
||||
|
||||
### Storage
|
||||
|
||||
All tokens are stored securely using the `safeStorage` utility:
|
||||
|
||||
```typescript
|
||||
// Keys used for token storage
|
||||
const STORAGE_KEYS = {
|
||||
MANA_TOKEN: '@auth/manaToken',
|
||||
APP_TOKEN: '@auth/appToken',
|
||||
REFRESH_TOKEN: '@auth/refreshToken',
|
||||
USER_EMAIL: '@auth/userEmail', // Email is stored separately to handle tokens without email
|
||||
};
|
||||
```
|
||||
|
||||
### Token Refresh
|
||||
|
||||
Token refresh happens automatically when:
|
||||
|
||||
1. The current token is validated and found to be expired
|
||||
2. The `authService.validateToken()` method is called
|
||||
3. The `authService.isAuthenticated()` method is called
|
||||
|
||||
When a token is refreshed:
|
||||
1. New tokens are obtained from the middleware
|
||||
2. Tokens are stored securely
|
||||
3. User data is extracted and the AuthContext is updated
|
||||
4. Supabase client is reconfigured with the new token
|
||||
|
||||
### Email Persistence
|
||||
|
||||
To handle cases where the refreshed token does not contain the email field:
|
||||
|
||||
1. The user's email is stored separately in secure storage during login/registration
|
||||
2. When decoding a token that doesn't have the email field, the email is retrieved from storage
|
||||
3. This ensures user information remains complete even after token refresh
|
||||
|
||||
## Middleware API Endpoints
|
||||
|
||||
The middleware service provides the following endpoints for authentication:
|
||||
|
||||
| Endpoint | Method | Description | Parameters |
|
||||
|----------|--------|-------------|------------|
|
||||
| `/auth/signin` | POST | Authenticate with email/password | `email`, `password`, `appId` |
|
||||
| `/auth/google-signin` | POST | Authenticate with Google | `token` (Google ID token), `appId` |
|
||||
| `/auth/refresh` | POST | Refresh an expired token | `refreshToken`, `appId` |
|
||||
| `/auth/validate` | POST | Validate an existing token | `appToken`, `appId` |
|
||||
| `/auth/logout` | POST | Log out and revoke refresh token | `refreshToken` |
|
||||
|
||||
## Row Level Security (RLS)
|
||||
|
||||
Supabase uses Row Level Security (RLS) policies to restrict access to data. The middleware-issued JWTs contain claims that RLS policies can use to determine access rights.
|
||||
|
||||
### Example RLS Policies
|
||||
|
||||
```sql
|
||||
-- Enable RLS on a table
|
||||
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Basic policy: Users can only access their own data
|
||||
CREATE POLICY "Users can only access their own data"
|
||||
ON your_table
|
||||
FOR ALL
|
||||
TO authenticated
|
||||
USING ((current_setting('request.jwt.claims', true)::json->>'sub')::text = user_id);
|
||||
|
||||
-- Role-based policy: Admins can access all data
|
||||
CREATE POLICY "Admins can access all data"
|
||||
ON your_table
|
||||
FOR ALL
|
||||
TO authenticated
|
||||
USING ((current_setting('request.jwt.claims', true)::json->>'role')::text = 'admin');
|
||||
```
|
||||
|
||||
### Key JWT Claims Used in RLS
|
||||
|
||||
- `sub`: The subject claim containing the user ID
|
||||
- `role`: The user's role for role-based access control
|
||||
- `app_id`: The application ID that the token was issued for
|
||||
- Custom claims: Additional claims can be added to the token for specific application needs
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Token Storage**:
|
||||
- Uses the safeStorage utility which is platform-aware
|
||||
- Web platform: Uses localStorage
|
||||
- Mobile platforms: Uses AsyncStorage
|
||||
- Both implementations are wrapped with error handling and consistent API
|
||||
|
||||
2. **Token Expiration**:
|
||||
- Tokens expire after 1 hour by default
|
||||
- Automatic token refresh is implemented
|
||||
- Handles graceful redirection to login on refresh failures
|
||||
|
||||
3. **Automatic Cleanup**:
|
||||
- Auth storage is automatically cleared on:
|
||||
- Manual logout
|
||||
- Token refresh failures
|
||||
- Authentication errors
|
||||
|
||||
4. **Loading States**:
|
||||
- Login/register buttons are disabled during authentication
|
||||
- Loading indicators prevent multiple simultaneous authentication attempts
|
||||
|
||||
5. **HTTPS**:
|
||||
- Always use HTTPS for all API communications
|
||||
- Never transmit tokens over insecure connections
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Lost user email/ID on settings page"**
|
||||
- This can happen when tokens are refreshed and the new token doesn't contain the email field
|
||||
- Solution: The app now stores email separately and retrieves it when needed
|
||||
|
||||
2. **"Multiple authentication attempts"**
|
||||
- Previously possible to trigger multiple sign-in attempts
|
||||
- Solution: Login/register buttons are now disabled during authentication
|
||||
|
||||
3. **"Authentication state inconsistency"**
|
||||
- Can occur if token refresh fails but app state isn't updated
|
||||
- Solution: Auth storage is now cleared on refresh failures with redirection to login
|
||||
|
||||
4. **"Invalid JWT" errors with Supabase**
|
||||
- Ensure the middleware is generating tokens with the correct format
|
||||
- Verify the JWT secret used by the middleware matches your Supabase project
|
||||
|
||||
5. **"Authentication works but data access fails"**
|
||||
- Check that RLS policies are configured correctly
|
||||
- Ensure the JWT claims match what your RLS policies expect (especially user ID)
|
||||
|
||||
6. **"Web platform errors"**
|
||||
- Check that the web implementation is being loaded correctly
|
||||
- Verify that the `window` object is defined before accessing localStorage
|
||||
- Ensure metro.config.js is properly configured to prioritize .web.ts files
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
1. Check browser console or app logs for authentication errors
|
||||
2. Use the AuthErrorDisplay component to show authentication errors
|
||||
3. Verify network requests to the middleware service
|
||||
4. Check that tokens are properly stored in secure storage
|
||||
5. Verify that the loading state is properly managed during auth operations
|
||||
6. Use JWT.io to decode and inspect your tokens during development
|
||||
7. For web-specific issues, check browser console and network tab for errors
|
||||
8. For native-specific issues, check the Expo/React Native logs
|
||||
|
||||
## References
|
||||
|
||||
- [Supabase JWT Integration Blueprint](/ReadMe/AuthIntegration.md) - Original integration blueprint
|
||||
- [Audio Recording Documentation](/ReadMe/AudioRecording.md) - Documentation for audio recording features
|
||||
- [Supabase Auth Documentation](https://supabase.com/docs/guides/auth) - Official Supabase auth documentation
|
||||
- [JWT.io](https://jwt.io) - For decoding and inspecting JWT tokens
|
||||
931
apps/memoro/apps/mobile/features/auth/auth.tsx
Normal file
931
apps/memoro/apps/mobile/features/auth/auth.tsx
Normal file
|
|
@ -0,0 +1,931 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
Linking,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Input from '~/components/atoms/Input';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import { useAuth } from '~/features/auth';
|
||||
import { authService } from '~/features/auth/services/authService';
|
||||
import { NotificationChannel } from '~/features/notifications/types';
|
||||
import useNotification from '~/features/notifications/useNotification';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import GoogleSignInButton from './components/GoogleSignInButton';
|
||||
import AppleSignInButton from './components/AppleSignInButton';
|
||||
import MemoroLogo from '~/components/atoms/MemoroLogo';
|
||||
import colors from '~/tailwind.config.js';
|
||||
import { useResponsive } from '~/hooks/useResponsive';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { useLanguage } from '~/features/i18n/LanguageContext';
|
||||
import LanguageSelector from '~/features/i18n/LanguageSelector';
|
||||
|
||||
export type AuthMode =
|
||||
| 'initial'
|
||||
| 'login'
|
||||
| 'register'
|
||||
| 'login-options'
|
||||
| 'register-options'
|
||||
| 'login-email'
|
||||
| 'register-email'
|
||||
| 'forgot-password'
|
||||
| 'password-reset-success';
|
||||
|
||||
export default function Auth({ initialMode = 'login' }: { initialMode?: AuthMode }) {
|
||||
// Auth state
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<AuthMode>(initialMode);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [isLanguageSelectorVisible, setIsLanguageSelectorVisible] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState(''); // Store email for success message
|
||||
|
||||
// Hooks
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
signIn,
|
||||
signUp,
|
||||
signInWithGoogle,
|
||||
loading,
|
||||
showPasswordResetModal,
|
||||
setShowPasswordResetModal,
|
||||
authModeOverride,
|
||||
setAuthModeOverride,
|
||||
} = useAuth();
|
||||
const { showNotification } = useNotification();
|
||||
const router = useRouter();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isMobile, isTablet } = useResponsive();
|
||||
const { currentLanguage, languages } = useLanguage();
|
||||
|
||||
// Helper functions to simplify complex theme color access
|
||||
const getThemeColor = (colorKey: string) => {
|
||||
const themeColors = isDark
|
||||
? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]
|
||||
: (colors as any).theme?.extend?.colors?.[themeVariant];
|
||||
return themeColors?.[colorKey];
|
||||
};
|
||||
|
||||
const getPageBackground = () => getThemeColor('pageBackground');
|
||||
const getContentBackground = () => getThemeColor('contentBackground');
|
||||
const getPrimaryColor = () => getThemeColor('primary');
|
||||
|
||||
// Helper for keyboard visibility
|
||||
const isKeyboardVisible = keyboardHeight > 0;
|
||||
|
||||
// Debounce timer ref
|
||||
const keyboardDebounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Keyboard height tracking with debouncing for Android
|
||||
useEffect(() => {
|
||||
const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', (e) => {
|
||||
if (Platform.OS === 'android') {
|
||||
// Debounce keyboard show on Android
|
||||
if (keyboardDebounceTimer.current) {
|
||||
clearTimeout(keyboardDebounceTimer.current);
|
||||
}
|
||||
keyboardDebounceTimer.current = setTimeout(() => {
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
}, 100);
|
||||
} else {
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
}
|
||||
});
|
||||
|
||||
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||
if (Platform.OS === 'android') {
|
||||
// Debounce keyboard hide on Android
|
||||
if (keyboardDebounceTimer.current) {
|
||||
clearTimeout(keyboardDebounceTimer.current);
|
||||
}
|
||||
keyboardDebounceTimer.current = setTimeout(() => {
|
||||
setKeyboardHeight(0);
|
||||
}, 100);
|
||||
} else {
|
||||
setKeyboardHeight(0);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
keyboardDidShowListener.remove();
|
||||
keyboardDidHideListener.remove();
|
||||
if (keyboardDebounceTimer.current) {
|
||||
clearTimeout(keyboardDebounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Apply auth mode override when it changes
|
||||
useEffect(() => {
|
||||
if (authModeOverride) {
|
||||
if (__DEV__) {
|
||||
console.log('Applying auth mode override:', authModeOverride);
|
||||
}
|
||||
setMode(authModeOverride as AuthMode);
|
||||
setAuthModeOverride(null); // Clear the override after applying
|
||||
}
|
||||
}, [authModeOverride, setAuthModeOverride]);
|
||||
|
||||
// Clear error when input changes
|
||||
const handleEmailChange = (text: string) => {
|
||||
setEmail(text);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (text: string) => {
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const handleConfirmPasswordChange = (text: string) => {
|
||||
setConfirmPassword(text);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
// Reset all form fields
|
||||
const resetForm = () => {
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
// Switch between auth modes
|
||||
const switchMode = (newMode: AuthMode) => {
|
||||
setMode(newMode);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
// Handle login
|
||||
async function handleLogin() {
|
||||
// Dismiss keyboard
|
||||
Keyboard.dismiss();
|
||||
|
||||
// Clear any previous errors
|
||||
setError(null);
|
||||
|
||||
// Validate inputs
|
||||
if (!email) {
|
||||
setError(t('auth.error_email_required', 'Bitte gib deine E-Mail-Adresse ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError(t('auth.error_password_required', 'Bitte gib dein Passwort ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
setError(t('auth.error_invalid_email', 'Bitte gib eine gültige E-Mail-Adresse ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to sign in and get the result
|
||||
const result = await signIn(email, password);
|
||||
|
||||
// If not successful, display the error message
|
||||
if (!result.success) {
|
||||
let errorMessage =
|
||||
result.error ||
|
||||
t(
|
||||
'auth.error_invalid_credentials',
|
||||
'Ungültige Anmeldedaten. Bitte überprüfe deine E-Mail und dein Passwort.'
|
||||
);
|
||||
|
||||
// Handle specific error codes with translations
|
||||
if (result.error === 'EMAIL_NOT_VERIFIED') {
|
||||
errorMessage = t(
|
||||
'auth.error_email_not_verified',
|
||||
'Bitte bestätige deine E-Mail-Adresse, um dich anzumelden. Überprüfe deinen Posteingang.'
|
||||
);
|
||||
} else if (result.error === 'INVALID_CREDENTIALS') {
|
||||
errorMessage = t(
|
||||
'auth.error_invalid_credentials',
|
||||
'Ungültige Anmeldedaten. Bitte überprüfe deine E-Mail und dein Passwort.'
|
||||
);
|
||||
} else if (result.error === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED') {
|
||||
errorMessage = t(
|
||||
'auth.error_firebase_password_reset_required',
|
||||
'Aufgrund eines Systemupdates musst du dein Passwort zurücksetzen. Bitte nutze die "Passwort vergessen" Funktion.'
|
||||
);
|
||||
|
||||
// Don't set error for this case - modal will be shown from AuthContext
|
||||
return; // Early return to prevent setting error below
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
// Show error notification (except for Firebase password reset which already shows modal)
|
||||
if (result.error !== 'FIREBASE_USER_PASSWORD_RESET_REQUIRED') {
|
||||
showNotification({
|
||||
title: t('auth.login_failed', 'Anmeldung fehlgeschlagen'),
|
||||
body: errorMessage,
|
||||
channelType: NotificationChannel.FUNCTIONAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle registration
|
||||
async function handleRegister() {
|
||||
// Dismiss keyboard
|
||||
Keyboard.dismiss();
|
||||
|
||||
// Clear any previous errors
|
||||
setError(null);
|
||||
|
||||
// Validate inputs
|
||||
if (!email) {
|
||||
setError(t('auth.error_email_required', 'Bitte gib deine E-Mail-Adresse ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError(t('auth.error_password_required', 'Bitte gib dein Passwort ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
setError(t('auth.error_confirm_password', 'Bitte bestätige dein Passwort'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('auth.error_passwords_not_match', 'Passwörter stimmen nicht überein'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
setError(t('auth.error_invalid_email', 'Bitte gib eine gültige E-Mail-Adresse ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check password strength
|
||||
if (password.length < 8) {
|
||||
setError(
|
||||
t('auth.error_password_too_short', 'Das Passwort muss mindestens 8 Zeichen lang sein')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for lowercase, uppercase, digits and symbols
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasDigit = /[0-9]/.test(password);
|
||||
const hasSymbol = /[^a-zA-Z0-9]/.test(password);
|
||||
|
||||
if (!hasLowercase || !hasUppercase || !hasDigit || !hasSymbol) {
|
||||
setError(
|
||||
t(
|
||||
'auth.error_password_requirements',
|
||||
'Das Passwort muss mindestens einen Kleinbuchstaben, einen Großbuchstaben, eine Ziffer und ein Sonderzeichen enthalten'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to sign up and get the result
|
||||
const result = await signUp(email, password);
|
||||
|
||||
// If not successful, display the error message
|
||||
if (!result.success) {
|
||||
const errorMessage =
|
||||
result.error ||
|
||||
t(
|
||||
'auth.error_registration_failed',
|
||||
'Registrierung fehlgeschlagen. Bitte versuche es erneut.'
|
||||
);
|
||||
setError(errorMessage);
|
||||
|
||||
// Show error notification
|
||||
showNotification({
|
||||
title: t('auth.registration_failed', 'Registrierung fehlgeschlagen'),
|
||||
body: errorMessage,
|
||||
channelType: NotificationChannel.FUNCTIONAL,
|
||||
});
|
||||
} else if (result.needsVerification) {
|
||||
// Set success message for inline display
|
||||
const confirmMessage = t(
|
||||
'auth.check_email_confirmation',
|
||||
'Registrierung erfolgreich! Bitte überprüfe deine E-Mail und klicke auf den Bestätigungslink, um dein Konto zu aktivieren.'
|
||||
);
|
||||
setSuccessMessage(confirmMessage);
|
||||
|
||||
// Show success notification for email verification
|
||||
showNotification({
|
||||
title: t('auth.registration_success', 'Registrierung erfolgreich'),
|
||||
body: confirmMessage,
|
||||
channelType: NotificationChannel.FUNCTIONAL,
|
||||
});
|
||||
|
||||
// Clear sensitive fields but keep email to show what was registered
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
// Switch to login mode so user can sign in after confirming email
|
||||
switchMode('login-email');
|
||||
} else {
|
||||
// Registration completed successfully without verification
|
||||
showNotification({
|
||||
title: t('auth.registration_success', 'Registrierung erfolgreich'),
|
||||
body: t('auth.welcome_message', 'Willkommen bei Memoro!'),
|
||||
channelType: NotificationChannel.FUNCTIONAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle password reset
|
||||
async function handleForgotPassword() {
|
||||
// Dismiss keyboard
|
||||
Keyboard.dismiss();
|
||||
|
||||
// Clear any previous errors
|
||||
setError(null);
|
||||
|
||||
// Validate inputs
|
||||
if (!email) {
|
||||
setError(t('auth.error_email_required', 'Bitte gib deine E-Mail-Adresse ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
setError(t('auth.error_invalid_email', 'Bitte gib eine gültige E-Mail-Adresse ein'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send password reset email
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (result.success) {
|
||||
// Store email for success message and switch to success screen
|
||||
setResetEmail(email);
|
||||
resetForm();
|
||||
switchMode('password-reset-success');
|
||||
} else {
|
||||
// Show error with specific handling for rate limiting
|
||||
let errorMessage = result.error || t('auth.passwordResetError', 'Password reset failed');
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (result.error?.includes('rate limit') || result.error?.includes('too many')) {
|
||||
errorMessage = t(
|
||||
'auth.reset_password_rate_limit',
|
||||
'Too many password reset attempts. Please wait a few minutes before trying again.'
|
||||
);
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
showNotification({
|
||||
title: t('auth.passwordResetError', 'Password reset failed'),
|
||||
body: errorMessage,
|
||||
channelType: NotificationChannel.FUNCTIONAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get title based on current mode
|
||||
const getTitle = () => {
|
||||
switch (mode) {
|
||||
case 'initial':
|
||||
return t('auth.welcome', 'Willkommen bei Memoro');
|
||||
case 'login':
|
||||
return t('auth.welcome_back', 'Willkommen zurück');
|
||||
case 'register':
|
||||
return t('auth.create_account', 'Konto erstellen');
|
||||
case 'login-options':
|
||||
return t('auth.login', 'Anmelden');
|
||||
case 'register-options':
|
||||
return t('auth.create_account', 'Konto erstellen');
|
||||
case 'login-email':
|
||||
return t('auth.login_with_email', 'Mit E-Mail anmelden');
|
||||
case 'register-email':
|
||||
return t('auth.register_with_email', 'Mit E-Mail registrieren');
|
||||
case 'forgot-password':
|
||||
return t('auth.reset_password', 'Passwort zurücksetzen');
|
||||
case 'password-reset-success':
|
||||
return t('auth.reset_email_sent_title', 'E-Mail wurde versendet!');
|
||||
default:
|
||||
return 'Memoro';
|
||||
}
|
||||
};
|
||||
|
||||
// Get action button text based on current mode
|
||||
const getActionButtonText = () => {
|
||||
switch (mode) {
|
||||
case 'login-email':
|
||||
return loading ? t('auth.logging_in', 'Anmelden...') : t('auth.login', 'Anmelden');
|
||||
case 'register-email':
|
||||
return loading
|
||||
? t('auth.registering', 'Registrieren...')
|
||||
: t('auth.register', 'Registrieren');
|
||||
case 'forgot-password':
|
||||
return loading
|
||||
? t('auth.sending', 'Senden...')
|
||||
: t('auth.reset_password', 'Passwort zurücksetzen');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get action button icon based on current mode
|
||||
const getActionButtonIcon = () => {
|
||||
switch (mode) {
|
||||
case 'login-email':
|
||||
return 'arrow-right';
|
||||
case 'register-email':
|
||||
return 'user-plus';
|
||||
case 'forgot-password':
|
||||
return 'key';
|
||||
default:
|
||||
return 'arrow-right';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle action button press based on current mode
|
||||
const handleActionButtonPress = () => {
|
||||
switch (mode) {
|
||||
case 'login-email':
|
||||
return handleLogin();
|
||||
case 'register-email':
|
||||
return handleRegister();
|
||||
case 'forgot-password':
|
||||
return handleForgotPassword();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: getPageBackground(),
|
||||
}}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : undefined}
|
||||
enabled={Platform.OS === 'ios'}>
|
||||
{/* Language selector button in top right */}
|
||||
<Pressable
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: insets.top + 16,
|
||||
right: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: getContentBackground(),
|
||||
zIndex: 100,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
}}
|
||||
onPress={() => setIsLanguageSelectorVisible(true)}>
|
||||
<Text style={{ fontSize: 20, marginRight: 6 }}>
|
||||
{languages[currentLanguage]?.emoji || '🌐'}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{currentLanguage}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Language Selector Modal */}
|
||||
<LanguageSelector
|
||||
isVisible={isLanguageSelectorVisible}
|
||||
onClose={() => setIsLanguageSelectorVisible(false)}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
bounces={Platform.OS === 'ios'}
|
||||
overScrollMode="never">
|
||||
{/* Logo */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 200,
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
width: 150,
|
||||
height: 150,
|
||||
borderRadius: 75,
|
||||
borderWidth: 7,
|
||||
borderColor: getPrimaryColor(),
|
||||
backgroundColor: getContentBackground(),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: isDark ? 0.4 : 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
opacity: isKeyboardVisible ? 0.8 : 1,
|
||||
transform: [{ scale: isKeyboardVisible ? 0.9 : 1 }],
|
||||
}}>
|
||||
<MemoroLogo size={65} color={getPrimaryColor()} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Button-Container */}
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
paddingBottom: insets.bottom,
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
}}>
|
||||
<View
|
||||
className="mt-auto w-full rounded-t-3xl px-5 pb-0 pt-8"
|
||||
style={{
|
||||
backgroundColor: getPageBackground(),
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
opacity: 0.6,
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
paddingLeft: 0,
|
||||
paddingTop: 4,
|
||||
zIndex: 100,
|
||||
}}>
|
||||
{getTitle()}
|
||||
</Text>
|
||||
|
||||
{mode === 'initial' ? (
|
||||
// Initial mode with login and register buttons
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Button
|
||||
onPress={() => switchMode('register-options')}
|
||||
title={t('auth.create_account', 'Konto erstellen')}
|
||||
variant="primary"
|
||||
iconName="person-add-outline"
|
||||
style={{ height: 56 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() => switchMode('login-options')}
|
||||
title={t('auth.login', 'Anmelden')}
|
||||
variant="secondary"
|
||||
iconName="log-in-outline"
|
||||
style={{ height: 56 }}
|
||||
/>
|
||||
</View>
|
||||
) : mode === 'login-options' || mode === 'register-options' ? (
|
||||
// Login or Register options (Google, Apple, Email)
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}>
|
||||
<Button
|
||||
onPress={() =>
|
||||
switchMode(mode === 'login-options' ? 'login-email' : 'register-email')
|
||||
}
|
||||
title={
|
||||
mode === 'login-options'
|
||||
? t('auth.login_with_email', 'Mit E-Mail anmelden')
|
||||
: t('auth.register_with_email', 'Mit E-Mail registrieren')
|
||||
}
|
||||
variant="primary"
|
||||
iconName="mail-outline"
|
||||
style={{ height: 56 }}
|
||||
/>
|
||||
|
||||
{/* Google Sign-In Button */}
|
||||
<GoogleSignInButton
|
||||
onSuccess={async () => {
|
||||
const result = await signInWithGoogle();
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
setError(error.message);
|
||||
showNotification({
|
||||
title: 'Google Anmeldung fehlgeschlagen',
|
||||
body: error.message,
|
||||
channelType: NotificationChannel.FUNCTIONAL,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Apple Sign-In Button */}
|
||||
<AppleSignInButton
|
||||
onSuccess={() => {
|
||||
if (__DEV__) {
|
||||
console.log('Apple Sign-In successful');
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
setError(error.message);
|
||||
showNotification({
|
||||
title: 'Apple Anmeldung fehlgeschlagen',
|
||||
body: error.message,
|
||||
channelType: NotificationChannel.FUNCTIONAL,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
resetForm();
|
||||
switchMode('initial');
|
||||
}}
|
||||
title={t('common.back', 'Zurück')}
|
||||
variant="text"
|
||||
iconName="arrow-back-outline"
|
||||
style={{ height: 40, marginBottom: 0, paddingBottom: 0 }}
|
||||
/>
|
||||
</View>
|
||||
) : mode === 'password-reset-success' ? (
|
||||
// Password reset success message
|
||||
<View className="mb-2 pb-4">
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: getPrimaryColor() + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<Icon name="mail-open-outline" size={40} color={getPrimaryColor()} />
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#9CA3AF' : '#4B5563',
|
||||
textAlign: 'left',
|
||||
paddingHorizontal: 8,
|
||||
lineHeight: 22,
|
||||
}}>
|
||||
{t(
|
||||
'auth.reset_email_sent_description',
|
||||
'Wir haben eine E-Mail mit Anweisungen zum Zurücksetzen deines Passworts an {email} gesendet. Bitte überprüfe deinen Posteingang und Spam-Ordner.'
|
||||
).replace('{email}', resetEmail)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 12 }}>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setResetEmail('');
|
||||
switchMode('login-email');
|
||||
}}
|
||||
title={t('auth.back_to_login', 'Zurück zum Login')}
|
||||
variant="primary"
|
||||
iconName="log-in-outline"
|
||||
style={{ height: 56 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() => switchMode('forgot-password')}
|
||||
title={t('auth.resend_email', 'E-Mail erneut senden')}
|
||||
variant="secondary"
|
||||
style={{ height: 48 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
// Email Login, Register or Forgot Password forms
|
||||
<View className="mb-2 pb-4">
|
||||
{error && (
|
||||
<View className="mb-4 rounded-lg bg-red-500/20 p-3">
|
||||
<Text variant="body" className="text-red-500">
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Input
|
||||
placeholder={t('auth.email', 'E-Mail')}
|
||||
value={email}
|
||||
onChangeText={handleEmailChange}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
className={`mb-2 h-14 rounded-xl border px-4 text-lg ${isDark ? 'border-gray-700 bg-black/20 text-white' : 'border-gray-300 bg-gray-100/50 text-black'}`}
|
||||
/>
|
||||
|
||||
{mode !== 'forgot-password' && (
|
||||
<Input
|
||||
placeholder={t('auth.password', 'Passwort')}
|
||||
value={password}
|
||||
onChangeText={handlePasswordChange}
|
||||
secureTextEntry
|
||||
showPasswordToggle
|
||||
className={`mb-2 h-14 rounded-xl border px-4 text-lg ${isDark ? 'border-gray-700 bg-black/20 text-white' : 'border-gray-300 bg-gray-100/50 text-black'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'register-email' && (
|
||||
<>
|
||||
<Input
|
||||
placeholder={t('auth.confirm_password', 'Passwort bestätigen')}
|
||||
value={confirmPassword}
|
||||
onChangeText={handleConfirmPasswordChange}
|
||||
secureTextEntry
|
||||
showPasswordToggle
|
||||
className={`mb-2 h-14 rounded-xl border px-4 text-lg ${isDark ? 'border-gray-700 bg-black/20 text-white' : 'border-gray-300 bg-gray-100/50 text-black'}`}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginBottom: 16,
|
||||
marginTop: 8,
|
||||
color: isDark ? '#9CA3AF' : '#4B5563',
|
||||
}}>
|
||||
{t(
|
||||
'auth.password_requirement',
|
||||
'Passwort muss mindestens 8 Zeichen lang sein und mindestens einen Kleinbuchstaben, einen Großbuchstaben, eine Ziffer und ein Sonderzeichen enthalten.'
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'login-email' && (
|
||||
<Button
|
||||
onPress={() => switchMode('forgot-password')}
|
||||
title={t('auth.forgotPassword', 'Passwort vergessen?')}
|
||||
variant="secondary"
|
||||
style={{ height: 40, marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View className="mb-0 gap-4">
|
||||
<Button
|
||||
onPress={handleActionButtonPress}
|
||||
title={getActionButtonText()}
|
||||
variant={mode === 'forgot-password' ? 'secondary' : 'primary'}
|
||||
iconName={
|
||||
mode === 'login-email'
|
||||
? 'log-in-outline'
|
||||
: mode === 'register-email'
|
||||
? 'person-add-outline'
|
||||
: 'key-outline'
|
||||
}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
style={{ height: 56 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
resetForm();
|
||||
if (mode === 'forgot-password') {
|
||||
switchMode('login-options');
|
||||
} else {
|
||||
switchMode(mode === 'login-email' ? 'login-options' : 'register-options');
|
||||
}
|
||||
}}
|
||||
title={t('common.back', 'Zurück')}
|
||||
variant="text"
|
||||
iconName="arrow-back-outline"
|
||||
style={{ height: 40, marginBottom: 0, paddingBottom: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
marginTop: 0,
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
fontWeight: '300',
|
||||
lineHeight: 20,
|
||||
}}>
|
||||
{t('auth.terms_agreement_prefix', 'Mit der Nutzung stimmst du unseren ')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => Linking.openURL('https://www.memoro.ai/de/privacy/')}>
|
||||
<Text
|
||||
style={{
|
||||
textDecorationLine: 'underline',
|
||||
fontWeight: '500',
|
||||
fontSize: 12,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
}}>
|
||||
{t('auth.terms', 'AGB')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
fontWeight: '300',
|
||||
}}>
|
||||
{' '}
|
||||
{t('auth.terms_agreement_conjunction', 'und der')}
|
||||
{' '}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
Linking.openURL('https://www.memoro.ai/de/privacy/')
|
||||
}>
|
||||
<Text
|
||||
style={{
|
||||
textDecorationLine: 'underline',
|
||||
fontWeight: '500',
|
||||
fontSize: 12,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
}}>
|
||||
{t('auth.privacy_policy', 'Datenschutzerklärung')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
fontWeight: '300',
|
||||
}}>
|
||||
{' '}
|
||||
{t('auth.terms_agreement_suffix', 'zu.')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import React, { useState } from 'react';
|
||||
import { StyleSheet, View, Platform, Alert, ActivityIndicator, TouchableOpacity, Text } from 'react-native';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import { AntDesign } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { authService } from '../services/authService';
|
||||
import { useAnalytics } from '~/features/analytics';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
type AppleSignInButtonProps = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apple Sign-In Button Component
|
||||
* Only renders on iOS devices
|
||||
*/
|
||||
const AppleSignInButton: React.FC<AppleSignInButtonProps> = ({ onSuccess, onError }) => {
|
||||
const { isDark } = useTheme();
|
||||
const { setUser } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { identify, track } = useAnalytics();
|
||||
|
||||
// Only render the button on iOS
|
||||
if (Platform.OS !== 'ios') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const signInWithApple = async () => {
|
||||
// Prevent multiple concurrent sign-in attempts
|
||||
if (loading) {
|
||||
console.log('Apple Sign-In already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Request Apple authentication
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
|
||||
console.log('Apple Sign-In successful');
|
||||
|
||||
// Make sure we have an identity token
|
||||
if (!credential.identityToken) {
|
||||
throw new Error('No identity token received from Apple');
|
||||
}
|
||||
|
||||
console.log('Got Apple identity token, sending to middleware...');
|
||||
|
||||
// Send the identity token to our middleware
|
||||
const result = await authService.signInWithApple(credential.identityToken);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to authenticate with the server');
|
||||
}
|
||||
|
||||
console.log('Successfully authenticated with middleware');
|
||||
|
||||
// Extract user data from the token
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (userData) {
|
||||
// Update the user context
|
||||
setUser(userData);
|
||||
|
||||
// Update Supabase auth state to ensure proper token sync
|
||||
const { updateSupabaseAuth } = await import('../lib/supabaseClient');
|
||||
await updateSupabaseAuth();
|
||||
|
||||
// Initialize RevenueCat conditionally
|
||||
try {
|
||||
const { initializeRevenueCatConditionally, identifyRevenueCatUser } = await import('~/features/subscription/revenueCatManager');
|
||||
await initializeRevenueCatConditionally();
|
||||
await identifyRevenueCatUser(userData.id);
|
||||
} catch (rcError) {
|
||||
console.error('Error with RevenueCat initialization:', rcError);
|
||||
}
|
||||
|
||||
// Track sign in event
|
||||
identify(userData.id, {
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
});
|
||||
track('user_signed_in', {
|
||||
method: 'apple',
|
||||
});
|
||||
|
||||
// Don't navigate manually - the app will automatically show protected routes
|
||||
// when the authentication state changes
|
||||
console.log('User data set, app will navigate automatically');
|
||||
|
||||
// Small delay to ensure state updates propagate and force navigation
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
// Force navigation to home if auth state didn't trigger it
|
||||
router.dismissAll();
|
||||
router.replace('/');
|
||||
}, 100);
|
||||
} else {
|
||||
throw new Error('No user data available after authentication');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Apple Sign-In error:', error);
|
||||
|
||||
// Handle user cancellation separately
|
||||
if ((error as any)?.code === 'ERR_REQUEST_CANCELED') {
|
||||
console.log('User canceled the sign-in flow');
|
||||
} else {
|
||||
// Show error to user
|
||||
Alert.alert('Sign-In Error', 'Failed to sign in with Apple. Please try again.');
|
||||
onError?.(error instanceof Error ? error : new Error('Unknown error during Apple Sign-In'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: isDark ? '#2c2c2c' : '#ffffff',
|
||||
borderColor: isDark ? '#404040' : '#dadce0'
|
||||
}
|
||||
]}
|
||||
onPress={signInWithApple}
|
||||
disabled={loading}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<View style={styles.appleIconContainer}>
|
||||
<AntDesign
|
||||
name="apple1"
|
||||
size={20}
|
||||
color={isDark ? '#ffffff' : '#000000'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.buttonText, { color: isDark ? '#ffffff' : '#3c4043' }]}>
|
||||
Mit Apple anmelden
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{loading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#dadce0',
|
||||
backgroundColor: '#ffffff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
appleIconContainer: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
marginRight: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#3c4043',
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppleSignInButton;
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, StyleSheet, Animated } from 'react-native';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
|
||||
interface AuthErrorDisplayProps {
|
||||
error: string | null;
|
||||
style?: object;
|
||||
}
|
||||
|
||||
const AuthErrorDisplay: React.FC<AuthErrorDisplayProps> = ({ error, style }) => {
|
||||
const opacity = new Animated.Value(0);
|
||||
const translateY = new Animated.Value(-10);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
// Reset and animate in when a new error appears
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-10);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
// Animate out when error is cleared
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: -10,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
style
|
||||
]}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Icon name="alert-circle" size={20} color="#e53935" />
|
||||
</View>
|
||||
<Text variant="body" style={styles.text}>{error}</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(229, 57, 53, 0.1)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: 8,
|
||||
},
|
||||
text: {
|
||||
color: '#e53935',
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default AuthErrorDisplay;
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet, View, ActivityIndicator } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import {
|
||||
GoogleSignin,
|
||||
statusCodes,
|
||||
} from '@react-native-google-signin/google-signin';
|
||||
import { AntDesign } from '@expo/vector-icons';
|
||||
import { authService } from '../services/authService';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
// Configure Google Sign-In
|
||||
|
||||
// Based on troubleshooting, for Android we only need webClientId
|
||||
// The androidClientId should NOT be used in the configure method
|
||||
GoogleSignin.configure({
|
||||
webClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID, // Web client ID (for Android)
|
||||
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, // iOS-specific client ID
|
||||
scopes: ['profile', 'email'],
|
||||
offlineAccess: false, // Setting to false based on troubleshooting recommendations
|
||||
});
|
||||
|
||||
type GoogleSignInButtonProps = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
const GoogleSignInButton: React.FC<GoogleSignInButtonProps> = ({ onSuccess, onError }) => {
|
||||
const { loading } = useAuth();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
try {
|
||||
// Check if Play Services are available (Android only)
|
||||
await GoogleSignin.hasPlayServices();
|
||||
|
||||
// Perform the sign-in
|
||||
const userInfo = await GoogleSignin.signIn();
|
||||
|
||||
// Get the ID token from the user info
|
||||
const { idToken } = await GoogleSignin.getTokens();
|
||||
console.log('Google Sign-In successful, received ID token');
|
||||
|
||||
// Extract nonce from the ID token if present
|
||||
let nonce = null;
|
||||
try {
|
||||
// Decode the ID token to extract the nonce
|
||||
const payload = JSON.parse(atob(idToken.split('.')[1]));
|
||||
nonce = payload.nonce || null;
|
||||
console.log('Extracted nonce from ID token:', nonce);
|
||||
} catch (e) {
|
||||
console.error('Error extracting nonce from ID token:', e);
|
||||
}
|
||||
|
||||
// Check if we have an ID token
|
||||
if (idToken) {
|
||||
// Use our custom auth service to authenticate with the mana-middleware
|
||||
const result = await authService.signInWithGoogle(idToken);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Error signing in with Google:', result.error);
|
||||
onError?.(new Error(result.error || 'Failed to authenticate with Google'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Successfully signed in with Google');
|
||||
|
||||
// Get user data from token to update the auth context
|
||||
try {
|
||||
const userData = await authService.getUserFromToken();
|
||||
if (userData) {
|
||||
// Navigate to home screen with navigation stack reset
|
||||
router.dismissAll();
|
||||
router.replace('/');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting user data after Google Sign-In:', err);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} else {
|
||||
throw new Error('No ID token present in Google Sign-In response');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
|
||||
// User cancelled the login flow
|
||||
console.log('User cancelled the login flow');
|
||||
} else if (error.code === statusCodes.IN_PROGRESS) {
|
||||
// Operation is in progress already
|
||||
console.log('Google Sign-In operation is in progress');
|
||||
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
|
||||
// Play services not available or outdated
|
||||
console.log('Google Play Services not available or outdated');
|
||||
} else {
|
||||
// Some other error happened
|
||||
console.error('Error during Google Sign-In:', error);
|
||||
onError?.(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.googleButton,
|
||||
{
|
||||
backgroundColor: isDark ? '#2c2c2c' : '#ffffff',
|
||||
borderColor: isDark ? '#404040' : '#dadce0'
|
||||
}
|
||||
]}
|
||||
onPress={signInWithGoogle}
|
||||
disabled={loading}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<View style={styles.googleIconContainer}>
|
||||
<AntDesign
|
||||
name="google"
|
||||
size={20}
|
||||
color="#4285f4"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.buttonText, { color: isDark ? '#ffffff' : '#3c4043' }]}>Mit Google anmelden</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{loading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
googleButton: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#dadce0',
|
||||
backgroundColor: '#ffffff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
googleIconContainer: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
marginRight: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#3c4043',
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default GoogleSignInButton;
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import React from 'react';
|
||||
import { View, Modal, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
|
||||
interface PasswordResetRequiredModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onResetPassword: () => void;
|
||||
}
|
||||
|
||||
const PasswordResetRequiredModal: React.FC<PasswordResetRequiredModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onResetPassword,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
console.log('PasswordResetRequiredModal rendered with visible:', visible);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#ffffff',
|
||||
paddingTop: Math.max(insets.top, 40),
|
||||
paddingBottom: Math.max(insets.bottom, 20)
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: isDark ? 'rgba(255, 193, 7, 0.2)' : 'rgba(255, 193, 7, 0.15)' }]}>
|
||||
<Icon name="key-outline" size={32} color="#FFC107" />
|
||||
</View>
|
||||
|
||||
<Text style={[styles.title, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
||||
{t('auth.password_reset_required', 'Password Reset Required')}
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.message, { color: isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.6)' }]}>
|
||||
{t('auth.error_firebase_password_reset_required',
|
||||
'Due to a system update, you need to reset your password. Please use the "Forgot Password" function.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<View style={styles.infoContainer}>
|
||||
<View style={[styles.infoCard, { backgroundColor: isDark ? 'rgba(33, 150, 243, 0.1)' : '#E3F2FD' }]}>
|
||||
<View style={styles.infoIconWrapper}>
|
||||
<Icon name="information-circle-outline" size={24} color="#2196F3" />
|
||||
</View>
|
||||
<Text style={[styles.infoText, { color: isDark ? '#90CAF9' : '#1976D2' }]}>
|
||||
{t('auth.password_reset_info',
|
||||
'This is a one-time requirement due to our authentication system update. Your account remains secure.'
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
title={t('auth.resetPassword', 'Reset Password')}
|
||||
variant="primary"
|
||||
onPress={onResetPassword}
|
||||
style={styles.resetButton}
|
||||
/>
|
||||
<Button
|
||||
title={t('common.cancel', 'Cancel')}
|
||||
variant="ghost"
|
||||
onPress={onClose}
|
||||
style={styles.cancelButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logo: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
lineHeight: 32,
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
infoContainer: {
|
||||
width: '100%',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoCard: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(33, 150, 243, 0.2)',
|
||||
},
|
||||
infoIconWrapper: {
|
||||
marginRight: 16,
|
||||
marginTop: 2,
|
||||
},
|
||||
infoText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
buttonContainer: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
resetButton: {
|
||||
height: 48,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
height: 48,
|
||||
},
|
||||
});
|
||||
|
||||
export default PasswordResetRequiredModal;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as AuthErrorDisplay } from './AuthErrorDisplay';
|
||||
503
apps/memoro/apps/mobile/features/auth/contexts/AuthContext.tsx
Normal file
503
apps/memoro/apps/mobile/features/auth/contexts/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import { authService } from '../services/authService';
|
||||
import { updateSupabaseAuth, setSupabaseToken } from '../lib/supabaseClient';
|
||||
import { setupGlobalFetchInterceptor, setupTokenObservers } from '../utils/fetchInterceptor';
|
||||
import { tokenManager, TokenState } from '../services/tokenManager';
|
||||
import {
|
||||
initializeRevenueCatConditionally,
|
||||
identifyRevenueCatUser,
|
||||
resetRevenueCatUser,
|
||||
} from '~/features/subscription/revenueCatManager';
|
||||
|
||||
// User type
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Define type for the Auth context
|
||||
type AuthContextType = {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
showPasswordResetModal: boolean;
|
||||
setShowPasswordResetModal: (show: boolean) => void;
|
||||
authModeOverride: string | null;
|
||||
setAuthModeOverride: (mode: string | null) => void;
|
||||
setUser: (userData: { id: string; email: string; role: string }) => void;
|
||||
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signUp: (email: string, password: string) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
||||
signInWithGoogle: () => Promise<{ success: boolean; error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Create context with default values
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
showPasswordResetModal: false,
|
||||
setShowPasswordResetModal: () => {},
|
||||
authModeOverride: null,
|
||||
setAuthModeOverride: () => {},
|
||||
setUser: () => {},
|
||||
signIn: async () => ({ success: false }),
|
||||
signUp: async () => ({ success: false }),
|
||||
signInWithGoogle: async () => ({ success: false }),
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
// Hook for easy access to the Auth context
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
// Auth Provider component
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
const [showPasswordResetModal, setShowPasswordResetModal] = useState(false);
|
||||
const [authModeOverride, setAuthModeOverride] = useState<string | null>(null);
|
||||
|
||||
// Use a ref to track the latest auth state for the token observer
|
||||
const authStateRef = useRef({ isAuthenticated, user });
|
||||
|
||||
// Update the ref whenever auth state changes
|
||||
useEffect(() => {
|
||||
authStateRef.current = { isAuthenticated, user };
|
||||
}, [isAuthenticated, user]);
|
||||
|
||||
// Update user data when it changes
|
||||
const updateUserData = useCallback((userData: { id: string; email: string; role: string }) => {
|
||||
console.debug('Auth: updateUserData called with:', userData);
|
||||
if (userData) {
|
||||
console.debug('Auth: Setting isAuthenticated to true and updating user');
|
||||
setIsAuthenticated(true);
|
||||
// Add name field derived from email
|
||||
const userWithName = {
|
||||
...userData,
|
||||
name: userData.email.split('@')[0]
|
||||
};
|
||||
setUser(userWithName);
|
||||
console.debug('Auth: User data updated successfully');
|
||||
} else {
|
||||
console.debug('Auth: updateUserData called with null/undefined userData');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load auth state on startup
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
// Set up the global fetch interceptor FIRST - this is critical for handling 401s during auth check
|
||||
setupGlobalFetchInterceptor();
|
||||
|
||||
// Set up token manager observers for Supabase integration
|
||||
setupTokenObservers();
|
||||
|
||||
// Register the token refresh callback BEFORE any auth operations
|
||||
authService.onTokenRefresh = updateUserData;
|
||||
|
||||
// Subscribe to token state changes for UI updates
|
||||
unsubscribe = tokenManager.subscribe((state: TokenState, token?: string) => {
|
||||
console.debug('Auth: Token state changed to', state);
|
||||
console.debug('Auth: Current auth state - isAuthenticated:', authStateRef.current.isAuthenticated, 'user:', !!authStateRef.current.user);
|
||||
|
||||
if (state === TokenState.VALID && token) {
|
||||
// We have a valid token, try to get user data
|
||||
console.debug('Auth: Token is VALID, attempting to get user data...');
|
||||
authService.getUserFromToken().then(userData => {
|
||||
console.debug('Auth: getUserFromToken result:', userData);
|
||||
if (userData) {
|
||||
console.debug('Auth: Calling updateUserData with:', userData);
|
||||
updateUserData(userData);
|
||||
// Force a re-check of auth state after update
|
||||
setTimeout(() => {
|
||||
console.debug('Auth: After updateUserData - isAuthenticated:', authStateRef.current.isAuthenticated, 'user:', !!authStateRef.current.user);
|
||||
}, 100);
|
||||
} else {
|
||||
console.debug('Auth: getUserFromToken returned null/undefined');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.debug('Error getting user data from token:', error);
|
||||
});
|
||||
} else if (state === TokenState.EXPIRED_OFFLINE) {
|
||||
// Token expired while offline - preserve auth state, don't logout
|
||||
console.debug('Auth: Token expired offline, preserving auth state');
|
||||
// User can continue using the app offline, token will refresh when connection returns
|
||||
return; // Don't trigger logout
|
||||
} else if (state === TokenState.EXPIRED && !isSigningOut) {
|
||||
// Only handle token expiration if we're not actively signing out
|
||||
console.debug('Auth: Token expired, handling expiration. isSigningOut:', isSigningOut);
|
||||
handleTokenExpiration().catch(error => {
|
||||
console.debug('Error during token expiration handling:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user is authenticated (uses local validation to avoid race conditions)
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
|
||||
if (authenticated) {
|
||||
// Get user information from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (userData) {
|
||||
updateUserData(userData);
|
||||
|
||||
// Update Supabase auth state only if we have a valid current token
|
||||
// to avoid triggering unnecessary token refresh during startup
|
||||
try {
|
||||
const currentToken = await authService.getAppToken();
|
||||
if (currentToken && authService.isTokenValidLocally(currentToken)) {
|
||||
// Only update Supabase if we have a valid token to avoid refresh cycles
|
||||
console.debug('Updating Supabase auth with existing valid token');
|
||||
await setSupabaseToken(currentToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error setting Supabase token during startup:', error);
|
||||
}
|
||||
|
||||
// Initialize RevenueCat conditionally for existing authenticated users
|
||||
try {
|
||||
await initializeRevenueCatConditionally();
|
||||
await identifyRevenueCatUser(userData.id);
|
||||
} catch (error) {
|
||||
console.error('Error with RevenueCat initialization for existing user:', error);
|
||||
// Don't block startup if initialization fails
|
||||
}
|
||||
} else {
|
||||
// If user data couldn't be retrieved, sign out
|
||||
await handleSignOut();
|
||||
}
|
||||
} else {
|
||||
// If not authenticated, make sure we're on the login screen
|
||||
if (router.canGoBack()) {
|
||||
router.replace('/(public)/login');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth state:', error);
|
||||
// On error, make sure we're signed out and on login screen
|
||||
await handleSignOut();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
|
||||
// Cleanup the callback and subscription on unmount
|
||||
return () => {
|
||||
authService.onTokenRefresh = null;
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, []); // Empty dependencies - we use refs to access latest values
|
||||
|
||||
// Handle sign in
|
||||
const handleSignIn = async (email: string, password: string) => {
|
||||
// Set loading state immediately
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Authenticate with the API
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (result.success) {
|
||||
// Reset password reset modal on successful login
|
||||
setShowPasswordResetModal(false);
|
||||
|
||||
try {
|
||||
// Get user information from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (userData) {
|
||||
updateUserData(userData);
|
||||
|
||||
// Update Supabase auth state
|
||||
await updateSupabaseAuth();
|
||||
|
||||
// Initialize RevenueCat conditionally based on B2B settings
|
||||
try {
|
||||
await initializeRevenueCatConditionally();
|
||||
await identifyRevenueCatUser(userData.id);
|
||||
} catch (error) {
|
||||
console.error('Error with RevenueCat initialization:', error);
|
||||
// Don't block auth flow if initialization fails
|
||||
}
|
||||
|
||||
// Use replace to prevent swiping back to login
|
||||
// Small delay to ensure auth state is updated
|
||||
setTimeout(() => {
|
||||
router.replace('/(protected)/(tabs)/');
|
||||
}, 100);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting user data after sign in:', err);
|
||||
// Keep loading false but return the success result
|
||||
// This will allow navigation to proceed even if we couldn't get user data
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a password reset required error
|
||||
if (!result.success && result.error === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED') {
|
||||
console.log('AuthContext: Setting showPasswordResetModal to true');
|
||||
setShowPasswordResetModal(true);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in handleSignIn:', error);
|
||||
return { success: false, error: 'An unexpected error occurred' };
|
||||
} finally {
|
||||
// Always set loading to false when done, regardless of success or failure
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sign up
|
||||
const handleSignUp = async (email: string, password: string) => {
|
||||
// Set loading state immediately
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Register with the API
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (result.success) {
|
||||
// Check if email verification is needed
|
||||
if (result.needsVerification) {
|
||||
// Don't authenticate user, just return success with verification flag
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user information from token (only if no verification needed)
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (userData) {
|
||||
updateUserData(userData);
|
||||
|
||||
// Update Supabase auth state
|
||||
await updateSupabaseAuth();
|
||||
|
||||
// Initialize RevenueCat conditionally based on B2B settings
|
||||
try {
|
||||
await initializeRevenueCatConditionally();
|
||||
await identifyRevenueCatUser(userData.id);
|
||||
} catch (error) {
|
||||
console.error('Error with RevenueCat initialization:', error);
|
||||
// Don't block auth flow if initialization fails
|
||||
}
|
||||
|
||||
// Use replace to prevent swiping back to login
|
||||
// Small delay to ensure auth state is updated
|
||||
setTimeout(() => {
|
||||
router.replace('/(protected)/(tabs)/');
|
||||
}, 100);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting user data after sign up:', err);
|
||||
// Keep loading false but return the success result
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in handleSignUp:', error);
|
||||
return { success: false, error: 'An unexpected error occurred' };
|
||||
} finally {
|
||||
// Always set loading to false when done, regardless of success or failure
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sign out
|
||||
const handleSignOut = async () => {
|
||||
// Prevent multiple simultaneous logout calls
|
||||
if (isSigningOut) {
|
||||
console.debug('Auth: Sign out already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
// Track sign out event before clearing user data
|
||||
// Use TokenManager to clear tokens and state
|
||||
await tokenManager.clearTokens();
|
||||
|
||||
// Also call authService signOut for any server-side cleanup
|
||||
await authService.signOut();
|
||||
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
|
||||
// Update Supabase auth state
|
||||
await updateSupabaseAuth();
|
||||
|
||||
// Reset RevenueCat user to anonymous
|
||||
try {
|
||||
await resetRevenueCatUser();
|
||||
} catch (error) {
|
||||
console.error('Error resetting RevenueCat user:', error);
|
||||
// Don't block sign out if reset fails
|
||||
}
|
||||
|
||||
// Don't navigate here - let the root layout handle navigation based on auth state
|
||||
// This prevents the infinite redirect loop on Android
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
} finally {
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle token expiration - try to refresh before logging out
|
||||
const handleTokenExpiration = async () => {
|
||||
console.debug('Auth: Handling token expiration');
|
||||
|
||||
// Check if we're offline first
|
||||
const { hasStableConnection, isDeviceConnected } = await import('~/features/errorHandling/utils/networkErrorUtils');
|
||||
const isOnline = await isDeviceConnected();
|
||||
|
||||
if (!isOnline) {
|
||||
console.debug('Auth: Device offline, preserving auth state');
|
||||
return; // Don't logout when offline
|
||||
}
|
||||
|
||||
// Check if connection is stable before attempting critical operations
|
||||
const isStable = await hasStableConnection();
|
||||
if (!isStable) {
|
||||
console.debug('Auth: Connection unstable during token expiration, deferring action');
|
||||
// Schedule a retry after network stabilizes
|
||||
setTimeout(() => {
|
||||
// Re-check token state after delay
|
||||
const currentState = tokenManager.getState();
|
||||
if (currentState === TokenState.EXPIRED) {
|
||||
handleTokenExpiration();
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we can attempt a refresh (has valid refresh token)
|
||||
const canRefresh = await tokenManager.canAttemptRefresh();
|
||||
|
||||
if (canRefresh) {
|
||||
console.debug('Auth: Refresh token available, attempting refresh before logout');
|
||||
|
||||
// Try to get a valid token (this will trigger refresh if needed)
|
||||
const validToken = await tokenManager.getValidToken();
|
||||
|
||||
if (validToken) {
|
||||
console.debug('Auth: Token refresh successful, staying logged in');
|
||||
// Token refresh succeeded, the TokenManager will notify us of the new valid state
|
||||
// Update user data to ensure UI is synchronized
|
||||
const userData = await authService.getUserFromToken();
|
||||
if (userData) {
|
||||
updateUserData(userData);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.debug('Auth: Token refresh failed despite having refresh token');
|
||||
}
|
||||
} else {
|
||||
console.debug('Auth: No refresh token available');
|
||||
}
|
||||
|
||||
// No refresh token or refresh failed, proceed with logout
|
||||
console.debug('Auth: Proceeding with logout due to expired tokens');
|
||||
await handleSignOut();
|
||||
|
||||
} catch (error) {
|
||||
console.debug('Error during token expiration handling:', error);
|
||||
// If anything fails, fall back to logout to ensure security
|
||||
await handleSignOut();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Google Sign-In
|
||||
const handleGoogleSignIn = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// The actual Google Sign-In is handled in the GoogleSignInButton component
|
||||
// Here we just handle the post-authentication flow
|
||||
|
||||
try {
|
||||
// Get user information from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (userData) {
|
||||
updateUserData(userData);
|
||||
|
||||
// Update Supabase auth state
|
||||
await updateSupabaseAuth();
|
||||
|
||||
// Identify user with RevenueCat
|
||||
try {
|
||||
await identifyRevenueCatUser(userData.id);
|
||||
} catch (rcError) {
|
||||
console.error('Error identifying user with RevenueCat:', rcError);
|
||||
// Don't block auth flow if RevenueCat identification fails
|
||||
}
|
||||
|
||||
// Track Google sign in event
|
||||
identify(userData.id, {
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
});
|
||||
track('user_signed_in', {
|
||||
method: 'google',
|
||||
});
|
||||
|
||||
// Use replace to prevent swiping back to login
|
||||
// Small delay to ensure auth state is updated
|
||||
setTimeout(() => {
|
||||
router.replace('/(protected)/(tabs)/');
|
||||
}, 100);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting user data after Google Sign-In:', err);
|
||||
}
|
||||
|
||||
return { success: false, error: 'No user data available after Google Sign-In' };
|
||||
} catch (error) {
|
||||
console.error('Error in handleGoogleSignIn:', error);
|
||||
return { success: false, error: 'An unexpected error occurred during Google Sign-In' };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
user,
|
||||
loading,
|
||||
showPasswordResetModal,
|
||||
setShowPasswordResetModal,
|
||||
authModeOverride,
|
||||
setAuthModeOverride,
|
||||
setUser: updateUserData,
|
||||
signIn: handleSignIn,
|
||||
signUp: handleSignUp,
|
||||
signInWithGoogle: handleGoogleSignIn,
|
||||
signOut: handleSignOut,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
1
apps/memoro/apps/mobile/features/auth/contexts/index.ts
Normal file
1
apps/memoro/apps/mobile/features/auth/contexts/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './AuthContext';
|
||||
5
apps/memoro/apps/mobile/features/auth/index.ts
Normal file
5
apps/memoro/apps/mobile/features/auth/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './contexts';
|
||||
export * from './services';
|
||||
export * from './lib';
|
||||
export * from './utils';
|
||||
export * from './components';
|
||||
2
apps/memoro/apps/mobile/features/auth/lib/index.ts
Normal file
2
apps/memoro/apps/mobile/features/auth/lib/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './supabaseClient';
|
||||
export { default as supabase } from './supabaseClient';
|
||||
274
apps/memoro/apps/mobile/features/auth/lib/supabaseClient.ts
Normal file
274
apps/memoro/apps/mobile/features/auth/lib/supabaseClient.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { createClient } from '@supabase/supabase-js';
|
||||
import { tokenManager, TokenState } from '../services/tokenManager';
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
// Supabase configuration from environment variables
|
||||
export const supabaseUrl =
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL || 'https://npgifbrwhftlbrbaglmi.supabase.co';
|
||||
export const supabaseAnonKey =
|
||||
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||
|
||||
'sb_publishable_HlAZpB4BxXaMcfOCNx6VJA_-64NTxu4';
|
||||
|
||||
// Store for the JWT token
|
||||
let jwtToken: string | null = null;
|
||||
|
||||
// Store for the authenticated client (singleton)
|
||||
let authClient: any = null;
|
||||
|
||||
// Track token state changes for automatic Supabase auth updates
|
||||
let tokenStateUnsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Create the base Supabase client with anonymous credentials
|
||||
*/
|
||||
const supabase = createClient(
|
||||
supabaseUrl,
|
||||
supabaseAnonKey, // Publishable keys work the same as anon keys
|
||||
{
|
||||
auth: {
|
||||
...(Platform.OS !== 'web' ? { storage: AsyncStorage } : {}),
|
||||
autoRefreshToken: false, // Handle token refresh manually
|
||||
persistSession: false, // Handle session persistence manually
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
global: {
|
||||
headers: {
|
||||
'x-application-name': 'memoro-app',
|
||||
'User-Agent': `Memoro/${Constants.expoConfig?.version || '2.0.5'} (${Platform.OS}; ${Platform.Version})`,
|
||||
'x-client-info': 'supabase-js/2.45.0',
|
||||
},
|
||||
},
|
||||
realtime: {
|
||||
headers: {
|
||||
'x-application-name': 'memoro-app',
|
||||
'User-Agent': `Memoro/${Constants.expoConfig?.version || '2.0.5'} (${Platform.OS}; ${Platform.Version})`,
|
||||
},
|
||||
params: {
|
||||
apikey: supabaseAnonKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Create an authenticated Supabase client with the JWT token
|
||||
* @param token JWT token to use for authentication
|
||||
* @returns Supabase client with authentication
|
||||
*/
|
||||
export const createAuthClient = (token: string) => {
|
||||
return createClient(
|
||||
supabaseUrl,
|
||||
supabaseAnonKey, // Publishable keys work the same as anon keys
|
||||
{
|
||||
auth: {
|
||||
...(Platform.OS !== 'web' ? { storage: AsyncStorage } : {}),
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
global: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'x-application-name': 'memoro-app',
|
||||
'User-Agent': `Memoro/${Constants.expoConfig?.version || '2.0.5'} (${Platform.OS}; ${Platform.Version})`,
|
||||
'x-client-info': 'supabase-js/2.45.0',
|
||||
},
|
||||
},
|
||||
realtime: {
|
||||
headers: {
|
||||
'x-application-name': 'memoro-app',
|
||||
'User-Agent': `Memoro/${Constants.expoConfig?.version || '2.0.5'} (${Platform.OS}; ${Platform.Version})`,
|
||||
},
|
||||
params: {
|
||||
apikey: supabaseAnonKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the stored JWT token and create a new authenticated client
|
||||
*/
|
||||
export const updateStoredToken = async (): Promise<void> => {
|
||||
try {
|
||||
// Get the JWT token from the token manager
|
||||
const token = await tokenManager.getValidToken();
|
||||
jwtToken = token;
|
||||
|
||||
if (token) {
|
||||
console.debug('JWT token updated:', `${token.substring(0, 20)}...`);
|
||||
|
||||
// Update Supabase headers with the new token
|
||||
setupSupabaseHeaders(token);
|
||||
|
||||
// Update realtime auth with the token
|
||||
supabase.realtime.setAuth(token);
|
||||
|
||||
// Debug: Try to decode the token to verify it has a sub claim
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
let payload;
|
||||
if (typeof window !== 'undefined' && window.atob) {
|
||||
payload = JSON.parse(window.atob(base64));
|
||||
} else {
|
||||
const base64js = require('base64-js');
|
||||
const uint8Array = base64js.toByteArray(base64);
|
||||
const decoded = String.fromCharCode.apply(null, uint8Array);
|
||||
payload = JSON.parse(decoded);
|
||||
}
|
||||
|
||||
console.debug('JWT payload:', {
|
||||
sub: payload.sub,
|
||||
role: payload.role,
|
||||
issuer: payload.iss,
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Error decoding JWT token:', error);
|
||||
}
|
||||
} else {
|
||||
console.debug('No JWT token available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error updating stored token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add Supabase-specific headers to requests
|
||||
* Note: Token refresh is now handled by the global fetch interceptor
|
||||
*/
|
||||
const setupSupabaseHeaders = (token: string) => {
|
||||
// Set auth token for Supabase realtime
|
||||
if (token) {
|
||||
supabase.realtime.setAuth(token);
|
||||
}
|
||||
};
|
||||
|
||||
// Note: Token will be initialized when explicitly needed to avoid infinite refresh loops
|
||||
|
||||
/**
|
||||
* Initialize automatic Supabase auth updates when token state changes
|
||||
*/
|
||||
export const initializeSupabaseAuth = (): void => {
|
||||
// Clean up existing listener
|
||||
if (tokenStateUnsubscribe) {
|
||||
tokenStateUnsubscribe();
|
||||
}
|
||||
|
||||
// Subscribe to token state changes
|
||||
tokenStateUnsubscribe = tokenManager.subscribe((state, token) => {
|
||||
console.debug('Supabase: Token state changed to', state);
|
||||
|
||||
if (state === TokenState.VALID && token) {
|
||||
// Update Supabase with new token
|
||||
updateStoredTokenFromData(token);
|
||||
} else if (state === TokenState.FAILED || state === TokenState.EXPIRED) {
|
||||
// Clear Supabase auth
|
||||
clearSupabaseAuth();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Supabase auth state when token changes
|
||||
* Only updates if we actually have a valid token to avoid triggering refresh loops
|
||||
*/
|
||||
export const updateSupabaseAuth = async (): Promise<void> => {
|
||||
try {
|
||||
// Get current token state from TokenManager
|
||||
const currentState = tokenManager.getState();
|
||||
|
||||
if (currentState === TokenState.VALID) {
|
||||
// Try to get a valid token (which will handle refresh if needed)
|
||||
const token = await tokenManager.getValidToken();
|
||||
if (token) {
|
||||
updateStoredTokenFromData(token);
|
||||
} else {
|
||||
console.debug('Skipping Supabase auth update - no valid token available');
|
||||
}
|
||||
} else {
|
||||
console.debug('Skipping Supabase auth update - token state is not valid:', currentState);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error in updateSupabaseAuth:', error);
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Supabase with a specific token
|
||||
*/
|
||||
const updateStoredTokenFromData = (token: string): void => {
|
||||
try {
|
||||
jwtToken = token;
|
||||
console.debug('Supabase: JWT token updated from TokenManager');
|
||||
|
||||
// Update Supabase headers with the new token
|
||||
setupSupabaseHeaders(token);
|
||||
|
||||
// Update realtime auth with the token
|
||||
supabase.realtime.setAuth(token);
|
||||
} catch (error) {
|
||||
console.debug('Error updating Supabase with token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear Supabase authentication
|
||||
*/
|
||||
const clearSupabaseAuth = (): void => {
|
||||
try {
|
||||
jwtToken = null;
|
||||
authClient = null;
|
||||
console.debug('Supabase: Auth cleared');
|
||||
|
||||
// Note: We don't clear supabase.realtime.setAuth(null) as this might cause issues
|
||||
// The realtime connection will naturally fail with an invalid token
|
||||
} catch (error) {
|
||||
console.debug('Error clearing Supabase auth:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set auth token for Supabase
|
||||
* @param token JWT token to use for authentication
|
||||
* @returns true if token was set, false otherwise
|
||||
*/
|
||||
export const setSupabaseToken = async (token: string | null): Promise<boolean> => {
|
||||
if (token) {
|
||||
jwtToken = token;
|
||||
supabase.realtime.setAuth(token);
|
||||
setupSupabaseHeaders(token);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an authenticated client using the current JWT token
|
||||
* This is useful when you need to ensure you're using the latest token
|
||||
* Uses a singleton pattern to avoid creating multiple client instances
|
||||
*/
|
||||
export const getAuthenticatedClient = async () => {
|
||||
const token = await tokenManager.getValidToken();
|
||||
|
||||
if (!token) {
|
||||
return supabase; // Return the base client if no token is available
|
||||
}
|
||||
|
||||
// Check if we need to create a new client (token changed or no client exists)
|
||||
if (!authClient || token !== jwtToken) {
|
||||
authClient = createAuthClient(token);
|
||||
jwtToken = token;
|
||||
}
|
||||
|
||||
return authClient;
|
||||
};
|
||||
|
||||
export default supabase;
|
||||
1373
apps/memoro/apps/mobile/features/auth/services/authService.ts
Normal file
1373
apps/memoro/apps/mobile/features/auth/services/authService.ts
Normal file
File diff suppressed because it is too large
Load diff
1
apps/memoro/apps/mobile/features/auth/services/index.ts
Normal file
1
apps/memoro/apps/mobile/features/auth/services/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './authService';
|
||||
664
apps/memoro/apps/mobile/features/auth/services/tokenManager.ts
Normal file
664
apps/memoro/apps/mobile/features/auth/services/tokenManager.ts
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
import { authService } from './authService';
|
||||
|
||||
// Token state management
|
||||
export enum TokenState {
|
||||
IDLE = 'idle',
|
||||
REFRESHING = 'refreshing',
|
||||
EXPIRED = 'expired',
|
||||
EXPIRED_OFFLINE = 'expired_offline', // Token expired while offline - preserve auth
|
||||
VALID = 'valid',
|
||||
}
|
||||
|
||||
// Request queue item
|
||||
interface QueuedRequest {
|
||||
id: string;
|
||||
input: RequestInfo | URL;
|
||||
init?: RequestInit;
|
||||
resolve: (value: Response) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Token refresh result
|
||||
interface TokenRefreshResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
shouldPreserveAuth?: boolean; // Don't clear auth on offline errors
|
||||
shouldRetry?: boolean; // Retry with longer delay
|
||||
}
|
||||
|
||||
// Observer for token state changes
|
||||
type TokenStateObserver = (state: TokenState, token?: string) => void;
|
||||
|
||||
/**
|
||||
* Centralized token manager to handle all authentication token operations
|
||||
* and eliminate race conditions in token refresh
|
||||
*/
|
||||
class TokenManager {
|
||||
private state: TokenState = TokenState.IDLE;
|
||||
private refreshPromise: Promise<TokenRefreshResult> | null = null;
|
||||
private requestQueue: QueuedRequest[] = [];
|
||||
private observers: Set<TokenStateObserver> = new Set();
|
||||
|
||||
// Configuration
|
||||
private readonly MAX_QUEUE_SIZE = 50;
|
||||
private readonly QUEUE_TIMEOUT_MS = 30000; // 30 seconds
|
||||
private readonly MAX_REFRESH_ATTEMPTS = 3;
|
||||
private refreshAttempts = 0;
|
||||
private lastRefreshTime = 0;
|
||||
private readonly REFRESH_COOLDOWN_MS = 5000; // 5 second cooldown
|
||||
|
||||
private static instance: TokenManager;
|
||||
|
||||
private constructor() {
|
||||
// Start with initial state check
|
||||
this.checkInitialState();
|
||||
}
|
||||
|
||||
static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to token state changes
|
||||
*/
|
||||
subscribe(observer: TokenStateObserver): () => void {
|
||||
this.observers.add(observer);
|
||||
return () => this.observers.delete(observer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all observers of state changes
|
||||
*/
|
||||
private notifyObservers(state: TokenState, token?: string): void {
|
||||
this.observers.forEach(observer => {
|
||||
try {
|
||||
observer(state, token);
|
||||
} catch (error) {
|
||||
console.debug('Error in token state observer:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current token state
|
||||
*/
|
||||
private setState(newState: TokenState, token?: string): void {
|
||||
if (this.state !== newState) {
|
||||
console.debug(`TokenManager: State transition ${this.state} -> ${newState}`);
|
||||
this.state = newState;
|
||||
this.notifyObservers(newState, token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token state
|
||||
*/
|
||||
getState(): TokenState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check initial token state on startup
|
||||
*/
|
||||
private async checkInitialState(): Promise<void> {
|
||||
try {
|
||||
const token = await authService.getAppToken();
|
||||
if (!token) {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
this.setState(TokenState.VALID, token);
|
||||
} else {
|
||||
this.setState(TokenState.EXPIRED);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error checking initial token state:', error);
|
||||
this.setState(TokenState.EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid token, refreshing if necessary
|
||||
*/
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const currentToken = await authService.getAppToken();
|
||||
|
||||
if (currentToken && authService.isTokenValidLocally(currentToken)) {
|
||||
this.setState(TokenState.VALID, currentToken);
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
// If there's no token at all (fresh install), don't attempt refresh
|
||||
if (!currentToken) {
|
||||
console.debug('TokenManager: No token available, skipping refresh (fresh install)');
|
||||
this.setState(TokenState.EXPIRED);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token is expired - check network status before attempting refresh
|
||||
const { isDeviceConnected } = await import('~/features/errorHandling/utils/networkErrorUtils');
|
||||
const isOnline = await isDeviceConnected();
|
||||
|
||||
if (!isOnline) {
|
||||
console.debug('TokenManager: Token expired while offline, preserving auth state');
|
||||
this.setState(TokenState.EXPIRED_OFFLINE, currentToken);
|
||||
return currentToken; // Return expired token for offline use
|
||||
}
|
||||
|
||||
// Online and expired - attempt refresh
|
||||
console.debug('TokenManager: Current token invalid, attempting refresh...');
|
||||
const refreshResult = await this.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
console.debug('TokenManager: Token refresh successful in getValidToken');
|
||||
return refreshResult.token;
|
||||
} else {
|
||||
console.debug('TokenManager: Token refresh failed in getValidToken:', refreshResult.error);
|
||||
|
||||
// Check if failure was due to going offline during refresh
|
||||
if (refreshResult.shouldPreserveAuth) {
|
||||
this.setState(TokenState.EXPIRED_OFFLINE, currentToken);
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 401 response by either refreshing token or queueing request
|
||||
*/
|
||||
async handle401Response(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
// Check if we're already refreshing
|
||||
if (this.state === TokenState.REFRESHING && this.refreshPromise) {
|
||||
return this.queueRequest(input, init);
|
||||
}
|
||||
|
||||
// Start token refresh
|
||||
const refreshResult = await this.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
// Retry the request with new token
|
||||
return this.retryRequestWithToken(input, init, refreshResult.token);
|
||||
} else {
|
||||
// Check if we're offline before throwing error
|
||||
if (refreshResult.error === 'offline') {
|
||||
console.debug('TokenManager: Offline during 401 handling, throwing network error');
|
||||
throw new Error('Network request failed: Device offline');
|
||||
}
|
||||
// Refresh failed, propagate error
|
||||
throw new Error(refreshResult.error || 'Token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a request during token refresh
|
||||
*/
|
||||
private async queueRequest(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check queue size limit
|
||||
if (this.requestQueue.length >= this.MAX_QUEUE_SIZE) {
|
||||
reject(new Error('Request queue full'));
|
||||
return;
|
||||
}
|
||||
|
||||
const queueItem: QueuedRequest = {
|
||||
id: Math.random().toString(36).substring(2, 11),
|
||||
input,
|
||||
init,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.requestQueue.push(queueItem);
|
||||
|
||||
// Set timeout for queued request
|
||||
setTimeout(() => {
|
||||
this.removeFromQueue(queueItem.id);
|
||||
reject(new Error('Queued request timeout'));
|
||||
}, this.QUEUE_TIMEOUT_MS);
|
||||
|
||||
console.debug(`TokenManager: Queued request ${queueItem.id}, queue size: ${this.requestQueue.length}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove request from queue by ID
|
||||
*/
|
||||
private removeFromQueue(requestId: string): void {
|
||||
const index = this.requestQueue.findIndex(item => item.id === requestId);
|
||||
if (index !== -1) {
|
||||
this.requestQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the authentication token with progressive backoff retry logic
|
||||
*/
|
||||
private async refreshToken(): Promise<TokenRefreshResult> {
|
||||
// Check cooldown to prevent rapid successive refresh attempts
|
||||
const now = Date.now();
|
||||
if (now - this.lastRefreshTime < this.REFRESH_COOLDOWN_MS) {
|
||||
console.debug('TokenManager: Refresh cooldown active, skipping refresh');
|
||||
return { success: false, error: 'Refresh cooldown active' };
|
||||
}
|
||||
|
||||
// Check max attempts
|
||||
if (this.refreshAttempts >= this.MAX_REFRESH_ATTEMPTS) {
|
||||
console.debug('TokenManager: Max refresh attempts reached');
|
||||
await this.handleRefreshFailure();
|
||||
return { success: false, error: 'Max refresh attempts reached' };
|
||||
}
|
||||
|
||||
// If already refreshing, wait for existing promise
|
||||
if (this.refreshPromise) {
|
||||
console.debug('TokenManager: Waiting for existing refresh to complete');
|
||||
return await this.refreshPromise;
|
||||
}
|
||||
|
||||
this.setState(TokenState.REFRESHING);
|
||||
this.lastRefreshTime = now;
|
||||
|
||||
// Use enhanced refresh with retry logic
|
||||
this.refreshPromise = this.performTokenRefreshWithRetry();
|
||||
|
||||
try {
|
||||
const result = await this.refreshPromise;
|
||||
|
||||
if (result.success) {
|
||||
this.refreshAttempts = 0; // Reset on success
|
||||
this.setState(TokenState.VALID, result.token);
|
||||
await this.processQueuedRequests(result.token!);
|
||||
} else {
|
||||
this.refreshAttempts++;
|
||||
this.setState(TokenState.EXPIRED);
|
||||
await this.rejectQueuedRequests(result.error || 'Token refresh failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced token refresh with progressive backoff for network issues
|
||||
*/
|
||||
private async performTokenRefreshWithRetry(): Promise<TokenRefreshResult> {
|
||||
const retryDelays = [0, 1000, 2000, 5000]; // Progressive backoff: 0ms, 1s, 2s, 5s
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
|
||||
try {
|
||||
// Wait for retry delay (except first attempt)
|
||||
if (retryDelays[attempt] > 0) {
|
||||
console.debug(`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelays[attempt]));
|
||||
}
|
||||
|
||||
const result = await this.performTokenRefresh();
|
||||
|
||||
if (result.success) {
|
||||
if (attempt > 0) {
|
||||
console.debug(`TokenManager: Token refresh succeeded on attempt ${attempt + 1}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle specific server-side errors that shouldn't be retried
|
||||
if (result.error === 'invalid_token' ||
|
||||
result.error === 'token_expired' ||
|
||||
result.error === 'invalid_token_state' ||
|
||||
result.error === 'token_collision' ||
|
||||
result.error?.includes('Device ID has changed')) {
|
||||
console.debug('TokenManager: Non-retryable error:', result.error);
|
||||
return result; // Don't retry permanent auth errors
|
||||
}
|
||||
|
||||
// Handle offline state - don't count as failure
|
||||
if (result.error === 'offline') {
|
||||
console.debug('TokenManager: Device offline, preserving auth state');
|
||||
return { success: false, error: 'offline', shouldPreserveAuth: true }; // Return without clearing tokens
|
||||
}
|
||||
|
||||
// Handle unstable connection - should retry with longer delay
|
||||
if (result.error === 'unstable_connection') {
|
||||
console.debug('TokenManager: Connection unstable, will retry with longer delay');
|
||||
// Use a longer delay for unstable connections
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
// Continue to next retry attempt
|
||||
}
|
||||
|
||||
// Handle refresh_in_progress or rotation_in_progress with shorter delay
|
||||
if (result.error === 'refresh_in_progress' || result.error === 'rotation_in_progress') {
|
||||
console.debug('TokenManager: Token rotation in progress, waiting...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s for other refresh
|
||||
// Try one more time after waiting
|
||||
const retryResult = await this.performTokenRefresh();
|
||||
if (retryResult.success) {
|
||||
return retryResult;
|
||||
}
|
||||
}
|
||||
|
||||
lastError = new Error(result.error || 'Token refresh failed');
|
||||
|
||||
// If this is the last attempt, return the error
|
||||
if (attempt === retryDelays.length - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Check if this is a recoverable network error
|
||||
const isRecoverable = this.isRecoverableError(error);
|
||||
|
||||
if (!isRecoverable) {
|
||||
console.debug('TokenManager: Non-recoverable error, stopping retries:', error);
|
||||
break; // Don't retry non-network errors
|
||||
}
|
||||
|
||||
console.debug(`TokenManager: Network error on attempt ${attempt + 1}, will retry:`, error);
|
||||
|
||||
// If this is the last attempt, break out
|
||||
if (attempt === retryDelays.length - 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
console.debug('TokenManager: All retry attempts failed');
|
||||
return {
|
||||
success: false,
|
||||
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual token refresh operation
|
||||
*/
|
||||
private async performTokenRefresh(): Promise<TokenRefreshResult> {
|
||||
try {
|
||||
console.debug('TokenManager: Starting token refresh');
|
||||
|
||||
// Check network status first - use stable connection check for critical operations
|
||||
const { hasStableConnection, isDeviceConnected } = await import('~/features/errorHandling/utils/networkErrorUtils');
|
||||
|
||||
// First check basic connectivity
|
||||
const isOnline = await isDeviceConnected();
|
||||
|
||||
if (!isOnline) {
|
||||
console.debug('TokenManager: Device offline, skipping refresh');
|
||||
// Preserve current token and mark as offline
|
||||
const currentToken = await authService.getAppToken();
|
||||
if (currentToken) {
|
||||
// Update state to EXPIRED_OFFLINE to preserve auth
|
||||
this.setState(TokenState.EXPIRED_OFFLINE, currentToken);
|
||||
}
|
||||
return { success: false, error: 'offline', shouldPreserveAuth: true };
|
||||
}
|
||||
|
||||
// For token refresh, ensure we have a stable connection
|
||||
const isStable = await hasStableConnection();
|
||||
if (!isStable) {
|
||||
console.debug('TokenManager: Connection not stable yet, will retry');
|
||||
// Return a specific error that indicates we should retry
|
||||
return { success: false, error: 'unstable_connection' };
|
||||
}
|
||||
|
||||
const refreshToken = await authService.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const refreshResult = await authService.refreshTokens(refreshToken);
|
||||
const { appToken, refreshToken: newRefreshToken, userData } = refreshResult;
|
||||
|
||||
if (!appToken || !newRefreshToken) {
|
||||
throw new Error('Invalid tokens received from refresh');
|
||||
}
|
||||
|
||||
// Note: authService.refreshTokens() already saves tokens to storage
|
||||
// No need to call updateTokens() again - this was causing race conditions
|
||||
|
||||
// If we have user data from the refresh, notify via the callback
|
||||
if (userData && authService.onTokenRefresh) {
|
||||
console.debug('TokenManager: Notifying auth context with fresh user data');
|
||||
authService.onTokenRefresh(userData);
|
||||
}
|
||||
|
||||
console.debug('TokenManager: Token refresh successful');
|
||||
return { success: true, token: appToken };
|
||||
|
||||
} catch (error) {
|
||||
console.debug('TokenManager: Token refresh failed:', error);
|
||||
|
||||
// Determine if this is a recoverable error
|
||||
const isRecoverable = this.isRecoverableError(error);
|
||||
|
||||
if (!isRecoverable) {
|
||||
await this.handleRefreshFailure();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown refresh error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry token refresh after coming back online
|
||||
* Call this when network connection is restored
|
||||
*/
|
||||
async retryRefreshIfNeeded(): Promise<boolean> {
|
||||
const currentState = this.state;
|
||||
|
||||
if (currentState !== TokenState.EXPIRED_OFFLINE) {
|
||||
console.debug('[TokenManager] No offline refresh needed, state:', currentState);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.debug('[TokenManager] Retrying token refresh after reconnection');
|
||||
|
||||
const { isDeviceConnected } = await import('~/features/errorHandling/utils/networkErrorUtils');
|
||||
const isOnline = await isDeviceConnected();
|
||||
|
||||
if (!isOnline) {
|
||||
console.debug('[TokenManager] Still offline, cannot refresh');
|
||||
return false;
|
||||
}
|
||||
|
||||
const refreshResult = await this.refreshToken();
|
||||
|
||||
if (refreshResult.success && refreshResult.token) {
|
||||
console.debug('[TokenManager] Token refresh successful after reconnection');
|
||||
return true;
|
||||
} else {
|
||||
console.warn('[TokenManager] Token refresh failed after reconnection:', refreshResult.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable (network issues vs auth failures)
|
||||
*/
|
||||
private isRecoverableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const networkErrors = [
|
||||
'network', 'Network', 'fetch', 'connection', 'timeout',
|
||||
'Failed to fetch', 'NetworkError', 'TypeError', 'ERR_NETWORK',
|
||||
'ERR_INTERNET_DISCONNECTED', 'ECONNREFUSED', 'ENOTFOUND',
|
||||
'ETIMEDOUT', 'Unable to resolve host', 'Request failed'
|
||||
];
|
||||
|
||||
const authErrors = [
|
||||
'401', '403', 'Unauthorized', 'Forbidden', 'Invalid token',
|
||||
'Token expired', 'jwt expired', 'jwt malformed'
|
||||
];
|
||||
|
||||
const errorString = `${error.message} ${error.name}`.toLowerCase();
|
||||
|
||||
const isNetworkError = networkErrors.some(keyword =>
|
||||
errorString.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const isAuthError = authErrors.some(keyword =>
|
||||
errorString.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
// Network errors are recoverable unless they also contain auth errors
|
||||
return isNetworkError && !isAuthError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle permanent refresh failure
|
||||
*/
|
||||
private async handleRefreshFailure(): Promise<void> {
|
||||
console.debug('TokenManager: Handling permanent refresh failure');
|
||||
|
||||
try {
|
||||
await authService.clearAuthStorage();
|
||||
this.setState(TokenState.EXPIRED);
|
||||
|
||||
// Don't automatically redirect here - let the AuthContext handle logout
|
||||
// The AuthContext will handle the logout flow properly
|
||||
} catch (error) {
|
||||
console.debug('Error in handleRefreshFailure:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should attempt token refresh (has valid refresh token)
|
||||
*/
|
||||
async canAttemptRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const refreshToken = await authService.getRefreshToken();
|
||||
return !!refreshToken;
|
||||
} catch (error) {
|
||||
console.debug('Error checking refresh token availability:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all queued requests with the new token
|
||||
*/
|
||||
private async processQueuedRequests(token: string): Promise<void> {
|
||||
console.debug(`TokenManager: Processing ${this.requestQueue.length} queued requests`);
|
||||
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
const response = await this.retryRequestWithToken(
|
||||
request.input,
|
||||
request.init,
|
||||
token
|
||||
);
|
||||
request.resolve(response);
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all queued requests with error
|
||||
*/
|
||||
private async rejectQueuedRequests(error: string): Promise<void> {
|
||||
console.debug(`TokenManager: Rejecting ${this.requestQueue.length} queued requests`);
|
||||
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a request with a new token
|
||||
*/
|
||||
private async retryRequestWithToken(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
token: string
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the token manager state (for testing or logout)
|
||||
*/
|
||||
reset(): void {
|
||||
this.state = TokenState.IDLE;
|
||||
this.refreshPromise = null;
|
||||
this.refreshAttempts = 0;
|
||||
this.lastRefreshTime = 0;
|
||||
|
||||
// Reject all queued requests
|
||||
const requests = [...this.requestQueue];
|
||||
this.requestQueue = [];
|
||||
|
||||
for (const request of requests) {
|
||||
request.reject(new Error('Token manager reset'));
|
||||
}
|
||||
|
||||
console.debug('TokenManager: Reset completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tokens and reset state (for logout)
|
||||
*/
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
await authService.clearAuthStorage();
|
||||
// Skip EXPIRED state transition during logout to prevent observer loops
|
||||
// Go directly to reset which sets IDLE state
|
||||
this.reset();
|
||||
} catch (error) {
|
||||
console.debug('Error clearing tokens:', error);
|
||||
// On error, still reset to ensure clean state
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue status for debugging
|
||||
*/
|
||||
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
|
||||
return {
|
||||
size: this.requestQueue.length,
|
||||
state: this.state,
|
||||
refreshAttempts: this.refreshAttempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export default tokenManager;
|
||||
174
apps/memoro/apps/mobile/features/auth/types/auth.types.ts
Normal file
174
apps/memoro/apps/mobile/features/auth/types/auth.types.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Authentication type definitions
|
||||
*/
|
||||
|
||||
import { WithId, Timestamps } from '~/features/core/types';
|
||||
|
||||
/**
|
||||
* JWT Token payload structure
|
||||
*/
|
||||
export interface DecodedToken {
|
||||
// Standard JWT claims
|
||||
sub: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
nbf?: number;
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
|
||||
// Custom claims
|
||||
email?: string;
|
||||
user_id?: string;
|
||||
session_id?: string;
|
||||
role?: string;
|
||||
app_settings?: AppSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* App settings from JWT
|
||||
*/
|
||||
export interface AppSettings {
|
||||
b2b?: B2BSettings;
|
||||
memoro?: MemoroSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* B2B customer settings
|
||||
*/
|
||||
export interface B2BSettings {
|
||||
disableRevenueCat: boolean;
|
||||
organizationId: string;
|
||||
plan: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoro app settings
|
||||
*/
|
||||
export interface MemoroSettings {
|
||||
dataUsageAcceptance: boolean;
|
||||
emailNewsletterOptIn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile
|
||||
*/
|
||||
export interface User extends WithId, Timestamps {
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
avatar_url?: string;
|
||||
phone?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication tokens
|
||||
*/
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login credentials
|
||||
*/
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration data
|
||||
*/
|
||||
export interface RegistrationData extends LoginCredentials {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
acceptTerms?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth provider types
|
||||
*/
|
||||
export type OAuthProvider = 'google' | 'apple' | 'facebook';
|
||||
|
||||
/**
|
||||
* OAuth credentials
|
||||
*/
|
||||
export interface OAuthCredentials {
|
||||
provider: OAuthProvider;
|
||||
idToken?: string;
|
||||
accessToken?: string;
|
||||
authCode?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
photo?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state
|
||||
*/
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
tokens: AuthTokens | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth response from API
|
||||
*/
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
tokens: AuthTokens;
|
||||
session?: {
|
||||
id: string;
|
||||
device?: string;
|
||||
ip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset request
|
||||
*/
|
||||
export interface PasswordResetRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset confirmation
|
||||
*/
|
||||
export interface PasswordResetConfirmation {
|
||||
token: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device information for multi-device support
|
||||
*/
|
||||
export interface DeviceInfo {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
deviceType: 'web' | 'ios' | 'android' | 'desktop';
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device session information
|
||||
*/
|
||||
export interface DeviceSession {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
deviceType: string;
|
||||
lastUsedAt: Date;
|
||||
createdAt: Date;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
123
apps/memoro/apps/mobile/features/auth/utils/deviceManager.ts
Normal file
123
apps/memoro/apps/mobile/features/auth/utils/deviceManager.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import * as Device from 'expo-device';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import Constants from 'expo-constants';
|
||||
import { Platform } from 'react-native';
|
||||
import { DeviceInfo } from '../types/auth.types';
|
||||
|
||||
export class DeviceManager {
|
||||
private static DEVICE_ID_KEY = 'mana_device_id';
|
||||
|
||||
static async getDeviceInfo(): Promise<DeviceInfo> {
|
||||
// Get or generate device ID
|
||||
let deviceId = await SecureStore.getItemAsync(this.DEVICE_ID_KEY);
|
||||
|
||||
// Debug log for loaded device ID
|
||||
if (deviceId) {
|
||||
console.debug(`Loaded existing device ID: ${deviceId}`);
|
||||
}
|
||||
|
||||
if (!deviceId) {
|
||||
// Generate a new UUID
|
||||
deviceId = await this.generateDeviceId();
|
||||
|
||||
// Try to store it persistently with retry logic
|
||||
let stored = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await SecureStore.setItemAsync(this.DEVICE_ID_KEY, deviceId);
|
||||
|
||||
// Verify it was actually stored
|
||||
const verifiedId = await SecureStore.getItemAsync(this.DEVICE_ID_KEY);
|
||||
if (verifiedId === deviceId) {
|
||||
console.debug(`Device ID stored successfully on attempt ${attempt}`);
|
||||
stored = true;
|
||||
break;
|
||||
} else {
|
||||
console.warn(`Device ID verification failed on attempt ${attempt}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to store device ID on attempt ${attempt}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!stored) {
|
||||
console.error('Failed to persist device ID after 3 attempts - using session-only ID');
|
||||
// The ID will work for this session but may change on app restart
|
||||
} else {
|
||||
console.debug('New device ID created and stored successfully');
|
||||
}
|
||||
}
|
||||
|
||||
// Always validate we have a device ID
|
||||
if (!deviceId) {
|
||||
throw new Error('Unable to generate device identifier');
|
||||
}
|
||||
|
||||
// Get device name
|
||||
const deviceName = await this.getDeviceName();
|
||||
|
||||
const deviceInfo = {
|
||||
deviceId,
|
||||
deviceName,
|
||||
deviceType: Platform.OS as 'ios' | 'android',
|
||||
userAgent: this.getUserAgent()
|
||||
};
|
||||
|
||||
console.debug('Device info:', {
|
||||
deviceId: deviceId.substring(0, 8) + '...',
|
||||
deviceName,
|
||||
deviceType: deviceInfo.deviceType
|
||||
});
|
||||
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
private static async getDeviceName(): Promise<string> {
|
||||
if (Device.deviceName) {
|
||||
return Device.deviceName;
|
||||
}
|
||||
|
||||
// Fallback device names
|
||||
const deviceModel = Device.modelName || 'Unknown';
|
||||
const osName = Device.osName || Platform.OS;
|
||||
return `${deviceModel} (${osName})`;
|
||||
}
|
||||
|
||||
private static getUserAgent(): string {
|
||||
// For Expo SDK 51+, use expoConfig instead of manifest
|
||||
const appName = Constants.expoConfig?.name || Constants.manifest?.name || 'Memoro';
|
||||
const appVersion = Constants.expoConfig?.version || Constants.manifest?.version || '1.0.0';
|
||||
const osName = Device.osName || Platform.OS;
|
||||
const osVersion = Device.osVersion || 'Unknown';
|
||||
const modelName = Device.modelName || 'Unknown Device';
|
||||
|
||||
return `${appName}/${appVersion} (${osName} ${osVersion}; ${modelName})`;
|
||||
}
|
||||
|
||||
private static async generateDeviceId(): Promise<string> {
|
||||
// Always generate a stable UUID instead of using unreliable platform-specific IDs
|
||||
// Constants.deviceId is deprecated and can change between app launches/installs
|
||||
return this.generateUUID();
|
||||
}
|
||||
|
||||
private static generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
static async clearDeviceId(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(this.DEVICE_ID_KEY);
|
||||
}
|
||||
|
||||
static async getStoredDeviceId(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(this.DEVICE_ID_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving stored device ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
156
apps/memoro/apps/mobile/features/auth/utils/fetchInterceptor.ts
Normal file
156
apps/memoro/apps/mobile/features/auth/utils/fetchInterceptor.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { tokenManager, TokenState } from '../services/tokenManager';
|
||||
import { updateSupabaseAuth } from '../lib/supabaseClient';
|
||||
import { authService } from '../services/authService';
|
||||
|
||||
/**
|
||||
* Set up a global fetch interceptor to handle token refresh for all API calls
|
||||
* Uses the enhanced TokenManager for race condition-free token management
|
||||
*/
|
||||
export const setupGlobalFetchInterceptor = (): void => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.fetch) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = extractUrl(input);
|
||||
|
||||
// Skip intercepting for non-API calls or calls that don't need auth
|
||||
if (shouldSkipInterception(url)) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
try {
|
||||
// First, try the request with current token
|
||||
const response = await makeRequestWithToken(originalFetch, input, init);
|
||||
|
||||
// If we get a 401, let TokenManager handle it
|
||||
if (response.status === 401) {
|
||||
const responseData = await response.clone().json().catch(() => ({}));
|
||||
console.debug('Received 401 response:', responseData);
|
||||
|
||||
const isTokenExpired = isTokenExpiredResponse(responseData);
|
||||
|
||||
if (isTokenExpired) {
|
||||
console.debug('Token expired, delegating to TokenManager');
|
||||
// Use TokenManager to handle the 401 and retry
|
||||
return await tokenManager.handle401Response(input, init);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.debug('Error in global fetch interceptor:', error);
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
}) as typeof fetch;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make a request with the current token
|
||||
*/
|
||||
async function makeRequestWithToken(
|
||||
originalFetch: typeof fetch,
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
// Get current token (don't force refresh here)
|
||||
const token = await authService.getAppToken();
|
||||
|
||||
if (token) {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
init = { ...init, headers };
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the refresh attempts counter (call this after successful operations)
|
||||
*/
|
||||
export const resetRefreshAttempts = (): void => {
|
||||
// TokenManager handles this internally, but we can provide a way to reset state
|
||||
tokenManager.reset();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle authentication failure by clearing storage and redirecting to login
|
||||
*/
|
||||
async function handleAuthFailure(): Promise<void> {
|
||||
try {
|
||||
await authService.clearAuthStorage();
|
||||
|
||||
// Use setTimeout to avoid React state updates during render
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const { router } = require('expo-router');
|
||||
router.replace('/(public)/login');
|
||||
} catch (routerError) {
|
||||
console.debug('Error redirecting to login:', routerError);
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.debug('Error handling auth failure:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract URL from various input types
|
||||
*/
|
||||
function extractUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
} else if (input instanceof URL) {
|
||||
return input.toString();
|
||||
} else if (input instanceof Request) {
|
||||
return input.url;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should skip interception
|
||||
*/
|
||||
function shouldSkipInterception(url: string): boolean {
|
||||
return !url ||
|
||||
url.includes('auth/signin') ||
|
||||
url.includes('auth/signup') ||
|
||||
url.includes('auth/refresh') ||
|
||||
url.includes('auth/forgot-password') ||
|
||||
// Skip Supabase storage operations to avoid ES256 JWT issues
|
||||
url.includes('.supabase.co/storage/') ||
|
||||
url.includes('/storage/v1/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response indicates token expiration
|
||||
*/
|
||||
function isTokenExpiredResponse(responseData: any): boolean {
|
||||
return responseData.error?.message === 'JWT expired' ||
|
||||
responseData.message === 'JWT expired' ||
|
||||
responseData.error === 'JWT expired' ||
|
||||
responseData.code === 'PGRST301' ||
|
||||
responseData.error?.code === 'PGRST301' ||
|
||||
responseData.message === 'Unauthorized' ||
|
||||
responseData.error === 'Unauthorized';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup token manager observers for Supabase integration
|
||||
*/
|
||||
export const setupTokenObservers = (): void => {
|
||||
// Subscribe to token state changes to update Supabase
|
||||
tokenManager.subscribe(async (state: TokenState, token?: string) => {
|
||||
try {
|
||||
if (state === TokenState.VALID && token) {
|
||||
console.debug('TokenManager: Token valid, updating Supabase auth');
|
||||
await updateSupabaseAuth();
|
||||
} else if (state === TokenState.EXPIRED) {
|
||||
console.debug('TokenManager: Token expired, clearing Supabase auth');
|
||||
// Supabase will handle the expired token automatically
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error updating Supabase auth from token observer:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
1
apps/memoro/apps/mobile/features/auth/utils/index.ts
Normal file
1
apps/memoro/apps/mobile/features/auth/utils/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './safeStorage';
|
||||
63
apps/memoro/apps/mobile/features/auth/utils/safeStorage.ts
Normal file
63
apps/memoro/apps/mobile/features/auth/utils/safeStorage.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
/**
|
||||
* Wrapper für AsyncStorage mit Fehlerbehandlung und Typisierung
|
||||
*/
|
||||
export const safeStorage = {
|
||||
/**
|
||||
* Speichert einen Wert im AsyncStorage
|
||||
* @param key Schlüssel unter dem der Wert gespeichert wird
|
||||
* @param value Wert der gespeichert werden soll (wird automatisch zu JSON konvertiert)
|
||||
*/
|
||||
setItem: async <T>(key: string, value: T): Promise<void> => {
|
||||
try {
|
||||
// Skip saving if value is undefined
|
||||
if (value === undefined) {
|
||||
console.warn(`Attempted to save undefined value for key: ${key}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
const jsonValue = JSON.stringify(value);
|
||||
await AsyncStorage.setItem(key, jsonValue);
|
||||
} catch (e) {
|
||||
console.error('Error saving data', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Holt einen Wert aus dem AsyncStorage
|
||||
* @param key Schlüssel des zu holenden Werts
|
||||
* @returns Der gespeicherte Wert oder null wenn nicht vorhanden
|
||||
*/
|
||||
getItem: async <T>(key: string): Promise<T | null> => {
|
||||
try {
|
||||
const jsonValue = await AsyncStorage.getItem(key);
|
||||
return jsonValue != null ? JSON.parse(jsonValue) : null;
|
||||
} catch (e) {
|
||||
console.error('Error reading data', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Entfernt einen Wert aus dem AsyncStorage
|
||||
* @param key Schlüssel des zu entfernenden Werts
|
||||
*/
|
||||
removeItem: async (key: string): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
console.error('Error removing data', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Löscht alle Werte aus dem AsyncStorage
|
||||
*/
|
||||
clear: async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.clear();
|
||||
} catch (e) {
|
||||
console.error('Error clearing data', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { View, ScrollView, Dimensions, StyleSheet, Pressable, NativeSyntheticEvent, NativeScrollEvent, Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface AdviceSection {
|
||||
id: string;
|
||||
content: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface AdviceData {
|
||||
sections: AdviceSection[];
|
||||
metadata: {
|
||||
version: string;
|
||||
lastUpdated: string;
|
||||
supportedLanguages: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface AdviceCarouselProps {
|
||||
blueprintId: string | null;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
const AdviceCarousel: React.FC<AdviceCarouselProps> = ({
|
||||
blueprintId,
|
||||
language = 'de', // Default to German
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const [advice, setAdvice] = useState<AdviceData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const isScrollingRef = useRef(false);
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
|
||||
// Direkter Zugriff auf die Datenbank für Blueprints
|
||||
const supabase = createClient(
|
||||
'https://npgifbrwhftlbrbaglmi.supabase.co',
|
||||
'sb_publishable_HlAZpB4BxXaMcfOCNx6VJA_-64NTxu4'
|
||||
);
|
||||
|
||||
// Direkter Zugriff auf die Farben aus der Tailwind-Konfiguration - exakt wie in PillFilter
|
||||
const menuBackgroundColor = useMemo(() => {
|
||||
const themeColors = (colors as any).theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#FFFFFF';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
|
||||
// Lade Advice-Daten aus der Datenbank
|
||||
useEffect(() => {
|
||||
const fetchAdvice = async () => {
|
||||
if (!blueprintId) {
|
||||
setAdvice(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('blueprints')
|
||||
.select('advice')
|
||||
.eq('id', blueprintId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.debug('Fehler beim Laden des Advice:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.advice) {
|
||||
setAdvice(data.advice as AdviceData);
|
||||
setCurrentSectionIndex(0); // Reset to first section when blueprint changes
|
||||
} else {
|
||||
setAdvice(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Unerwarteter Fehler:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAdvice();
|
||||
}, [blueprintId]);
|
||||
|
||||
// Scroll to the current section
|
||||
useEffect(() => {
|
||||
if (scrollViewRef.current && advice?.sections && advice.sections.length > 0 && !isScrollingRef.current) {
|
||||
// Ensure the index is within bounds
|
||||
const boundedIndex = Math.max(0, Math.min(currentSectionIndex, advice.sections.length - 1));
|
||||
|
||||
isScrollingRef.current = true;
|
||||
scrollViewRef.current.scrollTo({
|
||||
x: boundedIndex * screenWidth,
|
||||
animated: true,
|
||||
});
|
||||
|
||||
// Reset scroll flag after animation
|
||||
setTimeout(() => {
|
||||
isScrollingRef.current = false;
|
||||
}, 300);
|
||||
}
|
||||
}, [currentSectionIndex, screenWidth, advice]);
|
||||
|
||||
// Handle scroll to update current section index (more responsive)
|
||||
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (isScrollingRef.current || !advice?.sections) return;
|
||||
|
||||
const contentOffsetX = event.nativeEvent.contentOffset.x;
|
||||
|
||||
// Use Math.round for more accurate calculation on Android
|
||||
let newIndex = Math.round(contentOffsetX / screenWidth);
|
||||
|
||||
// Ensure bounds checking
|
||||
newIndex = Math.max(0, Math.min(newIndex, advice.sections.length - 1));
|
||||
|
||||
// Only update if we're close enough to a page boundary (helps with Android precision)
|
||||
const exactPosition = contentOffsetX / screenWidth;
|
||||
const distanceFromPage = Math.abs(exactPosition - newIndex);
|
||||
|
||||
if (distanceFromPage < 0.5 && newIndex !== currentSectionIndex) {
|
||||
setCurrentSectionIndex(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle scroll end to ensure final position is correct
|
||||
const handleScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!advice?.sections) return;
|
||||
|
||||
const contentOffsetX = event.nativeEvent.contentOffset.x;
|
||||
let newIndex = Math.round(contentOffsetX / screenWidth);
|
||||
|
||||
// Ensure bounds checking
|
||||
newIndex = Math.max(0, Math.min(newIndex, advice.sections.length - 1));
|
||||
|
||||
if (newIndex !== currentSectionIndex) {
|
||||
setCurrentSectionIndex(newIndex);
|
||||
}
|
||||
|
||||
isScrollingRef.current = false;
|
||||
};
|
||||
|
||||
// If no blueprint selected or no advice available
|
||||
if (!blueprintId || !advice || !advice.sections || (advice.sections && advice.sections.length <= 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort sections by order
|
||||
const sortedSections = [...advice.sections].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Einfacherer Markdown-Style ohne Überschriften
|
||||
const markdownStyles = {
|
||||
body: {
|
||||
color: textColor,
|
||||
fontSize: 16, // Größere Schrift für bessere Lesbarkeit
|
||||
},
|
||||
paragraph: {
|
||||
color: textColor,
|
||||
marginBottom: 0, // Kein Abstand zwischen Absätzen, da wir nur einen Tipp pro Seite haben
|
||||
marginTop: 0, // Kein Abstand nach oben
|
||||
lineHeight: 24, // Größerer Zeilenabstand für bessere Lesbarkeit
|
||||
textAlign: 'center', // Zentrierter Text
|
||||
fontWeight: '500', // Etwas fetter für bessere Lesbarkeit
|
||||
},
|
||||
// Andere Markdown-Elemente werden nicht benötigt, da wir nur einfachen Text anzeigen
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: menuBackgroundColor }]}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
// Android-specific improvements
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
snapToAlignment="center"
|
||||
>
|
||||
{sortedSections.map((section, index) => (
|
||||
<View
|
||||
key={section.id}
|
||||
style={[
|
||||
styles.sectionContainer,
|
||||
{
|
||||
width: screenWidth, // Volle Bildschirmbreite
|
||||
backgroundColor: menuBackgroundColor
|
||||
}
|
||||
]}
|
||||
>
|
||||
{/* Keine Überschrift mehr */}
|
||||
<View style={styles.contentContainer}>
|
||||
<Markdown
|
||||
style={markdownStyles}
|
||||
>
|
||||
{section.content[language as keyof typeof section.content] ||
|
||||
section.content.en ||
|
||||
section.content.de ||
|
||||
'No content available'}
|
||||
</Markdown>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Pagination dots */}
|
||||
<View style={styles.paginationContainer}>
|
||||
{sortedSections.map((_, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={[
|
||||
styles.paginationDot,
|
||||
index === currentSectionIndex && styles.paginationDotActive,
|
||||
{
|
||||
backgroundColor: index === currentSectionIndex
|
||||
? (isDark ? '#FFFFFF' : '#000000')
|
||||
: (isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)')
|
||||
}
|
||||
]}
|
||||
onPress={() => {
|
||||
if (index >= 0 && index < sortedSections.length) {
|
||||
setCurrentSectionIndex(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
scrollView: {
|
||||
// Keine feste Höhe, damit die Komponente so groß wie ihr Inhalt ist
|
||||
},
|
||||
scrollViewContent: {
|
||||
// Keine alignItems hier, da es mit pagingEnabled Probleme verursachen kann
|
||||
},
|
||||
sectionContainer: {
|
||||
marginHorizontal: 0,
|
||||
borderRadius: 0, // Keine Rundungen, wie bei PillFilter
|
||||
paddingHorizontal: 20, // Konsistente horizontale Abstände
|
||||
paddingVertical: 8, // Reduzierte vertikale Abstände
|
||||
// Keine Schatten, um den gleichen Look wie PillFilter zu haben
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
contentContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 40, // Reduzierte Mindesthöhe
|
||||
paddingVertical: 8, // Etwas mehr vertikaler Abstand
|
||||
},
|
||||
paginationContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 8, // Verringerter Abstand zwischen Text und Indikator
|
||||
marginBottom: 8, // Erhöhter Abstand zwischen Indikator und unterem Rand
|
||||
},
|
||||
paginationDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
paginationDotActive: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
});
|
||||
|
||||
export default AdviceCarousel;
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PillFilter from '~/components/molecules/PillFilter';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useLanguage } from '~/features/i18n/LanguageContext';
|
||||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
import {
|
||||
getActiveBlueprintsForUser,
|
||||
toggleBlueprintActive,
|
||||
} from '../lib/activeBlueprintService';
|
||||
import tagEvents from '~/features/tags/tagEvents';
|
||||
import { STANDARD_BLUEPRINT_ID } from '../constants';
|
||||
|
||||
interface Blueprint {
|
||||
id: string;
|
||||
name: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
description?: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
interface FilterItem {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface BlueprintSelectorProps {
|
||||
selectedBlueprintId: string | null;
|
||||
onSelectBlueprint: (blueprintId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* BlueprintSelector-Komponente
|
||||
*
|
||||
* Eine horizontal scrollbare Liste von Blueprints, aus denen der Benutzer wählen kann.
|
||||
* Zeigt öffentliche Blueprints an und ermöglicht die Auswahl eines Blueprints für die Aufnahme.
|
||||
*/
|
||||
const BlueprintSelector: React.FC<BlueprintSelectorProps> = ({
|
||||
selectedBlueprintId,
|
||||
onSelectBlueprint,
|
||||
}) => {
|
||||
const { themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { currentLanguage } = useLanguage();
|
||||
const [blueprints, setBlueprints] = useState<Blueprint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeBlueprintIds, setActiveBlueprintIds] = useState<string[]>([]);
|
||||
|
||||
// Fetch aktivierte Blueprints aus der Datenbank
|
||||
const fetchBlueprints = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Hole alle aktivierten Blueprint-IDs des Nutzers
|
||||
const activeIds = await getActiveBlueprintsForUser();
|
||||
setActiveBlueprintIds(activeIds);
|
||||
|
||||
// Get authenticated client
|
||||
const supabase = await getAuthenticatedClient();
|
||||
|
||||
// **WICHTIG: Ändere die Logik - zeige nur aktivierte Blueprints**
|
||||
const query = supabase
|
||||
.from('blueprints')
|
||||
.select('id, name, description, is_public')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Zeige nur aktivierte Blueprints an
|
||||
if (activeIds.length > 0) {
|
||||
query.in('id', activeIds);
|
||||
} else {
|
||||
// Wenn keine Blueprints aktiviert sind, zeige leere Liste
|
||||
setBlueprints([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
console.debug('Fehler beim Laden der Blueprints:', error.message);
|
||||
setError('Blueprints konnten nicht geladen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBlueprints(data || []);
|
||||
} catch (err) {
|
||||
console.debug('Unerwarteter Fehler:', err);
|
||||
setError('Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lade aktivierte Blueprints des Nutzers aus der Datenbank
|
||||
useEffect(() => {
|
||||
fetchBlueprints();
|
||||
|
||||
// Event-Listener für Blueprint-Pinning-Änderungen einrichten
|
||||
const blueprintPinnedUnsubscribe = tagEvents.onBlueprintPinned(({ blueprintId, isPinned }) => {
|
||||
console.debug('BlueprintSelector: Blueprint pinned event received', {
|
||||
blueprintId,
|
||||
isPinned,
|
||||
});
|
||||
// Blueprints neu laden, um die Änderung zu berücksichtigen
|
||||
fetchBlueprints();
|
||||
});
|
||||
|
||||
// Cleanup beim Unmounten
|
||||
return () => {
|
||||
blueprintPinnedUnsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Bestimme die Farben basierend auf dem Theme
|
||||
const getBlueprintColor = () => {
|
||||
if (themeVariant === 'nature') {
|
||||
return '#81C784';
|
||||
} else if (themeVariant === 'stone') {
|
||||
return '#90A4AE';
|
||||
} else if (themeVariant === 'ocean') {
|
||||
return '#4FC3F7';
|
||||
} else {
|
||||
// Lume theme
|
||||
return '#f8d62b';
|
||||
}
|
||||
};
|
||||
|
||||
const themeColor = getBlueprintColor();
|
||||
|
||||
// Bestimme die Sprache basierend auf der aktuellen UI-Sprache
|
||||
const lang = currentLanguage.startsWith('de') ? 'de' : 'en';
|
||||
|
||||
// Konvertiere Blueprints in das FilterItem-Format
|
||||
const filterItems: FilterItem[] = blueprints.map((blueprint) => ({
|
||||
id: blueprint.id,
|
||||
label:
|
||||
blueprint.name?.[lang] ||
|
||||
blueprint.name?.en ||
|
||||
blueprint.name?.de ||
|
||||
t('blueprints.unnamed_blueprint', 'Unbenannter Modus'),
|
||||
isPinned: activeBlueprintIds.includes(blueprint.id),
|
||||
}));
|
||||
|
||||
const handleSelectItem = (id: string) => {
|
||||
if (id === 'all') {
|
||||
// Use the standard blueprint ID instead of null
|
||||
onSelectBlueprint(STANDARD_BLUEPRINT_ID);
|
||||
} else {
|
||||
onSelectBlueprint(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle blueprint active status
|
||||
const handleToggleActive = async (blueprintId: string) => {
|
||||
try {
|
||||
const isCurrentlyActive = activeBlueprintIds.includes(blueprintId);
|
||||
const success = await toggleBlueprintActive(blueprintId, !isCurrentlyActive);
|
||||
|
||||
if (success) {
|
||||
// Update local state
|
||||
if (isCurrentlyActive) {
|
||||
setActiveBlueprintIds((prev) => prev.filter((id) => id !== blueprintId));
|
||||
} else {
|
||||
setActiveBlueprintIds((prev) => [...prev, blueprintId]);
|
||||
}
|
||||
|
||||
// Refresh blueprints list to reflect changes
|
||||
const newActiveIds = await getActiveBlueprintsForUser();
|
||||
setActiveBlueprintIds(newActiveIds);
|
||||
|
||||
// Update blueprints list by refetching
|
||||
fetchBlueprints();
|
||||
|
||||
// Event emittieren für andere Komponenten
|
||||
tagEvents.emitBlueprintPinned(blueprintId, !isCurrentlyActive);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Error toggling blueprint active status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to blueprints page and open blueprint modal
|
||||
const handleShowBlueprintInfo = (blueprintId: string) => {
|
||||
router.push({
|
||||
pathname: '/(protected)/blueprints',
|
||||
params: {
|
||||
openModal: 'true',
|
||||
blueprintId: blueprintId,
|
||||
},
|
||||
} as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<PillFilter
|
||||
items={filterItems}
|
||||
selectedIds={selectedBlueprintId && selectedBlueprintId !== STANDARD_BLUEPRINT_ID ? [selectedBlueprintId] : []}
|
||||
onSelectItem={handleSelectItem}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
iconName="clipboard-outline"
|
||||
iconNavigateTo="/(protected)/blueprints"
|
||||
showAllOption={true}
|
||||
allOptionLabel={t('blueprints.standard_analysis', 'Standard')}
|
||||
iconType="plus"
|
||||
enableContextMenu={true}
|
||||
onTogglePin={handleToggleActive}
|
||||
onEdit={handleShowBlueprintInfo}
|
||||
editLabel="Info"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlueprintSelector;
|
||||
7
apps/memoro/apps/mobile/features/blueprints/constants.ts
Normal file
7
apps/memoro/apps/mobile/features/blueprints/constants.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Blueprint Constants
|
||||
*/
|
||||
|
||||
// Standard blueprint ID - this should match the ID in the database
|
||||
// This blueprint is used as the default when no specific blueprint is selected
|
||||
export const STANDARD_BLUEPRINT_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
import { authService } from '~/features/auth/services/authService';
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Blueprint für den aktuellen Nutzer aktiviert ist
|
||||
*
|
||||
* @param blueprintId Die ID des Blueprints
|
||||
* @returns true, wenn der Blueprint aktiviert ist, sonst false
|
||||
*/
|
||||
export const isActiveBlueprintForUser = async (blueprintId: string): Promise<boolean> => {
|
||||
try {
|
||||
// Prüfe zuerst, ob der Nutzer authentifiziert ist
|
||||
const isAuthenticated = await authService.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
console.debug('Nutzer ist nicht authentifiziert');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hole den aktuellen Nutzer aus dem Token
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (!userData || !userData.id) {
|
||||
console.debug('Keine Nutzer-ID gefunden');
|
||||
return false;
|
||||
}
|
||||
|
||||
const supabase = await getAuthenticatedClient();
|
||||
if (!supabase) {
|
||||
console.debug('Konnte keinen authentifizierten Client erstellen');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prüfe, ob der Blueprint bereits aktiviert ist
|
||||
const { data, error } = await supabase
|
||||
.from('user_active_blueprints')
|
||||
.select('is_active')
|
||||
.eq('user_id', userData.id)
|
||||
.eq('blueprint_id', blueprintId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
// Wenn der Fehler "No rows found" ist, bedeutet das, dass der Blueprint nicht aktiviert ist
|
||||
if (error.code === 'PGRST116') {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.debug('Datenbankfehler beim Prüfen des Blueprint-Status:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return data?.is_active ?? false;
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Prüfen des Blueprint-Status:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Aktiviert oder deaktiviert einen Blueprint für den aktuellen Nutzer
|
||||
*
|
||||
* @param blueprintId Die ID des Blueprints
|
||||
* @param isActive true, um den Blueprint zu aktivieren, false, um ihn zu deaktivieren
|
||||
* @returns true bei Erfolg, false bei Fehler
|
||||
*/
|
||||
export const toggleBlueprintActive = async (blueprintId: string, isActive: boolean): Promise<boolean> => {
|
||||
try {
|
||||
// Prüfe zuerst, ob der Nutzer authentifiziert ist
|
||||
const isAuthenticated = await authService.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
console.debug('Nutzer ist nicht authentifiziert');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hole den aktuellen Nutzer aus dem Token
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (!userData || !userData.id) {
|
||||
console.debug('Keine Nutzer-ID gefunden');
|
||||
return false;
|
||||
}
|
||||
|
||||
const supabase = await getAuthenticatedClient();
|
||||
if (!supabase) {
|
||||
console.debug('Konnte keinen authentifizierten Client erstellen');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prüfe, ob der Blueprint bereits existiert
|
||||
const { data: existingData, error: queryError } = await supabase
|
||||
.from('user_active_blueprints')
|
||||
.select('id')
|
||||
.eq('user_id', userData.id)
|
||||
.eq('blueprint_id', blueprintId)
|
||||
.maybeSingle(); // Verwende maybeSingle statt single, um Fehler zu vermeiden
|
||||
|
||||
if (queryError && queryError.code !== 'PGRST116') { // PGRST116 = No rows found
|
||||
console.debug('Fehler beim Abfragen des Blueprint-Status:', queryError);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existingData?.id) {
|
||||
// Aktualisiere den bestehenden Eintrag
|
||||
const { error } = await supabase
|
||||
.from('user_active_blueprints')
|
||||
.update({ is_active: isActive, updated_at: new Date().toISOString() })
|
||||
.eq('id', existingData.id);
|
||||
|
||||
if (error) {
|
||||
console.debug('Fehler beim Aktualisieren des Blueprint-Status:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
// Erstelle einen neuen Eintrag
|
||||
const { error } = await supabase
|
||||
.from('user_active_blueprints')
|
||||
.insert({
|
||||
user_id: userData.id,
|
||||
blueprint_id: blueprintId,
|
||||
is_active: isActive,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.debug('Fehler beim Erstellen des Blueprint-Status:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Aktivieren/Deaktivieren des Blueprints:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ruft alle aktiven Blueprints für den aktuellen Nutzer ab
|
||||
*
|
||||
* @returns Ein Array mit den IDs der aktiven Blueprints
|
||||
*/
|
||||
export const getActiveBlueprintsForUser = async (): Promise<string[]> => {
|
||||
try {
|
||||
// Prüfe zuerst, ob der Nutzer authentifiziert ist
|
||||
const isAuthenticated = await authService.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
console.debug('Nutzer ist nicht authentifiziert');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Hole den aktuellen Nutzer aus dem Token
|
||||
const userData = await authService.getUserFromToken();
|
||||
|
||||
if (!userData || !userData.id) {
|
||||
console.debug('Keine Nutzer-ID gefunden');
|
||||
return [];
|
||||
}
|
||||
|
||||
const supabase = await getAuthenticatedClient();
|
||||
if (!supabase) {
|
||||
console.debug('Konnte keinen authentifizierten Client erstellen');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Hole alle aktiven Blueprints
|
||||
const { data, error } = await supabase
|
||||
.from('user_active_blueprints')
|
||||
.select('blueprint_id')
|
||||
.eq('user_id', userData.id)
|
||||
.eq('is_active', true);
|
||||
|
||||
if (error) {
|
||||
console.debug('Datenbankfehler beim Abrufen der aktiven Blueprints:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data?.map(item => item.blueprint_id) || [];
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Abrufen der aktiven Blueprints:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
163
apps/memoro/apps/mobile/features/core/hooks/README.md
Normal file
163
apps/memoro/apps/mobile/features/core/hooks/README.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# useEffect Cleanup Patterns
|
||||
|
||||
This directory contains custom hooks that help ensure proper cleanup in useEffect hooks to prevent memory leaks and other issues.
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
### 1. `useAutoFocus`
|
||||
Automatically focuses an input element with proper timeout cleanup.
|
||||
|
||||
```tsx
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
useAutoFocus(inputRef, { delay: 300 });
|
||||
```
|
||||
|
||||
### 2. `useTimeout`
|
||||
Manages setTimeout with automatic cleanup on unmount.
|
||||
|
||||
```tsx
|
||||
// Automatic cleanup
|
||||
useTimeout(() => {
|
||||
showToast('Welcome!');
|
||||
}, 2000);
|
||||
|
||||
// Conditional timeout
|
||||
useTimeout(() => {
|
||||
showToast('Welcome!');
|
||||
}, isFirstVisit ? 2000 : null);
|
||||
```
|
||||
|
||||
### 3. `useTimeoutFn`
|
||||
Returns functions to manually control timeouts with cleanup.
|
||||
|
||||
```tsx
|
||||
const { setTimeout, clearTimeout } = useTimeoutFn();
|
||||
|
||||
const handleClick = () => {
|
||||
setTimeout(() => {
|
||||
console.log('Delayed action');
|
||||
}, 1000);
|
||||
};
|
||||
```
|
||||
|
||||
### 4. `useAsyncEffect`
|
||||
Handles async operations in useEffect with proper cleanup.
|
||||
|
||||
```tsx
|
||||
useAsyncEffect(async (isMounted) => {
|
||||
const data = await fetchData();
|
||||
if (isMounted()) {
|
||||
setData(data);
|
||||
}
|
||||
}, [id]);
|
||||
```
|
||||
|
||||
### 5. `useAsyncEffectWithCleanup`
|
||||
Uses AbortController for cancellable async operations.
|
||||
|
||||
```tsx
|
||||
useAsyncEffectWithCleanup(async (signal) => {
|
||||
const response = await fetch(url, { signal });
|
||||
const data = await response.json();
|
||||
setData(data);
|
||||
}, [url]);
|
||||
```
|
||||
|
||||
## Common Patterns That Need Cleanup
|
||||
|
||||
### 1. setTimeout/setInterval
|
||||
```tsx
|
||||
// ❌ Bad - No cleanup
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
doSomething();
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
// ✅ Good - With cleanup
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
doSomething();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 2. Event Listeners
|
||||
```tsx
|
||||
// ❌ Bad - No cleanup
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// ✅ Good - With cleanup
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 3. Subscriptions
|
||||
```tsx
|
||||
// ❌ Bad - No cleanup
|
||||
useEffect(() => {
|
||||
const subscription = service.subscribe(handleUpdate);
|
||||
}, []);
|
||||
|
||||
// ✅ Good - With cleanup
|
||||
useEffect(() => {
|
||||
const subscription = service.subscribe(handleUpdate);
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 4. Async Operations
|
||||
```tsx
|
||||
// ❌ Bad - Can update unmounted component
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const data = await api.getData();
|
||||
setData(data); // Might run after unmount
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// ✅ Good - Checks if mounted
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function fetchData() {
|
||||
const data = await api.getData();
|
||||
if (isMounted) {
|
||||
setData(data);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
To migrate existing code:
|
||||
|
||||
1. Replace direct `setTimeout` in useEffect with `useTimeout` hook
|
||||
2. Replace manual auto-focus logic with `useAutoFocus` hook
|
||||
3. Use `useAsyncEffect` for async operations that update state
|
||||
4. Always return cleanup functions for:
|
||||
- Timers (setTimeout, setInterval)
|
||||
- Event listeners
|
||||
- Subscriptions
|
||||
- Observers
|
||||
- WebSocket connections
|
||||
3
apps/memoro/apps/mobile/features/core/hooks/index.ts
Normal file
3
apps/memoro/apps/mobile/features/core/hooks/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useAutoFocus } from './useAutoFocus';
|
||||
export { useAsyncEffect, useAsyncEffectWithCleanup } from './useAsyncEffect';
|
||||
export { useTimeout, useTimeoutFn } from './useTimeout';
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { useEffect, useRef, DependencyList } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for handling async operations in useEffect with proper cleanup
|
||||
* Prevents state updates on unmounted components
|
||||
*
|
||||
* @param asyncFunction - The async function to run
|
||||
* @param deps - Dependency array for the effect
|
||||
*
|
||||
* @example
|
||||
* useAsyncEffect(async (isMounted) => {
|
||||
* const data = await fetchData();
|
||||
* if (isMounted()) {
|
||||
* setData(data);
|
||||
* }
|
||||
* }, [id]);
|
||||
*/
|
||||
export function useAsyncEffect(
|
||||
asyncFunction: (isMounted: () => boolean) => Promise<void>,
|
||||
deps: DependencyList
|
||||
) {
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const isMounted = () => isMountedRef.current;
|
||||
|
||||
// Execute the async function
|
||||
asyncFunction(isMounted).catch((error) => {
|
||||
if (isMounted()) {
|
||||
console.error('Error in useAsyncEffect:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative implementation that returns a cleanup function
|
||||
*
|
||||
* @example
|
||||
* useAsyncEffectWithCleanup(async (signal) => {
|
||||
* const response = await fetch(url, { signal });
|
||||
* const data = await response.json();
|
||||
* setData(data);
|
||||
* }, [url]);
|
||||
*/
|
||||
export function useAsyncEffectWithCleanup(
|
||||
asyncFunction: (signal: AbortSignal) => Promise<void>,
|
||||
deps: DependencyList
|
||||
) {
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
asyncFunction(abortController.signal).catch((error) => {
|
||||
// Ignore abort errors
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error in useAsyncEffectWithCleanup:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
35
apps/memoro/apps/mobile/features/core/hooks/useAutoFocus.ts
Normal file
35
apps/memoro/apps/mobile/features/core/hooks/useAutoFocus.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
interface UseAutoFocusOptions {
|
||||
delay?: number;
|
||||
condition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to auto-focus an input element with proper cleanup
|
||||
*
|
||||
* @param ref - Reference to the input element
|
||||
* @param options - Options for auto-focus behavior
|
||||
* @param options.delay - Delay in milliseconds before focusing (default: 100)
|
||||
* @param options.condition - Additional condition that must be true to focus (default: true)
|
||||
*
|
||||
* @example
|
||||
* const inputRef = useRef<TextInput>(null);
|
||||
* useAutoFocus(inputRef, { delay: 300 });
|
||||
*/
|
||||
export function useAutoFocus<T extends { focus: () => void }>(
|
||||
ref: RefObject<T>,
|
||||
options: UseAutoFocusOptions = {}
|
||||
) {
|
||||
const { delay = 100, condition = true } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (condition && ref.current) {
|
||||
const timer = setTimeout(() => {
|
||||
ref.current?.focus();
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [ref, delay, condition]);
|
||||
}
|
||||
84
apps/memoro/apps/mobile/features/core/hooks/useTimeout.ts
Normal file
84
apps/memoro/apps/mobile/features/core/hooks/useTimeout.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { TimeoutId } from '../types/timer.types';
|
||||
|
||||
/**
|
||||
* Custom hook for setTimeout with automatic cleanup
|
||||
*
|
||||
* @param callback - Function to call after the delay
|
||||
* @param delay - Delay in milliseconds (null to disable)
|
||||
*
|
||||
* @example
|
||||
* // Will automatically cleanup on unmount
|
||||
* useTimeout(() => {
|
||||
* console.log('This runs after 1 second');
|
||||
* }, 1000);
|
||||
*
|
||||
* // Conditional timeout
|
||||
* useTimeout(() => {
|
||||
* showToast('Welcome!');
|
||||
* }, isFirstVisit ? 2000 : null);
|
||||
*/
|
||||
export function useTimeout(callback: () => void, delay: number | null) {
|
||||
const savedCallback = useRef(callback);
|
||||
|
||||
// Remember the latest callback
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Set up the timeout
|
||||
useEffect(() => {
|
||||
if (delay === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
savedCallback.current();
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns functions to set and clear timeouts
|
||||
* Useful when you need more control over when timeouts are set
|
||||
*
|
||||
* @example
|
||||
* const { setTimeout, clearTimeout } = useTimeoutFn();
|
||||
*
|
||||
* const handleClick = () => {
|
||||
* setTimeout(() => {
|
||||
* console.log('Delayed action');
|
||||
* }, 1000);
|
||||
* };
|
||||
*/
|
||||
export function useTimeoutFn() {
|
||||
const timeoutRef = useRef<TimeoutId | null>(null);
|
||||
|
||||
const setTimeoutFn = useCallback((callback: () => void, delay: number) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(callback, delay);
|
||||
}, []);
|
||||
|
||||
const clearTimeoutFn = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { setTimeout: setTimeoutFn, clearTimeout: clearTimeoutFn };
|
||||
}
|
||||
85
apps/memoro/apps/mobile/features/core/types/api.types.ts
Normal file
85
apps/memoro/apps/mobile/features/core/types/api.types.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Core API types for standardized responses
|
||||
*/
|
||||
|
||||
import { AppError } from './error.types';
|
||||
|
||||
/**
|
||||
* Standard API response wrapper
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data?: T;
|
||||
error?: AppError;
|
||||
meta?: {
|
||||
timestamp: number;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated API response
|
||||
*/
|
||||
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
nextCursor?: string;
|
||||
prevCursor?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API request options
|
||||
*/
|
||||
export interface ApiRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
params?: Record<string, string | number | boolean>;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has data
|
||||
*/
|
||||
export function hasData<T>(response: ApiResponse<T>): response is ApiResponse<T> & { data: T } {
|
||||
return response.data !== undefined && response.data !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has error
|
||||
*/
|
||||
export function hasError(response: ApiResponse): response is ApiResponse & { error: AppError } {
|
||||
return response.error !== undefined && response.error !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success response
|
||||
*/
|
||||
export function createSuccessResponse<T>(data: T, meta?: ApiResponse['meta']): ApiResponse<T> {
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
timestamp: Date.now(),
|
||||
...meta
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error response
|
||||
*/
|
||||
export function createErrorResponse(error: AppError, meta?: ApiResponse['meta']): ApiResponse {
|
||||
return {
|
||||
error,
|
||||
meta: {
|
||||
timestamp: Date.now(),
|
||||
...meta
|
||||
}
|
||||
};
|
||||
}
|
||||
128
apps/memoro/apps/mobile/features/core/types/common.types.ts
Normal file
128
apps/memoro/apps/mobile/features/core/types/common.types.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Common type definitions used throughout the application
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make all properties of T optional recursively
|
||||
*/
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* Make specific properties of T required
|
||||
*/
|
||||
export type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Make specific properties of T optional
|
||||
*/
|
||||
export type PartialFields<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Extract the type of array elements
|
||||
*/
|
||||
export type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
|
||||
|
||||
/**
|
||||
* Common async state
|
||||
*/
|
||||
export interface AsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common pagination params
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
cursor?: string;
|
||||
orderBy?: string;
|
||||
orderDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Common filter params
|
||||
*/
|
||||
export interface FilterParams {
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common timestamp fields
|
||||
*/
|
||||
export interface Timestamps {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common ID field
|
||||
*/
|
||||
export interface WithId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform types
|
||||
*/
|
||||
export type Platform = 'ios' | 'android' | 'web';
|
||||
|
||||
/**
|
||||
* Nullable type helper
|
||||
*/
|
||||
export type Nullable<T> = T | null;
|
||||
|
||||
/**
|
||||
* Optional type helper
|
||||
*/
|
||||
export type Optional<T> = T | undefined;
|
||||
|
||||
/**
|
||||
* Type guard to check if value is defined
|
||||
*/
|
||||
export function isDefined<T>(value: T | undefined | null): value is T {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is string
|
||||
*/
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is number
|
||||
*/
|
||||
export function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is boolean
|
||||
*/
|
||||
export function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is object
|
||||
*/
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if value is array
|
||||
*/
|
||||
export function isArray<T = unknown>(value: unknown): value is T[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Standardized credit error types for the frontend
|
||||
* These match the backend error response structure
|
||||
*/
|
||||
|
||||
/**
|
||||
* Credit error codes
|
||||
*/
|
||||
export type CreditErrorCode =
|
||||
| 'INSUFFICIENT_CREDITS'
|
||||
| 'CREDIT_VALIDATION_FAILED'
|
||||
| 'CREDIT_SYSTEM_ERROR';
|
||||
|
||||
/**
|
||||
* Credit type
|
||||
*/
|
||||
export type CreditType = 'user' | 'space';
|
||||
|
||||
/**
|
||||
* Detailed credit error information
|
||||
*/
|
||||
export interface CreditErrorDetails {
|
||||
requiredCredits: number;
|
||||
availableCredits: number;
|
||||
creditType: CreditType;
|
||||
operation?: string;
|
||||
operationCost?: number;
|
||||
spaceId?: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit error object structure
|
||||
*/
|
||||
export interface CreditError {
|
||||
code: CreditErrorCode;
|
||||
message: string;
|
||||
details: CreditErrorDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized credit error response from the API
|
||||
*/
|
||||
export interface CreditErrorResponse {
|
||||
error: CreditError;
|
||||
statusCode: number;
|
||||
timestamp: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a response is a credit error
|
||||
*/
|
||||
export function isCreditErrorResponse(response: any): response is CreditErrorResponse {
|
||||
return (
|
||||
response &&
|
||||
typeof response === 'object' &&
|
||||
'error' in response &&
|
||||
typeof response.error === 'object' &&
|
||||
'code' in response.error &&
|
||||
['INSUFFICIENT_CREDITS', 'CREDIT_VALIDATION_FAILED', 'CREDIT_SYSTEM_ERROR'].includes(response.error.code)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is specifically an insufficient credits error
|
||||
*/
|
||||
export function isInsufficientCreditsError(response: any): boolean {
|
||||
return isCreditErrorResponse(response) && response.error.code === 'INSUFFICIENT_CREDITS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a credit validation error
|
||||
*/
|
||||
export function isCreditValidationError(response: any): boolean {
|
||||
return isCreditErrorResponse(response) && response.error.code === 'CREDIT_VALIDATION_FAILED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a credit system error
|
||||
*/
|
||||
export function isCreditSystemError(response: any): boolean {
|
||||
return isCreditErrorResponse(response) && response.error.code === 'CREDIT_SYSTEM_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract credit error details from an error response
|
||||
*/
|
||||
export function extractCreditErrorDetails(error: any): CreditErrorDetails | null {
|
||||
if (isCreditErrorResponse(error)) {
|
||||
return error.error.details;
|
||||
}
|
||||
|
||||
// Check if the error is nested in a response object
|
||||
if (error?.response?.data && isCreditErrorResponse(error.response.data)) {
|
||||
return error.response.data.error.details;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for a credit error
|
||||
*/
|
||||
export function getCreditErrorMessage(error: any): string {
|
||||
if (isCreditErrorResponse(error)) {
|
||||
return error.error.message;
|
||||
}
|
||||
|
||||
// Check if the error is nested in a response object
|
||||
if (error?.response?.data && isCreditErrorResponse(error.response.data)) {
|
||||
return error.response.data.error.message;
|
||||
}
|
||||
|
||||
// Fallback messages
|
||||
if (error?.message?.toLowerCase().includes('insufficient')) {
|
||||
return 'You do not have enough credits for this operation.';
|
||||
}
|
||||
|
||||
return 'An error occurred while processing your credits. Please try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format credit amount for display
|
||||
*/
|
||||
export function formatCredits(credits: number): string {
|
||||
return credits.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a credit error response object (for testing or manual error creation)
|
||||
*/
|
||||
export function createCreditErrorResponse(
|
||||
code: CreditErrorCode,
|
||||
requiredCredits: number,
|
||||
availableCredits: number,
|
||||
options?: {
|
||||
creditType?: CreditType;
|
||||
operation?: string;
|
||||
spaceId?: string;
|
||||
message?: string;
|
||||
suggestions?: string[];
|
||||
path?: string;
|
||||
}
|
||||
): CreditErrorResponse {
|
||||
const creditType = options?.creditType || 'user';
|
||||
const defaultMessage = code === 'INSUFFICIENT_CREDITS'
|
||||
? `Insufficient ${creditType} credits. You need ${requiredCredits} credits but only have ${availableCredits}.`
|
||||
: code === 'CREDIT_VALIDATION_FAILED'
|
||||
? 'Credit validation failed'
|
||||
: 'An error occurred while processing credits';
|
||||
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
message: options?.message || defaultMessage,
|
||||
details: {
|
||||
requiredCredits,
|
||||
availableCredits,
|
||||
creditType,
|
||||
operation: options?.operation,
|
||||
operationCost: requiredCredits,
|
||||
spaceId: options?.spaceId,
|
||||
suggestions: options?.suggestions || [
|
||||
'Purchase more credits from the subscription page',
|
||||
'Check if you have credits in a shared space',
|
||||
'Contact support if you believe this is an error'
|
||||
]
|
||||
}
|
||||
},
|
||||
statusCode: code === 'INSUFFICIENT_CREDITS' ? 402 : code === 'CREDIT_VALIDATION_FAILED' ? 400 : 500,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: options?.path
|
||||
};
|
||||
}
|
||||
121
apps/memoro/apps/mobile/features/core/types/error.types.ts
Normal file
121
apps/memoro/apps/mobile/features/core/types/error.types.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Core error types for the application
|
||||
*/
|
||||
|
||||
export interface AppError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export interface NetworkError extends AppError {
|
||||
code: 'NETWORK_ERROR';
|
||||
statusCode: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AuthError extends AppError {
|
||||
code: 'AUTH_ERROR' | 'TOKEN_EXPIRED' | 'UNAUTHORIZED';
|
||||
statusCode: 401 | 403;
|
||||
}
|
||||
|
||||
export interface ValidationError extends AppError {
|
||||
code: 'VALIDATION_ERROR';
|
||||
fields?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is an AppError
|
||||
*/
|
||||
export function isAppError(error: unknown): error is AppError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
'message' in error &&
|
||||
typeof (error as any).code === 'string' &&
|
||||
typeof (error as any).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a NetworkError
|
||||
*/
|
||||
export function isNetworkError(error: unknown): error is NetworkError {
|
||||
return isAppError(error) && error.code === 'NETWORK_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is an AuthError
|
||||
*/
|
||||
export function isAuthError(error: unknown): error is AuthError {
|
||||
return isAppError(error) && (
|
||||
error.code === 'AUTH_ERROR' ||
|
||||
error.code === 'TOKEN_EXPIRED' ||
|
||||
error.code === 'UNAUTHORIZED'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unknown errors into AppError format
|
||||
*/
|
||||
export function parseError(error: unknown): AppError {
|
||||
// If it's already an AppError, return it
|
||||
if (isAppError(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// If it's a standard Error object
|
||||
if (error instanceof Error) {
|
||||
// Check for specific error types
|
||||
if (error.message.includes('Network request failed')) {
|
||||
return {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network connection failed. Please check your internet connection.',
|
||||
statusCode: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message,
|
||||
details: {
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If it's a string
|
||||
if (typeof error === 'string') {
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error
|
||||
};
|
||||
}
|
||||
|
||||
// Default case
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'An unexpected error occurred',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized error
|
||||
*/
|
||||
export function createError(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
statusCode?: number
|
||||
): AppError {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
statusCode
|
||||
};
|
||||
}
|
||||
9
apps/memoro/apps/mobile/features/core/types/index.ts
Normal file
9
apps/memoro/apps/mobile/features/core/types/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Core types re-exports
|
||||
*/
|
||||
|
||||
export * from './error.types';
|
||||
export * from './api.types';
|
||||
export * from './theme.types';
|
||||
export * from './common.types';
|
||||
export * from './timer.types';
|
||||
92
apps/memoro/apps/mobile/features/core/types/theme.types.ts
Normal file
92
apps/memoro/apps/mobile/features/core/types/theme.types.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Theme type definitions
|
||||
*/
|
||||
|
||||
export type ThemeVariant = 'memoro' | 'blue' | 'purple' | 'beige';
|
||||
|
||||
export interface ThemeColorSet {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
contentBackground: string;
|
||||
contentBackgroundHover: string;
|
||||
text: string;
|
||||
subtext: string;
|
||||
border: string;
|
||||
shadow: string;
|
||||
error: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
export interface ThemeColors {
|
||||
memoro: ThemeColorSet;
|
||||
blue: ThemeColorSet;
|
||||
purple: ThemeColorSet;
|
||||
beige: ThemeColorSet;
|
||||
dark: {
|
||||
memoro: ThemeColorSet;
|
||||
blue: ThemeColorSet;
|
||||
purple: ThemeColorSet;
|
||||
beige: ThemeColorSet;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
colors: ThemeColors;
|
||||
spacing: {
|
||||
xs: number;
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
xxl: number;
|
||||
};
|
||||
borderRadius: {
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
full: number;
|
||||
};
|
||||
fontSize: {
|
||||
xs: number;
|
||||
sm: number;
|
||||
base: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
'2xl': number;
|
||||
'3xl': number;
|
||||
};
|
||||
fontFamily: {
|
||||
regular: string;
|
||||
medium: string;
|
||||
semibold: string;
|
||||
bold: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme colors based on dark mode and variant
|
||||
*/
|
||||
export function getThemeColors(isDark: boolean, variant: ThemeVariant, colors: ThemeColors): ThemeColorSet {
|
||||
if (isDark) {
|
||||
return colors.dark[variant];
|
||||
}
|
||||
return colors[variant];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe theme color accessor
|
||||
*/
|
||||
export function getThemeColor(
|
||||
colors: ThemeColors,
|
||||
isDark: boolean,
|
||||
variant: ThemeVariant,
|
||||
colorKey: keyof ThemeColorSet
|
||||
): string {
|
||||
const themeColors = getThemeColors(isDark, variant, colors);
|
||||
return themeColors[colorKey];
|
||||
}
|
||||
21
apps/memoro/apps/mobile/features/core/types/timer.types.ts
Normal file
21
apps/memoro/apps/mobile/features/core/types/timer.types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Timer type definitions for React Native
|
||||
*
|
||||
* React Native uses number for timer IDs, but TypeScript's setTimeout/setInterval
|
||||
* return types are platform-specific. These type aliases ensure compatibility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type for setTimeout return value in React Native
|
||||
*/
|
||||
export type TimeoutId = ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* Type for setInterval return value in React Native
|
||||
*/
|
||||
export type IntervalId = ReturnType<typeof setInterval>;
|
||||
|
||||
/**
|
||||
* Type for requestAnimationFrame return value
|
||||
*/
|
||||
export type AnimationFrameId = ReturnType<typeof requestAnimationFrame>;
|
||||
109
apps/memoro/apps/mobile/features/credits/CreditContext.tsx
Normal file
109
apps/memoro/apps/mobile/features/credits/CreditContext.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||
import { creditService } from './creditService';
|
||||
|
||||
interface CreditContextType {
|
||||
credits: number | null;
|
||||
isLoading: boolean;
|
||||
refreshCredits: () => Promise<void>;
|
||||
setCredits: (credits: number) => void;
|
||||
updateCreditsAfterOperation: (creditsConsumed: number) => void;
|
||||
}
|
||||
|
||||
const CreditContext = createContext<CreditContextType | undefined>(undefined);
|
||||
|
||||
interface CreditProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const CreditProvider: React.FC<CreditProviderProps> = ({ children }) => {
|
||||
const [credits, setCreditsState] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
try {
|
||||
console.log('[CreditContext] Starting credit refresh...');
|
||||
setIsLoading(true);
|
||||
const creditsData = await creditService.getUserCredits();
|
||||
|
||||
console.log('[CreditContext] Credits data from service:', creditsData);
|
||||
|
||||
if (creditsData && typeof creditsData.credits === 'number') {
|
||||
console.log('[CreditContext] Setting credits to:', creditsData.credits);
|
||||
setCreditsState(creditsData.credits);
|
||||
} else {
|
||||
console.warn('[CreditContext] Invalid credits data structure:', creditsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CreditContext] Error refreshing credits:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setCredits = useCallback((newCredits: number) => {
|
||||
setCreditsState(newCredits);
|
||||
}, []);
|
||||
|
||||
const updateCreditsAfterOperation = useCallback((creditsConsumed: number) => {
|
||||
setCreditsState(prevCredits => {
|
||||
if (prevCredits === null) return null;
|
||||
return Math.max(0, prevCredits - creditsConsumed);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Register with credit service to receive notifications
|
||||
useEffect(() => {
|
||||
const unsubscribe = creditService.onCreditUpdate((creditsConsumed: number) => {
|
||||
console.log(`Credits consumed: ${creditsConsumed}, updating ManaCounter`);
|
||||
if (creditsConsumed === 0) {
|
||||
// If creditsConsumed is 0, it's a signal to refresh from server
|
||||
refreshCredits();
|
||||
} else {
|
||||
// Otherwise, update local state
|
||||
updateCreditsAfterOperation(creditsConsumed);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [updateCreditsAfterOperation, refreshCredits]);
|
||||
|
||||
const value: CreditContextType = {
|
||||
credits,
|
||||
isLoading,
|
||||
refreshCredits,
|
||||
setCredits,
|
||||
updateCreditsAfterOperation,
|
||||
};
|
||||
|
||||
return (
|
||||
<CreditContext.Provider value={value}>
|
||||
{children}
|
||||
</CreditContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCredits = (): CreditContextType => {
|
||||
const context = useContext(CreditContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCredits must be used within a CreditProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Hook for triggering credit refresh from anywhere in the app
|
||||
export const useCreditRefresh = () => {
|
||||
const { refreshCredits, updateCreditsAfterOperation } = useCredits();
|
||||
|
||||
return {
|
||||
refreshCredits,
|
||||
updateCreditsAfterOperation,
|
||||
// Helper function to call after any credit-consuming operation
|
||||
notifyCreditConsumption: (creditsConsumed: number) => {
|
||||
updateCreditsAfterOperation(creditsConsumed);
|
||||
// Optionally refresh from server after a delay to ensure accuracy
|
||||
setTimeout(() => {
|
||||
refreshCredits();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
};
|
||||
372
apps/memoro/apps/mobile/features/credits/creditService.ts
Normal file
372
apps/memoro/apps/mobile/features/credits/creditService.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
|
||||
/**
|
||||
* Service for handling credit operations from the frontend
|
||||
*/
|
||||
|
||||
export interface CreditCheckResponse {
|
||||
hasEnoughCredits: boolean;
|
||||
currentCredits: number;
|
||||
requiredCredits: number;
|
||||
creditType: 'user' | 'space';
|
||||
durationMinutes?: number;
|
||||
estimatedCostPerHour?: number;
|
||||
}
|
||||
|
||||
export interface CreditConsumptionResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
creditsConsumed: number;
|
||||
creditType: 'user' | 'space';
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
export interface OperationCreditResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
creditsConsumed: number;
|
||||
creditType: 'user' | 'space';
|
||||
operation: string;
|
||||
}
|
||||
|
||||
export interface PricingResponse {
|
||||
operationCosts: {
|
||||
TRANSCRIPTION_PER_HOUR: number;
|
||||
HEADLINE_GENERATION: number;
|
||||
MEMORY_CREATION: number;
|
||||
BLUEPRINT_PROCESSING: number;
|
||||
QUESTION_MEMO: number;
|
||||
NEW_MEMORY: number;
|
||||
MEMO_COMBINE: number;
|
||||
MEMO_SHARING: number;
|
||||
SPACE_OPERATION: number;
|
||||
};
|
||||
transcriptionPerHour: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
class CreditService {
|
||||
private readonly memoroServiceUrl: string;
|
||||
private readonly manaServiceUrl: string;
|
||||
private creditUpdateCallbacks: ((creditsConsumed: number) => void)[] = [];
|
||||
private cachedPricing: PricingResponse | null = null;
|
||||
private pricingLastFetched: number = 0;
|
||||
private readonly PRICING_CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
constructor() {
|
||||
// Use memoro service URL for all endpoints (including auth proxy)
|
||||
this.memoroServiceUrl =
|
||||
process.env.EXPO_PUBLIC_MEMORO_MIDDLEWARE_URL || 'http://localhost:3001';
|
||||
this.memoroServiceUrl = this.memoroServiceUrl.replace(/\/$/, '');
|
||||
|
||||
// manaServiceUrl now points to memoro service (auth proxy handles routing)
|
||||
this.manaServiceUrl = this.memoroServiceUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the credit service by preloading pricing
|
||||
* Call this during app startup
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.getPricing();
|
||||
console.log('CreditService initialized with backend pricing');
|
||||
} catch (error) {
|
||||
console.warn('CreditService initialization failed, using fallback pricing:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be notified when credits are consumed
|
||||
*/
|
||||
onCreditUpdate(callback: (creditsConsumed: number) => void) {
|
||||
this.creditUpdateCallbacks.push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.creditUpdateCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.creditUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered callbacks about credit consumption
|
||||
*/
|
||||
private notifyCreditUpdate(creditsConsumed: number) {
|
||||
this.creditUpdateCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(creditsConsumed);
|
||||
} catch (error) {
|
||||
console.error('Error in credit update callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to manually trigger credit update notifications
|
||||
* Use this when credits are consumed outside of the creditService methods
|
||||
*/
|
||||
triggerCreditUpdate(creditsConsumed: number) {
|
||||
this.notifyCreditUpdate(creditsConsumed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pricing information from backend with caching
|
||||
*/
|
||||
async getPricing(): Promise<PricingResponse> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached pricing if still valid
|
||||
if (this.cachedPricing && now - this.pricingLastFetched < this.PRICING_CACHE_DURATION) {
|
||||
return this.cachedPricing;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.memoroServiceUrl}/memoro/credits/pricing`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const pricing = await response.json();
|
||||
this.cachedPricing = pricing;
|
||||
this.pricingLastFetched = now;
|
||||
|
||||
return pricing;
|
||||
} catch (error) {
|
||||
console.error('Error fetching pricing:', error);
|
||||
|
||||
// Fallback to hardcoded pricing if backend fails
|
||||
if (this.cachedPricing) {
|
||||
return this.cachedPricing;
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
return {
|
||||
operationCosts: {
|
||||
TRANSCRIPTION_PER_HOUR: 120,
|
||||
HEADLINE_GENERATION: 10,
|
||||
MEMORY_CREATION: 10,
|
||||
BLUEPRINT_PROCESSING: 5,
|
||||
QUESTION_MEMO: 5,
|
||||
NEW_MEMORY: 5,
|
||||
MEMO_COMBINE: 5,
|
||||
MEMO_SHARING: 1,
|
||||
SPACE_OPERATION: 2,
|
||||
},
|
||||
transcriptionPerHour: 120,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user credits directly from mana-core-middleware
|
||||
*/
|
||||
async getUserCredits(): Promise<{ credits: number } | null> {
|
||||
try {
|
||||
console.log('[CreditService] Fetching user credits from:', `${this.manaServiceUrl}/auth/credits`);
|
||||
const { tokenManager } = await import('~/features/auth/services/tokenManager');
|
||||
const appToken = await tokenManager.getValidToken();
|
||||
if (!appToken) {
|
||||
console.error('[CreditService] No authentication token available for credits fetch');
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.manaServiceUrl}/auth/credits`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[CreditService] Credits response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('[CreditService] Credits fetch error:', errorData);
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[CreditService] Credits data received:', data);
|
||||
|
||||
// Handle wrapped response structure from backend
|
||||
if (data.data && typeof data.data.credits === 'number') {
|
||||
return { credits: data.data.credits };
|
||||
}
|
||||
|
||||
// Fallback to direct structure if already in correct format
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[CreditService] Error fetching user credits:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get estimated cost for operations using backend pricing
|
||||
*/
|
||||
async getOperationCost(
|
||||
operation:
|
||||
| 'HEADLINE_GENERATION'
|
||||
| 'MEMORY_CREATION'
|
||||
| 'BLUEPRINT_PROCESSING'
|
||||
| 'MEMO_SHARING'
|
||||
| 'SPACE_OPERATION'
|
||||
| 'QUESTION_MEMO'
|
||||
| 'NEW_MEMORY'
|
||||
| 'MEMO_COMBINE'
|
||||
): Promise<number> {
|
||||
try {
|
||||
const pricing = await this.getPricing();
|
||||
return pricing.operationCosts[operation];
|
||||
} catch (error) {
|
||||
console.error('Error getting operation cost:', error);
|
||||
// Fallback to hardcoded costs
|
||||
const fallbackCosts = {
|
||||
HEADLINE_GENERATION: 10,
|
||||
MEMORY_CREATION: 10,
|
||||
BLUEPRINT_PROCESSING: 5,
|
||||
MEMO_SHARING: 1,
|
||||
SPACE_OPERATION: 2,
|
||||
QUESTION_MEMO: 5,
|
||||
NEW_MEMORY: 5,
|
||||
MEMO_COMBINE: 5,
|
||||
};
|
||||
return fallbackCosts[operation];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost for memo combination based on number of memos
|
||||
*/
|
||||
async calculateMemoCombineCost(memoCount: number): Promise<number> {
|
||||
const costPerMemo = await this.getOperationCost('MEMO_COMBINE');
|
||||
return memoCount * costPerMemo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous versions for immediate UI display (uses cached values)
|
||||
*/
|
||||
getOperationCostSync(
|
||||
operation:
|
||||
| 'HEADLINE_GENERATION'
|
||||
| 'MEMORY_CREATION'
|
||||
| 'BLUEPRINT_PROCESSING'
|
||||
| 'MEMO_SHARING'
|
||||
| 'SPACE_OPERATION'
|
||||
| 'QUESTION_MEMO'
|
||||
| 'NEW_MEMORY'
|
||||
| 'MEMO_COMBINE'
|
||||
): number {
|
||||
if (this.cachedPricing) {
|
||||
return this.cachedPricing.operationCosts[operation];
|
||||
}
|
||||
|
||||
// Fallback to hardcoded costs if no cache
|
||||
const fallbackCosts = {
|
||||
HEADLINE_GENERATION: 10,
|
||||
MEMORY_CREATION: 10,
|
||||
BLUEPRINT_PROCESSING: 5,
|
||||
MEMO_SHARING: 1,
|
||||
SPACE_OPERATION: 2,
|
||||
QUESTION_MEMO: 5,
|
||||
NEW_MEMORY: 5,
|
||||
MEMO_COMBINE: 5,
|
||||
};
|
||||
return fallbackCosts[operation];
|
||||
}
|
||||
|
||||
calculateMemoCombineCostSync(memoCount: number): number {
|
||||
return memoCount * this.getOperationCostSync('MEMO_COMBINE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry transcription for a failed memo using the reprocess-memo endpoint
|
||||
*/
|
||||
async retryTranscription(memoId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { tokenManager } = await import('~/features/auth/services/tokenManager');
|
||||
const appToken = await tokenManager.getValidToken();
|
||||
if (!appToken) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.memoroServiceUrl}/memoro/reprocess-memo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
memoId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || 'Memo reprocessing started successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reprocessing memo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry headline generation for a failed memo
|
||||
*/
|
||||
async retryHeadline(memoId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { tokenManager } = await import('~/features/auth/services/tokenManager');
|
||||
const appToken = await tokenManager.getValidToken();
|
||||
if (!appToken) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.memoroServiceUrl}/memoro/retry-headline`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
memoId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || 'Headline generation retry initiated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error retrying headline generation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const creditService = new CreditService();
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { parseInsufficientCreditsError } from '../utils/insufficient-credits-handler';
|
||||
import { useInsufficientCreditsStore } from '../store/insufficientCreditsStore';
|
||||
|
||||
interface InsufficientCreditsState {
|
||||
isVisible: boolean;
|
||||
requiredCredits?: number;
|
||||
availableCredits?: number;
|
||||
operation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that intercepts HTTP 402 errors and manages the insufficient credits modal state
|
||||
*/
|
||||
export function useInsufficientCreditsInterceptor() {
|
||||
const [modalState, setModalState] = useState<InsufficientCreditsState>({
|
||||
isVisible: false
|
||||
});
|
||||
const setGlobalModalVisible = useInsufficientCreditsStore((state) => state.setModalVisible);
|
||||
|
||||
useEffect(() => {
|
||||
// Store the original fetch
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
// Override global fetch to intercept responses
|
||||
global.fetch = async (...args) => {
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
|
||||
// Check for 402 Payment Required status
|
||||
if (response.status === 402) {
|
||||
try {
|
||||
// Clone response to read body without consuming it
|
||||
const clonedResponse = response.clone();
|
||||
const errorData = await clonedResponse.json();
|
||||
|
||||
console.debug('[InsufficientCreditsInterceptor] 402 error detected:', errorData);
|
||||
|
||||
// Parse the error to extract credit information
|
||||
const creditInfo = parseInsufficientCreditsError(errorData);
|
||||
|
||||
// Show the modal with parsed information
|
||||
// Add small delay to ensure this modal shows first
|
||||
setTimeout(() => {
|
||||
setModalState({
|
||||
isVisible: true,
|
||||
requiredCredits: creditInfo.requiredCredits,
|
||||
availableCredits: creditInfo.availableCredits,
|
||||
operation: creditInfo.operation
|
||||
});
|
||||
setGlobalModalVisible(true);
|
||||
}, 100);
|
||||
} catch (parseError) {
|
||||
console.error('[InsufficientCreditsInterceptor] Error parsing 402 response:', parseError);
|
||||
// Show modal with default message if parsing fails
|
||||
setTimeout(() => {
|
||||
setModalState({ isVisible: true });
|
||||
setGlobalModalVisible(true);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Network errors, etc.
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
global.fetch = originalFetch;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState({ isVisible: false });
|
||||
setGlobalModalVisible(false);
|
||||
};
|
||||
|
||||
return {
|
||||
modalVisible: modalState.isVisible,
|
||||
requiredCredits: modalState.requiredCredits,
|
||||
availableCredits: modalState.availableCredits,
|
||||
operation: modalState.operation,
|
||||
closeModal
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
interface InsufficientCreditsStore {
|
||||
isModalVisible: boolean;
|
||||
setModalVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export const useInsufficientCreditsStore = create<InsufficientCreditsStore>((set) => ({
|
||||
isModalVisible: false,
|
||||
setModalVisible: (visible) => set({ isModalVisible: visible }),
|
||||
}));
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import {
|
||||
CreditErrorResponse,
|
||||
isCreditErrorResponse,
|
||||
isInsufficientCreditsError,
|
||||
extractCreditErrorDetails,
|
||||
getCreditErrorMessage
|
||||
} from '~/features/core/types/credit-error.types';
|
||||
|
||||
/**
|
||||
* Credit error handler utility
|
||||
* Provides centralized handling of credit-related errors
|
||||
*/
|
||||
|
||||
export interface CreditErrorHandlerOptions {
|
||||
onInsufficientCredits?: (details: CreditErrorResponse) => void;
|
||||
onValidationError?: (details: CreditErrorResponse) => void;
|
||||
onSystemError?: (details: CreditErrorResponse) => void;
|
||||
onGenericError?: (error: any) => void;
|
||||
showInsufficientCreditsModal?: boolean;
|
||||
logErrors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credit errors with appropriate actions
|
||||
*/
|
||||
export class CreditErrorHandler {
|
||||
private options: CreditErrorHandlerOptions;
|
||||
|
||||
constructor(options: CreditErrorHandlerOptions = {}) {
|
||||
this.options = {
|
||||
showInsufficientCreditsModal: true,
|
||||
logErrors: true,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an error and handle it appropriately
|
||||
*/
|
||||
handle(error: any): boolean {
|
||||
// Log error if enabled
|
||||
if (this.options.logErrors) {
|
||||
console.error('[CreditErrorHandler] Processing error:', error);
|
||||
}
|
||||
|
||||
// Extract the actual error response
|
||||
const errorResponse = this.extractErrorResponse(error);
|
||||
|
||||
// Check if it's a credit error
|
||||
if (isCreditErrorResponse(errorResponse)) {
|
||||
switch (errorResponse.error.code) {
|
||||
case 'INSUFFICIENT_CREDITS':
|
||||
this.handleInsufficientCredits(errorResponse);
|
||||
return true;
|
||||
|
||||
case 'CREDIT_VALIDATION_FAILED':
|
||||
this.handleValidationError(errorResponse);
|
||||
return true;
|
||||
|
||||
case 'CREDIT_SYSTEM_ERROR':
|
||||
this.handleSystemError(errorResponse);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Not a credit error
|
||||
if (this.options.onGenericError) {
|
||||
this.options.onGenericError(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error response from various error formats
|
||||
*/
|
||||
private extractErrorResponse(error: any): any {
|
||||
// Direct error response
|
||||
if (isCreditErrorResponse(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Axios error format
|
||||
if (error?.response?.data) {
|
||||
return error.response.data;
|
||||
}
|
||||
|
||||
// Fetch error with JSON body
|
||||
if (error?.body) {
|
||||
return error.body;
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle insufficient credits error
|
||||
*/
|
||||
private handleInsufficientCredits(errorResponse: CreditErrorResponse) {
|
||||
if (this.options.logErrors) {
|
||||
console.log('[CreditErrorHandler] Insufficient credits:', {
|
||||
required: errorResponse.error.details.requiredCredits,
|
||||
available: errorResponse.error.details.availableCredits,
|
||||
operation: errorResponse.error.details.operation
|
||||
});
|
||||
}
|
||||
|
||||
if (this.options.onInsufficientCredits) {
|
||||
this.options.onInsufficientCredits(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle validation error
|
||||
*/
|
||||
private handleValidationError(errorResponse: CreditErrorResponse) {
|
||||
if (this.options.logErrors) {
|
||||
console.warn('[CreditErrorHandler] Credit validation failed:', errorResponse.error.message);
|
||||
}
|
||||
|
||||
if (this.options.onValidationError) {
|
||||
this.options.onValidationError(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle system error
|
||||
*/
|
||||
private handleSystemError(errorResponse: CreditErrorResponse) {
|
||||
if (this.options.logErrors) {
|
||||
console.error('[CreditErrorHandler] Credit system error:', errorResponse.error.message);
|
||||
}
|
||||
|
||||
if (this.options.onSystemError) {
|
||||
this.options.onSystemError(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a credit error
|
||||
*/
|
||||
static isCreditError(error: any): boolean {
|
||||
const errorResponse = error?.response?.data || error;
|
||||
return isCreditErrorResponse(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is specifically an insufficient credits error
|
||||
*/
|
||||
static isInsufficientCredits(error: any): boolean {
|
||||
const errorResponse = error?.response?.data || error;
|
||||
return isInsufficientCreditsError(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message from error
|
||||
*/
|
||||
static getMessage(error: any): string {
|
||||
return getCreditErrorMessage(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract credit details from error
|
||||
*/
|
||||
static getDetails(error: any) {
|
||||
return extractCreditErrorDetails(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default credit error handler instance
|
||||
*/
|
||||
export const defaultCreditErrorHandler = new CreditErrorHandler();
|
||||
|
||||
/**
|
||||
* Utility function to check and handle credit errors
|
||||
* Returns true if the error was handled
|
||||
*/
|
||||
export function handleCreditError(
|
||||
error: any,
|
||||
options?: CreditErrorHandlerOptions
|
||||
): boolean {
|
||||
const handler = new CreditErrorHandler(options);
|
||||
return handler.handle(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for credit error handling
|
||||
*/
|
||||
export function useCreditErrorHandler(options?: CreditErrorHandlerOptions) {
|
||||
const handler = new CreditErrorHandler(options);
|
||||
|
||||
return {
|
||||
handleError: (error: any) => handler.handle(error),
|
||||
isCreditError: CreditErrorHandler.isCreditError,
|
||||
isInsufficientCredits: CreditErrorHandler.isInsufficientCredits,
|
||||
getMessage: CreditErrorHandler.getMessage,
|
||||
getDetails: CreditErrorHandler.getDetails
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { isInsufficientCreditsError } from './insufficient-credits-handler';
|
||||
|
||||
type ErrorHandler = (error: any) => void;
|
||||
|
||||
class GlobalErrorInterceptor {
|
||||
private insufficientCreditsHandler?: ErrorHandler;
|
||||
private originalFetch?: typeof fetch;
|
||||
|
||||
/**
|
||||
* Set handler for insufficient credits errors
|
||||
*/
|
||||
setInsufficientCreditsHandler(handler: ErrorHandler) {
|
||||
this.insufficientCreditsHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the interceptor
|
||||
*/
|
||||
initialize() {
|
||||
if (this.originalFetch) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
this.originalFetch = global.fetch;
|
||||
|
||||
// Override global fetch
|
||||
global.fetch = async (...args) => {
|
||||
try {
|
||||
const response = await this.originalFetch!(...args);
|
||||
|
||||
// If it's a 402 error, try to parse the body
|
||||
if (response.status === 402) {
|
||||
const clonedResponse = response.clone();
|
||||
try {
|
||||
const errorData = await clonedResponse.json();
|
||||
if (this.insufficientCreditsHandler && isInsufficientCreditsError(errorData)) {
|
||||
this.insufficientCreditsHandler(errorData);
|
||||
}
|
||||
} catch (e) {
|
||||
// Failed to parse JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Network errors, etc.
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the interceptor
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.originalFetch) {
|
||||
global.fetch = this.originalFetch;
|
||||
this.originalFetch = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const globalErrorInterceptor = new GlobalErrorInterceptor();
|
||||
30
apps/memoro/apps/mobile/features/credits/utils/index.ts
Normal file
30
apps/memoro/apps/mobile/features/credits/utils/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Credit utility exports
|
||||
* Centralized export for all credit-related utilities
|
||||
*/
|
||||
|
||||
// Credit error handler
|
||||
export {
|
||||
CreditErrorHandler,
|
||||
CreditErrorHandlerOptions,
|
||||
defaultCreditErrorHandler,
|
||||
handleCreditError,
|
||||
useCreditErrorHandler
|
||||
} from './credit-error-handler';
|
||||
|
||||
// Re-export credit error types for convenience
|
||||
export {
|
||||
CreditErrorCode,
|
||||
CreditType,
|
||||
CreditErrorDetails,
|
||||
CreditError,
|
||||
CreditErrorResponse,
|
||||
isCreditErrorResponse,
|
||||
isInsufficientCreditsError,
|
||||
isCreditValidationError,
|
||||
isCreditSystemError,
|
||||
extractCreditErrorDetails,
|
||||
getCreditErrorMessage,
|
||||
formatCredits,
|
||||
createCreditErrorResponse
|
||||
} from '~/features/core/types/credit-error.types';
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Handles insufficient credits errors with the new standardized format
|
||||
*/
|
||||
|
||||
interface InsufficientCreditsError {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details: {
|
||||
requiredCredits: number;
|
||||
availableCredits: number;
|
||||
creditType: 'user' | 'space';
|
||||
operation: string;
|
||||
operationCost: number;
|
||||
spaceId?: string;
|
||||
suggestions?: string[];
|
||||
};
|
||||
};
|
||||
statusCode: number;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error response is an insufficient credits error
|
||||
*/
|
||||
export function isInsufficientCreditsError(error: any): error is InsufficientCreditsError {
|
||||
return (
|
||||
error?.statusCode === 402 ||
|
||||
error?.error?.code === 'INSUFFICIENT_CREDITS' ||
|
||||
(error?.message && error.message.includes('Insufficient credits')) ||
|
||||
(error?.message && error.message.includes('Nicht genügend Mana'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error response to extract credit information
|
||||
*/
|
||||
export function parseInsufficientCreditsError(error: any): {
|
||||
requiredCredits?: number;
|
||||
availableCredits?: number;
|
||||
creditType?: 'user' | 'space';
|
||||
operation?: string;
|
||||
spaceId?: string;
|
||||
suggestions?: string[];
|
||||
} {
|
||||
// Handle new standardized format from memoro-service
|
||||
if (error?.details) {
|
||||
return {
|
||||
requiredCredits: error.details.requiredCredits,
|
||||
availableCredits: error.details.availableCredits,
|
||||
creditType: error.details.creditType,
|
||||
operation: error.details.operation,
|
||||
spaceId: error.details.spaceId,
|
||||
suggestions: error.details.suggestions
|
||||
};
|
||||
}
|
||||
|
||||
// Handle nested error object
|
||||
if (error?.error?.details) {
|
||||
return {
|
||||
requiredCredits: error.error.details.requiredCredits,
|
||||
availableCredits: error.error.details.availableCredits,
|
||||
creditType: error.error.details.creditType,
|
||||
operation: error.error.details.operation,
|
||||
spaceId: error.error.details.spaceId,
|
||||
suggestions: error.error.details.suggestions
|
||||
};
|
||||
}
|
||||
|
||||
// Handle legacy format with message parsing
|
||||
const message = error?.message || error?.error?.message || '';
|
||||
if (message) {
|
||||
const requiredMatch = message.match(/Required:\s*(\d+)/);
|
||||
const availableMatch = message.match(/Available:\s*(\d+)/);
|
||||
|
||||
return {
|
||||
requiredCredits: requiredMatch ? parseInt(requiredMatch[1]) : undefined,
|
||||
availableCredits: availableMatch ? parseInt(availableMatch[1]) : undefined,
|
||||
creditType: message.includes('space') ? 'space' : 'user',
|
||||
operation: 'transcription' // Default operation
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format insufficient credits message for display
|
||||
*/
|
||||
export function formatInsufficientCreditsMessage(
|
||||
errorData: ReturnType<typeof parseInsufficientCreditsError>,
|
||||
t: (key: string, fallback: string) => string
|
||||
): string {
|
||||
if (errorData.requiredCredits && errorData.availableCredits !== undefined) {
|
||||
return t(
|
||||
'credits.insufficient_detailed',
|
||||
`Du benötigst ${errorData.requiredCredits} Mana, hast aber nur ${errorData.availableCredits} verfügbar.`
|
||||
);
|
||||
}
|
||||
|
||||
return t(
|
||||
'credits.insufficient_message',
|
||||
'Du hast nicht genügend Mana für diese Operation.'
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
||||
|
||||
export interface NetworkStatus {
|
||||
isConnected: boolean;
|
||||
isInternetReachable: boolean | null;
|
||||
type: string;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for monitoring network connectivity status
|
||||
* Uses @react-native-community/netinfo for accurate detection
|
||||
*/
|
||||
export function useNetworkStatus() {
|
||||
const [networkStatus, setNetworkStatus] = useState<NetworkStatus>({
|
||||
isConnected: true, // Start with optimistic assumption
|
||||
isInternetReachable: null,
|
||||
type: 'unknown',
|
||||
isOffline: false,
|
||||
});
|
||||
|
||||
const updateNetworkStatus = useCallback((state: NetInfoState) => {
|
||||
const isOffline = state.isConnected === false || state.isInternetReachable === false;
|
||||
|
||||
setNetworkStatus({
|
||||
isConnected: state.isConnected ?? false,
|
||||
isInternetReachable: state.isInternetReachable,
|
||||
type: state.type || 'unknown',
|
||||
isOffline,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial network state
|
||||
NetInfo.fetch().then(updateNetworkStatus);
|
||||
|
||||
// Subscribe to network state changes
|
||||
const unsubscribe = NetInfo.addEventListener(updateNetworkStatus);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [updateNetworkStatus]);
|
||||
|
||||
/**
|
||||
* Manually refresh network status
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
const state = await NetInfo.fetch();
|
||||
updateNetworkStatus(state);
|
||||
return state;
|
||||
}, [updateNetworkStatus]);
|
||||
|
||||
return {
|
||||
...networkStatus,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
10
apps/memoro/apps/mobile/features/errorHandling/index.ts
Normal file
10
apps/memoro/apps/mobile/features/errorHandling/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Network error detection utilities
|
||||
export {
|
||||
analyzeNetworkError,
|
||||
analyzeNetworkErrorSync,
|
||||
isDeviceConnected,
|
||||
type NetworkErrorInfo
|
||||
} from './utils/networkErrorUtils';
|
||||
|
||||
// Network status hook
|
||||
export { useNetworkStatus, type NetworkStatus } from './hooks/useNetworkStatus';
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import NetInfo from '@react-native-community/netinfo';
|
||||
|
||||
/**
|
||||
* Utility functions for detecting and handling network errors
|
||||
*/
|
||||
|
||||
interface NetworkErrorInfo {
|
||||
isNetworkError: boolean;
|
||||
errorType: 'offline' | 'timeout' | 'connection' | 'server' | 'unknown';
|
||||
userMessage: string;
|
||||
technicalMessage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the device has internet connectivity using NetInfo
|
||||
*/
|
||||
export async function isDeviceConnected(): Promise<boolean> {
|
||||
try {
|
||||
const netInfo = await NetInfo.fetch();
|
||||
// During network transitions, isInternetReachable can be null
|
||||
// Only consider device online if both connected AND internet is explicitly reachable
|
||||
// If isInternetReachable is null (unknown), consider it offline to prevent premature API calls
|
||||
return netInfo.isConnected === true && netInfo.isInternetReachable === true;
|
||||
} catch (error) {
|
||||
console.debug('Error checking network connectivity:', error);
|
||||
// Fallback to true to avoid blocking app functionality
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the device has a stable internet connection
|
||||
* Useful for critical operations like token refresh
|
||||
*/
|
||||
export async function hasStableConnection(): Promise<boolean> {
|
||||
try {
|
||||
// First check basic connectivity
|
||||
const isConnected = await isDeviceConnected();
|
||||
if (!isConnected) return false;
|
||||
|
||||
// For critical operations, double-check after a short delay
|
||||
// This helps ensure the connection is stable, not just transitioning
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const netInfo = await NetInfo.fetch();
|
||||
// Require explicit true values for stable connection
|
||||
return netInfo.isConnected === true &&
|
||||
netInfo.isInternetReachable === true &&
|
||||
netInfo.details !== null;
|
||||
} catch (error) {
|
||||
console.debug('Error checking stable connectivity:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version for immediate error analysis (pattern matching only)
|
||||
*/
|
||||
export function analyzeNetworkErrorSync(error: unknown): NetworkErrorInfo {
|
||||
return analyzeErrorPatterns(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version with NetInfo connectivity check for enhanced detection
|
||||
*/
|
||||
export async function analyzeNetworkError(error: unknown): Promise<NetworkErrorInfo> {
|
||||
// First, check actual device connectivity using NetInfo
|
||||
const isConnected = await isDeviceConnected();
|
||||
if (!isConnected) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'offline',
|
||||
userMessage: 'No internet connection detected. Please check your connection and try again.',
|
||||
technicalMessage: 'NetInfo: Device is offline'
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to pattern matching
|
||||
return analyzeErrorPatterns(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to analyze error patterns
|
||||
*/
|
||||
function analyzeErrorPatterns(error: unknown): NetworkErrorInfo {
|
||||
const errorMessage = error?.message?.toLowerCase() || '';
|
||||
const errorName = error?.name?.toLowerCase() || '';
|
||||
|
||||
// Offline/No internet connection errors
|
||||
if (
|
||||
errorMessage.includes('not connected to the internet') ||
|
||||
errorMessage.includes('no internet connection') ||
|
||||
errorMessage.includes('offline') ||
|
||||
errorMessage.includes('internet connection appears to be offline') ||
|
||||
errorMessage.includes('the internet connection appears to be offline')
|
||||
) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'offline',
|
||||
userMessage: 'No internet connection detected. Please check your connection and try again.',
|
||||
technicalMessage: error.message || 'Device appears to be offline'
|
||||
};
|
||||
}
|
||||
|
||||
// Network connection errors
|
||||
if (
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('fetch') ||
|
||||
errorMessage.includes('network request failed') ||
|
||||
errorMessage.includes('network error') ||
|
||||
errorName.includes('networkerror') ||
|
||||
errorMessage.includes('failed to fetch') ||
|
||||
errorMessage.includes('request failed') ||
|
||||
errorMessage.includes('unable to connect')
|
||||
) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'connection',
|
||||
userMessage: 'Unable to connect to server. Please check your internet connection and try again.',
|
||||
technicalMessage: error.message || 'Network connection failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Timeout errors
|
||||
if (
|
||||
errorMessage.includes('timeout') ||
|
||||
errorMessage.includes('timed out') ||
|
||||
errorName.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'timeout',
|
||||
userMessage: 'Request timed out. Please check your connection and try again.',
|
||||
technicalMessage: error.message || 'Request timeout'
|
||||
};
|
||||
}
|
||||
|
||||
// Connection refused/reset errors
|
||||
if (
|
||||
errorMessage.includes('connection refused') ||
|
||||
errorMessage.includes('connection reset') ||
|
||||
errorMessage.includes('econnrefused') ||
|
||||
errorMessage.includes('econnreset')
|
||||
) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'connection',
|
||||
userMessage: 'Unable to reach server. Please check your internet connection.',
|
||||
technicalMessage: error.message || 'Connection refused'
|
||||
};
|
||||
}
|
||||
|
||||
// Check for HTTP status-based network errors if it's a fetch response
|
||||
if (error?.status || (error instanceof Error && 'status' in error)) {
|
||||
const status = error.status;
|
||||
|
||||
// Server errors that could be network-related
|
||||
if (status >= 500) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'server',
|
||||
userMessage: 'Server is temporarily unavailable. Please try again in a moment.',
|
||||
technicalMessage: `Server error: ${status}`
|
||||
};
|
||||
}
|
||||
|
||||
// Gateway timeout, bad gateway, service unavailable
|
||||
if (status === 502 || status === 503 || status === 504) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'server',
|
||||
userMessage: 'Service is temporarily unavailable. Please try again in a moment.',
|
||||
technicalMessage: `Gateway error: ${status}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// DNS resolution errors
|
||||
if (
|
||||
errorMessage.includes('dns') ||
|
||||
errorMessage.includes('getaddrinfo') ||
|
||||
errorMessage.includes('enotfound')
|
||||
) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'connection',
|
||||
userMessage: 'Unable to reach server. Please check your internet connection.',
|
||||
technicalMessage: error.message || 'DNS resolution failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Not a recognized network error
|
||||
return {
|
||||
isNetworkError: false,
|
||||
errorType: 'unknown',
|
||||
userMessage: 'An unexpected error occurred. Please try again.',
|
||||
technicalMessage: error.message || 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
85
apps/memoro/apps/mobile/features/i18n/LanguageContext.tsx
Normal file
85
apps/memoro/apps/mobile/features/i18n/LanguageContext.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import i18n from './index';
|
||||
import { getStoredLanguage, changeLanguage, LANGUAGES } from './index';
|
||||
|
||||
// Definiere den Typ für den Kontext
|
||||
interface LanguageContextType {
|
||||
currentLanguage: string;
|
||||
changeLanguage: (language: string) => Promise<void>;
|
||||
languages: typeof LANGUAGES;
|
||||
isRTL: boolean;
|
||||
}
|
||||
|
||||
// Erstelle den Kontext
|
||||
const LanguageContext = createContext<LanguageContextType>({
|
||||
currentLanguage: 'en',
|
||||
changeLanguage: async () => {},
|
||||
languages: LANGUAGES,
|
||||
isRTL: false,
|
||||
});
|
||||
|
||||
// Hook für den Zugriff auf den Kontext
|
||||
export const useLanguage = () => useContext(LanguageContext);
|
||||
|
||||
// Provider-Komponente
|
||||
interface LanguageProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
||||
const [currentLanguage, setCurrentLanguage] = useState<string>(i18n.language || 'en');
|
||||
const [isRTL, setIsRTL] = useState<boolean>(false);
|
||||
|
||||
// Aktualisiere den Zustand, wenn sich die i18n-Sprache ändert
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng: string) => {
|
||||
setCurrentLanguage(lng);
|
||||
setIsRTL(['ar', 'he'].includes(lng));
|
||||
};
|
||||
|
||||
// Registriere den Event-Listener für Sprachänderungen
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
|
||||
// Setze den initialen Zustand basierend auf der aktuellen i18n-Sprache
|
||||
setCurrentLanguage(i18n.language);
|
||||
setIsRTL(['ar', 'he'].includes(i18n.language));
|
||||
|
||||
return () => {
|
||||
// Entferne den Event-Listener beim Aufräumen
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Synchronisiere den Zustand mit AsyncStorage, wenn die App geladen wird
|
||||
useEffect(() => {
|
||||
const syncStoredLanguage = async () => {
|
||||
const storedLanguage = await getStoredLanguage();
|
||||
if (storedLanguage && storedLanguage !== i18n.language) {
|
||||
await i18n.changeLanguage(storedLanguage);
|
||||
}
|
||||
};
|
||||
|
||||
syncStoredLanguage();
|
||||
}, []);
|
||||
|
||||
// Funktion zum Ändern der Sprache
|
||||
const handleChangeLanguage = async (language: string) => {
|
||||
await changeLanguage(language);
|
||||
setCurrentLanguage(language);
|
||||
// Prüfe, ob die Sprache RTL ist (für zukünftige Unterstützung)
|
||||
setIsRTL(['ar', 'he'].includes(language));
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider
|
||||
value={{
|
||||
currentLanguage,
|
||||
changeLanguage: handleChangeLanguage,
|
||||
languages: LANGUAGES,
|
||||
isRTL,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
65
apps/memoro/apps/mobile/features/i18n/LanguageSelector.tsx
Normal file
65
apps/memoro/apps/mobile/features/i18n/LanguageSelector.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import BaseLanguageSelector, { LanguageItem } from '~/components/molecules/BaseLanguageSelector';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponente zur Auswahl der Sprache in der App.
|
||||
* Zeigt alle verfügbaren Sprachen in einer scrollbaren Liste an.
|
||||
*/
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ isVisible, onClose }) => {
|
||||
const { currentLanguage, changeLanguage, languages } = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Convert languages to BaseLanguageSelector format
|
||||
const languageItems: Record<string, LanguageItem> = Object.entries(languages).reduce(
|
||||
(acc, [code, lang]) => {
|
||||
acc[code] = {
|
||||
code,
|
||||
...lang,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LanguageItem>
|
||||
);
|
||||
|
||||
// Handler für die Auswahl einer Sprache
|
||||
const handleLanguageSelect = async (selectedLanguages: string[]) => {
|
||||
if (selectedLanguages.length > 0) {
|
||||
await changeLanguage(selectedLanguages[0]);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
title={t('language.interface_language_title', 'App Interface Language')}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={true}
|
||||
hideFooter={true}
|
||||
noPadding={true}
|
||||
size="medium"
|
||||
>
|
||||
<BaseLanguageSelector
|
||||
languages={languageItems}
|
||||
selectedLanguages={[currentLanguage]}
|
||||
onSelect={handleLanguageSelect}
|
||||
mode="single"
|
||||
showAutoDetect={false}
|
||||
height={450}
|
||||
autoSelectOnSingle={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
206
apps/memoro/apps/mobile/features/i18n/README.md
Normal file
206
apps/memoro/apps/mobile/features/i18n/README.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# Mehrsprachigkeitsunterstützung (i18n) für Memoro
|
||||
|
||||
Diese Komponente implementiert die Mehrsprachigkeitsunterstützung (Internationalisierung/i18n) für die Memoro-App mit Hilfe von `expo-localization`, `i18next` und `react-i18next`.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Automatische Erkennung der Gerätesprache
|
||||
- Unterstützung für 45 Sprachen
|
||||
- Persistente Speicherung der Spracheinstellung
|
||||
- Benutzerfreundliche Sprachauswahl-Komponente
|
||||
- Vollständige Integration in die App-Einstellungen
|
||||
- Kulturspezifische Zeit- und Datumsformatierung
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
features/i18n/
|
||||
├── index.ts # Hauptkonfiguration für i18next
|
||||
├── LanguageContext.tsx # React Context für die Sprachverwaltung
|
||||
├── LanguageSelector.tsx # UI-Komponente für die Sprachauswahl
|
||||
├── translations/ # Übersetzungsdateien
|
||||
│ ├── de.json # Deutsche Übersetzungen
|
||||
│ └── en.json # Englische Übersetzungen
|
||||
└── README.md # Diese Dokumentation
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Übersetzungen verwenden
|
||||
|
||||
Um Texte in der App zu übersetzen, verwende den `useTranslation`-Hook:
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Text>{t('common.save')}</Text>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Sprache ändern
|
||||
|
||||
Um die Sprache programmatisch zu ändern, verwende den `useLanguage`-Hook:
|
||||
|
||||
```tsx
|
||||
import { useLanguage } from '~/features/i18n/LanguageContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { changeLanguage } = useLanguage();
|
||||
|
||||
const handleChangeLanguage = async (language: string) => {
|
||||
await changeLanguage(language);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onPress={() => handleChangeLanguage('de')}>
|
||||
Deutsch
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Sprachauswahl-Komponente einbinden
|
||||
|
||||
Die Sprachauswahl-Komponente kann wie folgt eingebunden werden:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import LanguageSelector from '~/features/i18n/LanguageSelector';
|
||||
|
||||
function MyComponent() {
|
||||
const [isLanguageSelectorVisible, setIsLanguageSelectorVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={() => setIsLanguageSelectorVisible(true)}>
|
||||
Sprache wählen
|
||||
</Button>
|
||||
|
||||
<LanguageSelector
|
||||
isVisible={isLanguageSelectorVisible}
|
||||
onClose={() => setIsLanguageSelectorVisible(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Neue Sprachen hinzufügen
|
||||
|
||||
Um eine neue Sprache hinzuzufügen:
|
||||
|
||||
1. Erstelle eine neue Übersetzungsdatei in `translations/` (z.B. `fr.json`)
|
||||
2. Füge die Sprache zur `LANGUAGES`-Konstante in `index.ts` hinzu:
|
||||
|
||||
```typescript
|
||||
export const LANGUAGES = {
|
||||
de: { nativeName: 'Deutsch', emoji: '🇩🇪' },
|
||||
en: { nativeName: 'English', emoji: '🇬🇧' },
|
||||
fr: { nativeName: 'Français', emoji: '🇫🇷' }, // Neue Sprache
|
||||
};
|
||||
```
|
||||
|
||||
3. Importiere die neue Übersetzungsdatei in `index.ts` und füge sie zu den Ressourcen hinzu:
|
||||
|
||||
```typescript
|
||||
import fr from './translations/fr.json';
|
||||
|
||||
// ...
|
||||
|
||||
resources: {
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
fr: { translation: fr }, // Neue Sprache
|
||||
},
|
||||
```
|
||||
|
||||
## Übersetzungsschlüssel
|
||||
|
||||
Die Übersetzungsschlüssel sind hierarchisch organisiert:
|
||||
|
||||
- `common`: Allgemeine Begriffe (Speichern, Abbrechen, etc.)
|
||||
- `auth`: Authentifizierungsbezogene Texte
|
||||
- `home`: Texte für die Home-Seite
|
||||
- `memo`: Texte für Memo-bezogene Funktionen
|
||||
- `settings`: Texte für die Einstellungsseite
|
||||
|
||||
## Zeit- und Datumsformatierung
|
||||
|
||||
Die App unterstützt kulturspezifische Zeit- und Datumsformatierung für alle 45 Sprachen.
|
||||
|
||||
### Zeitformatierung
|
||||
|
||||
Die Zeitanzeige wird automatisch an die jeweilige Sprache angepasst:
|
||||
|
||||
#### Sprachen mit speziellen Suffixen/Präfixen:
|
||||
- 🇩🇪 **Deutsch**: "19:30 Uhr"
|
||||
- 🇳🇱 **Niederländisch**: "19:30 uur"
|
||||
- 🇩🇰 **Dänisch**: "kl. 19.30" (mit Punkt-Trenner)
|
||||
- 🇸🇪 **Schwedisch**: "kl. 19:30"
|
||||
- 🇫🇷 **Französisch**: "19h30"
|
||||
- 🇧🇬 **Bulgarisch**: "19:30 ч."
|
||||
- 🇱🇹 **Litauisch**: "19:30 val."
|
||||
- 🇫🇮 **Finnisch**: "klo 19.30" (mit Punkt-Trenner)
|
||||
- 🇹🇭 **Thailändisch**: "19:30 น."
|
||||
- 🇮🇩 **Indonesisch**: "19.30" (Punkt statt Doppelpunkt)
|
||||
|
||||
#### 12-Stunden-Format Sprachen:
|
||||
- 🇬🇧 **Englisch**: "7:30 PM"
|
||||
- 🇮🇳 **Hindi**: "7:30 PM"
|
||||
- 🇵🇰 **Urdu**: "7:30 PM"
|
||||
- 🇵🇭 **Tagalog**: "7:30 PM"
|
||||
- 🇲🇾 **Malaiisch**: "7:30 PM"
|
||||
|
||||
#### Einfaches 24-Stunden-Format:
|
||||
Alle anderen Sprachen verwenden "19:30" ohne Suffix.
|
||||
|
||||
### Datumsformatierung
|
||||
|
||||
Die Datumsanzeige nutzt die native `toLocaleDateString()` Funktion mit der aktuellen App-Sprache:
|
||||
|
||||
```typescript
|
||||
// Beispiele:
|
||||
// Deutsch: "Montag, 7. Januar 2025"
|
||||
// Englisch: "Monday, January 7, 2025"
|
||||
// Französisch: "lundi 7 janvier 2025"
|
||||
```
|
||||
|
||||
### Implementierung
|
||||
|
||||
Die Zeit- und Datumsformatierung ist in folgenden Dateien implementiert:
|
||||
|
||||
- `/utils/formatters.ts` - Zentrale Formatierungsfunktionen
|
||||
- `/features/memos/utils/dateFormatters.ts` - React Hooks für locale-aware Formatierung
|
||||
- Komponenten nutzen `useFormatTime()` und `useFormatDate()` Hooks für konsistente Formatierung
|
||||
|
||||
### Verwendung in Komponenten
|
||||
|
||||
```typescript
|
||||
import { useFormatTime, useFormatDate } from '~/features/memos/utils/dateFormatters';
|
||||
|
||||
function MyComponent() {
|
||||
const formatTime = useFormatTime();
|
||||
const formatDate = useFormatDate();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>{formatDate(now)}</Text>
|
||||
<Text>{formatTime(now)}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
- `expo-localization`: Für die Erkennung der Gerätesprache
|
||||
- `i18next`: Hauptbibliothek für Internationalisierung
|
||||
- `react-i18next`: React-Bindings für i18next
|
||||
- `@react-native-async-storage/async-storage`: Für die persistente Speicherung der Spracheinstellung
|
||||
219
apps/memoro/apps/mobile/features/i18n/index.ts
Normal file
219
apps/memoro/apps/mobile/features/i18n/index.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { getLocales } from 'expo-localization';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// Importiere die Übersetzungsdateien
|
||||
import de from './translations/de.json';
|
||||
import en from './translations/en.json';
|
||||
import it from './translations/it.json';
|
||||
import fr from './translations/fr.json';
|
||||
import es from './translations/es.json';
|
||||
import nl from './translations/nl.json';
|
||||
import sv from './translations/sv.json';
|
||||
import da from './translations/da.json';
|
||||
import fi from './translations/fi.json';
|
||||
import cs from './translations/cs.json';
|
||||
import pl from './translations/pl.json';
|
||||
import uk from './translations/uk.json';
|
||||
import sr from './translations/sr.json';
|
||||
import hr from './translations/hr.json';
|
||||
import hu from './translations/hu.json';
|
||||
import el from './translations/el.json';
|
||||
import lt from './translations/lt.json';
|
||||
import lv from './translations/lv.json';
|
||||
import sk from './translations/sk.json';
|
||||
import sl from './translations/sl.json';
|
||||
import ro from './translations/ro.json';
|
||||
import pt from './translations/pt.json';
|
||||
import mt from './translations/mt.json';
|
||||
import bg from './translations/bg.json';
|
||||
import et from './translations/et.json';
|
||||
import ga from './translations/ga.json';
|
||||
import zh from './translations/zh.json';
|
||||
import ru from './translations/ru.json';
|
||||
import ko from './translations/ko.json';
|
||||
import ja from './translations/ja.json';
|
||||
import tr from './translations/tr.json';
|
||||
import ar from './translations/ar.json';
|
||||
import hi from './translations/hi.json';
|
||||
import bn from './translations/bn.json';
|
||||
import ur from './translations/ur.json';
|
||||
import id from './translations/id.json';
|
||||
import fa from './translations/fa.json';
|
||||
import vi from './translations/vi.json';
|
||||
import ptBR from './translations/pt-BR.json';
|
||||
import esMX from './translations/es-MX.json';
|
||||
import th from './translations/th.json';
|
||||
import tl from './translations/tl.json';
|
||||
import ms from './translations/ms.json';
|
||||
import he from './translations/he.json';
|
||||
import af from './translations/af.json';
|
||||
|
||||
// Definiere die unterstützten Sprachen
|
||||
export const LANGUAGES = {
|
||||
de: { nativeName: 'Deutsch', emoji: '🇩🇪' },
|
||||
ch: { nativeName: 'Schwizerdütsch', emoji: '🇨🇭' },
|
||||
at: { nativeName: 'Österreichisch', emoji: '🇦🇹' },
|
||||
en: { nativeName: 'English', emoji: '🇬🇧' },
|
||||
nb: { nativeName: 'Norsk', emoji: '🇳🇴' },
|
||||
it: { nativeName: 'Italiano', emoji: '🇮🇹' },
|
||||
fr: { nativeName: 'Français', emoji: '🇫🇷' },
|
||||
es: { nativeName: 'Español', emoji: '🇪🇸' },
|
||||
'es-MX': { nativeName: 'Español (México)', emoji: '🇲🇽' },
|
||||
nl: { nativeName: 'Nederlands', emoji: '🇳🇱' },
|
||||
sv: { nativeName: 'Svenska', emoji: '🇸🇪' },
|
||||
da: { nativeName: 'Dansk', emoji: '🇩🇰' },
|
||||
fi: { nativeName: 'Suomi', emoji: '🇫🇮' },
|
||||
cs: { nativeName: 'Čeština', emoji: '🇨🇿' },
|
||||
pl: { nativeName: 'Polski', emoji: '🇵🇱' },
|
||||
uk: { nativeName: 'Українська', emoji: '🇺🇦' },
|
||||
sr: { nativeName: 'Српски', emoji: '🇷🇸' },
|
||||
hr: { nativeName: 'Hrvatski', emoji: '🇭🇷' },
|
||||
hu: { nativeName: 'Magyar', emoji: '🇭🇺' },
|
||||
el: { nativeName: 'Ελληνικά', emoji: '🇬🇷' },
|
||||
lt: { nativeName: 'Lietuvių', emoji: '🇱🇹' },
|
||||
lv: { nativeName: 'Latviešu', emoji: '🇱🇻' },
|
||||
sk: { nativeName: 'Slovenčina', emoji: '🇸🇰' },
|
||||
sl: { nativeName: 'Slovenščina', emoji: '🇸🇮' },
|
||||
ro: { nativeName: 'Română', emoji: '🇷🇴' },
|
||||
pt: { nativeName: 'Português', emoji: '🇵🇹' },
|
||||
'pt-BR': { nativeName: 'Português (Brasil)', emoji: '🇧🇷' },
|
||||
mt: { nativeName: 'Malti', emoji: '🇲🇹' },
|
||||
bg: { nativeName: 'Български', emoji: '🇧🇬' },
|
||||
et: { nativeName: 'Eesti', emoji: '🇪🇪' },
|
||||
ga: { nativeName: 'Gaeilge', emoji: '🇮🇪' },
|
||||
zh: { nativeName: '中文', emoji: '🇨🇳' },
|
||||
ru: { nativeName: 'Русский', emoji: '🇷🇺' },
|
||||
ko: { nativeName: '한국어', emoji: '🇰🇷' },
|
||||
ja: { nativeName: '日本語', emoji: '🇯🇵' },
|
||||
tr: { nativeName: 'Türkçe', emoji: '🇹🇷' },
|
||||
ar: { nativeName: 'العربية', emoji: '🇦🇪' },
|
||||
hi: { nativeName: 'हिन्दी', emoji: '🇮🇳' },
|
||||
bn: { nativeName: 'বাংলা', emoji: '🇧🇩' },
|
||||
ur: { nativeName: 'اردو', emoji: '🇵🇰' },
|
||||
id: { nativeName: 'Bahasa Indonesia', emoji: '🇮🇩' },
|
||||
fa: { nativeName: 'فارسی', emoji: '🇮🇷' },
|
||||
vi: { nativeName: 'Tiếng Việt', emoji: '🇻🇳' },
|
||||
th: { nativeName: 'ไทย', emoji: '🇹🇭' },
|
||||
tl: { nativeName: 'Filipino', emoji: '🇵🇭' },
|
||||
ms: { nativeName: 'Bahasa Melayu', emoji: '🇲🇾' },
|
||||
he: { nativeName: 'עברית', emoji: '🇮🇱' },
|
||||
af: { nativeName: 'Afrikaans', emoji: '🇿🇦' }
|
||||
};
|
||||
|
||||
// Konstante für den AsyncStorage-Schlüssel
|
||||
const LANGUAGE_STORAGE_KEY = 'memoro_language';
|
||||
|
||||
// Funktion zum Abrufen der gespeicherten Sprache
|
||||
export const getStoredLanguage = async (): Promise<string | null> => {
|
||||
try {
|
||||
return await AsyncStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.debug('Error getting stored language:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Speichern der ausgewählten Sprache
|
||||
export const storeLanguage = async (language: string): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch (error) {
|
||||
console.debug('Error storing language:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Ändern der Sprache
|
||||
export const changeLanguage = async (language: string): Promise<void> => {
|
||||
await i18n.changeLanguage(language);
|
||||
await storeLanguage(language);
|
||||
};
|
||||
|
||||
// Initialisiere i18next mit den Übersetzungen sofort
|
||||
// Dies stellt sicher, dass die Instanz bereits existiert, bevor Komponenten versuchen, sie zu verwenden
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
it: { translation: it },
|
||||
fr: { translation: fr },
|
||||
es: { translation: es },
|
||||
'es-MX': { translation: esMX },
|
||||
nl: { translation: nl },
|
||||
sv: { translation: sv },
|
||||
da: { translation: da },
|
||||
fi: { translation: fi },
|
||||
cs: { translation: cs },
|
||||
pl: { translation: pl },
|
||||
uk: { translation: uk },
|
||||
sr: { translation: sr },
|
||||
hr: { translation: hr },
|
||||
hu: { translation: hu },
|
||||
el: { translation: el },
|
||||
lt: { translation: lt },
|
||||
lv: { translation: lv },
|
||||
sk: { translation: sk },
|
||||
sl: { translation: sl },
|
||||
ro: { translation: ro },
|
||||
pt: { translation: pt },
|
||||
'pt-BR': { translation: ptBR },
|
||||
mt: { translation: mt },
|
||||
bg: { translation: bg },
|
||||
et: { translation: et },
|
||||
ga: { translation: ga },
|
||||
zh: { translation: zh },
|
||||
ru: { translation: ru },
|
||||
ko: { translation: ko },
|
||||
ja: { translation: ja },
|
||||
tr: { translation: tr },
|
||||
ar: { translation: ar },
|
||||
hi: { translation: hi },
|
||||
bn: { translation: bn },
|
||||
ur: { translation: ur },
|
||||
id: { translation: id },
|
||||
fa: { translation: fa },
|
||||
vi: { translation: vi },
|
||||
th: { translation: th },
|
||||
tl: { translation: tl },
|
||||
ms: { translation: ms },
|
||||
he: { translation: he },
|
||||
af: { translation: af },
|
||||
},
|
||||
lng: 'en', // Standardsprache, wird später aktualisiert
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false, // React kümmert sich bereits um XSS
|
||||
},
|
||||
compatibilityJSON: 'v4', // Aktualisiert auf v4 für Kompatibilität
|
||||
react: {
|
||||
useSuspense: false, // Verhindert Probleme mit React Suspense
|
||||
},
|
||||
});
|
||||
|
||||
// Funktion zur Initialisierung von i18n mit der korrekten Sprache
|
||||
export const initializeI18n = async (): Promise<void> => {
|
||||
try {
|
||||
// Versuche, die gespeicherte Sprache zu laden
|
||||
const storedLanguage = await getStoredLanguage();
|
||||
|
||||
// Wenn keine gespeicherte Sprache vorhanden ist, verwende die Gerätesprache
|
||||
const deviceLanguage = getLocales()[0]?.languageCode || 'en';
|
||||
|
||||
// Überprüfe, ob die Gerätesprache unterstützt wird
|
||||
const fallbackLanguage = Object.keys(LANGUAGES).includes(deviceLanguage)
|
||||
? deviceLanguage
|
||||
: 'en';
|
||||
|
||||
// Aktualisiere die Sprache
|
||||
await i18n.changeLanguage(storedLanguage || fallbackLanguage);
|
||||
} catch (error) {
|
||||
console.debug('Error initializing i18n:', error);
|
||||
// Bei Fehler bleibt die Standardsprache (en) aktiv
|
||||
}
|
||||
};
|
||||
|
||||
// Exportiere i18n für die Verwendung in der App
|
||||
export default i18n;
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
# Translation Rules for Memoro App
|
||||
|
||||
This document outlines the guidelines and rules for translating the Memoro app into different languages.
|
||||
|
||||
## General Principles
|
||||
|
||||
1. **Consistency**: Maintain consistent terminology throughout the entire translation
|
||||
2. **Brand Terms**: Keep brand-specific terms unchanged across all languages
|
||||
3. **Interpolation**: Never translate or modify interpolation variables (e.g., `{{count}}`, `{{name}}`)
|
||||
4. **JSON Structure**: Maintain the exact same JSON structure as the English reference file
|
||||
5. **Completeness**: All keys present in the English file must be present in translated files
|
||||
|
||||
## Brand Terms (Do NOT Translate)
|
||||
|
||||
The following terms are brand names or technical terms that should remain unchanged in all languages:
|
||||
|
||||
- **Memoro** - The app name
|
||||
- **Mana** - The credit/currency system
|
||||
- **Memory/Memories** - The feature name (not the general word)
|
||||
- **Blueprint** - The template/mode feature
|
||||
- **Tag/Tags** - Commonly used in original form across languages
|
||||
|
||||
## Language-Specific Guidelines
|
||||
|
||||
### Important: Informal Address
|
||||
**All languages should use informal address forms** to maintain a friendly, modern app experience:
|
||||
- German: "du/dein" (not "Sie")
|
||||
- French: "tu/ton" (not "vous")
|
||||
- Italian: "tu/tuo" (not "Lei")
|
||||
- Spanish: "tú/tu" (not "usted")
|
||||
- Dutch: "je/jij" (already informal)
|
||||
|
||||
### German (de)
|
||||
- Use informal "du" for user-facing text
|
||||
- Technical terms: Keep English terms that are commonly used in German IT contexts
|
||||
- Date format: DD.MM.YYYY
|
||||
- Time format: 24-hour (HH:mm)
|
||||
|
||||
### French (fr)
|
||||
- Use informal "tu" for user-facing text
|
||||
- Keep articles with brand terms where natural (e.g., "une Memory")
|
||||
- Date format: DD/MM/YYYY
|
||||
- Time format: 24-hour (HH:mm)
|
||||
|
||||
### Italian (it)
|
||||
- Use informal "tu" forms (second person singular)
|
||||
- Keep articles with brand terms where natural (e.g., "una Memory")
|
||||
- Date format: DD/MM/YYYY
|
||||
- Time format: 24-hour (HH:mm)
|
||||
|
||||
### Spanish (es)
|
||||
- Use informal "tú" forms for instructions and UI text
|
||||
- Date format: DD/MM/YYYY
|
||||
- Time format: 24-hour (HH:mm)
|
||||
|
||||
### Dutch (nl)
|
||||
- Use informal "je/jij" forms (modern app convention)
|
||||
- Date format: DD-MM-YYYY
|
||||
- Time format: 24-hour (HH:mm)
|
||||
|
||||
## Common Translations
|
||||
|
||||
### Core Features
|
||||
| English | German | French | Italian | Spanish | Dutch |
|
||||
|---------|---------|---------|----------|----------|--------|
|
||||
| Memo/Memos | Memo/Memos | Mémo/Mémos | Memo/Memo | Memo/Memos | Memo/Memo's |
|
||||
| Recording | Aufnahme | Enregistrement | Registrazione | Grabación | Opname |
|
||||
| Transcript | Transkript | Transcription | Trascrizione | Transcripción | Transcriptie |
|
||||
| Speaker | Sprecher | Intervenant | Relatore | Orador | Spreker |
|
||||
| Space/Spaces | Bereich/Bereiche | Espace/Espaces | Spazio/Spazi | Espacio/Espacios | Ruimte/Ruimtes |
|
||||
|
||||
### Actions
|
||||
| English | German | French | Italian | Spanish | Dutch |
|
||||
|---------|---------|---------|----------|----------|--------|
|
||||
| Share | Teilen | Partager | Condividi | Compartir | Delen |
|
||||
| Copy | Kopieren | Copier | Copia | Copiar | Kopiëren |
|
||||
| Delete | Löschen | Supprimer | Elimina | Eliminar | Verwijderen |
|
||||
| Pin/Unpin | Anpinnen/Loslösen | Épingler/Désépingler | Fissa/Sblocca | Fijar/Desfijar | Vastpinnen/Losmaken |
|
||||
|
||||
## Technical Terms
|
||||
|
||||
### Audio & Processing
|
||||
- **Diarization**:
|
||||
- DE: Sprechererkennung
|
||||
- FR: Diarisation
|
||||
- IT: Diarizzazione
|
||||
- ES: Diarización
|
||||
- NL: Sprekerherkenning
|
||||
|
||||
### Features to Keep in English
|
||||
- API
|
||||
- Webhook
|
||||
- Token
|
||||
- Provider
|
||||
- Analytics
|
||||
|
||||
## Context Menu Actions
|
||||
|
||||
When implementing context menus, ensure these standard actions are consistently translated:
|
||||
|
||||
1. Share functionality
|
||||
2. Copy functionality
|
||||
3. Pin/Unpin toggle
|
||||
4. Delete with confirmation
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before finalizing translations:
|
||||
|
||||
1. ✓ All keys from English file are present
|
||||
2. ✓ No interpolation variables were modified
|
||||
3. ✓ Brand terms remain unchanged
|
||||
4. ✓ Consistent terminology throughout
|
||||
5. ✓ Appropriate formality level maintained
|
||||
6. ✓ No hardcoded language strings in action handlers
|
||||
7. ✓ Menu actions use IDs, not translated strings
|
||||
|
||||
## Adding New Languages
|
||||
|
||||
When adding a new language:
|
||||
|
||||
1. Copy the English (en.json) file as template
|
||||
2. Maintain all keys and structure
|
||||
3. Translate values following these guidelines
|
||||
4. Keep brand terms unchanged
|
||||
5. Test all interactive elements (menus, buttons, etc.)
|
||||
6. Verify character encoding (UTF-8)
|
||||
7. Check text direction (RTL languages need special handling)
|
||||
|
||||
## Common Issues to Avoid
|
||||
|
||||
1. **Never translate**:
|
||||
- Variable names in curly braces: `{{variable}}`
|
||||
- Brand names: Memoro, Mana, Memory, Blueprint
|
||||
- Technical terms when commonly used in English
|
||||
|
||||
2. **Always translate**:
|
||||
- UI action words (Save, Cancel, Delete, etc.)
|
||||
- Descriptive text and messages
|
||||
- Error messages and confirmations
|
||||
|
||||
3. **Header Menu Issue**: The app uses action IDs for navigation, not translated strings. Ensure menu items have proper `id` fields.
|
||||
|
||||
## Notes for Developers
|
||||
|
||||
- The header menu (`HeaderMenu.tsx`) was updated to use action IDs instead of translated strings
|
||||
- Context menus must follow the same pattern - use IDs for action handling
|
||||
- Always test menu functionality after adding new translations
|
||||
1007
apps/memoro/apps/mobile/features/i18n/translations/af.json
Normal file
1007
apps/memoro/apps/mobile/features/i18n/translations/af.json
Normal file
File diff suppressed because it is too large
Load diff
1013
apps/memoro/apps/mobile/features/i18n/translations/ar.json
Normal file
1013
apps/memoro/apps/mobile/features/i18n/translations/ar.json
Normal file
File diff suppressed because it is too large
Load diff
1008
apps/memoro/apps/mobile/features/i18n/translations/bg.json
Normal file
1008
apps/memoro/apps/mobile/features/i18n/translations/bg.json
Normal file
File diff suppressed because it is too large
Load diff
1006
apps/memoro/apps/mobile/features/i18n/translations/bn.json
Normal file
1006
apps/memoro/apps/mobile/features/i18n/translations/bn.json
Normal file
File diff suppressed because it is too large
Load diff
1055
apps/memoro/apps/mobile/features/i18n/translations/cs.json
Normal file
1055
apps/memoro/apps/mobile/features/i18n/translations/cs.json
Normal file
File diff suppressed because it is too large
Load diff
1013
apps/memoro/apps/mobile/features/i18n/translations/da.json
Normal file
1013
apps/memoro/apps/mobile/features/i18n/translations/da.json
Normal file
File diff suppressed because it is too large
Load diff
1138
apps/memoro/apps/mobile/features/i18n/translations/de.json
Normal file
1138
apps/memoro/apps/mobile/features/i18n/translations/de.json
Normal file
File diff suppressed because it is too large
Load diff
1006
apps/memoro/apps/mobile/features/i18n/translations/el.json
Normal file
1006
apps/memoro/apps/mobile/features/i18n/translations/el.json
Normal file
File diff suppressed because it is too large
Load diff
1130
apps/memoro/apps/mobile/features/i18n/translations/en.json
Normal file
1130
apps/memoro/apps/mobile/features/i18n/translations/en.json
Normal file
File diff suppressed because it is too large
Load diff
1007
apps/memoro/apps/mobile/features/i18n/translations/es-MX.json
Normal file
1007
apps/memoro/apps/mobile/features/i18n/translations/es-MX.json
Normal file
File diff suppressed because it is too large
Load diff
1055
apps/memoro/apps/mobile/features/i18n/translations/es.json
Normal file
1055
apps/memoro/apps/mobile/features/i18n/translations/es.json
Normal file
File diff suppressed because it is too large
Load diff
1041
apps/memoro/apps/mobile/features/i18n/translations/et.json
Normal file
1041
apps/memoro/apps/mobile/features/i18n/translations/et.json
Normal file
File diff suppressed because it is too large
Load diff
1006
apps/memoro/apps/mobile/features/i18n/translations/fa.json
Normal file
1006
apps/memoro/apps/mobile/features/i18n/translations/fa.json
Normal file
File diff suppressed because it is too large
Load diff
1013
apps/memoro/apps/mobile/features/i18n/translations/fi.json
Normal file
1013
apps/memoro/apps/mobile/features/i18n/translations/fi.json
Normal file
File diff suppressed because it is too large
Load diff
1087
apps/memoro/apps/mobile/features/i18n/translations/fr.json
Normal file
1087
apps/memoro/apps/mobile/features/i18n/translations/fr.json
Normal file
File diff suppressed because it is too large
Load diff
1041
apps/memoro/apps/mobile/features/i18n/translations/ga.json
Normal file
1041
apps/memoro/apps/mobile/features/i18n/translations/ga.json
Normal file
File diff suppressed because it is too large
Load diff
1012
apps/memoro/apps/mobile/features/i18n/translations/he.json
Normal file
1012
apps/memoro/apps/mobile/features/i18n/translations/he.json
Normal file
File diff suppressed because it is too large
Load diff
1013
apps/memoro/apps/mobile/features/i18n/translations/hi.json
Normal file
1013
apps/memoro/apps/mobile/features/i18n/translations/hi.json
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue