timesafari
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.
 
 
 

455 lines
14 KiB

import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { FilePicker } from "@capawesome/capacitor-file-picker";
import { logger } from "../../utils/logger";
/**
* 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
*/
export class CapacitorPlatformService implements PlatformService {
/**
* 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 user-selected directory.
* Opens a directory picker for the user to choose where to save.
* @param path - Suggested filename
* @param content - Content to write to the file
* @throws Error if write operation fails
*/
async writeFile(path: string, content: string): Promise<void> {
try {
const logData = {
targetPath: path,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log(
"Starting writeFile operation",
JSON.stringify(logData, null, 2),
);
// Check and request storage permissions if needed
await this.checkStoragePermissions();
// Let user pick save location first
const result = await FilePicker.pickDirectory();
const pickerLogData = {
path: result.path,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log("FilePicker result:", JSON.stringify(pickerLogData, null, 2));
// Handle paths based on platform
let cleanPath = result.path;
if (this.getCapabilities().isIOS) {
const iosLogData = {
originalPath: cleanPath,
timestamp: new Date().toISOString(),
};
logger.log("Processing iOS path", JSON.stringify(iosLogData, null, 2));
cleanPath = result.path;
} else {
const androidLogData = {
originalPath: cleanPath,
timestamp: new Date().toISOString(),
};
logger.log(
"Processing Android path",
JSON.stringify(androidLogData, null, 2),
);
// For Android, use the content URI directly
if (cleanPath.startsWith("content://")) {
const uriLogData = {
uri: cleanPath,
filename: path,
timestamp: new Date().toISOString(),
};
logger.log(
"Using content URI for Android:",
JSON.stringify(uriLogData, null, 2),
);
// Extract the document ID from the content URI
const docIdMatch = cleanPath.match(/tree\/(.*?)(?:\/|$)/);
if (docIdMatch) {
const docId = docIdMatch[1];
const docIdLogData = {
docId,
timestamp: new Date().toISOString(),
};
logger.log(
"Extracted document ID:",
JSON.stringify(docIdLogData, null, 2),
);
// Use the document ID as the path
cleanPath = docId;
}
}
}
const finalPath = cleanPath;
const finalPathLogData = {
fullPath: finalPath,
filename: path,
timestamp: new Date().toISOString(),
};
logger.log(
"Final path details:",
JSON.stringify(finalPathLogData, null, 2),
);
// Write to the selected directory
const writeLogData = {
path: finalPath,
contentLength: content.length,
timestamp: new Date().toISOString(),
};
logger.log(
"Attempting file write:",
JSON.stringify(writeLogData, null, 2),
);
try {
if (this.getCapabilities().isIOS) {
await Filesystem.writeFile({
path: finalPath,
data: content,
directory: Directory.Documents,
recursive: true,
encoding: Encoding.UTF8,
});
} else {
// For Android, use the content URI directly
const androidPath = `Download/${path}`;
const directoryLogData = {
path: androidPath,
directory: Directory.ExternalStorage,
timestamp: new Date().toISOString(),
};
logger.log(
"Android path configuration:",
JSON.stringify(directoryLogData, null, 2),
);
await Filesystem.writeFile({
path: androidPath,
data: content,
directory: Directory.ExternalStorage,
recursive: true,
encoding: Encoding.UTF8,
});
}
const writeSuccessLogData = {
path: finalPath,
timestamp: new Date().toISOString(),
};
logger.log(
"File write successful",
JSON.stringify(writeSuccessLogData, null, 2),
);
} catch (writeError: unknown) {
const error = writeError as Error;
const writeErrorLogData = {
error: {
message: error.message,
name: error.name,
stack: error.stack,
},
path: finalPath,
contentLength: content.length,
timestamp: new Date().toISOString(),
};
logger.error(
"File write failed:",
JSON.stringify(writeErrorLogData, null, 2),
);
throw new Error(`Failed to write file: ${error.message}`);
}
} 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 to selected location: ${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();
}
}