forked from trent_larson/crowd-funder-for-time-pwa
- Add multi-layered migration validation strategy with schema detection - Implement database integrity checker that validates all core tables and columns - Add schema-based migration skipping to prevent re-running applied migrations - Enhanced error handling for duplicate table/column scenarios with validation - Add comprehensive logging for migration tracking and database state verification - Include final validation step to ensure all migrations are properly recorded The system now properly: 1. Checks if migrations are recorded in the migrations table 2. Validates actual schema exists before attempting to apply migrations 3. Handles edge cases where schema exists but isn't recorded 4. Provides detailed integrity checking of database structure 5. Eliminates SQL errors from duplicate table/column creation attempts Migration tracking is now working correctly with both migrations properly recorded.
879 lines
27 KiB
TypeScript
879 lines
27 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,
|
|
capSQLiteChanges,
|
|
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";
|
|
|
|
interface QueuedOperation {
|
|
type: "run" | "query";
|
|
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 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() {
|
|
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;
|
|
}
|
|
|
|
// Start initialization
|
|
this.initializationPromise = this._initialize();
|
|
try {
|
|
await this.initializationPromise;
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Initialize method failed:",
|
|
error,
|
|
);
|
|
this.initializationPromise = null; // Reset on failure
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async _initialize(): Promise<void> {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Create/Open database
|
|
this.db = await this.sqlite.createConnection(
|
|
this.dbName,
|
|
false,
|
|
"no-encryption",
|
|
1,
|
|
false,
|
|
);
|
|
|
|
await this.db.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;
|
|
}
|
|
}
|
|
operation.resolve(result);
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Error while processing SQL queue:",
|
|
error,
|
|
);
|
|
operation.reject(error);
|
|
}
|
|
}
|
|
|
|
this.isProcessingQueue = false;
|
|
}
|
|
|
|
private async queueOperation<R>(
|
|
type: QueuedOperation["type"],
|
|
sql: string,
|
|
params: unknown[] = [],
|
|
): Promise<R> {
|
|
// Convert parameters to SQLite-compatible types
|
|
const convertedParams = params.map((param) => {
|
|
if (param === null || param === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof param === "object" && param !== null) {
|
|
// Convert objects and arrays to JSON strings
|
|
return JSON.stringify(param);
|
|
}
|
|
if (typeof param === "boolean") {
|
|
// Convert boolean to integer (0 or 1)
|
|
return param ? 1 : 0;
|
|
}
|
|
// Numbers, strings, bigints, and buffers are already supported
|
|
return param;
|
|
});
|
|
|
|
return new Promise<R>((resolve, reject) => {
|
|
const operation: QueuedOperation = {
|
|
type,
|
|
sql,
|
|
params: convertedParams,
|
|
resolve: (value: unknown) => resolve(value as R),
|
|
reject,
|
|
};
|
|
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.",
|
|
);
|
|
}
|
|
}
|
|
|
|
private async runCapacitorMigrations(): Promise<void> {
|
|
if (!this.db) {
|
|
throw new Error("Database not initialized");
|
|
}
|
|
|
|
const sqlExec = async (sql: string, params?: unknown[]): Promise<capSQLiteChanges> => {
|
|
console.log(`🔧 [CapacitorMigration] Executing SQL:`, sql);
|
|
console.log(`📋 [CapacitorMigration] With params:`, params);
|
|
|
|
if (params && params.length > 0) {
|
|
// Use run method for parameterized queries
|
|
const result = await this.db!.run(sql, params);
|
|
console.log(`✅ [CapacitorMigration] Run result:`, result);
|
|
return result;
|
|
} else {
|
|
// Use execute method for non-parameterized queries
|
|
const result = await this.db!.execute(sql);
|
|
console.log(`✅ [CapacitorMigration] Execute result:`, result);
|
|
return result;
|
|
}
|
|
};
|
|
|
|
const sqlQuery = async (sql: string, params?: unknown[]): Promise<DBSQLiteValues> => {
|
|
console.log(`🔍 [CapacitorMigration] Querying SQL:`, sql);
|
|
console.log(`📋 [CapacitorMigration] With params:`, params);
|
|
|
|
const result = await this.db!.query(sql, params);
|
|
console.log(`📊 [CapacitorMigration] Query result:`, result);
|
|
return result;
|
|
};
|
|
|
|
const extractMigrationNames = (result: DBSQLiteValues): Set<string> => {
|
|
console.log(`🔍 [CapacitorMigration] Extracting migration names from:`, result);
|
|
|
|
// Handle the Capacitor SQLite result format
|
|
const names = result.values?.map((row: any) => {
|
|
// The row could be an object with 'name' property or an array where name is first element
|
|
if (typeof row === 'object' && row.name !== undefined) {
|
|
return row.name;
|
|
} else if (Array.isArray(row) && row.length > 0) {
|
|
return row[0];
|
|
}
|
|
return null;
|
|
}).filter(name => name !== null) || [];
|
|
|
|
console.log(`📋 [CapacitorMigration] Extracted names:`, names);
|
|
return new Set(names);
|
|
};
|
|
|
|
try {
|
|
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
|
|
|
// After migrations, run integrity check
|
|
await this.verifyDatabaseIntegrity();
|
|
} catch (error) {
|
|
console.error(`❌ [CapacitorMigration] Migration failed:`, error);
|
|
// Still try to verify what we have
|
|
await this.verifyDatabaseIntegrity();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify database integrity and migration status
|
|
*/
|
|
private async verifyDatabaseIntegrity(): Promise<void> {
|
|
if (!this.db) {
|
|
console.error(`❌ [DB-Integrity] Database not initialized`);
|
|
return;
|
|
}
|
|
|
|
console.log(`🔍 [DB-Integrity] Starting database integrity check...`);
|
|
|
|
try {
|
|
// Check migrations table
|
|
const migrationsResult = await this.db.query("SELECT name, applied_at FROM migrations ORDER BY applied_at");
|
|
console.log(`📊 [DB-Integrity] Applied migrations:`, migrationsResult);
|
|
|
|
// Check 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);
|
|
console.log(`✅ [DB-Integrity] Table ${tableName} exists`);
|
|
} else {
|
|
console.error(`❌ [DB-Integrity] Table ${tableName} missing`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ [DB-Integrity] Error checking table ${tableName}:`, error);
|
|
}
|
|
}
|
|
|
|
// Check contacts table schema (including iViewContent column)
|
|
if (existingTables.includes('contacts')) {
|
|
try {
|
|
const contactsSchema = await this.db.query("PRAGMA table_info(contacts)");
|
|
console.log(`📊 [DB-Integrity] Contacts table schema:`, contactsSchema);
|
|
|
|
const hasIViewContent = contactsSchema.values?.some((col: any) =>
|
|
(col.name === 'iViewContent') || (Array.isArray(col) && col[1] === 'iViewContent')
|
|
);
|
|
|
|
if (hasIViewContent) {
|
|
console.log(`✅ [DB-Integrity] iViewContent column exists in contacts table`);
|
|
} else {
|
|
console.error(`❌ [DB-Integrity] iViewContent column missing from contacts table`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ [DB-Integrity] Error checking contacts schema:`, error);
|
|
}
|
|
}
|
|
|
|
// Check for 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");
|
|
|
|
console.log(`📊 [DB-Integrity] Data counts - Accounts: ${JSON.stringify(accountCount)}, Settings: ${JSON.stringify(settingsCount)}, Contacts: ${JSON.stringify(contactsCount)}`);
|
|
} catch (error) {
|
|
console.error(`❌ [DB-Integrity] Error checking data counts:`, error);
|
|
}
|
|
|
|
console.log(`✅ [DB-Integrity] Database integrity check completed`);
|
|
} catch (error) {
|
|
console.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 || [],
|
|
);
|
|
}
|
|
}
|