Files
crowd-funder-for-time-pwa/src/services/platforms/CapacitorPlatformService.ts
Jose Olarte III 749204f96b fix: database connection error causing navigation redirect on iOS/Android
Handle "Connection already exists" error when initializing SQLite database
on Capacitor platforms. The native connection can persist across app
restarts while the JavaScript connection Map is empty, causing a mismatch.

When createConnection fails with "already exists":
- Check if connection exists in JavaScript Map and retrieve it if present
- If not in Map, close the native connection and recreate to sync both sides
- Handle "already open" errors gracefully when opening existing connections

This fixes the issue where clicking "Backup Identifier Seed" would redirect
to StartView instead of SeedBackupView due to database initialization
failures in the router navigation guard.

Fixes navigation issue on both iOS and Android platforms.
2025-11-06 21:38:51 +08:00

1417 lines
45 KiB
TypeScript

import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import {
Camera,
CameraResultType,
CameraSource,
CameraDirection,
} from "@capacitor/camera";
import { Capacitor } from "@capacitor/core";
import { Share } from "@capacitor/share";
import {
SQLiteConnection,
SQLiteDBConnection,
CapacitorSQLite,
DBSQLiteValues,
} from "@capacitor-community/sqlite";
import { runMigrations } from "@/db-sql/migration";
import { QueryExecResult } from "@/interfaces/database";
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService";
interface QueuedOperation {
type: "run" | "query" | "rawQuery";
sql: string;
params: unknown[];
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
/**
* Platform service implementation for Capacitor (mobile) platform.
* Provides native mobile functionality through Capacitor plugins for:
* - File system operations
* - Camera and image picker
* - Platform-specific features
* - SQLite database operations
*/
export class CapacitorPlatformService
extends BaseDatabaseService
implements PlatformService
{
/** Current camera direction */
private currentDirection: CameraDirection = CameraDirection.Rear;
private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null;
private dbName = "timesafari.sqlite";
private initialized = false;
private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false;
constructor() {
super();
this.sqlite = new SQLiteConnection(CapacitorSQLite);
}
private async initializeDatabase(): Promise<void> {
// If already initialized, return immediately
if (this.initialized) {
return;
}
// If initialization is in progress, wait for it
if (this.initializationPromise) {
return this.initializationPromise;
}
try {
// Start initialization
this.initializationPromise = this._initialize();
await this.initializationPromise;
} catch (error) {
logger.error(
"[CapacitorPlatformService] Initialize database method failed:",
error,
);
this.initializationPromise = null; // Reset on failure
throw error;
}
}
private async _initialize(): Promise<void> {
if (this.initialized) {
return;
}
try {
// Try to create/Open database connection
try {
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
const fullErrorMessage =
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
}
// Open the connection if it's not already open
try {
await this.db.open();
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
throw openError;
}
logger.debug(
"[CapacitorPlatformService] Database connection already open",
);
}
// Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;");
// Run migrations
await this.runCapacitorMigrations();
this.initialized = true;
logger.log(
"[CapacitorPlatformService] SQLite database initialized successfully",
);
// Start processing the queue after initialization
this.processQueue();
} catch (error) {
logger.error(
"[CapacitorPlatformService] Error initializing SQLite database:",
error,
);
throw new Error(
"[CapacitorPlatformService] Failed to initialize database",
);
}
}
private async processQueue(): Promise<void> {
if (this.isProcessingQueue || !this.initialized || !this.db) {
return;
}
this.isProcessingQueue = true;
while (this.operationQueue.length > 0) {
const operation = this.operationQueue.shift();
if (!operation) continue;
try {
let result: unknown;
switch (operation.type) {
case "run": {
const runResult = await this.db.run(
operation.sql,
operation.params,
);
result = {
changes: runResult.changes?.changes || 0,
lastId: runResult.changes?.lastId,
};
break;
}
case "query": {
const queryResult = await this.db.query(
operation.sql,
operation.params,
);
result = {
columns: Object.keys(queryResult.values?.[0] || {}),
values: (queryResult.values || []).map((row) =>
Object.values(row),
),
};
break;
}
case "rawQuery": {
const queryResult = await this.db.query(
operation.sql,
operation.params,
);
result = queryResult;
break;
}
}
operation.resolve(result);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Error while processing SQL queue:",
error,
);
logger.error(
`[CapacitorPlatformService] Failed operation - Type: ${operation.type}, SQL: ${operation.sql}`,
);
logger.error(
`[CapacitorPlatformService] Failed operation - Params:`,
operation.params,
);
operation.reject(error);
}
}
this.isProcessingQueue = false;
}
private async queueOperation<R>(
type: QueuedOperation["type"],
sql: string,
params: unknown[] = [],
): Promise<R> {
// Only log SQL operations in debug mode to reduce console noise
logger.debug(`[CapacitorPlatformService] queueOperation - SQL: ${sql}`);
// Convert parameters to SQLite-compatible types with robust serialization
const convertedParams = params.map((param, index) => {
if (param === null || param === undefined) {
return null;
}
if (typeof param === "object" && param !== null) {
// Special handling for Proxy objects (common cause of "An object could not be cloned")
const isProxy = this.isProxyObject(param);
// AGGRESSIVE: If toString contains "Proxy", treat as Proxy even if isProxyObject returns false
const stringRep = String(param);
const forceProxyDetection =
stringRep.includes("Proxy(") || stringRep.startsWith("Proxy");
if (isProxy || forceProxyDetection) {
logger.debug(
`[CapacitorPlatformService] Proxy object detected at index ${index}`,
);
try {
// AGGRESSIVE EXTRACTION: Try multiple methods to extract actual values
if (Array.isArray(param)) {
// Method 1: Array.from() to extract from Proxy(Array)
const actualArray = Array.from(param);
return actualArray;
} else {
// For Proxy(Object), try to extract actual object
const actualObject = Object.assign({}, param);
return actualObject;
}
} catch (proxyError) {
logger.debug(
`[CapacitorPlatformService] Failed to extract from Proxy at index ${index}:`,
proxyError,
);
// FALLBACK: Try to extract primitive values manually
if (Array.isArray(param)) {
try {
const fallbackArray: unknown[] = [];
for (let i = 0; i < param.length; i++) {
fallbackArray.push(param[i]);
}
return fallbackArray;
} catch (fallbackError) {
return `[Proxy Array - Could not extract]`;
}
}
return `[Proxy Object - Could not extract]`;
}
}
try {
// Safely convert objects and arrays to JSON strings
return JSON.stringify(param);
} catch (error) {
// Handle non-serializable objects
logger.debug(
`[CapacitorPlatformService] Failed to serialize parameter at index ${index}:`,
error,
);
// Fallback: Convert to string representation
if (Array.isArray(param)) {
return `[Array(${param.length})]`;
}
return `[Object ${param.constructor?.name || "Unknown"}]`;
}
}
if (typeof param === "boolean") {
// Convert boolean to integer (0 or 1)
return param ? 1 : 0;
}
if (typeof param === "function") {
// Functions can't be serialized - convert to string representation
logger.debug(
`[CapacitorPlatformService] Function parameter detected and converted to string at index ${index}`,
);
return `[Function ${param.name || "Anonymous"}]`;
}
if (typeof param === "symbol") {
// Symbols can't be serialized - convert to string representation
logger.debug(
`[CapacitorPlatformService] Symbol parameter detected and converted to string at index ${index}`,
);
return param.toString();
}
// Numbers, strings, bigints are supported, but ensure bigints are converted to strings
if (typeof param === "bigint") {
return param.toString();
}
return param;
});
return new Promise<R>((resolve, reject) => {
// Create completely plain objects that Vue cannot make reactive
// Step 1: Deep clone the converted params to ensure they're plain objects
const plainParams = JSON.parse(JSON.stringify(convertedParams));
// Step 2: Create operation object using Object.create(null) for no prototype
const operation = Object.create(null) as QueuedOperation;
operation.type = type;
operation.sql = sql;
operation.params = plainParams;
operation.resolve = (value: unknown) => resolve(value as R);
operation.reject = reject;
// Step 3: Freeze everything to prevent modification
Object.freeze(operation.params);
Object.freeze(operation);
this.operationQueue.push(operation);
// If we're already initialized, start processing the queue
if (this.initialized && this.db) {
this.processQueue();
}
});
}
private async waitForInitialization(): Promise<void> {
// If we have an initialization promise, wait for it
if (this.initializationPromise) {
await this.initializationPromise;
return;
}
// If not initialized and no promise, start initialization
if (!this.initialized) {
await this.initializeDatabase();
return;
}
// If initialized but no db, something went wrong
if (!this.db) {
logger.error(
"[CapacitorPlatformService] Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null",
);
throw new Error(
"[CapacitorPlatformService] The database could not be initialized. We recommend you restart or reinstall.",
);
}
}
/**
* Detect if an object is a Proxy object that cannot be serialized
* Proxy objects cause "An object could not be cloned" errors in Capacitor
* @param obj - Object to test
* @returns true if the object appears to be a Proxy
*/
private isProxyObject(obj: unknown): boolean {
if (typeof obj !== "object" || obj === null) {
return false;
}
try {
// Method 1: Check toString representation
const objString = obj.toString();
if (objString.includes("Proxy(") || objString.startsWith("Proxy")) {
logger.debug(
"[CapacitorPlatformService] Proxy detected via toString:",
objString,
);
return true;
}
// Method 2: Check constructor name
const constructorName = obj.constructor?.name;
if (constructorName === "Proxy") {
logger.debug(
"[CapacitorPlatformService] Proxy detected via constructor name",
);
return true;
}
// Method 3: Check Object.prototype.toString
const objToString = Object.prototype.toString.call(obj);
if (objToString.includes("Proxy")) {
logger.debug(
"[CapacitorPlatformService] Proxy detected via Object.prototype.toString",
);
return true;
}
// Method 4: Vue/Reactive Proxy detection - check for __v_ properties
if (typeof obj === "object" && obj !== null) {
// Check for Vue reactive proxy indicators
const hasVueProxy = Object.getOwnPropertyNames(obj).some(
(prop) => prop.startsWith("__v_") || prop.startsWith("__r_"),
);
if (hasVueProxy) {
logger.debug(
"[CapacitorPlatformService] Vue reactive Proxy detected",
);
return true;
}
}
// Method 5: Try JSON.stringify and check for Proxy in error or result
try {
const jsonString = JSON.stringify(obj);
if (jsonString.includes("Proxy")) {
logger.debug(
"[CapacitorPlatformService] Proxy detected in JSON serialization",
);
return true;
}
} catch (jsonError) {
// If JSON.stringify fails, it might be a non-serializable Proxy
const errorMessage =
jsonError instanceof Error ? jsonError.message : String(jsonError);
if (
errorMessage.includes("Proxy") ||
errorMessage.includes("circular") ||
errorMessage.includes("clone")
) {
logger.debug(
"[CapacitorPlatformService] Proxy detected via JSON serialization error",
);
return true;
}
}
return false;
} catch (error) {
// If we can't inspect the object, it might be a Proxy causing issues
logger.warn(
"[CapacitorPlatformService] Could not inspect object for Proxy detection:",
error,
);
return true; // Assume it's a Proxy if we can't inspect it
}
}
/**
* Execute database migrations for the Capacitor platform
*
* This method orchestrates the database migration process specifically for
* Capacitor-based platforms (mobile and Electron). It provides the platform-specific
* SQL execution functions to the migration service and handles Capacitor SQLite
* plugin integration.
*
* ## Migration Process:
*
* 1. **SQL Execution Setup**: Creates platform-specific SQL execution functions
* that properly handle the Capacitor SQLite plugin's API
*
* 2. **Parameter Handling**: Ensures proper parameter binding for prepared statements
* using the correct Capacitor SQLite methods (run vs execute)
*
* 3. **Result Parsing**: Provides extraction functions that understand the
* Capacitor SQLite result format
*
* 4. **Migration Execution**: Delegates to the migration service for the actual
* migration logic and tracking
*
* 5. **Integrity Verification**: Runs post-migration integrity checks to ensure
* the database is in the expected state
*
* ## Error Handling:
*
* The method includes comprehensive error handling for:
* - Database connection issues
* - SQL execution failures
* - Migration tracking problems
* - Schema validation errors
*
* Even if migrations fail, the integrity check still runs to assess the
* current database state and provide debugging information.
*
* ## Logging:
*
* Detailed logging is provided throughout the process using emoji-tagged
* console messages that appear in the Electron DevTools console. This
* includes:
* - SQL statement execution details
* - Parameter values for debugging
* - Migration success/failure status
* - Database integrity check results
*
* @throws {Error} If database is not initialized or migrations fail critically
* @private Internal method called during database initialization
*
* @example
* ```typescript
* // Called automatically during platform service initialization
* await this.runCapacitorMigrations();
* ```
*/
private async runCapacitorMigrations(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
/**
* SQL execution function for Capacitor SQLite plugin
*
* This function handles the execution of SQL statements (INSERT, UPDATE, CREATE, etc.)
* through the Capacitor SQLite plugin. It automatically chooses the appropriate
* method based on whether parameters are provided.
*
* @param sql - SQL statement to execute
* @param params - Optional parameters for prepared statements
* @returns Promise resolving to execution results
*/
const sqlExec = async (sql: string, params?: unknown[]): Promise<void> => {
logger.debug(`🔧 [CapacitorMigration] Executing SQL:`, sql);
if (params && params.length > 0) {
// Use run method for parameterized queries (prepared statements)
// This is essential for proper parameter binding and SQL injection prevention
await this.db!.run(sql, params);
} else {
// For multi-statement SQL (like migrations), use executeSet method
// This handles multiple statements properly
if (
sql.includes(";") &&
sql.split(";").filter((s) => s.trim()).length > 1
) {
// Multi-statement SQL - use executeSet for proper handling
const statements = sql.split(";").filter((s) => s.trim());
await this.db!.executeSet(
statements.map((stmt) => ({
statement: stmt.trim(),
values: [], // Empty values array for non-parameterized statements
})),
);
} else {
// Single statement - use execute method
await this.db!.execute(sql);
}
}
};
/**
* SQL query function for Capacitor SQLite plugin
*
* This function handles the execution of SQL queries (SELECT statements)
* through the Capacitor SQLite plugin. It returns the raw result data
* that can be processed by the migration service.
*
* @param sql - SQL query to execute
* @param params - Optional parameters for prepared statements
* @returns Promise resolving to query results
*/
const sqlQuery = async (
sql: string,
params?: unknown[],
): Promise<DBSQLiteValues> => {
logger.debug(`🔍 [CapacitorMigration] Querying SQL:`, sql);
const result = await this.db!.query(sql, params);
return result;
};
/**
* Extract migration names from Capacitor SQLite query results
*
* This function parses the result format returned by the Capacitor SQLite
* plugin and extracts migration names. It handles the specific data structure
* used by the plugin, which can vary between different result formats.
*
* ## Result Format Handling:
*
* The Capacitor SQLite plugin can return results in different formats:
* - Object format: `{ name: "migration_name" }`
* - Array format: `["migration_name", "timestamp"]`
*
* This function handles both formats to ensure robust migration name extraction.
*
* @param result - Query result from Capacitor SQLite plugin
* @returns Set of migration names found in the result
*/
const extractMigrationNames = (result: DBSQLiteValues): Set<string> => {
logger.debug(
`🔍 [CapacitorMigration] Extracting migration names from:`,
result,
);
// Handle the Capacitor SQLite result format
const names =
result.values
?.map((row: unknown) => {
// The row could be an object with 'name' property or an array where name is first element
if (typeof row === "object" && row !== null && "name" in row) {
return (row as { name: string }).name;
} else if (Array.isArray(row) && row.length > 0) {
return row[0];
}
return null;
})
.filter((name) => name !== null) || [];
logger.debug(`📋 [CapacitorMigration] Extracted names:`, names);
return new Set(names);
};
try {
// Execute the migration process
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
// After migrations, run integrity check to verify database state
await this.verifyDatabaseIntegrity();
} catch (error) {
logger.error(`❌ [CapacitorMigration] Migration failed:`, error);
// Still try to verify what we have for debugging purposes
await this.verifyDatabaseIntegrity();
throw error;
}
}
/**
* Verify database integrity and migration status
*
* This method performs comprehensive validation of the database structure
* and migration state. It's designed to help identify issues with the
* migration process and provide detailed debugging information.
*
* ## Validation Steps:
*
* 1. **Migration Records**: Checks which migrations are recorded as applied
* 2. **Table Existence**: Verifies all expected core tables exist
* 3. **Schema Validation**: Checks table schemas including column presence
* 4. **Data Integrity**: Validates basic data counts and structure
*
* ## Core Tables Validated:
*
* - `accounts`: User identity and cryptographic keys
* - `secret`: Application secrets and encryption keys
* - `settings`: Configuration and user preferences
* - `contacts`: Contact network and trust relationships
* - `logs`: Application event logging
* - `temp`: Temporary data storage
*
* ## Schema Checks:
*
* For critical tables like `contacts`, the method validates:
* - Table structure using `PRAGMA table_info`
* - Presence of important columns (e.g., `iViewContent`)
* - Column data types and constraints
*
* ## Error Handling:
*
* This method is designed to never throw errors - it captures and logs
* all validation issues for debugging purposes. This ensures that even
* if integrity checks fail, they don't prevent the application from starting.
*
* ## Logging Output:
*
* The method produces detailed console output with emoji tags:
* - `✅` for successful validations
* - `❌` for validation failures
* - `📊` for data summaries
* - `🔍` for investigation steps
*
* @private Internal method called after migrations
*
* @example
* ```typescript
* // Called automatically after migration completion
* await this.verifyDatabaseIntegrity();
* ```
*/
private async verifyDatabaseIntegrity(): Promise<void> {
if (!this.db) {
logger.error(`❌ [DB-Integrity] Database not initialized`);
return;
}
logger.debug(`🔍 [DB-Integrity] Starting database integrity check...`);
try {
// Step 1: Check migrations table and applied migrations
const migrationsResult = await this.db.query(
"SELECT name, applied_at FROM migrations ORDER BY applied_at",
);
logger.debug(`📊 [DB-Integrity] Applied migrations:`, migrationsResult);
// Step 2: Verify core tables exist
const coreTableNames = [
"accounts",
"secret",
"settings",
"contacts",
"logs",
"temp",
];
const existingTables: string[] = [];
for (const tableName of coreTableNames) {
try {
const tableCheck = await this.db.query(
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
);
if (tableCheck.values && tableCheck.values.length > 0) {
existingTables.push(tableName);
logger.debug(`✅ [DB-Integrity] Table ${tableName} exists`);
} else {
logger.error(`❌ [DB-Integrity] Table ${tableName} missing`);
}
} catch (error) {
logger.error(
`❌ [DB-Integrity] Error checking table ${tableName}:`,
error,
);
}
}
// Step 3: Check contacts table schema (including iViewContent column)
if (existingTables.includes("contacts")) {
try {
const contactsSchema = await this.db.query(
"PRAGMA table_info(contacts)",
);
logger.debug(
`📊 [DB-Integrity] Contacts table schema:`,
contactsSchema,
);
// Check for iViewContent column specifically
const hasIViewContent = contactsSchema.values?.some(
(col: unknown) =>
(typeof col === "object" &&
col !== null &&
"name" in col &&
(col as { name: string }).name === "iViewContent") ||
(Array.isArray(col) && col[1] === "iViewContent"),
);
if (hasIViewContent) {
logger.debug(
`✅ [DB-Integrity] iViewContent column exists in contacts table`,
);
} else {
logger.error(
`❌ [DB-Integrity] iViewContent column missing from contacts table`,
);
}
} catch (error) {
logger.error(
`❌ [DB-Integrity] Error checking contacts schema:`,
error,
);
}
}
// Step 4: Check for basic data integrity
try {
const accountCount = await this.db.query(
"SELECT COUNT(*) as count FROM accounts",
);
const settingsCount = await this.db.query(
"SELECT COUNT(*) as count FROM settings",
);
const contactsCount = await this.db.query(
"SELECT COUNT(*) as count FROM contacts",
);
logger.debug(
`📊 [DB-Integrity] Data counts - Accounts: ${JSON.stringify(accountCount)}, Settings: ${JSON.stringify(settingsCount)}, Contacts: ${JSON.stringify(contactsCount)}`,
);
} catch (error) {
logger.error(`❌ [DB-Integrity] Error checking data counts:`, error);
}
logger.log(`✅ [DB-Integrity] Database integrity check completed`);
} catch (error) {
logger.error(`❌ [DB-Integrity] Database integrity check failed:`, error);
}
}
/**
* Gets the capabilities of the Capacitor platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
const platform = Capacitor.getPlatform();
return {
hasFileSystem: true,
hasCamera: true,
isMobile: true, // Capacitor is always mobile
isIOS: platform === "ios",
hasFileDownload: false, // Mobile platforms need sharing
needsFileHandlingInstructions: true, // Mobile needs instructions
isNativeApp: true,
};
}
/**
* Checks and requests storage permissions if needed
* @returns Promise that resolves when permissions are granted
* @throws Error if permissions are denied
*/
private async checkStoragePermissions(): Promise<void> {
try {
const logData = {
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log(
"Checking storage permissions",
JSON.stringify(logData, null, 2),
);
if (this.getCapabilities().isIOS) {
// iOS uses different permission model
return;
}
// Try to access a test directory to check permissions
try {
await Filesystem.stat({
path: "/storage/emulated/0/Download",
directory: Directory.Documents,
});
logger.log(
"Storage permissions already granted",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
return;
} catch (error: unknown) {
const err = error as Error;
const errorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
// "File does not exist" is expected and not a permission error
if (err.message === "File does not exist") {
logger.log(
"Directory does not exist (expected), proceeding with write",
JSON.stringify(errorLogData, null, 2),
);
return;
}
// Check for actual permission errors
if (
err.message.includes("permission") ||
err.message.includes("access")
) {
logger.log(
"Permission check failed, requesting permissions",
JSON.stringify(errorLogData, null, 2),
);
// The Filesystem plugin will automatically request permissions when needed
// We just need to try the operation again
try {
await Filesystem.stat({
path: "/storage/emulated/0/Download",
directory: Directory.Documents,
});
logger.log(
"Storage permissions granted after request",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
return;
} catch (retryError: unknown) {
const retryErr = retryError as Error;
throw new Error(
`Failed to obtain storage permissions: ${retryErr.message}`,
);
}
}
// For any other error, log it but don't treat as permission error
logger.log(
"Unexpected error during permission check",
JSON.stringify(errorLogData, null, 2),
);
return;
}
} catch (error: unknown) {
const err = error as Error;
const errorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
logger.error(
"Error checking/requesting permissions",
JSON.stringify(errorLogData, null, 2),
);
throw new Error(`Failed to obtain storage permissions: ${err.message}`);
}
}
/**
* Reads a file from the app's data directory.
* @param path - Relative path to the file in the app's data directory
* @returns Promise resolving to the file contents as string
* @throws Error if file cannot be read or doesn't exist
*/
async readFile(path: string): Promise<string> {
const file = await Filesystem.readFile({
path,
directory: Directory.Data,
});
if (file.data instanceof Blob) {
return await file.data.text();
}
return file.data;
}
/**
* Writes content to a file in the app's safe storage and offers sharing.
*
* Platform-specific behavior:
* - Saves to app's Documents directory
* - Offers sharing functionality to move file elsewhere
*
* The method handles:
* 1. Writing to app-safe storage
* 2. Sharing the file with user's preferred app
* 3. Error handling and logging
*
* @param fileName - The name of the file to create (e.g. "backup.json")
* @param content - The content to write to the file
*
* @throws Error if:
* - File writing fails
* - Sharing fails
*
* @example
* ```typescript
* // Save and share a JSON file
* await platformService.writeFile(
* "backup.json",
* JSON.stringify(data)
* );
* ```
*/
async writeFile(fileName: string, content: string): Promise<void> {
try {
// Check storage permissions before proceeding
await this.checkStoragePermissions();
const logData = {
targetFileName: fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log(
"Starting writeFile operation",
JSON.stringify(logData, null, 2),
);
// For Android, we need to handle content URIs differently
if (this.getCapabilities().isIOS) {
// Write to app's Documents directory for iOS
const writeResult = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
const writeSuccessLogData = {
path: writeResult.uri,
timestamp: new Date().toISOString(),
};
logger.log(
"File write successful",
JSON.stringify(writeSuccessLogData, null, 2),
);
// Offer to share the file
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
url: writeResult.uri,
dialogTitle: "Share your backup",
});
logger.log(
"Share dialog shown",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
} catch (shareError) {
// Log share error but don't fail the operation
logger.error(
"Share dialog failed",
JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(),
},
null,
2,
),
);
}
} else {
// For Android, first write to app's Documents directory
const writeResult = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
const writeSuccessLogData = {
path: writeResult.uri,
timestamp: new Date().toISOString(),
};
logger.log(
"File write successful to app storage",
JSON.stringify(writeSuccessLogData, null, 2),
);
// Then share the file to let user choose where to save it
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
url: writeResult.uri,
dialogTitle: "Save your backup",
});
logger.log(
"Share dialog shown for Android",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
} catch (shareError) {
// Log share error but don't fail the operation
logger.error(
"Share dialog failed for Android",
JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(),
},
null,
2,
),
);
}
}
} catch (error: unknown) {
const err = error as Error;
const finalErrorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
logger.error(
"Error in writeFile operation:",
JSON.stringify(finalErrorLogData, null, 2),
);
throw new Error(`Failed to save file: ${err.message}`);
}
}
/**
* Writes content to a file in the device's app-private storage.
* Then shares the file using the system share dialog.
*
* Works on both Android and iOS without needing external storage permissions.
*
* @param fileName - The name of the file to create (e.g. "backup.json")
* @param content - The content to write to the file
*/
async writeAndShareFile(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "writeAndShareFile",
fileName,
contentLength: content.length,
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Check storage permissions before proceeding
await this.checkStoragePermissions();
const { uri } = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File write successful:", {
uri,
timestamp: new Date().toISOString(),
});
await Share.share({
title: "TimeSafari Backup",
text: "Here is your backup file.",
url: uri,
dialogTitle: "Share your backup file",
});
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error writing or sharing file:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to write or share file: ${err.message}`);
}
}
/**
* Deletes a file from the app's data directory.
* @param path - Relative path to the file to delete
* @throws Error if deletion fails or file doesn't exist
*/
async deleteFile(path: string): Promise<void> {
await Filesystem.deleteFile({
path,
directory: Directory.Data,
});
}
/**
* Lists files in the specified directory within app's data directory.
* @param directory - Relative path to the directory to list
* @returns Promise resolving to array of filenames
* @throws Error if directory cannot be read or doesn't exist
*/
async listFiles(directory: string): Promise<string[]> {
const result = await Filesystem.readdir({
path: directory,
directory: Directory.Data,
});
return result.files.map((file) =>
typeof file === "string" ? file : file.name,
);
}
/**
* Opens the device camera to take a picture.
* Configures camera for high quality images with editing enabled.
* @returns Promise resolving to the captured image data
* @throws Error if camera access fails or user cancels
*/
async takePicture(): Promise<ImageResult> {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Base64,
source: CameraSource.Camera,
direction: this.currentDirection,
});
const blob = await this.processImageData(image.base64String);
return {
blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`,
};
} catch (error) {
logger.error("Error taking picture with Capacitor:", error);
throw new Error("Failed to take picture");
}
}
/**
* Opens the device photo gallery to pick an existing image.
* Configures picker for high quality images with editing enabled.
* @returns Promise resolving to the selected image data
* @throws Error if gallery access fails or user cancels
*/
async pickImage(): Promise<ImageResult> {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Base64,
source: CameraSource.Photos,
});
const blob = await this.processImageData(image.base64String);
return {
blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`,
};
} catch (error) {
logger.error("Error picking image with Capacitor:", error);
throw new Error("Failed to pick image");
}
}
/**
* Converts base64 image data to a Blob.
* @param base64String - Base64 encoded image data
* @returns Promise resolving to image Blob
* @throws Error if conversion fails
*/
private async processImageData(base64String?: string): Promise<Blob> {
if (!base64String) {
throw new Error("No image data received");
}
// Convert base64 to blob
const byteCharacters = atob(base64String);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: "image/jpeg" });
}
/**
* Rotates the camera between front and back cameras.
* @returns Promise that resolves when the camera is rotated
*/
async rotateCamera(): Promise<void> {
this.currentDirection =
this.currentDirection === CameraDirection.Rear
? CameraDirection.Front
: CameraDirection.Rear;
logger.debug(`Camera rotated to ${this.currentDirection} camera`);
}
/**
* Handles deep link URLs for the application.
* Note: Capacitor handles deep links automatically.
* @param _url - The deep link URL (unused)
*/
async handleDeepLink(_url: string): Promise<void> {
// Capacitor handles deep links automatically
// This is just a placeholder for the interface
return Promise.resolve();
}
/**
* @see PlatformService.dbQuery
*/
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
await this.waitForInitialization();
return this.queueOperation<QueryExecResult>("query", sql, params || []);
}
/**
* @see PlatformService.dbExec
*/
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
await this.waitForInitialization();
return this.queueOperation<{ changes: number; lastId?: number }>(
"run",
sql,
params || [],
);
}
/**
* @see PlatformService.dbGetOneRow
*/
async dbGetOneRow(
sql: string,
params?: unknown[],
): Promise<unknown[] | undefined> {
await this.waitForInitialization();
const result = await this.queueOperation<QueryExecResult>(
"query",
sql,
params || [],
);
// Return the first row from the result, or undefined if no results
if (result && result.values && result.values.length > 0) {
return result.values[0];
}
return undefined;
}
/**
* @see PlatformService.dbRawQuery
*/
async dbRawQuery(sql: string, params?: unknown[]): Promise<unknown> {
await this.waitForInitialization();
return this.queueOperation("rawQuery", sql, params || []);
}
/**
* Checks if running on Capacitor platform.
* @returns true, as this is the Capacitor implementation
*/
isCapacitor(): boolean {
return true;
}
/**
* Checks if running on Electron platform.
* @returns false, as this is Capacitor, not Electron
*/
isElectron(): boolean {
return false;
}
/**
* Checks if running on web platform.
* @returns false, as this is not web
*/
isWeb(): boolean {
return false;
}
// --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {}
// Database utility methods - inherited from BaseDatabaseService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
}