Browse Source

feat(backup-browser): add folder navigation, breadcrumbs, and debug mode for file discovery

- Implement folder navigation with breadcrumbs in BackupFilesList
- Distinguish files and folders in UI, allow folder navigation
- Add debug mode to forcibly treat all entries as files for diagnosis
- Add detailed debug logging to file discovery (readdir, stat, entries)
- Show warning in UI when debug mode is active
- Prepare for further improvements to handle stat failures gracefully

Co-authored-by: Matthew Raymer
capacitor-local-save
Matthew Raymer 2 days ago
parent
commit
2635c22c33
  1. 597
      src/components/BackupFilesList.vue
  2. 43
      src/components/DataExportSection.vue
  3. 4
      src/components/ImageMethodDialog.vue
  4. 4
      src/components/PhotoDialog.vue
  5. 42
      src/services/PlatformService.ts
  6. 826
      src/services/platforms/CapacitorPlatformService.ts
  7. 54
      src/services/platforms/ElectronPlatformService.ts
  8. 130
      src/services/platforms/PyWebViewPlatformService.ts
  9. 58
      src/services/platforms/WebPlatformService.ts
  10. 19
      src/utils/logger.ts
  11. 82
      src/views/TestView.vue

597
src/components/BackupFilesList.vue

@ -0,0 +1,597 @@
/**
* Backup Files List Component
*
* Displays a list of backup files saved by the app and provides options to:
* - View backup files by type (contacts, seed, other)
* - Open individual files in the device's file viewer
* - Access the backup directory in the device's file explorer
*
* @component
* @displayName BackupFilesList
* @example
* ```vue
* <BackupFilesList />
* ```
*/
<template>
<div class="backup-files-list">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Backup Files</h3>
<div class="flex gap-2">
<button
v-if="platformCapabilities.hasFileSystem"
@click="refreshFiles()"
class="text-sm bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
>
<font-awesome
icon="refresh"
class="fa-fw"
:class="{ 'animate-spin': isLoading }"
/>
Refresh
</button>
<button
v-if="platformCapabilities.hasFileSystem"
@click="openBackupDirectory()"
class="text-sm bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
>
<font-awesome icon="folder-open" class="fa-fw" />
Open Directory
</button>
<button
v-if="platformCapabilities.hasFileSystem && isDevelopment"
@click="debugFileDiscovery()"
class="text-sm bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
title="Debug file discovery (development only)"
>
<font-awesome icon="bug" class="fa-fw" />
Debug
</button>
</div>
</div>
<div v-if="isLoading" class="text-center py-4">
<font-awesome icon="spinner" class="animate-spin fa-2x" />
<p class="mt-2">Loading backup files...</p>
</div>
<div v-else-if="backupFiles.length === 0" class="text-center py-4 text-gray-500">
<font-awesome icon="folder-open" class="fa-2x mb-2" />
<p>No backup files found</p>
<p class="text-sm mt-1">Create backups using the export functions above</p>
</div>
<div v-else class="space-y-2">
<!-- File Type Filter -->
<div class="flex gap-2 mb-3">
<button
v-for="type in ['all', 'contacts', 'seed', 'other'] as const"
:key="type"
@click="selectedType = type"
:class="[
'text-sm px-3 py-1 rounded border',
selectedType === type
? 'bg-blue-500 text-white border-blue-500'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
]"
>
{{ type === 'all' ? 'All' : type.charAt(0).toUpperCase() + type.slice(1) }}
<span class="ml-1 text-xs">
({{ getFileCountByType(type) }})
</span>
</button>
</div>
<!-- Files List -->
<div class="flex items-center gap-2 mb-2">
<span v-for="(crumb, idx) in breadcrumbs" :key="idx">
<span
class="text-blue-600 cursor-pointer underline"
@click="goToBreadcrumb(idx)"
v-if="idx < breadcrumbs.length - 1"
>
{{ crumb }}
</span>
<span v-else class="font-bold">{{ crumb }}</span>
<span v-if="idx < breadcrumbs.length - 1"> / </span>
</span>
</div>
<div v-if="currentPath.length > 1" class="mb-2">
<button @click="goUp" class="text-xs text-blue-500 underline"> Up</button>
</div>
<div class="mb-2">
<label class="inline-flex items-center">
<input type="checkbox" v-model="debugShowAll" @change="loadDirectory" class="mr-2" />
<span class="text-xs">Debug: Show all entries as files</span>
</label>
<span v-if="debugShowAll" class="text-xs text-red-600 ml-2">[Debug mode: forcibly treating all entries as files]</span>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div
v-for="entry in folders"
:key="'folder-' + entry.path"
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer"
@click="openFolder(entry)"
>
<div class="flex items-center gap-2">
<font-awesome icon="folder" class="fa-fw text-yellow-500" />
<span class="font-medium">{{ entry.name }}</span>
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-0.5 rounded-full ml-2">Folder</span>
</div>
</div>
<div
v-for="entry in files"
:key="'file-' + entry.path"
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<font-awesome icon="file-alt" class="fa-fw text-gray-500" />
<span class="font-medium truncate">{{ entry.name }}</span>
</div>
<div class="text-sm text-gray-500 mt-1">
<span v-if="entry.size">{{ formatFileSize(entry.size) }}</span>
<span v-else>Size unknown</span>
<span v-if="entry.path && !platformCapabilities.isIOS" class="ml-2 text-xs text-blue-600">📁 {{ entry.path }}</span>
</div>
</div>
<div class="flex gap-2 ml-3">
<button
@click="openFile(entry.uri, entry.name)"
class="text-blue-500 hover:text-blue-700 p-1"
title="Open file"
>
<font-awesome icon="external-link-alt" class="fa-fw" />
</button>
</div>
</div>
</div>
<!-- Summary -->
<div class="text-sm text-gray-500 mt-3 pt-3 border-t">
Showing {{ filteredFiles.length }} of {{ backupFiles.length }} backup files
</div>
<div class="text-sm text-gray-600 mb-2">
<p>📁 Backup files are saved to persistent storage that survives app installations:</p>
<ul class="list-disc list-inside ml-2 mt-1 text-xs">
<li v-if="platformCapabilities.isIOS">iOS: Documents folder (accessible via Files app)</li>
<li v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS">Android: Downloads/TimeSafari or external storage (accessible via file managers)</li>
<li v-if="!platformCapabilities.isMobile">Desktop: User's download directory</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
/**
* @vue-component
* Backup Files List Component
* Displays and manages backup files with platform-specific functionality
*/
@Component
export default class BackupFilesList extends Vue {
/**
* Notification function injected by Vue
* Used to show success/error messages to the user
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Platform service instance for platform-specific operations
*/
private platformService: PlatformService = PlatformServiceFactory.getInstance();
/**
* Platform capabilities for the current platform
*/
private get platformCapabilities(): PlatformCapabilities {
return this.platformService.getCapabilities();
}
/**
* List of backup files found on the device
*/
backupFiles: Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}> = [];
/**
* Currently selected file type filter
*/
selectedType: 'all' | 'contacts' | 'seed' | 'other' = 'all';
/**
* Loading state for file operations
*/
isLoading = false;
/**
* Interval for periodic refresh (5 minutes)
*/
private refreshInterval: number | null = null;
/**
* Current path for folder navigation (array for breadcrumbs)
*/
currentPath: string[] = [];
/**
* List of files/folders in the current directory
*/
directoryEntries: Array<{name: string, uri: string, size?: number, path: string, type: 'file' | 'folder'}> = [];
/**
* Temporary debug mode to show all entries as files
*/
debugShowAll = false;
/**
* Lifecycle hook to load backup files when component is mounted
*/
async mounted() {
if (this.platformCapabilities.hasFileSystem) {
// Set default root path
if (this.platformCapabilities.isIOS) {
this.currentPath = ['.'];
} else {
this.currentPath = ['Download', 'TimeSafari'];
}
await this.loadDirectory();
this.refreshInterval = window.setInterval(() => {
this.loadDirectory();
}, 5 * 60 * 1000);
}
}
/**
* Lifecycle hook to clean up resources when component is unmounted
*/
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
/**
* Computed property for filtered files based on selected type
*/
get filteredFiles() {
if (this.selectedType === 'all') {
return this.backupFiles;
}
return this.backupFiles.filter(file => file.type === this.selectedType);
}
/**
* Computed property to check if we're in development mode
*/
get isDevelopment(): boolean {
return import.meta.env.DEV;
}
/**
* Load the current directory entries
*/
async loadDirectory() {
if (!this.platformCapabilities.hasFileSystem) return;
this.isLoading = true;
try {
const path = this.currentPath.join('/') || (this.platformCapabilities.isIOS ? '.' : 'Download/TimeSafari');
this.directoryEntries = await (this.platformService as any).listFilesInDirectory(path, this.debugShowAll);
logger.log('[BackupFilesList] Loaded directory:', { path, entries: this.directoryEntries });
} catch (error) {
logger.error('[BackupFilesList] Failed to load directory:', error);
this.directoryEntries = [];
} finally {
this.isLoading = false;
}
}
/**
* Navigate into a folder
*/
async openFolder(entry: { name: string; path: string }) {
this.currentPath.push(entry.name);
await this.loadDirectory();
}
/**
* Navigate to a breadcrumb
*/
async goToBreadcrumb(index: number) {
this.currentPath = this.currentPath.slice(0, index + 1);
await this.loadDirectory();
}
/**
* Go up one directory
*/
async goUp() {
if (this.currentPath.length > 1) {
this.currentPath.pop();
await this.loadDirectory();
}
}
/**
* Computed property for breadcrumbs
*/
get breadcrumbs() {
return this.currentPath;
}
/**
* Computed property for showing files and folders
*/
get folders() {
return this.directoryEntries.filter(e => e.type === 'folder');
}
get files() {
return this.directoryEntries.filter(e => e.type === 'file');
}
/**
* Refreshes the list of backup files from the device
*/
async refreshFiles() {
if (!this.platformCapabilities.hasFileSystem) {
return;
}
this.isLoading = true;
try {
this.backupFiles = await this.platformService.listBackupFiles();
logger.log("[BackupFilesList] Refreshed backup files:", {
count: this.backupFiles.length,
files: this.backupFiles.map(f => ({
name: f.name,
type: f.type,
path: f.path,
size: f.size
})),
platform: this.platformCapabilities.isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Debug: Log file type distribution
const typeCounts = {
contacts: this.backupFiles.filter(f => f.type === 'contacts').length,
seed: this.backupFiles.filter(f => f.type === 'seed').length,
other: this.backupFiles.filter(f => f.type === 'other').length,
total: this.backupFiles.length
};
logger.log("[BackupFilesList] File type distribution:", typeCounts);
} catch (error) {
logger.error("[BackupFilesList] Failed to refresh backup files:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Files",
text: "Failed to load backup files from your device.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
/**
* Public method to refresh files from external components
* Used by DataExportSection to refresh after saving new files
*/
public async refreshAfterSave() {
logger.log("[BackupFilesList] Refreshing files after save operation");
await this.refreshFiles();
}
/**
* Opens a specific file in the device's file viewer
* @param fileUri - URI of the file to open
* @param fileName - Name of the file for display
*/
async openFile(fileUri: string, fileName: string) {
try {
const result = await this.platformService.openFile(fileUri, fileName);
if (result.success) {
logger.log("[BackupFilesList] File opened successfully:", {
fileName,
timestamp: new Date().toISOString(),
});
} else {
throw new Error(result.error || "Failed to open file");
}
} catch (error) {
logger.error("[BackupFilesList] Failed to open file:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Opening File",
text: `Failed to open ${fileName}. ${error instanceof Error ? error.message : String(error)}`,
},
5000,
);
}
}
/**
* Opens the backup directory in the device's file explorer
*/
async openBackupDirectory() {
try {
const result = await this.platformService.openBackupDirectory();
if (result.success) {
logger.log("[BackupFilesList] Backup directory opened successfully:", {
timestamp: new Date().toISOString(),
});
} else {
throw new Error(result.error || "Failed to open backup directory");
}
} catch (error) {
logger.error("[BackupFilesList] Failed to open backup directory:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Opening Directory",
text: `Failed to open backup directory. ${error instanceof Error ? error.message : String(error)}`,
},
5000,
);
}
}
/**
* Gets the count of files for a specific type
* @param type - File type to count
* @returns Number of files of the specified type
*/
getFileCountByType(type: 'all' | 'contacts' | 'seed' | 'other'): number {
if (type === 'all') {
return this.backupFiles.length;
}
return this.backupFiles.filter(file => file.type === type).length;
}
/**
* Gets the appropriate icon for a file type
* @param type - File type
* @returns FontAwesome icon name
*/
getFileIcon(type: 'contacts' | 'seed' | 'other'): string {
switch (type) {
case 'contacts':
return 'address-book';
case 'seed':
return 'key';
default:
return 'file-alt';
}
}
/**
* Gets the appropriate icon color for a file type
* @param type - File type
* @returns CSS color class
*/
getFileIconColor(type: 'contacts' | 'seed' | 'other'): string {
switch (type) {
case 'contacts':
return 'text-blue-500';
case 'seed':
return 'text-orange-500';
default:
return 'text-gray-500';
}
}
/**
* Gets the appropriate badge color for a file type
* @param type - File type
* @returns CSS color class
*/
getTypeBadgeColor(type: 'contacts' | 'seed' | 'other'): string {
switch (type) {
case 'contacts':
return 'bg-blue-100 text-blue-800';
case 'seed':
return 'bg-orange-100 text-orange-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
/**
* Formats file size in human-readable format
* @param bytes - File size in bytes
* @returns Formatted file size string
*/
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Debug method to test file discovery
* Can be called from browser console for troubleshooting
*/
public async debugFileDiscovery() {
try {
logger.log("[BackupFilesList] Starting debug file discovery...");
// Test the platform service's test methods
const platformService = PlatformServiceFactory.getInstance();
// Test listing all user files
const allFilesResult = await platformService.testListUserFiles();
logger.log("[BackupFilesList] All user files test result:", allFilesResult);
// Test listing backup files specifically
const backupFilesResult = await platformService.testBackupFiles();
logger.log("[BackupFilesList] Backup files test result:", backupFilesResult);
// Test listing all backup files (if available)
if ('testListAllBackupFiles' in platformService) {
const allBackupFilesResult = await (platformService as any).testListAllBackupFiles();
logger.log("[BackupFilesList] All backup files test result:", allBackupFilesResult);
}
// Test debug listing all files without filtering (if available)
if ('debugListAllFiles' in platformService) {
const debugAllFiles = await (platformService as any).debugListAllFiles();
logger.log("[BackupFilesList] Debug all files (no filtering):", {
count: debugAllFiles.length,
files: debugAllFiles.map((f: any) => ({ name: f.name, path: f.path, size: f.size }))
});
}
// Test comprehensive step-by-step debug (if available)
if ('debugFileDiscoveryStepByStep' in platformService) {
const stepByStepDebug = await (platformService as any).debugFileDiscoveryStepByStep();
logger.log("[BackupFilesList] Step-by-step debug output:", stepByStepDebug);
}
return {
allFiles: allFilesResult,
backupFiles: backupFilesResult,
currentBackupFiles: this.backupFiles,
debugAllFiles: 'debugListAllFiles' in platformService ? await (platformService as any).debugListAllFiles() : null
};
} catch (error) {
logger.error("[BackupFilesList] Debug file discovery failed:", error);
throw error;
}
}
@Watch('platformCapabilities.hasFileSystem', { immediate: true })
async onFileSystemCapabilityChanged(newVal: boolean) {
if (newVal) {
await this.refreshFiles();
}
}
}
</script>

43
src/components/DataExportSection.vue

@ -1,7 +1,8 @@
/** * Data Export Section Component * * Provides UI and functionality for
exporting user data and backing up identifier seeds. * Includes buttons for seed
backup and database export, with platform-specific download instructions. * *
@component * @displayName DataExportSection * @example * ```vue *
backup and database export, with platform-specific download instructions. * Also
displays a list of backup files with options to open them in the device's file
explorer. * * @component * @displayName DataExportSection * @example * ```vue *
<DataExportSection :active-did="currentDid" />
* ``` */
@ -43,18 +44,21 @@ backup and database export, with platform-specific download instructions. * *
v-if="platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On iOS: You will be prompted to choose a location to save your backup
file.
On iOS: Files are saved to Documents folder (accessible via Files app) and persist between app installations.
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On Android: You will be prompted to choose a location to save your
backup file.
On Android: Files are saved to Downloads/TimeSafari or external storage (accessible via file managers) and persist between app installations.
</li>
</ul>
</div>
<!-- Backup Files List -->
<div v-if="platformCapabilities.hasFileSystem" class="mt-6 pt-6 border-t border-gray-300">
<BackupFilesList ref="backupFilesList" />
</div>
</div>
</template>
@ -65,20 +69,21 @@ import { AppString, NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { logger } from "../utils/logger";
import { logger, getTimestampForFilename } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
import { contactsToExportJson } from "../libs/util";
import BackupFilesList from "./BackupFilesList.vue";
/**
* @vue-component
* Data Export Section Component
* Handles database export and seed backup functionality with platform-specific behavior
*/
@Component
@Component({ components: { BackupFilesList } })
export default class DataExportSection extends Vue {
/**
* Notification function injected by Vue
@ -151,7 +156,9 @@ export default class DataExportSection extends Vue {
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts.json`;
// Create timestamped filename
const timestamp = getTimestampForFilename();
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`;
if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link
@ -188,10 +195,16 @@ export default class DataExportSection extends Vue {
title: "Export Successful",
text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup."
: "Backup saved to multiple locations. Use the share dialog to access your file and choose where to save it permanently.",
: "Backup saved to persistent storage that survives app installations. Use the share dialog to access your file and choose where to save it permanently.",
},
5000,
);
// Refresh the backup files list
const backupFilesList = this.$refs.backupFilesList as any;
if (backupFilesList && typeof backupFilesList.refreshAfterSave === 'function') {
await backupFilesList.refreshAfterSave();
}
} catch (error) {
logger.error("Export Error:", error);
this.$notify(
@ -225,5 +238,15 @@ export default class DataExportSection extends Vue {
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
};
}
async mounted() {
// Ensure permissions are requested and refresh backup files list on mount
if (this.platformCapabilities.hasFileSystem) {
const backupFilesList = this.$refs.backupFilesList as any;
if (backupFilesList && typeof backupFilesList.refreshFiles === 'function') {
await backupFilesList.refreshFiles();
}
}
}
}
</script>

4
src/components/ImageMethodDialog.vue

@ -268,7 +268,7 @@ import {
} from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { logger, getTimestampForFilename } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import * as databaseUtil from "../db/databaseUtil";
@ -576,7 +576,7 @@ export default class ImageMethodDialog extends Vue {
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`;
this.fileName = `photo-${getTimestampForFilename()}.jpg`;
this.showRetry = true;
this.stopCameraPreview();
}

4
src/components/PhotoDialog.vue

@ -127,7 +127,7 @@ import {
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { logger, getTimestampForFilename } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
@Component({ components: { VuePictureCropper } })
@ -393,7 +393,7 @@ export default class PhotoDialog extends Vue {
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`;
this.fileName = `photo-${getTimestampForFilename()}.jpg`;
this.stopCameraPreview();
}
},

42
src/services/PlatformService.ts

@ -125,6 +125,18 @@ export interface PlatformService {
*/
testListUserFiles(): Promise<string>;
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
testBackupFiles(): Promise<string>;
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
testOpenBackupDirectory(): Promise<string>;
// Camera operations
/**
* Activates the device camera to take a picture.
@ -172,4 +184,34 @@ export interface PlatformService {
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }>;
/**
* Lists user-accessible files saved by the app.
* Returns files from Downloads (Android) or Documents (iOS) directories.
* @returns Promise resolving to array of file information
*/
listUserAccessibleFiles(): Promise<Array<{name: string, uri: string, size?: number}>>;
/**
* Lists backup files specifically saved by the app.
* Filters for files that appear to be TimeSafari backups.
* @returns Promise resolving to array of backup file information
*/
listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>>;
/**
* Opens a file in the device's default file viewer/app.
* Uses the native share dialog to provide options for opening the file.
* @param fileUri - URI of the file to open
* @param fileName - Name of the file (for display purposes)
* @returns Promise resolving to success status
*/
openFile(fileUri: string, fileName: string): Promise<{ success: boolean; error?: string }>;
/**
* Opens the directory containing backup files in the device's file explorer.
* Uses the native share dialog to provide options for accessing the directory.
* @returns Promise resolving to success status
*/
openBackupDirectory(): Promise<{ success: boolean; error?: string }>;
}

826
src/services/platforms/CapacitorPlatformService.ts

@ -22,7 +22,7 @@ import {
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { logger, getTimestampForFilename } from "../../utils/logger";
interface QueuedOperation {
type: "run" | "query";
@ -406,8 +406,8 @@ export class CapacitorPlatformService implements PlatformService {
// Determine save strategy based on options and platform
if (options.allowLocationSelection) {
// Use enhanced location selection with multiple save options
fileUri = await this.saveWithLocationOptions(fileName, content, options.mimeType, options.showLocationSelectionDialog);
// Use true user choice for file location
fileUri = await this.saveWithUserChoice(fileName, content, options.mimeType);
saved = true;
} else if (options.saveToPrivateStorage) {
// Save to app-private storage (for sensitive data)
@ -570,7 +570,7 @@ export class CapacitorPlatformService implements PlatformService {
});
// First, save the file to a temporary location
const tempFileName = `temp_${Date.now()}_${fileName}`;
const tempFileName = `temp-${getTimestampForFilename()}-${fileName}`;
const tempResult = await Filesystem.writeFile({
path: tempFileName,
data: content,
@ -634,7 +634,7 @@ export class CapacitorPlatformService implements PlatformService {
locations.push(primaryLocation);
// Save to app data directory as backup
const backupFileName = `backup_${Date.now()}_${fileName}`;
const backupFileName = `backup-${getTimestampForFilename()}-${fileName}`;
const backupResult = await Filesystem.writeFile({
path: backupFileName,
data: content,
@ -689,8 +689,9 @@ export class CapacitorPlatformService implements PlatformService {
}
/**
* Saves a file directly to the Downloads folder (Android) or Documents (iOS).
* These locations are user-accessible through file managers and the app.
* Saves a file directly to user-accessible storage that persists between installations.
* On Android: Saves to external storage (Downloads or app-specific directory)
* On iOS: Saves to Documents directory (accessible via Files app)
* @param fileName - Name of the file to save
* @param content - File content
* @returns Promise resolving to the saved file URI
@ -698,7 +699,7 @@ export class CapacitorPlatformService implements PlatformService {
private async saveToDownloads(fileName: string, content: string): Promise<string> {
try {
if (this.getCapabilities().isIOS) {
// iOS: Save to Documents directory (user accessible)
// iOS: Save to Documents directory (persists between installations, accessible via Files app)
const result = await Filesystem.writeFile({
path: fileName,
data: content,
@ -706,40 +707,66 @@ export class CapacitorPlatformService implements PlatformService {
encoding: Encoding.UTF8,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
logger.log("[CapacitorPlatformService] File saved to iOS Documents (persistent):", {
uri: result.uri,
fileName,
note: "File persists between app installations and is accessible via Files app",
timestamp: new Date().toISOString(),
});
return result.uri;
} else {
// Android: Save to app's external storage (accessible via file managers)
// Due to Android 11+ restrictions, we can't directly write to public Downloads
// Users can access files through file managers or use share dialog to save to Downloads
const downloadsPath = `TimeSafari/${fileName}`;
const result = await Filesystem.writeFile({
path: downloadsPath,
data: content,
directory: Directory.External, // App's external storage (accessible via file managers)
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to Android external storage:", {
uri: result.uri,
fileName,
downloadsPath,
note: "File is accessible via file managers. Use share dialog to save to Downloads.",
timestamp: new Date().toISOString(),
});
return result.uri;
// Android: Save to external storage that persists between installations
// Try to save to Downloads first, then fallback to app's external storage
try {
// Attempt to save to Downloads directory (most accessible)
const downloadsPath = `Download/TimeSafari/${fileName}`;
const result = await Filesystem.writeFile({
path: downloadsPath,
data: content,
directory: Directory.ExternalStorage, // External storage (persists between installations)
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to Android Downloads (persistent):", {
uri: result.uri,
fileName,
downloadsPath,
note: "File persists between app installations and is accessible via file managers",
timestamp: new Date().toISOString(),
});
return result.uri;
} catch (downloadsError) {
logger.warn("[CapacitorPlatformService] Could not save to Downloads, using app external storage:", downloadsError);
// Fallback: Save to app's external storage directory
const appStoragePath = `TimeSafari/${fileName}`;
const result = await Filesystem.writeFile({
path: appStoragePath,
data: content,
directory: Directory.ExternalStorage, // External storage (persists between installations)
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to Android app external storage (persistent):", {
uri: result.uri,
fileName,
appStoragePath,
note: "File persists between app installations and is accessible via file managers",
timestamp: new Date().toISOString(),
});
return result.uri;
}
}
} catch (error) {
logger.error("[CapacitorPlatformService] Save to downloads failed:", error);
throw new Error(`Failed to save to downloads: ${error}`);
logger.error("[CapacitorPlatformService] Save to persistent storage failed:", error);
throw new Error(`Failed to save to persistent storage: ${error}`);
}
}
@ -772,41 +799,78 @@ export class CapacitorPlatformService implements PlatformService {
}
/**
* Lists user-accessible files saved by the app.
* Returns files from Downloads (Android) or Documents (iOS) directories.
* Lists user-accessible files saved by the app from persistent storage locations.
* Returns files from persistent storage that survive app installations.
* @returns Promise resolving to array of file information
*/
async listUserAccessibleFiles(): Promise<Array<{name: string, uri: string, size?: number}>> {
try {
if (this.getCapabilities().isIOS) {
// iOS: List files in Documents directory
// iOS: List files in Documents directory (persistent, accessible via Files app)
const result = await Filesystem.readdir({
path: ".",
directory: Directory.Documents,
});
logger.log("[CapacitorPlatformService] Files in iOS Documents:", {
files: result.files.map((file) => (typeof file === "string" ? file : file.name)),
timestamp: new Date().toISOString(),
});
return result.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
}));
} else {
// Android: List files in app's external storage (TimeSafari subdirectory)
// Android: List files from persistent storage locations
const allFiles: Array<{name: string, uri: string, size?: number}> = [];
// Try to list files from Downloads/TimeSafari directory
try {
const result = await Filesystem.readdir({
path: "TimeSafari",
directory: Directory.External,
const downloadsResult = await Filesystem.readdir({
path: "Download/TimeSafari",
directory: Directory.ExternalStorage,
});
return result.files.map((file) => ({
logger.log("[CapacitorPlatformService] Files in Downloads/TimeSafari:", {
files: downloadsResult.files.map((file) => (typeof file === "string" ? file : file.name)),
timestamp: new Date().toISOString(),
});
const downloadFiles = downloadsResult.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
}));
allFiles.push(...downloadFiles);
} catch (downloadsError) {
logger.warn("[CapacitorPlatformService] Could not read external storage directory:", downloadsError);
return [];
logger.warn("[CapacitorPlatformService] Could not read Downloads/TimeSafari directory:", downloadsError);
}
// Try to list files from app's external storage directory
try {
const appStorageResult = await Filesystem.readdir({
path: "TimeSafari",
directory: Directory.ExternalStorage,
});
logger.log("[CapacitorPlatformService] Files in TimeSafari (external storage):", {
files: appStorageResult.files.map((file) => (typeof file === "string" ? file : file.name)),
timestamp: new Date().toISOString(),
});
const appStorageFiles = appStorageResult.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
}));
allFiles.push(...appStorageFiles);
} catch (appStorageError) {
logger.warn("[CapacitorPlatformService] Could not read app external storage directory:", appStorageError);
}
// Remove duplicates based on filename
const uniqueFiles = allFiles.filter((file, index, self) =>
index === self.findIndex(f => f.name === file.name)
);
logger.log("[CapacitorPlatformService] Total unique files found in persistent storage:", {
total: uniqueFiles.length,
files: uniqueFiles.map(f => f.name),
timestamp: new Date().toISOString(),
});
return uniqueFiles;
}
} catch (error) {
logger.error("[CapacitorPlatformService] Failed to list user accessible files:", error);
@ -814,6 +878,49 @@ export class CapacitorPlatformService implements PlatformService {
}
}
/**
* Test method: List all files in backup directories for debugging
*/
async testListAllBackupFiles(): Promise<string> {
try {
let output = '';
if (this.getCapabilities().isIOS) {
const result = await Filesystem.readdir({
path: ".",
directory: Directory.Documents,
});
output += `iOS Documents files:\n`;
output += result.files.map((file) => (typeof file === "string" ? file : file.name)).join("\n");
} else {
output += `Android Downloads/TimeSafari files:\n`;
try {
const downloadsResult = await Filesystem.readdir({
path: "Download/TimeSafari",
directory: Directory.ExternalStorage,
});
output += downloadsResult.files.map((file) => (typeof file === "string" ? file : file.name)).join("\n");
} catch (e) {
output += "(Could not read Downloads/TimeSafari)\n";
}
output += `\nAndroid TimeSafari (external storage) files:\n`;
try {
const appStorageResult = await Filesystem.readdir({
path: "TimeSafari",
directory: Directory.ExternalStorage,
});
output += appStorageResult.files.map((file) => (typeof file === "string" ? file : file.name)).join("\n");
} catch (e) {
output += "(Could not read TimeSafari external storage)\n";
}
}
logger.log("[CapacitorPlatformService] testListAllBackupFiles output:\n" + output);
return output;
} catch (error) {
logger.error("[CapacitorPlatformService] testListAllBackupFiles error:", error);
return `Error: ${error}`;
}
}
/**
* Tests the file sharing functionality by creating and sharing a test file.
* @returns Promise resolving to a test result message
@ -827,7 +934,7 @@ export class CapacitorPlatformService implements PlatformService {
test: true
};
const fileName = `timesafari-test-${Date.now()}.json`;
const fileName = `timesafari-test-${getTimestampForFilename()}.json`;
const content = JSON.stringify(testContent, null, 2);
const result = await this.writeAndShareFile(fileName, content, {
@ -859,7 +966,7 @@ export class CapacitorPlatformService implements PlatformService {
saveOnly: true
};
const fileName = `timesafari-save-only-${Date.now()}.json`;
const fileName = `timesafari-save-only-${getTimestampForFilename()}.json`;
const content = JSON.stringify(testContent, null, 2);
const result = await this.writeAndShareFile(fileName, content, {
@ -892,7 +999,7 @@ export class CapacitorPlatformService implements PlatformService {
locationSelection: true
};
const fileName = `timesafari-location-test-${Date.now()}.json`;
const fileName = `timesafari-location-test-${getTimestampForFilename()}.json`;
const content = JSON.stringify(testContent, null, 2);
// Use the FilePicker to let user choose where to save the file
@ -920,7 +1027,7 @@ export class CapacitorPlatformService implements PlatformService {
silent: true
};
const fileName = `timesafari-silent-location-test-${Date.now()}.json`;
const fileName = `timesafari-silent-location-test-${getTimestampForFilename()}.json`;
const content = JSON.stringify(testContent, null, 2);
const result = await this.writeAndShareFile(fileName, content, {
@ -959,7 +1066,7 @@ export class CapacitorPlatformService implements PlatformService {
const blob = await this.processImageData(image.base64String);
return {
blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`,
fileName: `photo-${getTimestampForFilename()}.${image.format || "jpg"}`,
};
} catch (error) {
logger.error("Error taking picture with Capacitor:", error);
@ -985,7 +1092,7 @@ export class CapacitorPlatformService implements PlatformService {
const blob = await this.processImageData(image.base64String);
return {
blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`,
fileName: `photo-${getTimestampForFilename()}.${image.format || "jpg"}`,
};
} catch (error) {
logger.error("Error picking image with Capacitor:", error);
@ -1120,6 +1227,39 @@ export class CapacitorPlatformService implements PlatformService {
}
}
/**
* Saves a file with true user choice of location using FilePicker
* This gives users real control over where their files are saved
* @param fileName - Name of the file to save
* @param content - File content
* @param mimeType - MIME type of the file
* @returns Promise resolving to the saved file URI
*/
private async saveWithUserChoice(fileName: string, content: string, mimeType: string = "application/json"): Promise<string> {
try {
logger.log("[CapacitorPlatformService] Providing true user choice for file location:", {
fileName,
mimeType,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
if (this.getCapabilities().isIOS) {
// iOS: Use the native share dialog which includes "Save to Files" options
// This allows users to choose where to save using the iOS Files app
return await this.saveFileWithPicker(fileName, content, mimeType);
} else {
// Android: Use FilePicker to let user choose the exact directory
return await this.saveFileWithLocationPicker(fileName, content, mimeType);
}
} catch (error) {
logger.error("[CapacitorPlatformService] User choice save failed, falling back to default location:", error);
// Fallback to default location if user choice fails
return await this.saveToDownloads(fileName, content);
}
}
/**
* Saves a file using the FilePicker to let user choose the save location.
* This provides true location selection rather than using a share dialog.
@ -1219,4 +1359,588 @@ export class CapacitorPlatformService implements PlatformService {
return `❌ Failed to list user files: ${err.message}`;
}
}
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
async testBackupFiles(): Promise<string> {
try {
const files = await this.listBackupFiles();
if (files.length === 0) {
return `📁 No backup files found. Try creating some backups first using the export functions.`;
}
const fileList = files.map(file => {
const pathInfo = file.path && !this.getCapabilities().isIOS ? ` (📁 ${file.path})` : '';
return `- ${file.name} (${file.type}) (${file.size ? `${file.size} bytes` : 'size unknown'})${pathInfo}`;
}).join('\n');
return `📁 Found ${files.length} backup file(s):\n${fileList}`;
} catch (error) {
const err = error as Error;
return `❌ Failed to list backup files: ${err.message}`;
}
}
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
async testOpenBackupDirectory(): Promise<string> {
try {
const result = await this.openBackupDirectory();
if (result.success) {
return `✅ Backup directory access dialog opened successfully. Use the share options to access your backup files.`;
} else {
return `❌ Failed to open backup directory: ${result.error}`;
}
} catch (error) {
const err = error as Error;
return `❌ Failed to open backup directory: ${err.message}`;
}
}
/**
* Lists backup files specifically saved by the app.
* Filters for files that appear to be TimeSafari backups.
* @returns Promise resolving to array of backup file information
*/
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
try {
// Use enhanced file discovery to find files regardless of where users saved them
const allFiles = await this.listUserAccessibleFilesEnhanced();
logger.log("[CapacitorPlatformService] All user accessible files found (enhanced):", {
total: allFiles.length,
files: allFiles.map(f => ({ name: f.name, path: f.path, size: f.size })),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Show ALL JSON files and any files with backup-related keywords
const backupFiles = allFiles
.filter(file => {
const name = file.name.toLowerCase();
// Exclude directory-access notification files
if (name.startsWith('timesafari-directory-access-') && name.endsWith('.txt')) {
logger.log("[CapacitorPlatformService] Excluding directory access file:", file.name);
return false;
}
// Check if file matches any backup criteria
const isJson = name.endsWith('.json');
const hasTimeSafari = name.includes('timesafari');
const hasBackup = name.includes('backup');
const hasContacts = name.includes('contacts');
const hasSeed = name.includes('seed');
const hasExport = name.includes('export');
const hasData = name.includes('data');
const isBackupFile = isJson || hasTimeSafari || hasBackup || hasContacts || hasSeed || hasExport || hasData;
if (!isBackupFile) {
logger.log("[CapacitorPlatformService] Excluding file (no backup keywords):", {
name: file.name,
path: file.path,
isJson,
hasTimeSafari,
hasBackup,
hasContacts,
hasSeed,
hasExport,
hasData
});
}
return isBackupFile;
})
.map(file => {
const name = file.name.toLowerCase();
let type: 'contacts' | 'seed' | 'other' = 'other';
// Categorize files based on content
if (name.includes('contacts') || (name.includes('timesafari') && name.includes('backup'))) {
type = 'contacts';
} else if (name.includes('seed') || name.includes('mnemonic') || name.includes('private')) {
type = 'seed';
} else if (name.endsWith('.json')) {
// All JSON files are considered backup files
type = 'other';
}
return {
...file,
type
};
});
logger.log("[CapacitorPlatformService] Found backup files (enhanced discovery):", {
total: backupFiles.length,
files: backupFiles.map(f => ({
name: f.name,
type: f.type,
path: f.path
})),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
return backupFiles;
} catch (error) {
logger.error("[CapacitorPlatformService] Failed to list backup files:", error);
return [];
}
}
/**
* Opens the directory containing backup files in the device's file explorer.
* Uses the native share dialog to provide options for accessing the directory.
* @returns Promise resolving to success status
*/
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
try {
logger.log("[CapacitorPlatformService] User requested to open backup directory:", {
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Instead of creating a .txt file, just notify the user
if (this.getCapabilities().isIOS) {
// iOS: Instruct user to open Files app > Documents > TimeSafari
alert("To access your backups, open the Files app and navigate to the Documents folder. Look for the TimeSafari folder or files.");
} else {
// Android: Instruct user to open Downloads/TimeSafari or TimeSafari in their file manager
alert("To access your backups, open your file manager and navigate to Downloads/TimeSafari or TimeSafari in external storage.");
}
return { success: true };
} catch (error) {
const err = error as Error;
logger.error("[CapacitorPlatformService] Failed to open backup directory:", {
error: err.message,
timestamp: new Date().toISOString(),
});
return { success: false, error: err.message };
}
}
/**
* Opens a file in the device's default file viewer/app.
* Uses the native share dialog to provide options for opening the file.
* @param fileUri - URI of the file to open
* @param fileName - Name of the file (for display purposes)
* @returns Promise resolving to success status
*/
async openFile(fileUri: string, fileName: string): Promise<{ success: boolean; error?: string }> {
try {
logger.log("[CapacitorPlatformService] Opening file:", {
uri: fileUri,
fileName,
timestamp: new Date().toISOString(),
});
// Use the share dialog to provide options for opening the file
await this.handleShareDialog({
title: "Open TimeSafari File",
text: `Open ${fileName} with your preferred app`,
url: fileUri,
dialogTitle: "Choose how to open your file",
});
logger.log("[CapacitorPlatformService] File open dialog completed successfully");
return { success: true };
} catch (error) {
const err = error as Error;
logger.error("[CapacitorPlatformService] Failed to open file:", {
error: err.message,
uri: fileUri,
fileName,
timestamp: new Date().toISOString(),
});
// Check if it's a user cancellation
if (err.message.includes("cancel") ||
err.message.includes("dismiss") ||
err.message.includes("timeout")) {
logger.log("[CapacitorPlatformService] User cancelled file open dialog");
return { success: true }; // Don't treat cancellation as error
}
return { success: false, error: err.message };
}
}
/**
* Debug method: List all files without filtering for troubleshooting
* @returns Promise resolving to all files found in user accessible locations
*/
async debugListAllFiles(): Promise<Array<{name: string, uri: string, size?: number, path?: string}>> {
try {
const allFiles = await this.listUserAccessibleFiles();
logger.log("[CapacitorPlatformService] DEBUG: All files found (no filtering):", {
total: allFiles.length,
files: allFiles.map(f => ({
name: f.name,
size: f.size,
uri: f.uri
})),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Add path information for Android
const filesWithPath = allFiles.map(file => {
let path: string | undefined;
if (!this.getCapabilities().isIOS) {
try {
const uri = file.uri;
if (uri.startsWith('file://')) {
const decodedPath = decodeURIComponent(uri.substring(7));
if (decodedPath.includes('Download/TimeSafari')) {
const timeSafariIndex = decodedPath.indexOf('Download/TimeSafari');
path = decodedPath.substring(timeSafariIndex);
} else if (decodedPath.includes('TimeSafari')) {
const timeSafariIndex = decodedPath.indexOf('TimeSafari');
path = decodedPath.substring(timeSafariIndex);
} else if (decodedPath.includes('Download')) {
const downloadIndex = decodedPath.indexOf('Download');
path = decodedPath.substring(downloadIndex);
} else if (decodedPath.includes('Android')) {
const androidIndex = decodedPath.indexOf('Android');
path = decodedPath.substring(androidIndex);
} else {
path = `./${file.name}`;
}
} else {
path = `./${file.name}`;
}
} catch (pathError) {
path = `./${file.name}`;
}
}
return {
...file,
path
};
});
return filesWithPath;
} catch (error) {
logger.error("[CapacitorPlatformService] DEBUG: Failed to list all files:", error);
return [];
}
}
/**
* Comprehensive debug method to test file discovery step by step
* @returns Promise resolving to detailed debug information
*/
async debugFileDiscoveryStepByStep(): Promise<string> {
try {
let debugOutput = '';
debugOutput += `=== TimeSafari File Discovery Debug ===\n`;
debugOutput += `Platform: ${this.getCapabilities().isIOS ? "iOS" : "Android"}\n`;
debugOutput += `Timestamp: ${new Date().toISOString()}\n\n`;
// Step 1: Test basic file system access
debugOutput += `1. Testing basic file system access...\n`;
try {
await this.checkStoragePermissions();
debugOutput += `✅ Storage permissions OK\n`;
} catch (error) {
debugOutput += `❌ Storage permissions failed: ${error}\n`;
return debugOutput;
}
// Step 2: List all user accessible files
debugOutput += `\n2. Listing all user accessible files...\n`;
const allFiles = await this.listUserAccessibleFiles();
debugOutput += `Found ${allFiles.length} total files:\n`;
allFiles.forEach((file, index) => {
debugOutput += ` ${index + 1}. ${file.name} (${file.size || 'unknown'} bytes)\n`;
});
// Step 3: Test backup file filtering
debugOutput += `\n3. Testing backup file filtering...\n`;
const backupFiles = await this.listBackupFiles();
debugOutput += `Found ${backupFiles.length} backup files:\n`;
backupFiles.forEach((file, index) => {
debugOutput += ` ${index + 1}. ${file.name} (${file.type}) (${file.size || 'unknown'} bytes)\n`;
});
// Step 4: Test individual directory access
debugOutput += `\n4. Testing individual directory access...\n`;
if (this.getCapabilities().isIOS) {
try {
const iosResult = await Filesystem.readdir({
path: ".",
directory: Directory.Documents,
});
debugOutput += `iOS Documents directory: ${iosResult.files.length} files\n`;
iosResult.files.forEach((file, index) => {
const fileName = typeof file === "string" ? file : file.name;
debugOutput += ` ${index + 1}. ${fileName}\n`;
});
} catch (error) {
debugOutput += `❌ iOS Documents directory access failed: ${error}\n`;
}
} else {
// Android: Test both directories
try {
const downloadsResult = await Filesystem.readdir({
path: "Download/TimeSafari",
directory: Directory.ExternalStorage,
});
debugOutput += `Android Downloads/TimeSafari: ${downloadsResult.files.length} files\n`;
downloadsResult.files.forEach((file, index) => {
const fileName = typeof file === "string" ? file : file.name;
debugOutput += ` ${index + 1}. ${fileName}\n`;
});
} catch (error) {
debugOutput += `❌ Android Downloads/TimeSafari access failed: ${error}\n`;
}
try {
const appStorageResult = await Filesystem.readdir({
path: "TimeSafari",
directory: Directory.ExternalStorage,
});
debugOutput += `Android TimeSafari (external storage): ${appStorageResult.files.length} files\n`;
appStorageResult.files.forEach((file, index) => {
const fileName = typeof file === "string" ? file : file.name;
debugOutput += ` ${index + 1}. ${fileName}\n`;
});
} catch (error) {
debugOutput += `❌ Android TimeSafari external storage access failed: ${error}\n`;
}
}
// Step 5: Test file filtering criteria
debugOutput += `\n5. Testing file filtering criteria...\n`;
allFiles.forEach((file, index) => {
const name = file.name.toLowerCase();
const isJson = name.endsWith('.json');
const hasTimeSafari = name.includes('timesafari');
const hasBackup = name.includes('backup');
const hasContacts = name.includes('contacts');
const hasSeed = name.includes('seed');
const hasExport = name.includes('export');
const hasData = name.includes('data');
const isExcluded = name.startsWith('timesafari-directory-access-') && name.endsWith('.txt');
const isBackupFile = isJson || hasTimeSafari || hasBackup || hasContacts || hasSeed || hasExport || hasData;
const shouldInclude = isBackupFile && !isExcluded;
debugOutput += ` ${index + 1}. ${file.name}:\n`;
debugOutput += ` - isJson: ${isJson}\n`;
debugOutput += ` - hasTimeSafari: ${hasTimeSafari}\n`;
debugOutput += ` - hasBackup: ${hasBackup}\n`;
debugOutput += ` - hasContacts: ${hasContacts}\n`;
debugOutput += ` - hasSeed: ${hasSeed}\n`;
debugOutput += ` - hasExport: ${hasExport}\n`;
debugOutput += ` - hasData: ${hasData}\n`;
debugOutput += ` - isExcluded: ${isExcluded}\n`;
debugOutput += ` - isBackupFile: ${isBackupFile}\n`;
debugOutput += ` - shouldInclude: ${shouldInclude}\n`;
debugOutput += ` - actuallyIncluded: ${backupFiles.some(bf => bf.name === file.name)}\n`;
});
debugOutput += `\n=== Debug Complete ===\n`;
logger.log("[CapacitorPlatformService] File discovery debug output:\n" + debugOutput);
return debugOutput;
} catch (error) {
const errorMsg = `❌ Debug failed: ${error}`;
logger.error("[CapacitorPlatformService] File discovery debug failed:", error);
return errorMsg;
}
}
/**
* Enhanced file discovery that searches multiple user-accessible locations
* This helps find files regardless of where users chose to save them
* @returns Promise resolving to array of file information
*/
async listUserAccessibleFilesEnhanced(): Promise<Array<{name: string, uri: string, size?: number, path?: string}>> {
try {
const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = [];
if (this.getCapabilities().isIOS) {
// iOS: List files in Documents directory
const result = await Filesystem.readdir({
path: ".",
directory: Directory.Documents,
});
logger.log("[CapacitorPlatformService] Files in iOS Documents:", {
files: result.files.map((file) => (typeof file === "string" ? file : file.name)),
timestamp: new Date().toISOString(),
});
const files = result.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: "Documents"
}));
allFiles.push(...files);
} else {
// Android: Search multiple locations where users might have saved files
// 1. App's default locations (for backward compatibility)
try {
const downloadsResult = await Filesystem.readdir({
path: "Download/TimeSafari",
directory: Directory.ExternalStorage,
});
const downloadFiles = downloadsResult.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: "Download/TimeSafari"
}));
allFiles.push(...downloadFiles);
} catch (error) {
logger.warn("[CapacitorPlatformService] Could not read Downloads/TimeSafari:", error);
}
try {
const appStorageResult = await Filesystem.readdir({
path: "TimeSafari",
directory: Directory.ExternalStorage,
});
const appStorageFiles = appStorageResult.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: "TimeSafari"
}));
allFiles.push(...appStorageFiles);
} catch (error) {
logger.warn("[CapacitorPlatformService] Could not read TimeSafari external storage:", error);
}
// 2. Common user-chosen locations (if accessible)
const commonPaths = [
"Download",
"Documents",
"Backups",
"TimeSafari",
"Data"
];
for (const path of commonPaths) {
try {
const result = await Filesystem.readdir({
path: path,
directory: Directory.ExternalStorage,
});
// Filter for TimeSafari-related files
const relevantFiles = result.files
.filter(file => {
const fileName = typeof file === "string" ? file : file.name;
const name = fileName.toLowerCase();
return name.includes('timesafari') ||
name.includes('backup') ||
name.includes('contacts') ||
name.endsWith('.json');
})
.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: path
}));
if (relevantFiles.length > 0) {
logger.log(`[CapacitorPlatformService] Found ${relevantFiles.length} relevant files in ${path}:`, {
files: relevantFiles.map(f => f.name),
timestamp: new Date().toISOString(),
});
allFiles.push(...relevantFiles);
}
} catch (error) {
// Silently skip inaccessible directories
logger.debug(`[CapacitorPlatformService] Could not access ${path}:`, error);
}
}
}
// Remove duplicates based on filename and path
const uniqueFiles = allFiles.filter((file, index, self) =>
index === self.findIndex(f => f.name === file.name && f.path === file.path)
);
logger.log("[CapacitorPlatformService] Enhanced file discovery results:", {
total: uniqueFiles.length,
files: uniqueFiles.map(f => ({ name: f.name, path: f.path })),
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
return uniqueFiles;
} catch (error) {
logger.error("[CapacitorPlatformService] Enhanced file discovery failed:", error);
return [];
}
}
/**
* Lists files and folders in a given directory, with type detection.
* Supports folder navigation for the backup browser UI.
* @param path - Directory path to list (relative to root or external storage)
* @param debugShowAll - If true, forcibly treat all entries as files (for debugging)
* @returns Promise resolving to array of file/folder info
*/
async listFilesInDirectory(path: string = "Download/TimeSafari", debugShowAll: boolean = false): Promise<Array<{name: string, uri: string, size?: number, path: string, type: 'file' | 'folder'}>> {
try {
logger.log('[DEBUG] Reading directory:', path);
const entries: Array<{name: string, uri: string, size?: number, path: string, type: 'file' | 'folder'}> = [];
const directory = this.getCapabilities().isIOS ? Directory.Documents : Directory.ExternalStorage;
const result = await Filesystem.readdir({ path, directory });
logger.log('[DEBUG] Raw readdir result:', result.files);
for (const entry of result.files) {
const name = typeof entry === 'string' ? entry : entry.name;
const entryPath = path === '.' ? name : `${path}/${name}`;
let type: 'file' | 'folder' = 'file';
let size: number | undefined = undefined;
let uri: string = '';
if (debugShowAll) {
// Forcibly treat all as files for debugging
type = 'file';
uri = `file://${entryPath}`;
logger.log('[DEBUG] Forcing file type for entry:', { entryPath, type });
} else {
try {
const stat = await Filesystem.stat({ path: entryPath, directory });
if (stat.type === 'directory') {
type = 'folder';
uri = '';
} else {
type = 'file';
size = stat.size;
uri = stat.uri ? stat.uri : `file://${entryPath}`;
}
logger.log('[DEBUG] Stat for entry:', { entryPath, stat, type });
} catch (e) {
// If stat fails, assume file
type = 'file';
uri = `file://${entryPath}`;
logger.warn('[DEBUG] Stat failed for entry, assuming file:', { entryPath, error: e });
}
}
entries.push({ name, uri, size, path: entryPath, type });
}
// Sort: folders first, then files
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'folder' ? -1 : 1));
logger.log('[DEBUG] Final directoryEntries:', entries);
return entries;
} catch (error) {
logger.error('[CapacitorPlatformService] Failed to list files in directory:', { path, error });
return [];
}
}
}

54
src/services/platforms/ElectronPlatformService.ts

@ -413,4 +413,58 @@ export class ElectronPlatformService implements PlatformService {
async testListUserFiles(): Promise<string> {
return "File listing not available in Electron platform - not implemented";
}
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
async testBackupFiles(): Promise<string> {
return "Backup file listing not available in Electron platform - not implemented";
}
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
async testOpenBackupDirectory(): Promise<string> {
return "Directory access not available in Electron platform - not implemented";
}
/**
* Lists user-accessible files saved by the app.
* Not implemented in Electron platform.
* @returns Promise resolving to empty array
*/
async listUserAccessibleFiles(): Promise<Array<{name: string, uri: string, size?: number}>> {
return [];
}
/**
* Lists backup files specifically saved by the app.
* Not implemented in Electron platform.
* @returns Promise resolving to empty array
*/
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
return [];
}
/**
* Opens a file in the device's default file viewer/app.
* Not implemented in Electron platform.
* @param _fileUri - URI of the file to open
* @param _fileName - Name of the file (for display purposes)
* @returns Promise resolving to error status
*/
async openFile(_fileUri: string, _fileName: string): Promise<{ success: boolean; error?: string }> {
return { success: false, error: "File opening not implemented in Electron platform" };
}
/**
* Opens the directory containing backup files in the device's file explorer.
* Not implemented in Electron platform.
* @returns Promise resolving to error status
*/
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
return { success: false, error: "Directory access not implemented in Electron platform" };
}
}

130
src/services/platforms/PyWebViewPlatformService.ts

@ -32,6 +32,7 @@ export class PyWebViewPlatformService implements PlatformService {
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
isNativeApp: true,
};
}
@ -122,14 +123,127 @@ export class PyWebViewPlatformService implements PlatformService {
}
/**
* Should write and share a file using the Python backend.
* @param _fileName - Name of the file to write and share
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing and sharing through pywebview's Python-JavaScript bridge
* Writes content to a file at the specified path and shares it.
* Not implemented in PyWebView platform.
* @param _fileName - The filename of the file to write
* @param _content - The content to write to the file
* @param _options - Optional parameters for file saving behavior
* @returns Promise that resolves to save/share result
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
logger.error("writeAndShareFile not implemented in PyWebView platform");
throw new Error("Not implemented");
async writeAndShareFile(
_fileName: string,
_content: string,
_options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
}
): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }> {
return { saved: false, shared: false, error: "File sharing not implemented in PyWebView platform" };
}
/**
* Lists user-accessible files saved by the app.
* Not implemented in PyWebView platform.
* @returns Promise resolving to empty array
*/
async listUserAccessibleFiles(): Promise<Array<{name: string, uri: string, size?: number}>> {
return [];
}
/**
* Lists backup files specifically saved by the app.
* Not implemented in PyWebView platform.
* @returns Promise resolving to empty array
*/
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
return [];
}
/**
* Opens a file in the device's default file viewer/app.
* Not implemented in PyWebView platform.
* @param _fileUri - URI of the file to open
* @param _fileName - Name of the file (for display purposes)
* @returns Promise resolving to error status
*/
async openFile(_fileUri: string, _fileName: string): Promise<{ success: boolean; error?: string }> {
return { success: false, error: "File opening not implemented in PyWebView platform" };
}
/**
* Opens the directory containing backup files in the device's file explorer.
* Not implemented in PyWebView platform.
* @returns Promise resolving to error status
*/
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
return { success: false, error: "Directory access not implemented in PyWebView platform" };
}
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
async testListUserFiles(): Promise<string> {
return "File listing not available in PyWebView platform - not implemented";
}
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
async testBackupFiles(): Promise<string> {
return "Backup file listing not available in PyWebView platform - not implemented";
}
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
async testOpenBackupDirectory(): Promise<string> {
return "Directory access not available in PyWebView platform - not implemented";
}
/**
* Tests the file sharing functionality.
* @returns Promise resolving to a test result message
*/
async testFileSharing(): Promise<string> {
return "File sharing not available in PyWebView platform - not implemented";
}
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
async testFileSaveOnly(): Promise<string> {
return "File saving not available in PyWebView platform - not implemented";
}
/**
* Tests the location selection functionality.
* @returns Promise resolving to a test result message
*/
async testLocationSelection(): Promise<string> {
return "Location selection not available in PyWebView platform - not implemented";
}
/**
* Tests location selection without showing the dialog.
* @returns Promise resolving to a test result message
*/
async testLocationSelectionSilent(): Promise<string> {
return "Silent location selection not available in PyWebView platform - not implemented";
}
/**
* Rotates the camera between front and back.
* Not implemented in PyWebView platform.
*/
async rotateCamera(): Promise<void> {
// Not implemented
}
}

