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.
1876 lines
60 KiB
1876 lines
60 KiB
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 { DailyNotification } from "@timesafari/daily-notification-plugin";
|
|
|
|
import { runMigrations } from "@/db-sql/migration";
|
|
import { QueryExecResult } from "@/interfaces/database";
|
|
import {
|
|
ImageResult,
|
|
PlatformService,
|
|
PlatformCapabilities,
|
|
NotificationStatus,
|
|
PermissionStatus,
|
|
PermissionResult,
|
|
ScheduleOptions,
|
|
NativeFetcherConfig,
|
|
} 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 {}
|
|
|
|
// Daily notification operations
|
|
/**
|
|
* Get the status of scheduled daily notifications
|
|
* @see PlatformService.getDailyNotificationStatus
|
|
*/
|
|
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
|
|
try {
|
|
logger.debug(
|
|
"[CapacitorPlatformService] Getting daily notification status...",
|
|
);
|
|
|
|
const pluginStatus = await DailyNotification.getNotificationStatus();
|
|
|
|
// Get permissions separately
|
|
const permissions = await DailyNotification.checkPermissions();
|
|
|
|
// Map plugin PermissionState to our PermissionStatus format
|
|
const notificationsPermission = permissions.notifications;
|
|
let notifications: "granted" | "denied" | "prompt";
|
|
|
|
if (notificationsPermission === "granted") {
|
|
notifications = "granted";
|
|
} else if (notificationsPermission === "denied") {
|
|
notifications = "denied";
|
|
} else {
|
|
notifications = "prompt";
|
|
}
|
|
|
|
// Handle lastNotificationTime which can be a Promise<number>
|
|
let lastTriggered: string | undefined;
|
|
const lastNotificationTime = pluginStatus.lastNotificationTime;
|
|
if (lastNotificationTime) {
|
|
const timeValue = await Promise.resolve(lastNotificationTime);
|
|
if (typeof timeValue === "number") {
|
|
lastTriggered = new Date(timeValue).toISOString();
|
|
}
|
|
}
|
|
|
|
return {
|
|
isScheduled: pluginStatus.isScheduled ?? false,
|
|
scheduledTime: pluginStatus.settings?.time,
|
|
lastTriggered,
|
|
permissions: {
|
|
notifications,
|
|
exactAlarms: undefined, // Plugin doesn't expose this in status
|
|
},
|
|
};
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to get notification status:",
|
|
errorMessage,
|
|
error,
|
|
);
|
|
logger.warn(
|
|
"[CapacitorPlatformService] Daily notification section will be hidden - plugin may not be installed or available",
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check notification permissions
|
|
* @see PlatformService.checkNotificationPermissions
|
|
*/
|
|
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
|
|
try {
|
|
const permissions = await DailyNotification.checkPermissions();
|
|
|
|
// Log the raw permission state for debugging
|
|
logger.info(
|
|
`[CapacitorPlatformService] Raw permission state from plugin:`,
|
|
permissions,
|
|
);
|
|
|
|
// Map plugin PermissionState to our PermissionStatus format
|
|
const notificationsPermission = permissions.notifications;
|
|
let notifications: "granted" | "denied" | "prompt";
|
|
|
|
// Handle all possible PermissionState values
|
|
if (notificationsPermission === "granted") {
|
|
notifications = "granted";
|
|
} else if (
|
|
notificationsPermission === "denied" ||
|
|
notificationsPermission === "ephemeral"
|
|
) {
|
|
notifications = "denied";
|
|
} else {
|
|
// Treat "prompt", "prompt-with-rationale", "unknown", "provisional" as "prompt"
|
|
// This allows Android to show the permission dialog
|
|
notifications = "prompt";
|
|
}
|
|
|
|
logger.info(
|
|
`[CapacitorPlatformService] Mapped permission state: ${notifications} (from ${notificationsPermission})`,
|
|
);
|
|
|
|
return {
|
|
notifications,
|
|
exactAlarms: undefined, // Plugin doesn't expose this directly
|
|
};
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to check permissions:",
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request notification permissions
|
|
* @see PlatformService.requestNotificationPermissions
|
|
*/
|
|
async requestNotificationPermissions(): Promise<PermissionResult | null> {
|
|
try {
|
|
logger.info(
|
|
`[CapacitorPlatformService] Requesting notification permissions...`,
|
|
);
|
|
|
|
const result = await DailyNotification.requestPermissions();
|
|
|
|
logger.info(
|
|
`[CapacitorPlatformService] Permission request result:`,
|
|
result,
|
|
);
|
|
|
|
// Map plugin PermissionState to boolean
|
|
const notificationsGranted = result.notifications === "granted";
|
|
|
|
logger.info(
|
|
`[CapacitorPlatformService] Mapped permission result: ${notificationsGranted} (from ${result.notifications})`,
|
|
);
|
|
|
|
return {
|
|
notifications: notificationsGranted,
|
|
exactAlarms: undefined, // Plugin doesn't expose this directly
|
|
};
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to request permissions:",
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule a daily notification
|
|
* @see PlatformService.scheduleDailyNotification
|
|
*/
|
|
async scheduleDailyNotification(options: ScheduleOptions): Promise<void> {
|
|
try {
|
|
await DailyNotification.scheduleDailyNotification({
|
|
time: options.time,
|
|
title: options.title,
|
|
body: options.body,
|
|
sound: options.sound ?? true,
|
|
priority: options.priority ?? "high",
|
|
});
|
|
|
|
logger.info(
|
|
`[CapacitorPlatformService] Scheduled daily notification for ${options.time}`,
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to schedule notification:",
|
|
error,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel scheduled daily notification
|
|
* @see PlatformService.cancelDailyNotification
|
|
*/
|
|
async cancelDailyNotification(): Promise<void> {
|
|
try {
|
|
await DailyNotification.cancelAllNotifications();
|
|
|
|
logger.info("[CapacitorPlatformService] Cancelled daily notification");
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to cancel notification:",
|
|
error,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure native fetcher for background operations
|
|
*
|
|
* This method configures the daily notification plugin's native content fetcher
|
|
* with authentication credentials for background prefetch operations. It automatically
|
|
* retrieves the active DID from the database and generates a fresh JWT token with
|
|
* 72-hour expiration.
|
|
*
|
|
* **Authentication Flow:**
|
|
* 1. Retrieves active DID from `active_identity` table (single source of truth)
|
|
* 2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()`
|
|
* 3. Configures plugin with API server URL, active DID, and JWT token
|
|
* 4. Plugin stores token in its Room database for background workers
|
|
*
|
|
* **Token Management:**
|
|
* - Tokens are valid for 72 hours (4320 minutes)
|
|
* - Tokens are refreshed proactively when app comes to foreground
|
|
* - If token expires while offline, plugin uses cached content
|
|
* - Token refresh happens automatically via `DailyNotificationSection.refreshNativeFetcherConfig()`
|
|
*
|
|
* **Offline-First Design:**
|
|
* - 72-hour validity supports extended offline periods
|
|
* - Plugin can prefetch content when online and use cached content when offline
|
|
* - No app wake-up required for token refresh (happens when app is already open)
|
|
*
|
|
* **Error Handling:**
|
|
* - Returns `null` if active DID not found (no user logged in)
|
|
* - Returns `null` if JWT generation fails
|
|
* - Logs errors but doesn't throw (allows graceful degradation)
|
|
*
|
|
* @param config - Native fetcher configuration
|
|
* @param config.apiServer - API server URL (optional, uses default if not provided)
|
|
* @param config.jwt - JWT token (ignored, generated automatically)
|
|
* @param config.starredPlanHandleIds - Array of starred plan handle IDs for prefetch
|
|
* @returns Promise that resolves when configured, or `null` if configuration failed
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* await platformService.configureNativeFetcher({
|
|
* apiServer: "https://api.endorser.ch",
|
|
* jwt: "", // Generated automatically
|
|
* starredPlanHandleIds: ["plan-123", "plan-456"]
|
|
* });
|
|
* ```
|
|
*
|
|
* @see {@link accessTokenForBackground} For JWT token generation
|
|
* @see {@link DailyNotificationSection.refreshNativeFetcherConfig} For proactive token refresh
|
|
* @see PlatformService.configureNativeFetcher
|
|
*/
|
|
async configureNativeFetcher(
|
|
config: NativeFetcherConfig,
|
|
): Promise<void | null> {
|
|
try {
|
|
// Step 1: Get activeDid from database (single source of truth)
|
|
// This ensures we're using the correct user identity for authentication
|
|
const activeIdentity = await this.getActiveIdentity();
|
|
const activeDid = activeIdentity.activeDid;
|
|
|
|
if (!activeDid) {
|
|
logger.warn(
|
|
"[CapacitorPlatformService] No activeDid found, cannot configure native fetcher",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Step 2: Generate JWT token for background operations
|
|
// Use 72-hour expiration for offline-first prefetch operations
|
|
// This allows the plugin to work offline for extended periods
|
|
const { accessTokenForBackground } = await import(
|
|
"../../libs/crypto/index"
|
|
);
|
|
// Use 72 hours (4320 minutes) for background prefetch tokens
|
|
// This is longer than passkey expiration to support offline scenarios
|
|
const expirationMinutes = 72 * 60; // 72 hours
|
|
const jwtToken = await accessTokenForBackground(
|
|
activeDid,
|
|
expirationMinutes,
|
|
);
|
|
|
|
if (!jwtToken) {
|
|
logger.error("[CapacitorPlatformService] Failed to generate JWT token");
|
|
return null;
|
|
}
|
|
|
|
// Step 3: Get API server from config or use default
|
|
// This ensures the plugin knows where to fetch content from
|
|
let apiServer =
|
|
config.apiServer ||
|
|
(await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER;
|
|
|
|
// Step 3.5: Convert localhost to 10.0.2.2 for Android emulators
|
|
// Android emulators can't reach localhost - they need 10.0.2.2 to access the host machine
|
|
const platform = Capacitor.getPlatform();
|
|
if (platform === "android" && apiServer) {
|
|
// Replace localhost or 127.0.0.1 with 10.0.2.2 for Android emulator compatibility
|
|
apiServer = apiServer.replace(
|
|
/http:\/\/(localhost|127\.0\.0\.1)(:\d+)?/,
|
|
"http://10.0.2.2$2",
|
|
);
|
|
}
|
|
|
|
// Step 4: Configure plugin with credentials
|
|
// Plugin stores these in its Room database for background workers
|
|
await DailyNotification.configureNativeFetcher({
|
|
apiBaseUrl: apiServer,
|
|
activeDid,
|
|
jwtToken,
|
|
});
|
|
|
|
// Step 5: Update starred plans if provided
|
|
// This stores the starred plan IDs in SharedPreferences for the native fetcher
|
|
if (
|
|
config.starredPlanHandleIds &&
|
|
config.starredPlanHandleIds.length > 0
|
|
) {
|
|
await DailyNotification.updateStarredPlans({
|
|
planIds: config.starredPlanHandleIds,
|
|
});
|
|
logger.info(
|
|
`[CapacitorPlatformService] Updated starred plans: ${config.starredPlanHandleIds.length} plans`,
|
|
);
|
|
} else {
|
|
// Clear starred plans if none provided
|
|
await DailyNotification.updateStarredPlans({
|
|
planIds: [],
|
|
});
|
|
logger.info(
|
|
"[CapacitorPlatformService] Cleared starred plans (none provided)",
|
|
);
|
|
}
|
|
|
|
logger.info("[CapacitorPlatformService] Configured native fetcher", {
|
|
activeDid,
|
|
apiServer,
|
|
tokenExpirationHours: 72,
|
|
tokenExpirationMinutes: expirationMinutes,
|
|
starredPlansCount: config.starredPlanHandleIds?.length || 0,
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to configure native fetcher:",
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update starred plans for background fetcher
|
|
* @see PlatformService.updateStarredPlans
|
|
*/
|
|
async updateStarredPlans(plans: { planIds: string[] }): Promise<void | null> {
|
|
try {
|
|
await DailyNotification.updateStarredPlans({
|
|
planIds: plans.planIds,
|
|
});
|
|
|
|
logger.info(
|
|
`[CapacitorPlatformService] Updated starred plans: ${plans.planIds.length} plans`,
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to update starred plans:",
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the app's notification settings in the system settings
|
|
* @see PlatformService.openAppNotificationSettings
|
|
*/
|
|
async openAppNotificationSettings(): Promise<void | null> {
|
|
try {
|
|
const platform = Capacitor.getPlatform();
|
|
|
|
if (platform === "android") {
|
|
// Android: Open app details settings page
|
|
// From there, users can navigate to "Notifications" section
|
|
// This is more reliable than trying to open notification settings directly
|
|
const packageName = "app.timesafari.app"; // Full application ID from build.gradle
|
|
|
|
// Use APPLICATION_DETAILS_SETTINGS which opens the app's settings page
|
|
// Users can then navigate to "Notifications" section
|
|
// Try multiple URL formats to ensure compatibility
|
|
const intentUrl1 = `intent:#Intent;action=android.settings.APPLICATION_DETAILS_SETTINGS;data=package:${packageName};end`;
|
|
const intentUrl2 = `intent://settings/app_detail?package=${packageName}#Intent;scheme=android-app;end`;
|
|
|
|
logger.info(
|
|
`[CapacitorPlatformService] Opening Android app settings for ${packageName}`,
|
|
);
|
|
|
|
// Log current permission state before opening settings
|
|
try {
|
|
const currentPerms = await this.checkNotificationPermissions();
|
|
logger.info(
|
|
`[CapacitorPlatformService] Current permission state before opening settings:`,
|
|
currentPerms,
|
|
);
|
|
} catch (e) {
|
|
logger.warn(
|
|
`[CapacitorPlatformService] Could not check permissions before opening settings:`,
|
|
e,
|
|
);
|
|
}
|
|
|
|
// Try multiple approaches to ensure it works
|
|
try {
|
|
// Method 1: Direct window.location.href (most reliable)
|
|
window.location.href = intentUrl1;
|
|
|
|
// Method 2: Fallback with window.open
|
|
setTimeout(() => {
|
|
try {
|
|
window.open(intentUrl1, "_blank");
|
|
} catch (e) {
|
|
logger.warn(
|
|
"[CapacitorPlatformService] window.open fallback failed:",
|
|
e,
|
|
);
|
|
}
|
|
}, 100);
|
|
|
|
// Method 3: Alternative format
|
|
setTimeout(() => {
|
|
try {
|
|
window.location.href = intentUrl2;
|
|
} catch (e) {
|
|
logger.warn(
|
|
"[CapacitorPlatformService] Alternative format failed:",
|
|
e,
|
|
);
|
|
}
|
|
}, 200);
|
|
} catch (e) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to open intent URL:",
|
|
e,
|
|
);
|
|
}
|
|
} else if (platform === "ios") {
|
|
// iOS: Use app settings URL scheme
|
|
const settingsUrl = `app-settings:`;
|
|
window.location.href = settingsUrl;
|
|
|
|
logger.info("[CapacitorPlatformService] Opening iOS app settings");
|
|
} else {
|
|
logger.warn(
|
|
`[CapacitorPlatformService] Cannot open settings on platform: ${platform}`,
|
|
);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
"[CapacitorPlatformService] Failed to open app notification settings:",
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Database utility methods - inherited from BaseDatabaseService
|
|
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
|
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
|
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
|
|
}
|
|
|