You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
16 KiB
16 KiB
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
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
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
)
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
)
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
)
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
)
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
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
)
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
// 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
)**
// 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
)
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
)
// 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
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
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
@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
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
{
"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
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
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
andname
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:
- Robust Data Storage: SQLite-based contact storage with proper indexing
- Cross-Platform Compatibility: Works on web, iOS, Android, and desktop
- Flexible Export Options: Multiple file formats and storage locations
- Intelligent File Discovery: Finds backup files regardless of user-chosen locations
- User-Friendly Interface: Clear categorization and easy file management
- Comprehensive Logging: Detailed tracking for debugging and monitoring
- 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.