Compare commits
11 Commits
master
...
capacitor-
Author | SHA1 | Date |
---|---|---|
|
a1b6add178 | 1 day ago |
|
d3a26a54d4 | 1 day ago |
|
122b5b1a06 | 1 day ago |
|
9e8f08aa49 | 1 day ago |
|
1529cc9689 | 1 day ago |
|
f7ed05d13f | 1 day ago |
|
7c8a6d0666 | 1 day ago |
|
1aa285be55 | 2 days ago |
|
2635c22c33 | 3 days ago |
|
2d516b90b0 | 4 days ago |
|
7a1329e1a4 | 4 days ago |
15 changed files with 6011 additions and 592 deletions
@ -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<string, unknown>, "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<string, unknown>, "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<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> { |
||||
|
return []; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 4. Backup File Listing System |
||||
|
|
||||
|
#### A. File Discovery (`CapacitorPlatformService.ts`) |
||||
|
|
||||
|
##### 1. Enhanced File Discovery |
||||
|
|
||||
|
```typescript |
||||
|
async listUserAccessibleFilesEnhanced(): Promise<Array<{name: string, uri: string, size?: number, path?: string}>> { |
||||
|
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<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> { |
||||
|
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. |
@ -0,0 +1,894 @@ |
|||||
|
/** * Backup Files List Component * * Displays a list of backup files saved by |
||||
|
the app and provides options to: * - View backup files by type (contacts, seed, |
||||
|
other) * - Open individual files in the device's file viewer * - Access the |
||||
|
backup directory in the device's file explorer * * @component * @displayName |
||||
|
BackupFilesList * @example * ```vue * |
||||
|
<BackupFilesList /> |
||||
|
* ``` */ |
||||
|
|
||||
|
<template> |
||||
|
<div class="backup-files-list"> |
||||
|
<div class="flex justify-between items-center mb-4"> |
||||
|
<h3 class="text-lg font-semibold">Backup Files</h3> |
||||
|
<div class="flex gap-2"> |
||||
|
<button |
||||
|
v-if="platformCapabilities.hasFileSystem" |
||||
|
class="text-sm bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded" |
||||
|
:disabled="isLoading" |
||||
|
@click="refreshFiles()" |
||||
|
> |
||||
|
<font-awesome |
||||
|
icon="refresh" |
||||
|
class="fa-fw" |
||||
|
:class="{ 'animate-spin': isLoading }" |
||||
|
/> |
||||
|
Refresh |
||||
|
</button> |
||||
|
<button |
||||
|
v-if="platformCapabilities.hasFileSystem" |
||||
|
class="text-sm bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded" |
||||
|
:disabled="isLoading" |
||||
|
@click="openBackupDirectory()" |
||||
|
> |
||||
|
<font-awesome icon="folder-open" class="fa-fw" /> |
||||
|
Open Directory |
||||
|
</button> |
||||
|
<button |
||||
|
v-if="platformCapabilities.hasFileSystem && isDevelopment" |
||||
|
class="text-sm bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded" |
||||
|
:disabled="isLoading" |
||||
|
title="Debug file discovery (development only)" |
||||
|
@click="debugFileDiscovery()" |
||||
|
> |
||||
|
<font-awesome icon="bug" class="fa-fw" /> |
||||
|
Debug |
||||
|
</button> |
||||
|
<button |
||||
|
:disabled="isLoading" |
||||
|
class="px-3 py-1 bg-green-500 text-white rounded text-sm hover:bg-green-600 disabled:opacity-50" |
||||
|
@click="createTestBackup" |
||||
|
> |
||||
|
Create Test Backup |
||||
|
</button> |
||||
|
<button |
||||
|
:disabled="isLoading" |
||||
|
class="px-3 py-1 bg-purple-500 text-white rounded text-sm hover:bg-purple-600 disabled:opacity-50" |
||||
|
@click="testDirectoryContexts" |
||||
|
> |
||||
|
Test Contexts |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="isLoading" class="text-center py-4"> |
||||
|
<font-awesome icon="spinner" class="animate-spin fa-2x" /> |
||||
|
<p class="mt-2">Loading backup files...</p> |
||||
|
</div> |
||||
|
|
||||
|
<div |
||||
|
v-else-if="backupFiles.length === 0" |
||||
|
class="text-center py-4 text-gray-500" |
||||
|
> |
||||
|
<font-awesome icon="folder-open" class="fa-2x mb-2" /> |
||||
|
<p>No backup files found</p> |
||||
|
<p class="text-sm mt-1"> |
||||
|
Create backups using the export functions above |
||||
|
</p> |
||||
|
<div |
||||
|
class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg text-left" |
||||
|
> |
||||
|
<p class="text-sm font-medium text-blue-800 mb-2"> |
||||
|
💡 How to create backup files: |
||||
|
</p> |
||||
|
<ul class="text-xs text-blue-700 space-y-1"> |
||||
|
<li> |
||||
|
• Use the "Export Contacts" button above to create contact backups |
||||
|
</li> |
||||
|
<li>• Use the "Export Seed" button to backup your recovery phrase</li> |
||||
|
<li> |
||||
|
• Backup files are saved to persistent storage that survives app |
||||
|
installations |
||||
|
</li> |
||||
|
<li |
||||
|
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS" |
||||
|
class="text-orange-700" |
||||
|
> |
||||
|
• On Android: Files are saved to Downloads/TimeSafari or app data |
||||
|
directory |
||||
|
</li> |
||||
|
<li v-if="platformCapabilities.isIOS" class="text-orange-700"> |
||||
|
• On iOS: Files are saved to Documents folder (accessible via Files |
||||
|
app) |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-else class="space-y-2"> |
||||
|
<!-- File Type Filter --> |
||||
|
<div class="flex gap-2 mb-3"> |
||||
|
<button |
||||
|
v-for="type in ['all', 'contacts', 'seed', 'other'] as const" |
||||
|
:key="type" |
||||
|
:class="[ |
||||
|
'text-sm px-3 py-1 rounded border', |
||||
|
selectedType === type |
||||
|
? 'bg-blue-500 text-white border-blue-500' |
||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50', |
||||
|
]" |
||||
|
@click="selectedType = type" |
||||
|
> |
||||
|
{{ |
||||
|
type === "all" |
||||
|
? "All" |
||||
|
: type.charAt(0).toUpperCase() + type.slice(1) |
||||
|
}} |
||||
|
<span class="ml-1 text-xs"> ({{ getFileCountByType(type) }}) </span> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Files List --> |
||||
|
<div class="flex items-center gap-2 mb-2"> |
||||
|
<span v-for="(crumb, idx) in breadcrumbs" :key="idx"> |
||||
|
<span |
||||
|
v-if="idx < breadcrumbs.length - 1" |
||||
|
class="text-blue-600 cursor-pointer underline" |
||||
|
@click="goToBreadcrumb(idx)" |
||||
|
> |
||||
|
{{ crumb }} |
||||
|
</span> |
||||
|
<span v-else class="font-bold">{{ crumb }}</span> |
||||
|
<span v-if="idx < breadcrumbs.length - 1"> / </span> |
||||
|
</span> |
||||
|
</div> |
||||
|
<div v-if="currentPath.length > 1" class="mb-2"> |
||||
|
<button class="text-xs text-blue-500 underline" @click="goUp"> |
||||
|
⬅ Up |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="mb-2"> |
||||
|
<label class="inline-flex items-center"> |
||||
|
<input |
||||
|
v-model="debugShowAll" |
||||
|
type="checkbox" |
||||
|
class="mr-2" |
||||
|
@change="loadDirectory" |
||||
|
/> |
||||
|
<span class="text-xs">Debug: Show all entries as files</span> |
||||
|
</label> |
||||
|
<span v-if="debugShowAll" class="text-xs text-red-600 ml-2" |
||||
|
>[Debug mode: forcibly treating all entries as files]</span |
||||
|
> |
||||
|
</div> |
||||
|
<div class="space-y-2 max-h-64 overflow-y-auto"> |
||||
|
<div |
||||
|
v-for="entry in folders" |
||||
|
:key="'folder-' + entry.path" |
||||
|
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer" |
||||
|
@click="openFolder(entry)" |
||||
|
> |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<font-awesome icon="folder" class="fa-fw text-yellow-500" /> |
||||
|
<span class="font-medium">{{ entry.name }}</span> |
||||
|
<span |
||||
|
class="text-xs bg-gray-200 text-gray-700 px-2 py-0.5 rounded-full ml-2" |
||||
|
>Folder</span |
||||
|
> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div |
||||
|
v-for="entry in files" |
||||
|
:key="'file-' + entry.path" |
||||
|
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50" |
||||
|
> |
||||
|
<div class="flex-1 min-w-0"> |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<font-awesome icon="file-alt" class="fa-fw text-gray-500" /> |
||||
|
<span class="font-medium truncate">{{ entry.name }}</span> |
||||
|
</div> |
||||
|
<div class="text-sm text-gray-500 mt-1"> |
||||
|
<span v-if="entry.size">{{ formatFileSize(entry.size) }}</span> |
||||
|
<span v-else>Size unknown</span> |
||||
|
<span |
||||
|
v-if="entry.path && !platformCapabilities.isIOS" |
||||
|
class="ml-2 text-xs text-blue-600" |
||||
|
>📁 {{ entry.path }}</span |
||||
|
> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="flex gap-2 ml-3"> |
||||
|
<button |
||||
|
class="text-blue-500 hover:text-blue-700 p-1" |
||||
|
title="Open file" |
||||
|
@click="openFile(entry.uri, entry.name)" |
||||
|
> |
||||
|
<font-awesome icon="external-link-alt" class="fa-fw" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Summary --> |
||||
|
<div class="text-sm text-gray-500 mt-3 pt-3 border-t"> |
||||
|
Showing {{ filteredFiles.length }} of {{ backupFiles.length }} backup |
||||
|
files |
||||
|
</div> |
||||
|
|
||||
|
<div class="text-sm text-gray-600 mb-2"> |
||||
|
<p> |
||||
|
📁 Backup files are saved to persistent storage that survives app |
||||
|
installations: |
||||
|
</p> |
||||
|
<ul class="list-disc list-inside ml-2 mt-1 text-xs"> |
||||
|
<li v-if="platformCapabilities.isIOS"> |
||||
|
iOS: Documents folder (accessible via Files app) |
||||
|
</li> |
||||
|
<li |
||||
|
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS" |
||||
|
> |
||||
|
Android: Downloads/TimeSafari or external storage (accessible via |
||||
|
file managers) |
||||
|
</li> |
||||
|
<li v-if="!platformCapabilities.isMobile"> |
||||
|
Desktop: User's download directory |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue, Watch } from "vue-facing-decorator"; |
||||
|
import { NotificationIface } from "../constants/app"; |
||||
|
import { logger } from "../utils/logger"; |
||||
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; |
||||
|
import { |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../services/PlatformService"; |
||||
|
|
||||
|
/** |
||||
|
* @vue-component |
||||
|
* Backup Files List Component |
||||
|
* Displays and manages backup files with platform-specific functionality |
||||
|
*/ |
||||
|
@Component |
||||
|
export default class BackupFilesList extends Vue { |
||||
|
/** |
||||
|
* Notification function injected by Vue |
||||
|
* Used to show success/error messages to the user |
||||
|
*/ |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
/** |
||||
|
* Platform service instance for platform-specific operations |
||||
|
*/ |
||||
|
private platformService: PlatformService = |
||||
|
PlatformServiceFactory.getInstance(); |
||||
|
|
||||
|
/** |
||||
|
* Platform capabilities for the current platform |
||||
|
*/ |
||||
|
private get platformCapabilities(): PlatformCapabilities { |
||||
|
return this.platformService.getCapabilities(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* List of backup files found on the device |
||||
|
*/ |
||||
|
backupFiles: Array<{ |
||||
|
name: string; |
||||
|
uri: string; |
||||
|
size?: number; |
||||
|
type: "contacts" | "seed" | "other"; |
||||
|
path?: string; |
||||
|
}> = []; |
||||
|
|
||||
|
/** |
||||
|
* Currently selected file type filter |
||||
|
*/ |
||||
|
selectedType: "all" | "contacts" | "seed" | "other" = "all"; |
||||
|
|
||||
|
/** |
||||
|
* Loading state for file operations |
||||
|
*/ |
||||
|
isLoading = false; |
||||
|
|
||||
|
/** |
||||
|
* Interval for periodic refresh (5 minutes) |
||||
|
*/ |
||||
|
private refreshInterval: number | null = null; |
||||
|
|
||||
|
/** |
||||
|
* Current path for folder navigation (array for breadcrumbs) |
||||
|
*/ |
||||
|
currentPath: string[] = []; |
||||
|
|
||||
|
/** |
||||
|
* List of files/folders in the current directory |
||||
|
*/ |
||||
|
directoryEntries: Array<{ |
||||
|
name: string; |
||||
|
uri: string; |
||||
|
size?: number; |
||||
|
path: string; |
||||
|
type: "file" | "folder"; |
||||
|
}> = []; |
||||
|
|
||||
|
/** |
||||
|
* Temporary debug mode to show all entries as files |
||||
|
*/ |
||||
|
debugShowAll = false; |
||||
|
|
||||
|
/** |
||||
|
* Checks and requests storage permissions if needed. |
||||
|
* Returns true if permission is granted, false otherwise. |
||||
|
*/ |
||||
|
private async ensureStoragePermission(): Promise<boolean> { |
||||
|
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); |
||||
|
|
||||
|
// Get specific guidance for the platform |
||||
|
let guidance = |
||||
|
"This app needs permission to access your files to list and restore backups."; |
||||
|
if ( |
||||
|
typeof platformService.getStoragePermissionGuidance === "function" |
||||
|
) { |
||||
|
try { |
||||
|
guidance = await platformService.getStoragePermissionGuidance(); |
||||
|
} catch (guidanceError) { |
||||
|
logger.warn( |
||||
|
"[BackupFilesList] Could not get permission guidance:", |
||||
|
guidanceError, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Storage Permission Required", |
||||
|
text: guidance, |
||||
|
}, |
||||
|
10000, // Show for 10 seconds to give user time to read |
||||
|
); |
||||
|
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) { |
||||
|
// 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, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lifecycle hook to clean up resources when component is unmounted |
||||
|
*/ |
||||
|
beforeUnmount() { |
||||
|
if (this.refreshInterval) { |
||||
|
clearInterval(this.refreshInterval); |
||||
|
this.refreshInterval = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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; |
||||
|
} |
||||
|
const filtered = this.backupFiles.filter( |
||||
|
(file) => file.type === this.selectedType, |
||||
|
); |
||||
|
logger.log( |
||||
|
`[BackupFilesList] filteredFiles (${this.selectedType}):`, |
||||
|
filtered, |
||||
|
); |
||||
|
return filtered; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computed property to check if we're in development mode |
||||
|
*/ |
||||
|
get isDevelopment(): boolean { |
||||
|
return import.meta.env.DEV; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Load the current directory entries |
||||
|
*/ |
||||
|
async loadDirectory() { |
||||
|
if (!this.platformCapabilities.hasFileSystem) return; |
||||
|
this.isLoading = true; |
||||
|
try { |
||||
|
const path = |
||||
|
this.currentPath.join("/") || |
||||
|
(this.platformCapabilities.isIOS ? "." : "Download/TimeSafari"); |
||||
|
this.directoryEntries = await ( |
||||
|
this.platformService as PlatformService |
||||
|
).listFilesInDirectory(path, this.debugShowAll); |
||||
|
logger.log("[BackupFilesList] Loaded directory:", { |
||||
|
path, |
||||
|
entries: this.directoryEntries, |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
logger.error("[BackupFilesList] Failed to load directory:", error); |
||||
|
this.directoryEntries = []; |
||||
|
} finally { |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Navigate into a folder |
||||
|
*/ |
||||
|
async openFolder(entry: { name: string; path: string }) { |
||||
|
this.currentPath.push(entry.name); |
||||
|
await this.loadDirectory(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Navigate to a breadcrumb |
||||
|
*/ |
||||
|
async goToBreadcrumb(index: number) { |
||||
|
this.currentPath = this.currentPath.slice(0, index + 1); |
||||
|
await this.loadDirectory(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Go up one directory |
||||
|
*/ |
||||
|
async goUp() { |
||||
|
if (this.currentPath.length > 1) { |
||||
|
this.currentPath.pop(); |
||||
|
await this.loadDirectory(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computed property for breadcrumbs |
||||
|
*/ |
||||
|
get breadcrumbs() { |
||||
|
return this.currentPath; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computed property for showing files and folders |
||||
|
*/ |
||||
|
get folders() { |
||||
|
return this.directoryEntries.filter((e) => e.type === "folder"); |
||||
|
} |
||||
|
get files() { |
||||
|
return this.directoryEntries.filter((e) => e.type === "file"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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:", { |
||||
|
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(), |
||||
|
}); |
||||
|
// Debug: 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, |
||||
|
}; |
||||
|
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); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error Loading Files", |
||||
|
text: "Failed to load backup files from your device.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} finally { |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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 |
||||
|
logger.log("=== Directory Context Test Results ==="); |
||||
|
logger.log(debugOutput); |
||||
|
logger.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 |
||||
|
*/ |
||||
|
async refreshAfterSave() { |
||||
|
logger.log("[BackupFilesList] refreshAfterSave called"); |
||||
|
await this.refreshFiles(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens a specific file in the device's file viewer |
||||
|
* @param fileUri - URI of the file to open |
||||
|
* @param fileName - Name of the file for display |
||||
|
*/ |
||||
|
async openFile(fileUri: string, fileName: string) { |
||||
|
try { |
||||
|
const result = await this.platformService.openFile(fileUri, fileName); |
||||
|
|
||||
|
if (result.success) { |
||||
|
logger.log("[BackupFilesList] File opened successfully:", { |
||||
|
fileName, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}); |
||||
|
} else { |
||||
|
throw new Error(result.error || "Failed to open file"); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error("[BackupFilesList] Failed to open file:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error Opening File", |
||||
|
text: `Failed to open ${fileName}. ${error instanceof Error ? error.message : String(error)}`, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the backup directory in the device's file explorer |
||||
|
*/ |
||||
|
async openBackupDirectory() { |
||||
|
try { |
||||
|
const result = await this.platformService.openBackupDirectory(); |
||||
|
|
||||
|
if (result.success) { |
||||
|
logger.log("[BackupFilesList] Backup directory opened successfully:", { |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}); |
||||
|
} else { |
||||
|
throw new Error(result.error || "Failed to open backup directory"); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error("[BackupFilesList] Failed to open backup directory:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error Opening Directory", |
||||
|
text: `Failed to open backup directory. ${error instanceof Error ? error.message : String(error)}`, |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gets the count of files for a specific 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") { |
||||
|
count = this.backupFiles.length; |
||||
|
logger.log( |
||||
|
"[BackupFilesList] getFileCountByType (All):", |
||||
|
count, |
||||
|
this.backupFiles, |
||||
|
); |
||||
|
return count; |
||||
|
} |
||||
|
count = this.backupFiles.filter((file) => file.type === type).length; |
||||
|
logger.log(`[BackupFilesList] getFileCountByType (${type}):`, count); |
||||
|
return count; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gets the appropriate icon for a file type |
||||
|
* @param type - File type |
||||
|
* @returns FontAwesome icon name |
||||
|
*/ |
||||
|
getFileIcon(type: "contacts" | "seed" | "other"): string { |
||||
|
switch (type) { |
||||
|
case "contacts": |
||||
|
return "address-book"; |
||||
|
case "seed": |
||||
|
return "key"; |
||||
|
default: |
||||
|
return "file-alt"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gets the appropriate icon color for a file type |
||||
|
* @param type - File type |
||||
|
* @returns CSS color class |
||||
|
*/ |
||||
|
getFileIconColor(type: "contacts" | "seed" | "other"): string { |
||||
|
switch (type) { |
||||
|
case "contacts": |
||||
|
return "text-blue-500"; |
||||
|
case "seed": |
||||
|
return "text-orange-500"; |
||||
|
default: |
||||
|
return "text-gray-500"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Gets the appropriate badge color for a file type |
||||
|
* @param type - File type |
||||
|
* @returns CSS color class |
||||
|
*/ |
||||
|
getTypeBadgeColor(type: "contacts" | "seed" | "other"): string { |
||||
|
switch (type) { |
||||
|
case "contacts": |
||||
|
return "bg-blue-100 text-blue-800"; |
||||
|
case "seed": |
||||
|
return "bg-orange-100 text-orange-800"; |
||||
|
default: |
||||
|
return "bg-gray-100 text-gray-800"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Formats file size in human-readable format |
||||
|
* @param bytes - File size in bytes |
||||
|
* @returns Formatted file size string |
||||
|
*/ |
||||
|
formatFileSize(bytes: number): string { |
||||
|
if (bytes === 0) return "0 Bytes"; |
||||
|
|
||||
|
const k = 1024; |
||||
|
const sizes = ["Bytes", "KB", "MB", "GB"]; |
||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
||||
|
|
||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Debug method to test file discovery |
||||
|
* Can be called from browser console for troubleshooting |
||||
|
*/ |
||||
|
public async debugFileDiscovery() { |
||||
|
try { |
||||
|
logger.log("[BackupFilesList] Starting debug file discovery..."); |
||||
|
|
||||
|
// Test the platform service's test methods |
||||
|
const platformService = PlatformServiceFactory.getInstance(); |
||||
|
|
||||
|
// Test listing all user files |
||||
|
const allFilesResult = await platformService.testListUserFiles(); |
||||
|
logger.log( |
||||
|
"[BackupFilesList] All user files test result:", |
||||
|
allFilesResult, |
||||
|
); |
||||
|
|
||||
|
// Test listing backup files specifically |
||||
|
const backupFilesResult = await platformService.testBackupFiles(); |
||||
|
logger.log( |
||||
|
"[BackupFilesList] Backup files test result:", |
||||
|
backupFilesResult, |
||||
|
); |
||||
|
|
||||
|
// Note: testListAllBackupFiles method is not part of the PlatformService interface |
||||
|
// It exists only in CapacitorPlatformService implementation |
||||
|
// If needed, this could be added to the interface or called via type assertion |
||||
|
|
||||
|
// Test debug listing all files without filtering (if available) |
||||
|
if ("debugListAllFiles" in platformService) { |
||||
|
const debugAllFiles = await ( |
||||
|
platformService as any |
||||
|
).debugListAllFiles(); |
||||
|
logger.log("[BackupFilesList] Debug all files (no filtering):", { |
||||
|
count: debugAllFiles.length, |
||||
|
files: debugAllFiles.map((f: any) => ({ |
||||
|
name: f.name, |
||||
|
path: f.path, |
||||
|
size: f.size, |
||||
|
})), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Test comprehensive step-by-step debug (if available) |
||||
|
if ("debugFileDiscoveryStepByStep" in platformService) { |
||||
|
const stepByStepDebug = await ( |
||||
|
platformService as any |
||||
|
).debugFileDiscoveryStepByStep(); |
||||
|
logger.log( |
||||
|
"[BackupFilesList] Step-by-step debug output:", |
||||
|
stepByStepDebug, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
allFiles: allFilesResult, |
||||
|
backupFiles: backupFilesResult, |
||||
|
currentBackupFiles: this.backupFiles, |
||||
|
debugAllFiles: |
||||
|
"debugListAllFiles" in platformService |
||||
|
? await (platformService as any).debugListAllFiles() |
||||
|
: null, |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
logger.error("[BackupFilesList] Debug file discovery failed:", error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Watch("platformCapabilities.hasFileSystem", { immediate: true }) |
||||
|
async onFileSystemCapabilityChanged(newVal: boolean) { |
||||
|
if (newVal) { |
||||
|
await this.refreshFiles(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
File diff suppressed because it is too large
Loading…
Reference in new issue