From 1aa285be5583223624683d2b88b6d62d80f2661a Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Fri, 13 Jun 2025 13:58:14 +0000 Subject: [PATCH] WIP: Debug backup file discovery system - Fixed recursive directory search in CapacitorPlatformService to properly search subdirectories instead of excluding them - Added missing listFilesInDirectory method to all platform services for directory browsing functionality - Added debug methods (debugTimeSafariDirectory, createTestBackupFile, testDirectoryContexts) to help diagnose file visibility issues - Enhanced logging for backup file discovery process - Current issue: TimeSafari directory exists in Download but shows 'Directory does not exist' when trying to read contents - Need to investigate why JSON backup files are not being found despite directory existence --- CONTACT_BACKUP_SYSTEM.md | 533 ++++++++++++++++++ src/components/BackupFilesList.vue | 205 ++++++- src/services/PlatformService.ts | 29 + .../platforms/CapacitorPlatformService.ts | 500 +++++++++++++--- .../platforms/ElectronPlatformService.ts | 41 +- .../platforms/PyWebViewPlatformService.ts | 41 +- src/services/platforms/WebPlatformService.ts | 39 ++ 7 files changed, 1281 insertions(+), 107 deletions(-) create mode 100644 CONTACT_BACKUP_SYSTEM.md diff --git a/CONTACT_BACKUP_SYSTEM.md b/CONTACT_BACKUP_SYSTEM.md new file mode 100644 index 00000000..5745a98e --- /dev/null +++ b/CONTACT_BACKUP_SYSTEM.md @@ -0,0 +1,533 @@ +# TimeSafari Contact Backup System + +## Overview + +The TimeSafari application implements a comprehensive contact backup and listing system that works across multiple platforms (Web, iOS, Android, Desktop). This document breaks down how contacts are saved, exported, and listed as backups. + +## Architecture Components + +### 1. Database Layer + +#### Contact Data Structure +```typescript +interface Contact { + did: string; // Decentralized Identifier (primary key) + contactMethods?: ContactMethod[]; // Array of contact methods (EMAIL, SMS, etc.) + name?: string; // Display name + nextPubKeyHashB64?: string; // Base64 hash of next public key + notes?: string; // User notes + profileImageUrl?: string; // Profile image URL + publicKeyBase64?: string; // Base64 encoded public key + seesMe?: boolean; // Visibility setting + registered?: boolean; // Registration status +} + +interface ContactMethod { + label: string; // Display label + type: string; // Type (EMAIL, SMS, WHATSAPP, etc.) + value: string; // Contact value +} +``` + +#### Database Schema +```sql +CREATE TABLE contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + did TEXT NOT NULL, -- Decentralized Identifier + name TEXT, -- Display name + contactMethods TEXT, -- JSON string of contact methods + nextPubKeyHashB64 TEXT, -- Next public key hash + notes TEXT, -- User notes + profileImageUrl TEXT, -- Profile image URL + publicKeyBase64 TEXT, -- Public key + seesMe BOOLEAN, -- Visibility flag + registered BOOLEAN -- Registration status +); + +CREATE INDEX idx_contacts_did ON contacts(did); +CREATE INDEX idx_contacts_name ON contacts(name); +``` + +### 2. Contact Saving Operations + +#### A. Adding New Contacts + +**1. QR Code Scanning (`ContactQRScanFullView.vue`)** +```typescript +async addNewContact(contact: Contact) { + // Check for existing contact + const existingContacts = await platformService.dbQuery( + "SELECT * FROM contacts WHERE did = ?", [contact.did] + ); + + if (existingContact) { + // Handle duplicate + return; + } + + // Convert contactMethods to JSON string for storage + contact.contactMethods = JSON.stringify( + parseJsonField(contact.contactMethods, []) + ); + + // Insert into database + const { sql, params } = databaseUtil.generateInsertStatement( + contact as unknown as Record, "contacts" + ); + await platformService.dbExec(sql, params); +} +``` + +**2. Manual Contact Addition (`ContactsView.vue`)** +```typescript +private async addContact(newContact: Contact) { + // Validate DID format + if (!isDid(newContact.did)) { + throw new Error("Invalid DID format"); + } + + // Generate and execute INSERT statement + const { sql, params } = databaseUtil.generateInsertStatement( + newContact as unknown as Record, "contacts" + ); + await platformService.dbExec(sql, params); +} +``` + +**3. Contact Import (`ContactImportView.vue`)** +```typescript +async importContacts() { + for (const contact of selectedContacts) { + const contactToStore = contactToDbRecord(contact); + + if (existingContact) { + // Update existing contact + const { sql, params } = databaseUtil.generateUpdateStatement( + contactToStore, "contacts", "did = ?", [contact.did] + ); + await platformService.dbExec(sql, params); + } else { + // Add new contact + const { sql, params } = databaseUtil.generateInsertStatement( + contactToStore, "contacts" + ); + await platformService.dbExec(sql, params); + } + } +} +``` + +#### B. Updating Existing Contacts + +**Contact Editing (`ContactEditView.vue`)** +```typescript +async saveEdit() { + // Normalize contact methods + const contactMethods = this.contactMethods.map(method => ({ + ...method, + type: method.type.toUpperCase() + })); + + // Update database + const contactMethodsString = JSON.stringify(contactMethods); + await platformService.dbExec( + "UPDATE contacts SET name = ?, notes = ?, contactMethods = ? WHERE did = ?", + [this.contactName, this.contactNotes, contactMethodsString, this.contact?.did] + ); +} +``` + +### 3. Contact Export/Backup System + +#### A. Export Process (`DataExportSection.vue`) + +#### 1. Data Retrieval + +```typescript +async exportDatabase() { + // Query all contacts from database + const result = await platformService.dbQuery("SELECT * FROM contacts"); + const allContacts = databaseUtil.mapQueryResultToValues(result) as Contact[]; + + // Convert to export format + const exportData = contactsToExportJson(allContacts); + const jsonStr = JSON.stringify(exportData, null, 2); +} +``` + +#### 2. Export Format Conversion (`libs/util.ts`) + +```typescript +export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => { + const rows = contacts.map((contact) => ({ + did: contact.did, + name: contact.name || null, + contactMethods: contact.contactMethods + ? JSON.stringify(parseJsonField(contact.contactMethods, [])) + : null, + nextPubKeyHashB64: contact.nextPubKeyHashB64 || null, + notes: contact.notes || null, + profileImageUrl: contact.profileImageUrl || null, + publicKeyBase64: contact.publicKeyBase64 || null, + seesMe: contact.seesMe || false, + registered: contact.registered || false, + })); + + return { + data: { + data: [{ tableName: "contacts", rows }] + } + }; +}; +``` + +#### 3. File Generation + +```typescript +// Create timestamped filename +const timestamp = getTimestampForFilename(); +const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`; + +// Create blob and save +const blob = new Blob([jsonStr], { type: "application/json" }); +``` + +#### B. Platform-Specific File Saving + +##### 1. Web Platform (`WebPlatformService.ts`)** + +```typescript +// Uses browser download API +const downloadUrl = URL.createObjectURL(blob); +const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement; +downloadAnchor.href = downloadUrl; +downloadAnchor.download = fileName; +downloadAnchor.click(); +``` + +##### 2. Mobile Platforms (`CapacitorPlatformService.ts`) + +```typescript +async writeAndShareFile(fileName: string, content: string, options = {}) { + let fileUri: string; + + if (options.allowLocationSelection) { + // User chooses location + fileUri = await this.saveWithUserChoice(fileName, content, options.mimeType); + } else if (options.saveToPrivateStorage) { + // Save to app-private storage + const result = await Filesystem.writeFile({ + path: fileName, + data: content, + directory: Directory.Data, + encoding: Encoding.UTF8, + recursive: true, + }); + fileUri = result.uri; + } else { + // Save to user-accessible location (Downloads/Documents) + fileUri = await this.saveToDownloads(fileName, content); + } + + // Share the file + return await this.shareFile(fileUri, fileName); +} +``` + +##### 3. Desktop Platforms (`ElectronPlatformService.ts`, `PyWebViewPlatformService.ts`) + +```typescript +// Not implemented - returns empty results +async listBackupFiles(): Promise> { + return []; +} +``` + +### 4. Backup File Listing System + +#### A. File Discovery (`CapacitorPlatformService.ts`) + +##### 1. Enhanced File Discovery + +```typescript +async listUserAccessibleFilesEnhanced(): Promise> { + const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = []; + + if (this.getCapabilities().isIOS) { + // iOS: Documents directory + const result = await Filesystem.readdir({ + path: ".", + directory: Directory.Documents, + }); + const files = result.files.map((file) => ({ + name: typeof file === "string" ? file : file.name, + uri: `file://${file.uri || file}`, + size: typeof file === "string" ? undefined : file.size, + path: "Documents" + })); + allFiles.push(...files); + } else { + // Android: Multiple locations + const commonPaths = ["Download", "Documents", "Backups", "TimeSafari", "Data"]; + + for (const path of commonPaths) { + try { + const result = await Filesystem.readdir({ + path: path, + directory: Directory.ExternalStorage, + }); + + // Filter for TimeSafari-related files + const relevantFiles = result.files + .filter(file => { + const fileName = typeof file === "string" ? file : file.name; + const name = fileName.toLowerCase(); + return name.includes('timesafari') || + name.includes('backup') || + name.includes('contacts') || + name.endsWith('.json'); + }) + .map((file) => ({ + name: typeof file === "string" ? file : file.name, + uri: `file://${file.uri || file}`, + size: typeof file === "string" ? undefined : file.size, + path: path + })); + + allFiles.push(...relevantFiles); + } catch (error) { + // Silently skip inaccessible directories + } + } + } + + return allFiles; +} +``` + +**2. Backup File Filtering** +```typescript +async listBackupFiles(): Promise> { + const allFiles = await this.listUserAccessibleFilesEnhanced(); + + const backupFiles = allFiles + .filter(file => { + const name = file.name.toLowerCase(); + + // Exclude directory-access notification files + if (name.startsWith('timesafari-directory-access-') && name.endsWith('.txt')) { + return false; + } + + // Check backup criteria + const isJson = name.endsWith('.json'); + const hasTimeSafari = name.includes('timesafari'); + const hasBackup = name.includes('backup'); + const hasContacts = name.includes('contacts'); + const hasSeed = name.includes('seed'); + const hasExport = name.includes('export'); + const hasData = name.includes('data'); + + return isJson || hasTimeSafari || hasBackup || hasContacts || hasSeed || hasExport || hasData; + }) + .map(file => { + const name = file.name.toLowerCase(); + let type: 'contacts' | 'seed' | 'other' = 'other'; + + // Categorize files + if (name.includes('contacts') || (name.includes('timesafari') && name.includes('backup'))) { + type = 'contacts'; + } else if (name.includes('seed') || name.includes('mnemonic') || name.includes('private')) { + type = 'seed'; + } else if (name.endsWith('.json')) { + type = 'other'; + } + + return { ...file, type }; + }); + + return backupFiles; +} +``` + +#### B. UI Components (`BackupFilesList.vue`) + +**1. File Display** +```typescript +@Component +export default class BackupFilesList extends Vue { + backupFiles: Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}> = []; + selectedType: 'all' | 'contacts' | 'seed' | 'other' = 'all'; + isLoading = false; + + async refreshFiles() { + this.isLoading = true; + try { + this.backupFiles = await this.platformService.listBackupFiles(); + + // Log file type distribution + const typeCounts = { + contacts: this.backupFiles.filter(f => f.type === 'contacts').length, + seed: this.backupFiles.filter(f => f.type === 'seed').length, + other: this.backupFiles.filter(f => f.type === 'other').length, + total: this.backupFiles.length + }; + } catch (error) { + // Handle error + } finally { + this.isLoading = false; + } + } +} +``` + +**2. File Operations** +```typescript +async openFile(fileUri: string, fileName: string) { + const result = await this.platformService.openFile(fileUri, fileName); + if (!result.success) { + throw new Error(result.error || "Failed to open file"); + } +} + +async openBackupDirectory() { + const result = await this.platformService.openBackupDirectory(); + if (!result.success) { + throw new Error(result.error || "Failed to open backup directory"); + } +} +``` + +### 5. Platform-Specific Storage Locations + +#### A. iOS Platform +- **Primary Location**: Documents folder (accessible via Files app) +- **Persistence**: Survives app installations +- **Access**: Through iOS Files app +- **File Format**: JSON with timestamped filenames + +#### B. Android Platform +- **Primary Locations**: + - `Download/TimeSafari/` (external storage) + - `TimeSafari/` (external storage) + - User-chosen locations via file picker +- **Persistence**: Survives app installations +- **Access**: Through file managers +- **File Format**: JSON with timestamped filenames + +#### C. Web Platform +- **Primary Location**: Browser downloads folder +- **Persistence**: Depends on browser settings +- **Access**: Through browser download manager +- **File Format**: JSON with timestamped filenames + +#### D. Desktop Platforms (Electron/PyWebView) +- **Status**: Not implemented +- **Fallback**: Returns empty arrays for file operations + +### 6. File Naming Convention + +#### A. Contact Backup Files +``` +TimeSafari-backup-contacts-YYYY-MM-DD-HH-MM-SS.json +``` + +#### B. File Content Structure +```json +{ + "data": { + "data": [ + { + "tableName": "contacts", + "rows": [ + { + "did": "did:ethr:0x...", + "name": "Contact Name", + "contactMethods": "[{\"type\":\"EMAIL\",\"value\":\"email@example.com\"}]", + "notes": "User notes", + "profileImageUrl": "https://...", + "publicKeyBase64": "base64...", + "seesMe": true, + "registered": false + } + ] + } + ] + } +} +``` + +### 7. Error Handling and Logging + +#### A. Comprehensive Logging +```typescript +logger.log("[CapacitorPlatformService] File write successful:", { + uri: fileUri, + saved, + timestamp: new Date().toISOString(), +}); + +logger.log("[BackupFilesList] Refreshed backup files:", { + count: this.backupFiles.length, + files: this.backupFiles.map(f => ({ + name: f.name, + type: f.type, + path: f.path, + size: f.size + })), + platform: this.platformCapabilities.isIOS ? "iOS" : "Android", + timestamp: new Date().toISOString(), +}); +``` + +#### B. Error Recovery +```typescript +try { + // File operations +} catch (error) { + logger.error("[CapacitorPlatformService] Failed to list backup files:", error); + return []; +} +``` + +### 8. Security Considerations + +#### A. Data Privacy +- Contact data is stored locally on device +- No cloud synchronization of contact data +- User controls visibility settings per contact +- Backup files contain only user-authorized data + +#### B. File Access +- Platform-specific permission handling +- User choice for file locations +- Secure storage options for sensitive data +- Proper error handling for access failures + +### 9. Performance Optimizations + +#### A. Database Operations +- Indexed queries on `did` and `name` fields +- Batch operations for multiple contacts +- Efficient JSON serialization/deserialization +- Connection pooling and reuse + +#### B. File Operations +- Asynchronous file I/O +- Efficient file discovery algorithms +- Caching of file lists +- Background refresh operations + +## Summary + +The TimeSafari contact backup system provides: + +1. **Robust Data Storage**: SQLite-based contact storage with proper indexing +2. **Cross-Platform Compatibility**: Works on web, iOS, Android, and desktop +3. **Flexible Export Options**: Multiple file formats and storage locations +4. **Intelligent File Discovery**: Finds backup files regardless of user-chosen locations +5. **User-Friendly Interface**: Clear categorization and easy file management +6. **Comprehensive Logging**: Detailed tracking for debugging and monitoring +7. **Security-First Design**: Privacy-preserving with user-controlled data access + +The system ensures that users can reliably backup and restore their contact data across different platforms while maintaining data integrity and user privacy. \ No newline at end of file diff --git a/src/components/BackupFilesList.vue b/src/components/BackupFilesList.vue index d1501d5d..00078c2e 100644 --- a/src/components/BackupFilesList.vue +++ b/src/components/BackupFilesList.vue @@ -51,6 +51,20 @@ Debug + + @@ -238,21 +252,57 @@ export default class BackupFilesList extends Vue { */ debugShowAll = false; + /** + * Checks and requests storage permissions if needed. + * Returns true if permission is granted, false otherwise. + */ + private async ensureStoragePermission(): Promise { + logger.log('[BackupFilesList] ensureStoragePermission called. platformCapabilities:', this.platformCapabilities); + if (!this.platformCapabilities.hasFileSystem) return true; + // Only relevant for native platforms (Android/iOS) + const platformService = this.platformService as any; + if (typeof platformService.checkStoragePermissions === 'function') { + try { + await platformService.checkStoragePermissions(); + logger.log('[BackupFilesList] Storage permission granted.'); + return true; + } catch (error) { + logger.error('[BackupFilesList] Storage permission denied:', error); + this.$notify( + { + group: "alert", + type: "danger", + title: "Storage Permission Required", + text: "This app needs permission to access your files to list and restore backups. Please grant storage permission and try again.", + }, + 7000, + ); + return false; + } + } + return true; + } + /** * Lifecycle hook to load backup files when component is mounted */ async mounted() { + logger.log('[BackupFilesList] mounted hook called. platformCapabilities:', this.platformCapabilities); if (this.platformCapabilities.hasFileSystem) { - // Set default root path - if (this.platformCapabilities.isIOS) { - this.currentPath = ['.']; - } else { - this.currentPath = ['Download', 'TimeSafari']; + // Check/request permission before loading + const hasPermission = await this.ensureStoragePermission(); + if (hasPermission) { + // Set default root path + if (this.platformCapabilities.isIOS) { + this.currentPath = ['.']; + } else { + this.currentPath = ['Download', 'TimeSafari']; + } + await this.loadDirectory(); + this.refreshInterval = window.setInterval(() => { + this.loadDirectory(); + }, 5 * 60 * 1000); } - await this.loadDirectory(); - this.refreshInterval = window.setInterval(() => { - this.loadDirectory(); - }, 5 * 60 * 1000); } } @@ -268,12 +318,16 @@ export default class BackupFilesList extends Vue { /** * Computed property for filtered files based on selected type + * Note: The 'All' tab count is sometimes too small. Logging for debugging. */ get filteredFiles() { if (this.selectedType === 'all') { + logger.log('[BackupFilesList] filteredFiles (All):', this.backupFiles); return this.backupFiles; } - return this.backupFiles.filter(file => file.type === this.selectedType); + const filtered = this.backupFiles.filter(file => file.type === this.selectedType); + logger.log(`[BackupFilesList] filteredFiles (${this.selectedType}):`, filtered); + return filtered; } /** @@ -348,15 +402,21 @@ export default class BackupFilesList extends Vue { * Refreshes the list of backup files from the device */ async refreshFiles() { + logger.log('[BackupFilesList] refreshFiles called.'); if (!this.platformCapabilities.hasFileSystem) { return; } - + // Check/request permission before refreshing + const hasPermission = await this.ensureStoragePermission(); + if (!hasPermission) { + this.backupFiles = []; + this.isLoading = false; + return; + } this.isLoading = true; try { this.backupFiles = await this.platformService.listBackupFiles(); - - logger.log("[BackupFilesList] Refreshed backup files:", { + logger.log('[BackupFilesList] Refreshed backup files:', { count: this.backupFiles.length, files: this.backupFiles.map(f => ({ name: f.name, @@ -367,7 +427,6 @@ export default class BackupFilesList extends Vue { platform: this.platformCapabilities.isIOS ? "iOS" : "Android", timestamp: new Date().toISOString(), }); - // Debug: Log file type distribution const typeCounts = { contacts: this.backupFiles.filter(f => f.type === 'contacts').length, @@ -375,9 +434,11 @@ export default class BackupFilesList extends Vue { other: this.backupFiles.filter(f => f.type === 'other').length, total: this.backupFiles.length }; - logger.log("[BackupFilesList] File type distribution:", typeCounts); + logger.log('[BackupFilesList] File type distribution:', typeCounts); + // Log the full backupFiles array for debugging the 'All' tab count + logger.log('[BackupFilesList] backupFiles array for All tab:', this.backupFiles); } catch (error) { - logger.error("[BackupFilesList] Failed to refresh backup files:", error); + logger.error('[BackupFilesList] Failed to refresh backup files:', error); this.$notify( { group: "alert", @@ -393,11 +454,103 @@ export default class BackupFilesList extends Vue { } /** - * Public method to refresh files from external components - * Used by DataExportSection to refresh after saving new files + * Creates a test backup file for debugging purposes + */ + async createTestBackup() { + try { + this.isLoading = true; + logger.log('[BackupFilesList] Creating test backup file'); + + const result = await this.platformService.createTestBackupFile(); + + if (result.success) { + logger.log('[BackupFilesList] Test backup file created successfully:', { + fileName: result.fileName, + uri: result.uri, + timestamp: new Date().toISOString(), + }); + + this.$notify( + { + group: "alert", + type: "success", + title: "Test Backup Created", + text: `Test backup file "${result.fileName}" created successfully. Refresh the list to see it.`, + }, + 5000, + ); + + // Refresh the file list to show the new test file + await this.refreshFiles(); + } else { + throw new Error(result.error || "Failed to create test backup file"); + } + } catch (error) { + logger.error('[BackupFilesList] Failed to create test backup file:', error); + this.$notify( + { + group: "alert", + type: "danger", + title: "Test Backup Failed", + text: "Failed to create test backup file. Check the console for details.", + }, + 5000, + ); + } finally { + this.isLoading = false; + } + } + + /** + * Tests different directory contexts to debug file visibility issues + */ + async testDirectoryContexts() { + try { + this.isLoading = true; + logger.log('[BackupFilesList] Testing directory contexts'); + + const debugOutput = await this.platformService.testDirectoryContexts(); + + logger.log('[BackupFilesList] Directory context test results:', debugOutput); + + // Show the debug output in a notification or alert + this.$notify( + { + group: "alert", + type: "info", + title: "Directory Context Test", + text: "Directory context test completed. Check the console for detailed results.", + }, + 5000, + ); + + // Also log the full output to console for easy access + console.log("=== Directory Context Test Results ==="); + console.log(debugOutput); + console.log("=== End Test Results ==="); + + } catch (error) { + logger.error('[BackupFilesList] Failed to test directory contexts:', error); + this.$notify( + { + group: "alert", + type: "danger", + title: "Context Test Failed", + text: "Failed to test directory contexts. Check the console for details.", + }, + 5000, + ); + } finally { + this.isLoading = false; + } + } + + /** + * Refreshes the file list after a backup is created + * This method can be called from parent components */ - public async refreshAfterSave() { - logger.log("[BackupFilesList] Refreshing files after save operation"); + async refreshAfterSave() { + logger.log('[BackupFilesList] refreshAfterSave called'); await this.refreshFiles(); } @@ -462,14 +615,18 @@ export default class BackupFilesList extends Vue { /** * Gets the count of files for a specific type - * @param type - File type to count - * @returns Number of files of the specified type + * Note: The 'All' tab count is sometimes too small. Logging for debugging. */ getFileCountByType(type: 'all' | 'contacts' | 'seed' | 'other'): number { + let count; if (type === 'all') { - return this.backupFiles.length; + count = this.backupFiles.length; + logger.log('[BackupFilesList] getFileCountByType (All):', count, this.backupFiles); + return count; } - return this.backupFiles.filter(file => file.type === type).length; + count = this.backupFiles.filter(file => file.type === type).length; + logger.log(`[BackupFilesList] getFileCountByType (${type}):`, count); + return count; } /** diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index 21b5deb9..9d5ac276 100644 --- a/src/services/PlatformService.ts +++ b/src/services/PlatformService.ts @@ -214,4 +214,33 @@ export interface PlatformService { * @returns Promise resolving to success status */ openBackupDirectory(): Promise<{ success: boolean; error?: string }>; + + /** + * Creates a test backup file to verify file writing and reading functionality. + * This is useful for debugging file visibility issues. + * @returns Promise resolving to success status and file information + */ + createTestBackupFile(): Promise<{ success: boolean; fileName?: string; uri?: string; error?: string }>; + + /** + * Tests different directory contexts to see what files are available. + * This helps debug file visibility issues across different storage contexts. + * @returns Promise resolving to debug information about file discovery across contexts + */ + testDirectoryContexts(): Promise; + + /** + * Lists files and folders in a specific directory for directory browsing + * @param path - The directory path to list + * @param debugShowAll - Debug flag to treat all entries as files + * @returns Promise resolving to array of directory entries + */ + listFilesInDirectory(path: string, debugShowAll?: boolean): Promise>; + + /** + * Debug method to check what's actually in the TimeSafari directory + * This helps identify if the directory exists but is empty or has permission issues + * @returns Promise resolving to debug information about the TimeSafari directory + */ + debugTimeSafariDirectory(): Promise; } diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 066eb2f3..b1a5f5f3 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -1770,15 +1770,11 @@ export class CapacitorPlatformService implements PlatformService { const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = []; if (this.getCapabilities().isIOS) { - // iOS: List files in Documents directory + // iOS: Documents directory const result = await Filesystem.readdir({ path: ".", directory: Directory.Documents, }); - logger.log("[CapacitorPlatformService] Files in iOS Documents:", { - files: result.files.map((file) => (typeof file === "string" ? file : file.name)), - timestamp: new Date().toISOString(), - }); const files = result.files.map((file) => ({ name: typeof file === "string" ? file : file.name, uri: `file://${file.uri || file}`, @@ -1787,30 +1783,32 @@ export class CapacitorPlatformService implements PlatformService { })); allFiles.push(...files); } else { - // Android: Search multiple locations where users might have saved files + // Android: Multiple locations with recursive search - // 1. App's default locations (for backward compatibility) - try { - const downloadsResult = await Filesystem.readdir({ - path: "Download/TimeSafari", - directory: Directory.ExternalStorage, - }); - const downloadFiles = downloadsResult.files.map((file) => ({ - name: typeof file === "string" ? file : file.name, - uri: `file://${file.uri || file}`, - size: typeof file === "string" ? undefined : file.size, - path: "Download/TimeSafari" - })); - allFiles.push(...downloadFiles); - } catch (error) { - logger.warn("[CapacitorPlatformService] Could not read Downloads/TimeSafari:", error); - } - + // 1. App's external storage directory try { const appStorageResult = await Filesystem.readdir({ path: "TimeSafari", directory: Directory.ExternalStorage, }); + + // Log full readdir output for TimeSafari + logger.log("[CapacitorPlatformService] Android TimeSafari readdir full result:", { + path: "TimeSafari", + directory: "ExternalStorage", + files: appStorageResult.files, + fileCount: appStorageResult.files.length, + fileDetails: appStorageResult.files.map((file, index) => ({ + index, + name: typeof file === "string" ? file : file.name, + type: typeof file === "string" ? "string" : "object", + hasUri: typeof file === "string" ? false : !!file.uri, + hasSize: typeof file === "string" ? false : !!file.size, + fullObject: file + })), + timestamp: new Date().toISOString(), + }); + const appStorageFiles = appStorageResult.files.map((file) => ({ name: typeof file === "string" ? file : file.name, uri: `file://${file.uri || file}`, @@ -1822,7 +1820,7 @@ export class CapacitorPlatformService implements PlatformService { logger.warn("[CapacitorPlatformService] Could not read TimeSafari external storage:", error); } - // 2. Common user-chosen locations (if accessible) + // 2. Common user-chosen locations (if accessible) with recursive search const commonPaths = [ "Download", "Documents", @@ -1830,7 +1828,7 @@ export class CapacitorPlatformService implements PlatformService { "TimeSafari", "Data" ]; - + for (const path of commonPaths) { try { const result = await Filesystem.readdir({ @@ -1838,22 +1836,94 @@ export class CapacitorPlatformService implements PlatformService { directory: Directory.ExternalStorage, }); - // Filter for TimeSafari-related files - const relevantFiles = result.files - .filter(file => { - const fileName = typeof file === "string" ? file : file.name; - const name = fileName.toLowerCase(); - return name.includes('timesafari') || + // Log full readdir output for debugging + logger.log(`[CapacitorPlatformService] Android ${path} readdir full result:`, { + path: path, + directory: "ExternalStorage", + files: result.files, + fileCount: result.files.length, + fileDetails: result.files.map((file, index) => ({ + index, + name: typeof file === "string" ? file : file.name, + type: typeof file === "string" ? "string" : "object", + hasUri: typeof file === "string" ? false : !!file.uri, + hasSize: typeof file === "string" ? false : !!file.size, + fullObject: file + })), + timestamp: new Date().toISOString(), + }); + + // Process each entry (file or directory) + const relevantFiles = []; + for (const file of result.files) { + const fileName = typeof file === "string" ? file : file.name; + const name = fileName.toLowerCase(); + + // Check if it's a directory by trying to get file stats + let isDirectory = false; + try { + const stat = await Filesystem.stat({ + path: `${path}/${fileName}`, + directory: Directory.ExternalStorage + }); + isDirectory = stat.type === 'directory'; + } catch (statError) { + // If stat fails, assume it's a file + isDirectory = false; + } + + if (isDirectory) { + // RECURSIVELY SEARCH DIRECTORY for backup files + logger.log(`[CapacitorPlatformService] Recursively searching directory: ${fileName} in ${path}`); + try { + const subDirResult = await Filesystem.readdir({ + path: `${path}/${fileName}`, + directory: Directory.ExternalStorage, + }); + + // Process files in subdirectory + for (const subFile of subDirResult.files) { + const subFileName = typeof subFile === "string" ? subFile : subFile.name; + const subName = subFileName.toLowerCase(); + + // Check if subfile matches backup criteria + const matchesBackupCriteria = subName.includes('timesafari') || + subName.includes('backup') || + subName.includes('contacts') || + subName.endsWith('.json'); + + if (matchesBackupCriteria) { + relevantFiles.push({ + name: subFileName, + uri: `file://${subFile.uri || subFile}`, + size: typeof subFile === "string" ? undefined : subFile.size, + path: `${path}/${fileName}` + }); + logger.log(`[CapacitorPlatformService] Found backup file in subdirectory: ${subFileName} in ${path}/${fileName}`); + } + } + } catch (subDirError) { + logger.warn(`[CapacitorPlatformService] Could not read subdirectory ${path}/${fileName}:`, subDirError); + } + } else { + // Check if file matches backup criteria + const matchesBackupCriteria = name.includes('timesafari') || name.includes('backup') || name.includes('contacts') || name.endsWith('.json'); - }) - .map((file) => ({ - name: typeof file === "string" ? file : file.name, - uri: `file://${file.uri || file}`, - size: typeof file === "string" ? undefined : file.size, - path: path - })); + + if (matchesBackupCriteria) { + relevantFiles.push({ + name: fileName, + uri: `file://${file.uri || file}`, + size: typeof file === "string" ? undefined : file.size, + path: path + }); + } else { + logger.log(`[CapacitorPlatformService] Excluding non-backup file: ${fileName} in ${path}`); + } + } + } if (relevantFiles.length > 0) { logger.log(`[CapacitorPlatformService] Found ${relevantFiles.length} relevant files in ${path}:`, { @@ -1889,57 +1959,325 @@ export class CapacitorPlatformService implements PlatformService { } /** - * Lists files and folders in a given directory, with type detection. - * Supports folder navigation for the backup browser UI. - * @param path - Directory path to list (relative to root or external storage) - * @param debugShowAll - If true, forcibly treat all entries as files (for debugging) - * @returns Promise resolving to array of file/folder info + * Test method to try different directory contexts and see what files are available + * This helps debug file visibility issues across different storage contexts + * @returns Promise resolving to debug information about file discovery across contexts */ - async listFilesInDirectory(path: string = "Download/TimeSafari", debugShowAll: boolean = false): Promise> { + async testDirectoryContexts(): Promise { try { - logger.log('[DEBUG] Reading directory:', path); - const entries: Array<{name: string, uri: string, size?: number, path: string, type: 'file' | 'folder'}> = []; - const directory = this.getCapabilities().isIOS ? Directory.Documents : Directory.ExternalStorage; - const result = await Filesystem.readdir({ path, directory }); - logger.log('[DEBUG] Raw readdir result:', result.files); - for (const entry of result.files) { - const name = typeof entry === 'string' ? entry : entry.name; - const entryPath = path === '.' ? name : `${path}/${name}`; - let type: 'file' | 'folder' = 'file'; - let size: number | undefined = undefined; - let uri: string = ''; - if (debugShowAll) { - // Forcibly treat all as files for debugging - type = 'file'; - uri = `file://${entryPath}`; - logger.log('[DEBUG] Forcing file type for entry:', { entryPath, type }); - } else { + let debugOutput = "=== Directory Context Testing ===\n\n"; + + if (this.getCapabilities().isIOS) { + debugOutput += "iOS Platform - Testing Documents directory:\n"; + try { + const result = await Filesystem.readdir({ + path: ".", + directory: Directory.Documents, + }); + debugOutput += `Documents directory: ${result.files.length} files found\n`; + result.files.forEach((file, index) => { + const name = typeof file === "string" ? file : file.name; + debugOutput += ` ${index + 1}. ${name}\n`; + }); + } catch (error) { + debugOutput += `Documents directory error: ${error}\n`; + } + } else { + debugOutput += "Android Platform - Testing multiple directory contexts:\n\n"; + + const contexts = [ + { name: "Data (App Private)", directory: Directory.Data, path: "." }, + { name: "Documents", directory: Directory.Documents, path: "." }, + { name: "External Storage Root", directory: Directory.ExternalStorage, path: "." }, + { name: "External Storage Downloads", directory: Directory.ExternalStorage, path: "Download" }, + { name: "External Storage TimeSafari", directory: Directory.ExternalStorage, path: "TimeSafari" }, + { name: "External Storage Download/TimeSafari", directory: Directory.ExternalStorage, path: "Download/TimeSafari" }, + ]; + + for (const context of contexts) { + debugOutput += `${context.name}:\n`; try { - const stat = await Filesystem.stat({ path: entryPath, directory }); - if (stat.type === 'directory') { - type = 'folder'; - uri = ''; - } else { - type = 'file'; - size = stat.size; - uri = stat.uri ? stat.uri : `file://${entryPath}`; - } - logger.log('[DEBUG] Stat for entry:', { entryPath, stat, type }); - } catch (e) { - // If stat fails, assume file - type = 'file'; - uri = `file://${entryPath}`; - logger.warn('[DEBUG] Stat failed for entry, assuming file:', { entryPath, error: e }); + const result = await Filesystem.readdir({ + path: context.path, + directory: context.directory, + }); + debugOutput += ` Found ${result.files.length} entries\n`; + result.files.forEach((file, index) => { + const name = typeof file === "string" ? file : file.name; + debugOutput += ` ${index + 1}. ${name}\n`; + }); + } catch (error) { + debugOutput += ` Error: ${error}\n`; + } + debugOutput += "\n"; + } + } + + debugOutput += "=== Context Testing Complete ===\n"; + logger.log("[CapacitorPlatformService] Directory context test results:\n" + debugOutput); + return debugOutput; + } catch (error) { + const errorMsg = `❌ Directory context testing failed: ${error}`; + logger.error("[CapacitorPlatformService] Directory context testing failed:", error); + return errorMsg; + } + } + + /** + * Creates a test backup file to verify file writing and reading functionality. + * This is useful for debugging file visibility issues. + * @returns Promise resolving to success status and file information + */ + async createTestBackupFile(): Promise<{ success: boolean; fileName?: string; uri?: string; error?: string }> { + try { + logger.log("[CapacitorPlatformService] Creating test backup file for debugging"); + + // Create a simple test backup file + const testData = { + type: "test_backup", + timestamp: new Date().toISOString(), + message: "This is a test backup file created for debugging file visibility issues", + contacts: [ + { + did: "did:ethr:0x1234567890abcdef", + name: "Test Contact", + contactMethods: JSON.stringify([{ type: "EMAIL", value: "test@example.com" }]), + notes: "Test contact for debugging" + } + ] + }; + + const content = JSON.stringify(testData, null, 2); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `TimeSafari-test-backup-${timestamp}.json`; + + logger.log("[CapacitorPlatformService] Test backup file details:", { + fileName, + contentLength: content.length, + timestamp: new Date().toISOString(), + }); + + // Save the test file using the same method as regular backups + const result = await this.writeAndShareFile(fileName, content, { + allowLocationSelection: false, + saveToDownloads: true, + showShareDialog: false + }); + + if (result.saved) { + logger.log("[CapacitorPlatformService] Test backup file created successfully:", { + fileName, + uri: result.uri, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + fileName, + uri: result.uri + }; + } else { + throw new Error(result.error || "Failed to save test backup file"); + } + } catch (error) { + const err = error as Error; + logger.error("[CapacitorPlatformService] Failed to create test backup file:", { + error: err.message, + timestamp: new Date().toISOString(), + }); + + return { + success: false, + error: err.message + }; + } + } + + /** + * Debug method to check what's actually in the TimeSafari directory + * This helps identify if the directory exists but is empty or has permission issues + * @returns Promise resolving to debug information about the TimeSafari directory + */ + async debugTimeSafariDirectory(): Promise { + try { + let debugOutput = "=== TimeSafari Directory Debug ===\n\n"; + + if (this.getCapabilities().isIOS) { + debugOutput += "iOS: Checking Documents directory for TimeSafari files\n"; + try { + const result = await Filesystem.readdir({ + path: ".", + directory: Directory.Documents, + }); + debugOutput += `Found ${result.files.length} files in Documents:\n`; + result.files.forEach((file, index) => { + const name = typeof file === "string" ? file : file.name; + debugOutput += ` ${index + 1}. ${name}\n`; + }); + } catch (error) { + debugOutput += `Error reading Documents: ${error}\n`; + } + } else { + debugOutput += "Android: Checking multiple TimeSafari locations\n\n"; + + // Test 1: Check if Download/TimeSafari exists and can be read + debugOutput += "1. Testing Download/TimeSafari directory:\n"; + try { + const downloadResult = await Filesystem.readdir({ + path: "Download/TimeSafari", + directory: Directory.ExternalStorage, + }); + debugOutput += ` ✅ Success! Found ${downloadResult.files.length} files:\n`; + downloadResult.files.forEach((file, index) => { + const name = typeof file === "string" ? file : file.name; + const size = typeof file === "string" ? "unknown" : file.size; + debugOutput += ` ${index + 1}. ${name} (${size} bytes)\n`; + }); + } catch (error) { + debugOutput += ` ❌ Error: ${error}\n`; + } + + // Test 2: Check if TimeSafari exists in root external storage + debugOutput += "\n2. Testing TimeSafari in root external storage:\n"; + try { + const rootResult = await Filesystem.readdir({ + path: "TimeSafari", + directory: Directory.ExternalStorage, + }); + debugOutput += ` ✅ Success! Found ${rootResult.files.length} files:\n`; + rootResult.files.forEach((file, index) => { + const name = typeof file === "string" ? file : file.name; + const size = typeof file === "string" ? "unknown" : file.size; + debugOutput += ` ${index + 1}. ${name} (${size} bytes)\n`; + }); + } catch (error) { + debugOutput += ` ❌ Error: ${error}\n`; + } + + // Test 3: Check what's in the Download directory + debugOutput += "\n3. Testing Download directory contents:\n"; + try { + const downloadDirResult = await Filesystem.readdir({ + path: "Download", + directory: Directory.ExternalStorage, + }); + debugOutput += ` ✅ Success! Found ${downloadDirResult.files.length} items in Download:\n`; + downloadDirResult.files.forEach((file, index) => { + const name = typeof file === "string" ? file : file.name; + const size = typeof file === "string" ? "unknown" : file.size; + debugOutput += ` ${index + 1}. ${name} (${size} bytes)\n`; + }); + } catch (error) { + debugOutput += ` ❌ Error: ${error}\n`; + } + + // Test 4: Try to create a test file in Download/TimeSafari + debugOutput += "\n4. Testing file creation in Download/TimeSafari:\n"; + try { + const testResult = await this.createTestBackupFile(); + if (testResult.success) { + debugOutput += ` ✅ Successfully created test file: ${testResult.fileName}\n`; + debugOutput += ` URI: ${testResult.uri}\n`; + } else { + debugOutput += ` ❌ Failed to create test file: ${testResult.error}\n`; } + } catch (error) { + debugOutput += ` ❌ Error creating test file: ${error}\n`; + } + } + + debugOutput += "\n=== TimeSafari Directory Debug Complete ===\n"; + logger.log("[CapacitorPlatformService] TimeSafari directory debug results:\n" + debugOutput); + return debugOutput; + } catch (error) { + const errorMsg = `❌ TimeSafari directory debug failed: ${error}`; + logger.error("[CapacitorPlatformService] TimeSafari directory debug failed:", error); + return errorMsg; + } + } + + /** + * Lists files and folders in a specific directory for directory browsing + * @param path - The directory path to list + * @param debugShowAll - Debug flag to treat all entries as files + * @returns Promise resolving to array of directory entries + */ + async listFilesInDirectory(path: string, debugShowAll: boolean = false): Promise> { + try { + logger.log("[CapacitorPlatformService] Listing directory:", { path, debugShowAll }); + + let directory: Directory; + let actualPath: string; + + if (this.getCapabilities().isIOS) { + directory = Directory.Documents; + actualPath = path === '.' ? '.' : path; + } else { + directory = Directory.ExternalStorage; + // Handle nested paths properly - use the full path as provided + actualPath = path; + } + + logger.log("[CapacitorPlatformService] Attempting to read directory:", { + actualPath, + directory: directory.toString(), + platform: this.getCapabilities().isIOS ? "iOS" : "Android" + }); + + const result = await Filesystem.readdir({ + path: actualPath, + directory: directory, + }); + + const entries: Array<{name: string, uri: string, size?: number, path: string, type: 'file' | 'folder'}> = []; + + for (const file of result.files) { + const fileName = typeof file === "string" ? file : file.name; + + // In debug mode, treat everything as a file + if (debugShowAll) { + entries.push({ + name: fileName, + uri: `file://${file.uri || file}`, + size: typeof file === "string" ? undefined : file.size, + path: path, + type: 'file' + }); + continue; } - entries.push({ name, uri, size, path: entryPath, type }); + + // Check if it's a directory by trying to get file stats + let isDirectory = false; + try { + const stat = await Filesystem.stat({ + path: `${actualPath}/${fileName}`, + directory: directory + }); + isDirectory = stat.type === 'directory'; + } catch (statError) { + // If stat fails, assume it's a file + isDirectory = false; + } + + entries.push({ + name: fileName, + uri: `file://${file.uri || file}`, + size: typeof file === "string" ? undefined : file.size, + path: path, + type: isDirectory ? 'folder' : 'file' + }); } - // Sort: folders first, then files - entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'folder' ? -1 : 1)); - logger.log('[DEBUG] Final directoryEntries:', entries); + + logger.log("[CapacitorPlatformService] Directory listing result:", { + path, + entryCount: entries.length, + folders: entries.filter(e => e.type === 'folder').length, + files: entries.filter(e => e.type === 'file').length + }); + return entries; } catch (error) { - logger.error('[CapacitorPlatformService] Failed to list files in directory:', { path, error }); + logger.error("[CapacitorPlatformService] Failed to list directory:", { path, error }); return []; } } diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 0db6f57a..b06bf349 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -441,7 +441,7 @@ export class ElectronPlatformService implements PlatformService { /** * Lists backup files specifically saved by the app. - * Not implemented in Electron platform. + * Not implemented for Electron platform. * @returns Promise resolving to empty array */ async listBackupFiles(): Promise> { @@ -467,4 +467,43 @@ export class ElectronPlatformService implements PlatformService { async openBackupDirectory(): Promise<{ success: boolean; error?: string }> { return { success: false, error: "Directory access not implemented in Electron platform" }; } + + /** + * Lists files and folders in a specific directory for directory browsing. + * Not implemented for Electron platform. + * @returns Promise resolving to empty array + */ + async listFilesInDirectory(path: string, debugShowAll?: boolean): Promise> { + return []; + } + + /** + * Debug method to check what's actually in the TimeSafari directory. + * Not implemented for Electron platform. + * @returns Promise resolving to debug information + */ + async debugTimeSafariDirectory(): Promise { + return "Electron platform does not support file system access for debugging TimeSafari directory."; + } + + /** + * Creates a test backup file to verify file writing and reading functionality. + * Not implemented for Electron platform. + * @returns Promise resolving to error status + */ + async createTestBackupFile(): Promise<{ success: boolean; fileName?: string; uri?: string; error?: string }> { + return { + success: false, + error: "Electron platform does not support file system access for creating test backup files." + }; + } + + /** + * Test method to try different directory contexts and see what files are available. + * Not implemented for Electron platform. + * @returns Promise resolving to debug information + */ + async testDirectoryContexts(): Promise { + return "Electron platform does not support file system access for testing directory contexts."; + } } diff --git a/src/services/platforms/PyWebViewPlatformService.ts b/src/services/platforms/PyWebViewPlatformService.ts index ef27c3f4..44dde1e6 100644 --- a/src/services/platforms/PyWebViewPlatformService.ts +++ b/src/services/platforms/PyWebViewPlatformService.ts @@ -156,7 +156,7 @@ export class PyWebViewPlatformService implements PlatformService { /** * Lists backup files specifically saved by the app. - * Not implemented in PyWebView platform. + * Not implemented for PyWebView platform. * @returns Promise resolving to empty array */ async listBackupFiles(): Promise> { @@ -246,4 +246,43 @@ export class PyWebViewPlatformService implements PlatformService { async rotateCamera(): Promise { // Not implemented } + + /** + * Lists files and folders in a specific directory for directory browsing. + * Not implemented for PyWebView platform. + * @returns Promise resolving to empty array + */ + async listFilesInDirectory(path: string, debugShowAll?: boolean): Promise> { + return []; + } + + /** + * Debug method to check what's actually in the TimeSafari directory. + * Not implemented for PyWebView platform. + * @returns Promise resolving to debug information + */ + async debugTimeSafariDirectory(): Promise { + return "PyWebView platform does not support file system access for debugging TimeSafari directory."; + } + + /** + * Creates a test backup file to verify file writing and reading functionality. + * Not implemented for PyWebView platform. + * @returns Promise resolving to error status + */ + async createTestBackupFile(): Promise<{ success: boolean; fileName?: string; uri?: string; error?: string }> { + return { + success: false, + error: "PyWebView platform does not support file system access for creating test backup files." + }; + } + + /** + * Test method to try different directory contexts and see what files are available. + * Not implemented for PyWebView platform. + * @returns Promise resolving to debug information + */ + async testDirectoryContexts(): Promise { + return "PyWebView platform does not support file system access for testing directory contexts."; + } } diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index bec2e6cb..fed63bf0 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -513,4 +513,43 @@ export class WebPlatformService implements PlatformService { // Not supported in web platform return Promise.resolve(); } + + /** + * Lists files and folders in a specific directory for directory browsing. + * Not supported in web platform. + * @returns Promise resolving to empty array + */ + async listFilesInDirectory(path: string, debugShowAll?: boolean): Promise> { + return []; + } + + /** + * Debug method to check what's actually in the TimeSafari directory. + * Not supported in web platform. + * @returns Promise resolving to debug information + */ + async debugTimeSafariDirectory(): Promise { + return "Web platform does not support file system access for debugging TimeSafari directory."; + } + + /** + * Creates a test backup file to verify file writing and reading functionality. + * Not supported in web platform. + * @returns Promise resolving to error status + */ + async createTestBackupFile(): Promise<{ success: boolean; fileName?: string; uri?: string; error?: string }> { + return { + success: false, + error: "Web platform does not support file system access for creating test backup files." + }; + } + + /** + * Test method to try different directory contexts and see what files are available. + * Not supported in web platform. + * @returns Promise resolving to debug information + */ + async testDirectoryContexts(): Promise { + return "Web platform does not support file system access for testing directory contexts."; + } }