58
src/services/platforms/WebPlatformService.ts

@ -3,7 +3,7 @@ import {
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { logger, getTimestampForFilename } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
import databaseService from "../AbsurdSqlDatabaseService";
@ -193,7 +193,7 @@ export class WebPlatformService implements PlatformService {
if (blob) {
resolve({
blob,
fileName: `photo_${Date.now()}.jpg`,
fileName: `photo-${getTimestampForFilename()}.jpg`,
});
} else {
reject(new Error("Failed to capture image from webcam"));
@ -450,6 +450,60 @@ export class WebPlatformService implements PlatformService {
return "File listing not available in web platform - files are downloaded directly";
}
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
async testBackupFiles(): Promise<string> {
return "Backup file listing not available in web platform - files are downloaded directly";
}
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
async testOpenBackupDirectory(): Promise<string> {
return "Directory access not available in web platform - files are downloaded directly";
}
/**
* Lists user-accessible files saved by the app.
* Not supported in web platform.
* @returns Promise resolving to empty array
*/
async listUserAccessibleFiles(): Promise<Array<{name: string, uri: string, size?: number}>> {
return [];
}
/**
* Lists backup files specifically saved by the app.
* Not supported in web platform.
* @returns Promise resolving to empty array
*/
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
return [];
}
/**
* Opens a file in the device's default file viewer/app.
* Not supported in web platform.
* @param _fileUri - URI of the file to open
* @param _fileName - Name of the file (for display purposes)
* @returns Promise resolving to error status
*/
async openFile(_fileUri: string, _fileName: string): Promise<{ success: boolean; error?: string }> {
return { success: false, error: "File opening not available in web platform" };
}
/**
* Opens the directory containing backup files in the device's file explorer.
* Not supported in web platform.
* @returns Promise resolving to error status
*/
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
return { success: false, error: "Directory access not available in web platform" };
}
/**
* Rotates the camera between front and back cameras.
* Not supported in web platform.

19
src/utils/logger.ts

@ -79,3 +79,22 @@ if (typeof module !== "undefined" && module.exports) {
// Add default export for ESM
export default { logger };
/**
* Formats current timestamp for use in filenames.
* Returns ISO string with colons and periods replaced with hyphens, truncated to seconds.
* Format: 2024-01-15T14-30-45
* @returns Formatted timestamp string safe for filenames
*/
export function getTimestampForFilename(): string {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
/**
* Formats current timestamp for use in filenames with date only.
* Format: 2024-01-15
* @returns Date-only timestamp string safe for filenames
*/
export function getDateForFilename(): string {
return new Date().toISOString().slice(0, 10);
}

82
src/views/TestView.vue

@ -249,6 +249,24 @@
>
List User Files
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testBackupFiles()"
>
Test Backup Files
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testOpenBackupDirectory()"
>
Test Open Directory
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testFileDiscoveryDebug()"
>
Debug File Discovery
</button>
<div v-if="fileSharingResult" class="mt-2 p-2 bg-gray-100 rounded">
<strong>Result:</strong> {{ fileSharingResult }}
</div>
@ -763,5 +781,69 @@ export default class Help extends Vue {
);
}
}
public async testBackupFiles() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testBackupFiles();
this.fileSharingResult = result;
logger.log("Backup Files Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("Backup Files Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Backup Files Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testOpenBackupDirectory() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testOpenBackupDirectory();
this.fileSharingResult = result;
logger.log("Open Backup Directory Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("Open Backup Directory Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Open Backup Directory Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testFileDiscoveryDebug() {
const platformService = PlatformServiceFactory.getInstance();
try {
if ('debugFileDiscoveryStepByStep' in platformService) {
const result = await (platformService as any).debugFileDiscoveryStepByStep();
this.fileSharingResult = result;
logger.log("File Discovery Debug Test Result:", this.fileSharingResult);
} else {
this.fileSharingResult = "Debug method not available on this platform";
}
} catch (error) {
logger.error("File Discovery Debug Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "File Discovery Debug Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
}
</script>

Loading…
Cancel
Save