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.
 
 
 
 
 
 

510 lines
15 KiB

import {
ImageResult,
PlatformService,
PlatformCapabilities
} from '../PlatformService'
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem'
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
import { Share } from '@capacitor/share'
import { logger } from '../../utils/logger'
import { Clipboard } from '@capacitor/clipboard'
/**
* 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 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 {
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 {
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()
}
/**
* Writes text to the system clipboard using Capacitor's Clipboard plugin.
* @param text - The text to write to the clipboard
* @returns Promise that resolves when the write is complete
*/
async writeToClipboard(text: string): Promise<void> {
try {
await Clipboard.write({
string: text
})
} catch (error) {
logger.error('Error writing to clipboard:', error)
throw new Error('Failed to write to clipboard')
}
}
/**
* Reads text from the system clipboard using Capacitor's Clipboard plugin.
* @returns Promise resolving to the clipboard text
*/
async readFromClipboard(): Promise<string> {
try {
const { value } = await Clipboard.read()
return value
} catch (error) {
logger.error('Error reading from clipboard:', error)
throw new Error('Failed to read from clipboard')
}
}
}