import AsyncStorage from '@react-native-async-storage/async-storage'; import { createAudioPlayer } from 'expo-audio'; import 'react-native-url-polyfill/auto'; import { AudioFile } from './storage.types'; import { formatDurationWithUnits } from '~/utils/formatters'; import { authService } from '../auth'; import { getLocationForMemo, EnhancedLocationData } from '~/features/location/locationService'; import { analyzeNetworkError } from '~/features/errorHandling/utils/networkErrorUtils'; import NetInfo from '@react-native-community/netinfo'; import { FileMetadata, FileData, FileStatus, FileStorageConfig, TranscriptionResult, IFileStorageService, } from './fileStorage.types'; import { cleanBase64Data, createFileUri } from './fileStorage.utils'; import { creditService } from '~/features/credits/creditService'; import * as FileSystem from './fileSystemUtils'; import { AZURE_SUPPORTED_LANGUAGES } from '../audioRecordingV2'; import { useUploadStatusStore } from './store/uploadStatusStore'; import { UploadStatus } from './uploadStatus.types'; // UUID v4 generator for React Native function generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** * Default configuration for file storage */ const DEFAULT_CONFIG: FileStorageConfig = { retentionPeriodDays: 90, }; /** * Service for file storage operations on native platforms */ class FileStorageService implements IFileStorageService { // ====================================================================== // PROPERTIES & INITIALIZATION // ====================================================================== private readonly fileDirectory: string; private config: FileStorageConfig = { ...DEFAULT_CONFIG }; constructor() { // Create a permanent directory for files const docDir = FileSystem.getDocumentDirectory(); // Check if docDir already ends with 'files/' to avoid double 'files/files/' if (docDir.endsWith('files/')) { this.fileDirectory = docDir; } else { this.fileDirectory = `${docDir}files/`; } console.log('[FileStorage] Constructor - docDir:', docDir); console.log('[FileStorage] Constructor - fileDirectory set to:', this.fileDirectory); this.ensureDirectoryExists().catch(console.debug); } // ====================================================================== // PRIVATE HELPER METHODS // ====================================================================== /** * Ensures that the file directory exists */ private async ensureDirectoryExists(): Promise { try { const dirInfo = await FileSystem.getFileInfo(this.fileDirectory); if (!dirInfo.exists) { await FileSystem.createDirectory(this.fileDirectory, { intermediates: true }); console.debug('File directory created:', this.fileDirectory); } } catch (error) { console.debug('Error creating file directory:', error); } } // ====================================================================== // PUBLIC UTILITY METHODS // ====================================================================== /** * Formats the duration of an audio file as MM:SS * @param seconds Duration in seconds * @returns Formatted duration */ formatDuration(seconds: number): string { // Use the centralized time formatter utility return formatDurationWithUnits(seconds); } /** * Saves file content with associated metadata * @param fileName Name of the file to save * @param base64Data Base64-encoded file content * @param metadata Metadata for the file * @returns Path to the saved file */ async saveFileWithMetadata( fileName: string, base64Data: string, metadata: FileMetadata ): Promise { const filePath = `${this.fileDirectory}${fileName}`; try { const cleanedBase64Data = cleanBase64Data(base64Data); const metadataWithTimestamp = { ...metadata, timestamp: Date.now(), }; await FileSystem.writeStringToFile(filePath, cleanedBase64Data, { encoding: FileSystem.EncodingType.Base64, }); await AsyncStorage.setItem(fileName, JSON.stringify(metadataWithTimestamp)); return filePath; } catch (error) { console.debug('Error saving file:', error); throw error; } } /** * Retrieves file content and metadata * @param fileName Name of the file to retrieve * @returns File data including content, metadata, and URI */ async getFileWithMetadata(fileName: string): Promise { try { const filePath = `${this.fileDirectory}${fileName}`; const fileInfo = await FileSystem.getFileInfo(filePath); if (!fileInfo.exists) { throw new Error('File not found'); } const base64Data = await FileSystem.readStringFromFile(filePath, { encoding: FileSystem.EncodingType.Base64, }); const metadataString = await AsyncStorage.getItem(fileName); if (!metadataString) { throw new Error('Metadata not found'); } const metadata = JSON.parse(metadataString); const uri = createFileUri(fileName, base64Data); return { base64Data, metadata, uri }; } catch (error) { console.debug('Error loading file:', error); throw error; } } /** * Deletes a file and its metadata * @param fileName Name of the file to delete */ async deleteFileWithMetadata(fileName: string): Promise { try { const filePath = `${this.fileDirectory}${fileName}`; await FileSystem.deleteFile(filePath, { idempotent: true }); await AsyncStorage.removeItem(fileName); } catch (error) { console.debug('Error deleting file:', error); throw error; } } /** * Lists all files in storage * @returns Array of file names */ async listAllFiles(): Promise { try { const documentDir = this.fileDirectory; if (!documentDir) { throw new Error('File directory not available'); } const files = await FileSystem.readDirectory(documentDir); return files.filter((file) => { // Filter out system files and directories const isSystemFile = file.startsWith('.') || file === 'BridgeReactNativeDevBundle.js'; return !isSystemFile; }); } catch (error) { console.debug('Error listing files:', error); throw error; } } // ====================================================================== // CORE STORAGE OPERATIONS // ====================================================================== /** * Moves an audio file from cache to permanent storage * @param uri URI of the audio file in cache * @param title Optional title for the file * @param duration Optional duration in seconds (if known, to avoid loading audio) * @returns The moved audio file or null on error */ async saveRecording(uri: string, title?: string, duration?: number): Promise { console.log('[FileStorage] saveRecording called with uri:', uri, 'title:', title, 'duration:', duration); try { await this.ensureDirectoryExists(); console.log('[FileStorage] Directory ensured for saving'); // Get location data for filename const locationData = (await getLocationForMemo(true)) as EnhancedLocationData | null; // Generate a unique filename with location using local time const now = new Date(); const timestamp = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0') + 'T' + String(now.getHours()).padStart(2, '0') + '-' + String(now.getMinutes()).padStart(2, '0') + '-' + String(now.getSeconds()).padStart(2, '0'); const fileExtension = uri.split('.').pop() || 'm4a'; let filename = `audio-${timestamp}`; // Add location if available if (locationData?.address) { const { street, streetNumber, city } = locationData.address; let locationPart = ''; if (street && city) { const streetWithNumber = streetNumber ? `${street}-${streetNumber}` : street; const sanitizedStreet = streetWithNumber.replace(/[^a-z0-9]/gi, '-').toLowerCase(); const sanitizedCity = city.replace(/[^a-z0-9]/gi, '-').toLowerCase(); locationPart = `-${sanitizedStreet}-${sanitizedCity}`; } else if (city) { const sanitizedCity = city.replace(/[^a-z0-9]/gi, '-').toLowerCase(); locationPart = `-${sanitizedCity}`; } filename += locationPart; } const finalFilename = `${filename}.${fileExtension}`; const newUri = `${this.fileDirectory}${finalFilename}`; console.log('[FileStorage] Saving file as:', finalFilename, 'to:', newUri); // Copy the file from cache await FileSystem.copyFile(uri, newUri); console.log('[FileStorage] File copied successfully to permanent storage'); // Delete the original file from cache (optional) try { await FileSystem.deleteFile(uri); } catch (error) { console.debug('Could not delete original cache file:', error); } // Create metadata for the audio file const fileInfo = await FileSystem.getFileInfo(newUri); // Create file metadata const fileMetadata: FileMetadata = { title: title || finalFilename, timestamp: Date.now(), }; // Save metadata await AsyncStorage.setItem(finalFilename, JSON.stringify(fileMetadata)); const audioFile: AudioFile = { id: finalFilename, uri: newUri, filename: finalFilename, duration: duration || 0, // Use provided duration if available createdAt: new Date(), }; // Only load the audio file to get duration if not provided if (!duration) { // Load the audio file to get the duration with improved error handling let player = null; try { player = createAudioPlayer(newUri); // Wait for metadata to load await new Promise(resolve => setTimeout(resolve, 100)); const durationSeconds = player.duration; if (durationSeconds && durationSeconds > 0) { audioFile.duration = durationSeconds; // Already in seconds } } catch (error) { console.debug('Could not get audio duration:', error); } finally { // Ensure player is always released to prevent memory leaks if (player) { try { player.release(); } catch (unloadError) { console.debug('Error releasing audio player:', unloadError); // Force null reference to help GC player = null; } } } } // Cache the audio metadata for faster future loading const fileSize = fileInfo.exists ? (fileInfo as any).size || 0 : 0; await this.setCachedAudioMetadata( finalFilename, audioFile.duration, audioFile.createdAt, fileSize ); console.debug('Recording saved to permanent storage:', newUri); return audioFile; } catch (error) { console.debug('Error saving recording:', error); return null; } } /** * Converts a URI to base64 data * @param uri URI of the file * @returns Base64-encoded file content */ async getBase64FromUri(uri: string): Promise { return FileSystem.readStringFromFile(uri, { encoding: FileSystem.EncodingType.Base64, }); } /** * Deletes all files in storage */ async cleanupAllFiles(): Promise { try { const files = await this.listAllFiles(); await Promise.all(files.map((fileName) => this.deleteFileWithMetadata(fileName))); } catch (error) { console.debug('Error cleaning up files:', error); throw error; } } /** * Retrieves all file data * @returns Array of file data objects */ async getAllFileData(): Promise { try { const files = await this.listAllFiles(); const fileDataPromises = files.map((fileName) => this.getFileWithMetadata(fileName)); return await Promise.all(fileDataPromises); } catch (error) { console.debug('Error getting all file data:', error); throw error; } } /** * Loads cached metadata for an audio file * @param filename Name of the audio file * @returns Cached metadata or null if not found */ private async getCachedAudioMetadata( filename: string ): Promise<{ duration: number; createdAt: Date; size?: number } | null> { try { const cacheKey = `audio_metadata_${filename}`; const cached = await AsyncStorage.getItem(cacheKey); if (cached) { const metadata = JSON.parse(cached); return { duration: metadata.duration, createdAt: new Date(metadata.createdAt), size: metadata.size, }; } } catch (error) { console.debug(`Error loading cached metadata for ${filename}:`, error); } return null; } /** * Saves metadata for an audio file to cache * @param filename Name of the audio file * @param duration Duration in seconds * @param createdAt Creation date * @param size File size in bytes */ private async setCachedAudioMetadata( filename: string, duration: number, createdAt: Date, size?: number ): Promise { try { const cacheKey = `audio_metadata_${filename}`; const metadata = { duration, createdAt: createdAt.toISOString(), size, cachedAt: Date.now(), }; await AsyncStorage.setItem(cacheKey, JSON.stringify(metadata)); } catch (error) { console.debug(`Error caching metadata for ${filename}:`, error); } } /** * Loads all saved audio files with pagination and caching * @param limit Maximum number of files to return (default: 10) * @param offset Number of files to skip (default: 0) * @returns List of audio files */ async getAllRecordings(limit: number = 10, offset: number = 0): Promise { console.log('[FileStorage] getAllRecordings called with limit:', limit, 'offset:', offset); try { await this.ensureDirectoryExists(); console.log('[FileStorage] Directory ensured, path:', this.fileDirectory); const files = await FileSystem.readDirectory(this.fileDirectory); console.log('[FileStorage] readDirectory returned:', files.length, 'files:', files); const audioFiles: AudioFile[] = []; // Filter audio files first const audioFileNames = files.filter( (filename) => filename.endsWith('.m4a') || filename.endsWith('.mp3') || filename.endsWith('.wav') ); console.log('[FileStorage] Filtered audio files:', audioFileNames.length, 'files:', audioFileNames); // Process files to get creation dates for sorting const filesWithDates: Array<{ filename: string; createdAt: Date; fileInfo: any }> = []; for (const filename of audioFileNames) { const uri = `${this.fileDirectory}${filename}`; const fileInfo = await FileSystem.getFileInfo(uri, { size: true }); // Check cache first for creation date const cached = await this.getCachedAudioMetadata(filename); let createdAt: Date; if (cached) { createdAt = cached.createdAt; } else { // Extract creation date from filename or use file info const dateMatch = filename.match(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/); if (dateMatch) { const dateStr = dateMatch[0].replace(/-/g, (m, i) => (i > 9 ? ':' : '-')); createdAt = new Date(dateStr); } else { // Use file modification time or current date if ('modificationTime' in fileInfo && typeof fileInfo.modificationTime === 'number') { createdAt = new Date(fileInfo.modificationTime * 1000); } else { createdAt = new Date(); } } } filesWithDates.push({ filename, createdAt, fileInfo }); } // Sort by creation date (newest first) and apply pagination filesWithDates.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); const paginatedFiles = filesWithDates.slice(offset, offset + limit); // Now load metadata for only the files we need for (const { filename, createdAt, fileInfo } of paginatedFiles) { const uri = `${this.fileDirectory}${filename}`; // Check cache for duration const cached = await this.getCachedAudioMetadata(filename); let duration = 0; if (cached) { duration = cached.duration; console.debug(`Using cached duration for ${filename}: ${duration}s`); } else { // Load duration from audio file let player = null; try { const sound = createAudioPlayer(uri); player = sound; // Wait for metadata to load await new Promise(resolve => setTimeout(resolve, 100)); const durationSeconds = sound.duration; if (durationSeconds && durationSeconds > 0) { duration = durationSeconds; } // Cache the metadata await this.setCachedAudioMetadata(filename, duration, createdAt, fileInfo.size); console.debug(`Cached duration for ${filename}: ${duration}s`); } catch (error) { console.debug(`Could not get duration for ${filename}:`, error); } finally { // Ensure sound is always unloaded to prevent memory leaks if (player) { try { player.release(); } catch (unloadError) { console.debug(`Error unloading sound object for ${filename}:`, unloadError); player = null; } } } } audioFiles.push({ id: filename, uri, filename, duration, createdAt, size: fileInfo.size, }); } return audioFiles; } catch (error) { console.debug('Error getting recordings:', error); return []; } } /** * Gets the total count of audio recordings * @returns Total number of audio files */ async getRecordingsCount(): Promise { try { await this.ensureDirectoryExists(); const files = await FileSystem.readDirectory(this.fileDirectory); return files.filter( (filename) => filename.endsWith('.m4a') || filename.endsWith('.mp3') || filename.endsWith('.wav') ).length; } catch (error) { console.debug('Error getting recordings count:', error); return 0; } } /** * Gets comprehensive statistics about the audio archive * @returns Statistics including total count, duration, and size */ async getArchiveStatistics(): Promise<{ totalCount: number; totalDurationSeconds: number; totalSizeBytes: number; }> { try { await this.ensureDirectoryExists(); const files = await FileSystem.readDirectory(this.fileDirectory); // Filter audio files const audioFileNames = files.filter( (filename) => filename.endsWith('.m4a') || filename.endsWith('.mp3') || filename.endsWith('.wav') ); let totalDurationSeconds = 0; let totalSizeBytes = 0; // Process each audio file for (const filename of audioFileNames) { const uri = `${this.fileDirectory}${filename}`; // Try to get cached metadata first const cached = await this.getCachedAudioMetadata(filename); if (cached) { // Use cached values totalDurationSeconds += cached.duration; if (cached.size) { totalSizeBytes += cached.size; } } else { // Fallback: get file info and try to load duration const fileInfo = await FileSystem.getFileInfo(uri); if (fileInfo.exists && fileInfo.size) { totalSizeBytes += fileInfo.size; } // Try to get duration (this will also cache it for future use) let duration = 0; let player = null; try { const sound = createAudioPlayer(uri); player = sound; // Wait for metadata to load await new Promise(resolve => setTimeout(resolve, 100)); const durationSeconds = sound.duration; if (durationSeconds && durationSeconds > 0) { duration = durationSeconds; totalDurationSeconds += duration; } // Cache this metadata for future use if (duration > 0) { const createdAt = this.extractCreationDateFromFilename(filename) || new Date(); const fileSize = fileInfo.exists ? (fileInfo as any).size || 0 : 0; await this.setCachedAudioMetadata(filename, duration, createdAt, fileSize); } } catch (error) { console.debug(`Could not get duration for ${filename}:`, error); } finally { if (player) { try { player.release(); } catch (unloadError) { console.debug(`Error unloading sound object for ${filename}:`, unloadError); player = null; } } } } } return { totalCount: audioFileNames.length, totalDurationSeconds, totalSizeBytes, }; } catch (error) { console.debug('Error getting archive statistics:', error); return { totalCount: 0, totalDurationSeconds: 0, totalSizeBytes: 0, }; } } /** * Extracts creation date from filename * @param filename Name of the file * @returns Date object or null */ private extractCreationDateFromFilename(filename: string): Date | null { const dateMatch = filename.match(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/); if (dateMatch) { const dateStr = dateMatch[0].replace(/-/g, (m, i) => (i > 9 ? ':' : '-')); return new Date(dateStr); } return null; } /** * Retrieves metadata for all files * @returns Array of objects containing file name, metadata, and status */ async getAllFileMetadata(): Promise< { fileName: string; metadata: FileMetadata; status?: FileStatus }[] > { try { const files = await this.listAllFiles(); const allMetadataKeys = await this.getAllMetadataKeys(); // Collect all file names (from file system and AsyncStorage) const allFileNames = new Set([...files, ...allMetadataKeys]); // Create metadata objects for all files const metadataPromises = Array.from(allFileNames).map(async (fileName) => { try { // Check if the file physically exists const filePath = `${this.fileDirectory}${fileName}`; const fileInfo = await FileSystem.getFileInfo(filePath); const fileExists = fileInfo.exists; // Load metadata if available const metadataString = await AsyncStorage.getItem(fileName); const metadata = metadataString ? JSON.parse(metadataString) : null; // Determine file status let status: FileStatus | undefined; if (!fileExists && metadata) { status = 'metadata_only'; } else if (fileExists && !metadata) { status = 'file_only'; } else if (!fileExists && !metadata) { // Should not happen since we only consider files that exist // either in the file system or in AsyncStorage return null; } return { fileName, metadata, status }; } catch (error) { console.debug(`Error loading metadata for ${fileName}:`, error); return { fileName, metadata: null, status: 'error' }; } }); const results = await Promise.all(metadataPromises); return results.filter(Boolean) as { fileName: string; metadata: FileMetadata; status?: FileStatus; }[]; } catch (error) { console.debug('Error loading file metadata:', error); throw error; } } /** * Gets all keys for which metadata exists * @returns Array of file names */ async getAllMetadataKeys(): Promise { try { // Get all keys from AsyncStorage const allKeys = await AsyncStorage.getAllKeys(); // Filter for potential file metadata keys // This is a heuristic and could be adjusted based on use case const fileMetadataKeys = allKeys.filter((key) => { // Check for common file extensions in the key const hasFileExtension = /\.(\w+)$/.test(key); // Or check for file naming pattern const hasFilePrefix = key.startsWith('file_'); return hasFileExtension || hasFilePrefix; }); return fileMetadataKeys; } catch (error) { console.debug('Error getting metadata keys:', error); return []; } } /** * Deletes an audio file * @param recording AudioFile object to delete * @returns true on success, false on error */ async deleteRecording(recording: AudioFile): Promise { try { if (!recording || !recording.uri || !recording.filename) { console.debug('Invalid recording object provided for deletion'); return false; } // Check if the file exists const fileInfo = await FileSystem.getFileInfo(recording.uri); if (fileInfo.exists) { // Delete the file await FileSystem.deleteFile(recording.uri); // Also delete the metadata await AsyncStorage.removeItem(recording.filename); // Clean up upload status const uploadStatusStore = useUploadStatusStore.getState(); await uploadStatusStore.removeStatus(recording.id); console.debug('Recording deleted:', recording.filename); return true; } console.debug('Recording file not found:', recording.uri); return false; } catch (error) { console.debug('Error deleting recording:', error); return false; } } /** * Reconstructs missing metadata for a file * @param fileName Name of the file * @param userId Optional user ID to associate with the file * @returns Reconstructed metadata */ async reconstructMetadata(fileName: string, userId?: string): Promise { // Extract information from the file name if possible const now = Date.now(); const reconstructedMetadata: FileMetadata = { title: fileName, timestamp: now, }; if (userId) { reconstructedMetadata.userId = userId; } // Save the reconstructed metadata await AsyncStorage.setItem(fileName, JSON.stringify(reconstructedMetadata)); return reconstructedMetadata; } /** * Repairs files with missing metadata * @param userId Optional user ID to associate with the files * @returns Object with counts of repaired and failed files */ async repairInconsistentFiles(userId?: string): Promise<{ repaired: number; failed: number }> { try { const allMetadata = await this.getAllFileMetadata(); const inconsistentFiles = allMetadata.filter((item) => item.status === 'file_only'); const repairResults = await Promise.all( inconsistentFiles.map(async (file) => { try { await this.reconstructMetadata(file.fileName, userId); return { success: true }; } catch (error) { console.debug(`Failed to repair metadata for ${file.fileName}:`, error); return { success: false }; } }) ); const repairedCount = repairResults.filter((result) => result.success).length; const failedCount = repairResults.length - repairedCount; return { repaired: repairedCount, failed: failedCount }; } catch (error) { console.debug('Error repairing inconsistent files:', error); return { repaired: 0, failed: 0 }; } } // ====================================================================== // AUDIO PLAYBACK & PROCESSING // ====================================================================== /** * Returns the actual audio URL for a recording * This method is used by the AudioPlayer to play the actual audio file * @param uri The URI of the audio file * @returns The URL to use for playback */ getAudioUrl(uri: string): string { // With expo-audio we can use the URI directly return uri; } /** * Deletes files older than the retention period */ async cleanupOldFiles(): Promise { try { // Get retention period from config const retentionDays = this.config.retentionPeriodDays; // If retentionDays is null or 0, don't delete any files if (!retentionDays) { console.debug('[FileStorage] Auto-cleanup disabled'); return; } console.debug(`[FileStorage] Starting cleanup (retention: ${retentionDays} days)`); const files = await this.listAllFiles(); console.debug(`[FileStorage] Found ${files.length} total files`); const now = Date.now(); const maxAge = retentionDays * 24 * 60 * 60 * 1000; const metadataPromises = files.map(async (fileName) => { const metadataString = await AsyncStorage.getItem(fileName); return { fileName, metadata: metadataString ? (JSON.parse(metadataString) as FileMetadata) : null, }; }); const filesWithMetadata = await Promise.all(metadataPromises); const filesToDelete = filesWithMetadata .filter(({ metadata }) => { if (!metadata) return false; const fileTime = metadata.timestamp; const age = now - fileTime; return age >= maxAge; }) .map(({ fileName }) => fileName); console.debug(`[FileStorage] Found ${filesToDelete.length} files to delete`); await Promise.all(filesToDelete.map((fileName) => this.deleteFileWithMetadata(fileName))); console.debug('[FileStorage] Cleanup completed successfully'); } catch (error) { console.debug('[FileStorage] Error during cleanup:', error); throw error; } } /** * Updates configuration settings * @param config New configuration options */ setConfig(newConfig: Partial): void { this.config = { ...this.config, ...newConfig, }; } /** * Uploads an audio or video file for transcription * @param audioFile The audio/video file to transcribe * @param memoId Optional memo ID to associate with the transcription * @returns Transcription result * Direct upload to Supabase Storage then process via memoro-service * This bypasses Cloud Run's 32MB limit by uploading directly to storage first */ async uploadForTranscription( audioFile: AudioFile, memoId?: string, spaceId?: string, blueprintId?: string | null, recordingLanguagesOverride?: string[], recordingStartedAt?: Date, enableDiarization?: boolean, skipOfflineQueue?: boolean, appendToMemo?: boolean, mediaType?: 'audio' | 'video' ): Promise { // Get upload status store reference const uploadStatusStore = useUploadStatusStore.getState(); try { // Auto-detect media type from filename if not provided if (!mediaType) { const fileExtension = audioFile.filename.split('.').pop()?.toLowerCase(); const videoExtensions = ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm', 'm4v', '3gp']; mediaType = videoExtensions.includes(fileExtension || '') ? 'video' : 'audio'; } console.debug('Starting direct upload transcription with file:', { uri: audioFile.uri, filename: audioFile.filename, duration: audioFile.duration, memoId: memoId, appendToMemo: appendToMemo, skipOfflineQueue: skipOfflineQueue, mediaType: mediaType, }); // Update status to UPLOADING if not skipping offline queue (meaning this is the initial upload attempt) if (!skipOfflineQueue) { await uploadStatusStore.updateStatus(audioFile.id, UploadStatus.UPLOADING, { lastAttemptAt: Date.now(), }); } // Note: Automatic offline queueing removed - uploads will fail if offline // Users can manually retry failed uploads through the UI // Check if the file exists const fileInfo = await FileSystem.getFileInfo(audioFile.uri); if (!fileInfo.exists) { console.debug('Audio file does not exist:', audioFile.uri); return null; } // Get the user's app token for authenticated requests with automatic refresh const { tokenManager } = await import('~/features/auth/services/tokenManager'); let appToken = await tokenManager.getValidToken(); // If token is not available, check if it's due to network issues if (!appToken) { console.debug( 'Token not immediately available, checking network and waiting for refresh...' ); // Check if we're offline first const netState = await NetInfo.fetch(); if (!netState.isConnected) { console.debug('Device is offline, cannot refresh token - upload will fail'); throw new Error('No internet connection. Please check your network and try again.'); } // We're online, so wait for token refresh const tokenAvailable = new Promise((resolve) => { const unsubscribe = tokenManager.subscribe((state, token) => { if (state === 'valid' && token) { unsubscribe(); resolve(token); } }); // Set a timeout to prevent hanging forever setTimeout(() => { unsubscribe(); resolve(null); }, 30000); // 30 second timeout }); // Wait for token to become available appToken = await tokenAvailable; // If still no token after waiting, try one more time if (!appToken) { appToken = await tokenManager.getValidToken(); } if (!appToken) { // Still no token after waiting - check network again const finalNetState = await NetInfo.fetch(); if (!finalNetState.isConnected) { console.debug('Lost connection while waiting for token - upload will fail'); throw new Error('No internet connection. Please check your network and try again.'); } // We're online but can't get a token - this is an auth issue console.error('No authenticated token found for upload after waiting'); throw new Error('Authentication failed. Please try logging in again.'); } } // Get current user ID from auth service let userId = 'default_user'; try { const userData = await authService.getUserFromToken(); if (userData && userData.id) { userId = userData.id; console.debug('Using authenticated user ID:', userId); } else { console.debug('No authenticated user found, using default_user'); } } catch (authError) { console.debug('Error getting user ID:', authError); } // Load recording languages from AsyncStorage or use override let recordingLanguageCodes: string[] = []; let useAutoDetection = false; if (recordingLanguagesOverride && recordingLanguagesOverride.length > 0) { // Use the override languages from upload modal and map them to Azure locales console.debug('Using language override from upload modal:', recordingLanguagesOverride); recordingLanguageCodes = recordingLanguagesOverride; } else { // Fall back to stored languages from AsyncStorage try { const storedLanguages = await AsyncStorage.getItem('memoro_recording_languages'); console.debug('Stored languages from AsyncStorage:', storedLanguages); if (storedLanguages) { const languageCodes = JSON.parse(storedLanguages); console.debug('Parsed language codes:', languageCodes); if (languageCodes.includes('auto')) { useAutoDetection = true; console.debug('Auto language detection mode selected'); } else { recordingLanguageCodes = languageCodes .map((code: string) => { const mapped = AZURE_SUPPORTED_LANGUAGES[code as keyof typeof AZURE_SUPPORTED_LANGUAGES] ?.locale; console.debug(`Mapping ${code} to ${mapped}`); return mapped; }) .filter(Boolean); console.debug('Using recording languages for transcription:', recordingLanguageCodes); // If no valid languages after mapping, fall back to auto-detection if (recordingLanguageCodes.length === 0) { console.debug( 'No valid language codes after mapping, falling back to auto-detection' ); useAutoDetection = true; } } } else { console.debug('No recording languages selected, using auto-detection'); useAutoDetection = true; } } catch (error) { console.debug('Error loading recording languages:', error); useAutoDetection = true; } } // Step 1: Upload directly to Supabase Storage console.debug('📤 Uploading to Supabase Storage...'); // Use FormData approach which works reliably in React Native const formData = new FormData(); // Determine MIME type based on media type and file extension const fileExtension = audioFile.filename.split('.').pop()?.toLowerCase(); let mimeType = 'audio/mp4'; // default if (mediaType === 'video') { // Video MIME types const videoMimeTypes: { [key: string]: string } = { 'mp4': 'video/mp4', 'mov': 'video/quicktime', 'avi': 'video/x-msvideo', 'mkv': 'video/x-matroska', 'webm': 'video/webm', 'm4v': 'video/x-m4v', '3gp': 'video/3gpp', 'wmv': 'video/x-ms-wmv', 'flv': 'video/x-flv' }; mimeType = videoMimeTypes[fileExtension || ''] || 'video/mp4'; } else { // Audio MIME types const audioMimeTypes: { [key: string]: string } = { 'mp3': 'audio/mpeg', 'm4a': 'audio/mp4', 'wav': 'audio/wav', 'aac': 'audio/aac', 'ogg': 'audio/ogg', 'flac': 'audio/flac', 'wma': 'audio/x-ms-wma', 'opus': 'audio/opus' }; mimeType = audioMimeTypes[fileExtension || ''] || 'audio/mp4'; } formData.append('file', { uri: audioFile.uri, type: mimeType, name: audioFile.filename, } as any); // Generate a proper UUID for the memo const generatedMemoId = memoId || generateUUID(); console.debug('Generated memo UUID:', generatedMemoId); // Generate storage path with the memo ID const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); // Use appropriate file name prefix and extension based on media type const filePrefix = mediaType === 'video' ? 'video' : 'audio'; const fileName = `${filePrefix}_${timestamp}.${fileExtension || 'm4a'}`; const storagePath = `${userId}/${generatedMemoId}/${fileName}`; console.debug('Uploading to storage path:', storagePath); // Upload to Supabase Storage using authenticated request try { const { supabaseUrl, supabaseAnonKey } = await import('../auth/lib/supabaseClient'); const { tokenManager } = await import('../auth/services/tokenManager'); // Get valid authentication token const token = await tokenManager.getValidToken(); if (!token) { throw new Error('Not authenticated - unable to upload'); } // Use fetch directly for React Native compatibility const uploadUrl = `${supabaseUrl}/storage/v1/object/user-uploads/${storagePath}`; console.debug('Uploading with authenticated request'); // Configure timeout for large file uploads (10 minutes) const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); console.error('Upload timeout: Aborting after 10 minutes'); }, 600000); // 10 minutes for large files try { const uploadResponse = await fetch(uploadUrl, { method: 'POST', headers: { apikey: supabaseAnonKey, Authorization: `Bearer ${token}`, 'x-upsert': 'true', // Don't set Content-Type when using FormData - let the browser set it with boundary }, body: formData, signal: controller.signal, }); clearTimeout(timeoutId); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); console.error('Storage upload failed:', { status: uploadResponse.status, error: errorText, storagePath: storagePath, }); throw new Error(`Storage upload failed: ${uploadResponse.status} - ${errorText}`); } console.debug('✅ Upload successful with authentication'); } catch (uploadError) { clearTimeout(timeoutId); // Check if it's an abort error (timeout) if (uploadError instanceof Error && uploadError.name === 'AbortError') { const timeoutError = new Error( 'Upload timed out after 10 minutes. Please check your internet connection and try again.' ); console.error('Upload timeout error:', timeoutError); throw timeoutError; } throw uploadError; } const uploadData = { path: storagePath }; console.debug('✅ Upload to storage successful:', uploadData.path); // Step 2: Decide whether to append or create new memo if (appendToMemo && memoId) { // Call memoro-service append-transcription endpoint console.debug( '🔄 Appending to existing memo with memoro-service append-transcription...' ); const memoroServiceUrl = ( process.env.EXPO_PUBLIC_MEMORO_MIDDLEWARE_URL || 'http://localhost:3001' ).replace(/\/$/, ''); const appendResponse = await fetch(`${memoroServiceUrl}/memoro/append-transcription`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}`, }, body: JSON.stringify({ memoId: memoId, filePath: uploadData.path, duration: audioFile.duration, recordingLanguages: recordingLanguageCodes, enableDiarization: enableDiarization !== false, }), }); if (!appendResponse.ok) { const errorText = await appendResponse.text(); console.debug('Error calling append-transcription:', { status: appendResponse.status, statusText: appendResponse.statusText, error: errorText, }); throw new Error(`append-transcription failed: ${appendResponse.status} - ${errorText}`); } const appendResult = await appendResponse.json(); console.debug('✅ Append transcription successful:', appendResult); // Update status to SUCCESS await uploadStatusStore.updateStatus(audioFile.id, UploadStatus.SUCCESS, { uploadedAt: Date.now(), memoId: memoId, }); return { status: 'completed' as const, message: appendResult.message || 'Additional recording added successfully', filePath: uploadData.path, memoId: memoId, } as TranscriptionResult; } else { // Call memoro-service to create new memo console.debug('🔄 Processing uploaded file with memoro-service...'); // Get location data for memo const locationData = (await getLocationForMemo(true)) as EnhancedLocationData | null; console.debug('Location data for memo:', locationData); const memoroServiceUrl = ( process.env.EXPO_PUBLIC_MEMORO_MIDDLEWARE_URL || 'http://localhost:3001' ).replace(/\/$/, ''); const processPayload = { filePath: uploadData.path, duration: audioFile.duration, memoId: generatedMemoId, // Always include the generated UUID spaceId, blueprintId, recordingLanguages: useAutoDetection ? [] : recordingLanguageCodes, location: locationData, // Add location data to payload, recordingStartedAt: recordingStartedAt?.toISOString(), // Pass recording start time if provided enableDiarization: enableDiarization, // Pass diarization setting mediaType: mediaType, // Pass the media type (audio/video) to backend }; console.debug('Calling process-uploaded-audio endpoint with payload:', processPayload); console.debug('Using memoro service URL:', memoroServiceUrl); const fullUrl = `${memoroServiceUrl}/memoro/process-uploaded-audio`; console.debug('Full URL for request:', fullUrl); const response = await fetch(fullUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}`, }, body: JSON.stringify(processPayload), }); if (!response.ok) { const errorText = await response.text(); console.debug('Error calling process-uploaded-audio:', { status: response.status, statusText: response.statusText, error: errorText, }); // Don't handle 402 errors here - let the global interceptor handle them throw new Error(`memoro-service processing failed: ${response.status} - ${errorText}`); } const result = await response.json(); console.debug('✅ Direct upload processing successful:', result); // Update status to SUCCESS await uploadStatusStore.updateStatus(audioFile.id, UploadStatus.SUCCESS, { uploadedAt: Date.now(), memoId: result.memoId, }); // Trigger credit update notification to refresh UI setTimeout(() => { try { creditService.triggerCreditUpdate(0); console.debug('Credit update triggered after direct upload transcription'); } catch (error) { console.debug('Error triggering credit update:', error); } }, 1000); // Return success with the processing route information and full memo if available return { status: 'pending', message: result.message || 'Audio uploaded directly and processing started', filePath: result.filePath, processingRoute: result.processingRoute, memoId: result.memoId, batchJobId: result.batchJobId, memo: result.memo, // Include the full memo object if returned by backend }; } } catch (storageError) { console.debug('Storage upload failed:', storageError); // Analyze if this is a network error const networkErrorInfo = await analyzeNetworkError(storageError); // Update status to FAILED await uploadStatusStore.updateStatus(audioFile.id, UploadStatus.FAILED, { lastError: networkErrorInfo.userMessage, isNetworkError: networkErrorInfo.isNetworkError, }); if (networkErrorInfo.isNetworkError) { console.error('Network error during upload:', { errorType: networkErrorInfo.errorType, technicalMessage: networkErrorInfo.technicalMessage, }); // Create a more descriptive error for the UI const networkError = new Error(networkErrorInfo.userMessage); networkError.name = 'NetworkError'; throw networkError; } throw storageError; // Let the caller handle other errors } } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); console.debug('Error in direct upload transcription:', { message: err.message, stack: err.stack, name: err.name, }); // Analyze network error for proper status update const networkErrorInfo = await analyzeNetworkError(err); // Update status to FAILED with error details await uploadStatusStore.updateStatus(audioFile.id, UploadStatus.FAILED, { lastError: err.message, isNetworkError: networkErrorInfo.isNetworkError, }); // Don't check for insufficient credits - let the global interceptor handle 402 errors // For other errors, throw so the caller can handle fallback throw err; } } } // Export singleton instance export const fileStorageService = new FileStorageService();