refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View file

@ -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;

View file

@ -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;
};

View 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;

View file

@ -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,
};
};

View file

@ -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 {};
};

View file

@ -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
);
};

View 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';

View file

@ -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();

View file

@ -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();

View 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;
}

View file

@ -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(),
});
};

View file

@ -0,0 +1,11 @@
/**
* Status eines Audio-Players
*/
export enum AudioPlayerStatus {
IDLE = 'idle',
LOADING = 'loading',
PLAYING = 'playing',
PAUSED = 'paused',
STOPPED = 'stopped',
ERROR = 'error',
}

View file

@ -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;
},
}));

View 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);
};

View file

@ -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`

View file

@ -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

View file

@ -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

View 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

View 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

File diff suppressed because it is too large Load diff

View file

@ -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>
);
};

View file

@ -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));
}
}

View 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,
};

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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();

View file

@ -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');
};

View 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
};

View file

@ -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;

View 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)

View 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

View 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>
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1 @@
export { default as AuthErrorDisplay } from './AuthErrorDisplay';

View 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>
);
};

View file

@ -0,0 +1 @@
export * from './AuthContext';

View file

@ -0,0 +1,5 @@
export * from './contexts';
export * from './services';
export * from './lib';
export * from './utils';
export * from './components';

View file

@ -0,0 +1,2 @@
export * from './supabaseClient';
export { default as supabase } from './supabaseClient';

View 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;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export * from './authService';

View 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;

View 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;
}

View 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;
}
}
}

View 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);
}
});
};

View file

@ -0,0 +1 @@
export * from './safeStorage';

View 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);
}
}
};

View file

@ -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;

View file

@ -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;

View 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';

View file

@ -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 [];
}
};

View 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

View file

@ -0,0 +1,3 @@
export { useAutoFocus } from './useAutoFocus';
export { useAsyncEffect, useAsyncEffectWithCleanup } from './useAsyncEffect';
export { useTimeout, useTimeoutFn } from './useTimeout';

View file

@ -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);
}

View 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]);
}

View 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 };
}

View 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
}
};
}

View 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);
}

View file

@ -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
};
}

View 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
};
}

View 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';

View 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];
}

View 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>;

View 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);
}
};
};

View 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();

View file

@ -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
};
}

View file

@ -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 }),
}));

View file

@ -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
};
}

View file

@ -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();

View 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';

View file

@ -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.'
);
}

View file

@ -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,
};
}

View 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';

View file

@ -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'
};
}

View 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>
);
};

View 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;

View 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

View 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;

View file

@ -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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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