7 changed files with 1281 additions and 107 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. |
Loading…
Reference in new issue