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