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.
742 lines
21 KiB
742 lines
21 KiB
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
|
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
|
|
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" | "getOneRow" | "getAll";
|
|
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 {
|
|
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: [], // SQLite plugin doesn't provide column names
|
|
values: queryResult.values || [],
|
|
};
|
|
break;
|
|
}
|
|
case "getOneRow": {
|
|
const oneRowResult = await this.db.query(
|
|
operation.sql,
|
|
operation.params,
|
|
);
|
|
result = oneRowResult.values?.[0];
|
|
break;
|
|
}
|
|
case "getAll": {
|
|
const allResult = await this.db.query(
|
|
operation.sql,
|
|
operation.params,
|
|
);
|
|
result = allResult.values || [];
|
|
break;
|
|
}
|
|
}
|
|
operation.resolve(result);
|
|
} catch (error) {
|
|
// make sure you don't try to log to the DB... infinite loop!
|
|
// eslint-disable-next-line no-console
|
|
console.error(
|
|
"[CapacitorPlatformService] Error while processing SQL queue:",
|
|
error,
|
|
" ... for sql:",
|
|
operation.sql,
|
|
" ... with params:",
|
|
operation.params,
|
|
);
|
|
operation.reject(error);
|
|
}
|
|
}
|
|
|
|
this.isProcessingQueue = false;
|
|
}
|
|
|
|
private async queueOperation<R>(
|
|
type: QueuedOperation["type"],
|
|
sql: string,
|
|
params: unknown[] = [],
|
|
): Promise<R> {
|
|
return new Promise<R>((resolve, reject) => {
|
|
const operation: QueuedOperation = {
|
|
type,
|
|
sql,
|
|
params,
|
|
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 extractMigrationNames: (result: DBSQLiteValues) => Set<string> = (
|
|
result,
|
|
) => {
|
|
const names =
|
|
result.values?.map((row: { name: string }) => row.name) || [];
|
|
return new Set(names);
|
|
};
|
|
const sqlExec: (sql: string) => Promise<capSQLiteChanges> =
|
|
this.db.execute.bind(this.db);
|
|
const sqlQuery: (sql: string) => Promise<DBSQLiteValues> =
|
|
this.db.query.bind(this.db);
|
|
runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
|
}
|
|
|
|
/**
|
|
* Gets the capabilities of the Capacitor platform
|
|
* @returns Platform capabilities object
|
|
*/
|
|
getCapabilities(): PlatformCapabilities {
|
|
return {
|
|
hasFileSystem: true,
|
|
hasCamera: true,
|
|
isMobile: true,
|
|
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
|
hasFileDownload: false,
|
|
needsFileHandlingInstructions: 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,
|
|
});
|
|
|
|
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" });
|
|
}
|
|
|
|
/**
|
|
* 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 || [],
|
|
);
|
|
}
|
|
}
|
|
|