Compare commits

...

4 Commits

Author SHA1 Message Date
Matthew Raymer
d8328ef89f Merge branch 'master' into android-file-save 2025-10-22 07:33:48 +00:00
Matthew Raymer
a9b3f6dfab feat(export): implement unique filename generation with device identification
- Add generateUniqueFileName() method to all platform services
- Implement device ID hashing for privacy-friendly identification
- Add JSON validation before file operations
- Generate filenames with format: base_deviceHash_timestamp.json
- Support multiple exports in same second with counter system
- Ensure filenames fit within platform length limits (200 chars max)
- Update saveToDevice() and saveAs() methods across all platforms

Platforms updated:
- CapacitorPlatformService: Mobile file operations with unique names
- WebPlatformService: Browser download with device fingerprinting
- ElectronPlatformService: Desktop IPC with machine identification

Resolves file naming conflicts and improves debugging capabilities
while maintaining user privacy through hashed device identifiers.
2025-08-19 10:31:12 +00:00
Matthew Raymer
6b1937e37b feat(git-hooks): enhance pre-commit hook with whitelist support for console statements
Add whitelist functionality to debug checker to allow intentional console statements in specific files:
- Add WHITELIST_FILES configuration for platform services and utilities
- Update pre-commit hook to skip console pattern checks for whitelisted files
- Support regex patterns in whitelist for flexible file matching
- Maintain security while allowing legitimate debug code in platform services

This resolves the issue where the hook was blocking commits due to intentional console statements in whitelisted files like WebPlatformService and CapacitorPlatformService.
2025-08-19 07:41:45 +00:00
Matthew Raymer
b735aac1fc feat(platform): implement dual-flow file sharing with Save to Device option
Add new platform service methods for direct file saving alongside existing share functionality:
- Add saveToDevice() and saveAs() methods to PlatformService interface
- Implement cross-platform support in WebPlatformService, ElectronPlatformService, and CapacitorPlatformService
- Update DataExportSection UI to provide Share Contacts and Save to Device buttons
- Add AndroidFileSaver plugin architecture with fallback implementation
- Include comprehensive documentation for native Android plugin implementation

This addresses the Android simulator file sharing limitation by providing users with clear choices between app-to-app sharing and direct device storage, while maintaining backward compatibility across all platforms.

- CapacitorPlatformService: Add MediaStore/SAF support with graceful fallbacks
- UI Components: Replace single download button with dual-action interface
- Documentation: Add AndroidFileSaver plugin implementation guide
- Type Safety: Maintain interface consistency across all platform services
2025-08-19 07:39:57 +00:00
6 changed files with 1121 additions and 19 deletions

View File

@@ -0,0 +1,251 @@
# Android File Saver Plugin Implementation Guide
## Overview
This document outlines the implementation of the `AndroidFileSaver` Capacitor plugin that provides Storage Access Framework (SAF) and MediaStore functionality for direct file saving on Android devices.
## Plugin Purpose
The `AndroidFileSaver` plugin enables two key file operations:
1. **`saveToDownloads`**: Direct save to Downloads folder using MediaStore (API 29+)
2. **`saveAs`**: User-chosen location using Storage Access Framework (SAF)
## Implementation Requirements
### 1. Plugin Structure
```typescript
// Plugin interface
interface AndroidFileSaverPlugin {
saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string
}): Promise<{
success: boolean;
path?: string;
error?: string
}>;
saveAs(options: {
fileName: string;
content: string;
mimeType: string
}): Promise<{
success: boolean;
path?: string;
error?: string
}>;
}
```
### 2. Android Implementation
#### MediaStore for Downloads (API 29+)
```java
@PluginMethod
public void saveToDownloads(PluginCall call) {
String fileName = call.getString("fileName");
String content = call.getString("content");
String mimeType = call.getString("mimeType");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Use MediaStore for Downloads
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
values.put(MediaStore.Downloads.IS_PENDING, 1);
ContentResolver resolver = getContext().getContentResolver();
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
if (uri != null) {
try (ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "w")) {
if (pfd != null) {
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
fos.write(content.getBytes());
fos.flush();
// Mark as no longer pending
values.clear();
values.put(MediaStore.Downloads.IS_PENDING, 0);
resolver.update(uri, values, null, null);
call.resolve(new JSObject()
.put("success", true)
.put("path", uri.toString()));
return;
}
}
} catch (IOException e) {
resolver.delete(uri, null, null);
}
}
call.resolve(new JSObject()
.put("success", false)
.put("error", "Failed to save file"));
} else {
// Fallback for older Android versions
call.resolve(new JSObject()
.put("success", false)
.put("error", "Requires Android API 29+"));
}
}
```
#### Storage Access Framework (SAF) for Save As
```java
@PluginMethod
public void saveAs(PluginCall call) {
String fileName = call.getString("fileName");
String content = call.getString("content");
String mimeType = call.getString("mimeType");
// Store call for later use
bridge.saveCall(call);
// Create intent for SAF
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, fileName);
// Start activity for result
bridge.startActivityForResult(call, intent, "saveAsResult");
}
@ActivityCallback
private void saveAsResult(PluginCall call, ActivityResult result) {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
String content = call.getString("content");
try (ParcelFileDescriptor pfd = getContext().getContentResolver()
.openFileDescriptor(uri, "w")) {
if (pfd != null) {
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
fos.write(content.getBytes());
fos.flush();
call.resolve(new JSObject()
.put("success", true)
.put("path", uri.toString()));
return;
}
}
} catch (IOException e) {
// Handle error
}
call.resolve(new JSObject()
.put("success", false)
.put("error", "Failed to save file"));
} else {
call.resolve(new JSObject()
.put("success", false)
.put("error", "User cancelled or failed"));
}
}
```
### 3. Plugin Registration
```java
@CapacitorPlugin(name = "AndroidFileSaver")
public class AndroidFileSaverPlugin extends Plugin {
// Implementation methods here
}
```
## Integration Steps
### 1. Create Plugin Project
```bash
# Create new Capacitor plugin
npx @capacitor/cli plugin:generate android-file-saver
cd android-file-saver
```
### 2. Add to Android Project
```bash
# In your main project
npm install ./android-file-saver
npx cap sync android
```
### 3. Update Android Manifest
Ensure the following permissions are present:
```xml
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
```
## Fallback Behavior
When the plugin is not available, the system falls back to:
1. **Web**: Browser download mechanism
2. **Electron**: Native file save via IPC
3. **Capacitor**: Share dialog (existing behavior)
## Testing
### 1. Plugin Availability
```typescript
// Check if plugin is available
if (AndroidFileSaver) {
// Plugin available - use native methods
} else {
// Plugin not available - use fallback
}
```
### 2. Error Handling
```typescript
try {
const result = await AndroidFileSaver.saveToDownloads({
fileName: "test.json",
content: '{"test": "data"}',
mimeType: "application/json"
});
if (result.success) {
logger.info("File saved:", result.path);
} else {
logger.error("Save failed:", result.error);
}
} catch (error) {
logger.error("Plugin error:", error);
}
```
## Security Considerations
1. **Content Validation**: Validate file content before saving
2. **MIME Type Verification**: Ensure MIME type matches file content
3. **Permission Handling**: Request storage permissions appropriately
4. **Error Logging**: Log errors without exposing sensitive data
## Future Enhancements
1. **Progress Callbacks**: Add progress reporting for large files
2. **Batch Operations**: Support saving multiple files
3. **Custom Locations**: Allow saving to app-specific directories
4. **File Compression**: Add optional file compression
## References
- [Android Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider)
- [MediaStore Downloads](https://developer.android.com/reference/android/provider/MediaStore.Downloads)
- [Capacitor Plugin Development](https://capacitorjs.com/docs/plugins/android)
- [Android Scoped Storage](https://developer.android.com/training/data-storage)

View File

@@ -25,33 +25,46 @@ messages * - Conditional UI based on platform capabilities * * @component *
Backup Identifier Seed
</router-link>
<button
:disabled="isExporting"
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
</button>
<div class="flex flex-col gap-2 mt-2">
<button
:disabled="isExporting"
:class="shareButtonClasses"
@click="shareContacts()"
>
{{ isExporting ? "Exporting..." : "Share Contacts" }}
</button>
<button
:disabled="isExporting"
:class="saveButtonClasses"
@click="saveContactsToDevice()"
>
{{ isExporting ? "Exporting..." : "Save to Device" }}
</button>
</div>
<div
v-if="capabilities.needsFileHandlingInstructions"
:class="instructionsContainerClasses"
>
<p>
After the export, you can save the file in your preferred storage
location.
</p>
<p>Choose how you want to export your contacts:</p>
<ul>
<li :class="listItemClasses">
<strong>Share Contacts:</strong> Opens the system share dialog to send
to apps like Gmail, Drive, or messaging apps.
</li>
<li :class="listItemClasses">
<strong>Save to Device:</strong> Saves directly to your device's
Downloads folder (Android) or Documents folder (iOS).
</li>
<li v-if="capabilities.isIOS" :class="listItemClasses">
On iOS: You will be prompted to choose a location to save your backup
file.
On iOS: Files are saved to the Files app in your Documents folder.
</li>
<li
v-if="capabilities.isMobile && !capabilities.isIOS"
:class="listItemClasses"
>
On Android: You will be prompted to choose a location to save your
backup file.
On Android: Files are saved directly to your Downloads folder.
</li>
</ul>
</div>
@@ -151,6 +164,20 @@ export default class DataExportSection extends Vue {
return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the share button
*/
get shareButtonClasses(): string {
return "block w-full text-center text-md bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the save to device button
*/
get saveButtonClasses(): string {
return "block w-full text-center text-md bg-gradient-to-b from-purple-400 to-purple-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the instructions container
*/
@@ -228,6 +255,100 @@ export default class DataExportSection extends Vue {
}
}
/**
* Shares contacts data using the platform's share functionality
* Opens the system share dialog for app-to-app handoff
*
* @throws {Error} If sharing fails
*/
public async shareContacts(): Promise<void> {
if (this.isExporting) {
return; // Prevent multiple simultaneous exports
}
try {
this.isExporting = true;
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const processedContacts: Contact[] = allContacts.map((contact) => {
const exContact: Contact = R.omit(["contactMethods"], contact);
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
return exContact;
});
const exportData = contactsToExportJson(processedContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Use platform service to share the file
await this.platformService.writeAndShareFile(this.fileName, jsonStr);
this.notify.success(
"Contact sharing completed successfully. Use the share dialog to send to your preferred app.",
);
} catch (error) {
logger.error("Share Error:", error);
this.notify.error(
`There was an error sharing the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
this.isExporting = false;
}
}
/**
* Saves contacts data directly to the device's storage
* Uses platform-specific save methods (Downloads folder, Documents, etc.)
*
* @throws {Error} If saving fails
*/
public async saveContactsToDevice(): Promise<void> {
if (this.isExporting) {
return; // Prevent multiple simultaneous exports
}
try {
this.isExporting = true;
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const processedContacts: Contact[] = allContacts.map((contact) => {
const exContact: Contact = R.omit(["contactMethods"], contact);
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
return exContact;
});
const exportData = contactsToExportJson(processedContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Use platform service to save directly to device
await this.platformService.saveToDevice(this.fileName, jsonStr);
this.notify.success("Contact data saved successfully to your device.");
} catch (error) {
logger.error("Save Error:", error);
this.notify.error(
`There was an error saving the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
this.isExporting = false;
}
}
created() {
this.notify = createNotifyHelpers(this.$notify);
this.loadSeedBackupStatus();

View File

@@ -88,6 +88,24 @@ export interface PlatformService {
*/
writeAndShareFile(fileName: string, content: string): Promise<void>;
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
saveToDevice(fileName: string, content: string): Promise<void>;
/**
* Opens the system file picker to let the user choose where to save a file.
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
saveAs(fileName: string, content: string): Promise<void>;
/**
* Deletes a file at the specified path.
* @param path - The path to the file to delete

View File

@@ -14,6 +14,65 @@ import {
DBSQLiteValues,
} from "@capacitor-community/sqlite";
// Android-specific imports for SAF and MediaStore
import { registerPlugin } from "@capacitor/core";
// Define interfaces for Android-specific functionality
interface AndroidFileSaverPlugin {
saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }>;
saveAs(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }>;
}
// Register the plugin (will be undefined if not available)
const AndroidFileSaver =
registerPlugin<AndroidFileSaverPlugin>("AndroidFileSaver");
// Fallback implementation for when the plugin is not available
class AndroidFileSaverFallback implements AndroidFileSaverPlugin {
async saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }> {
logger.warn(
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
{
fileName: options.fileName,
mimeType: options.mimeType,
contentLength: options.content.length,
},
);
return { success: false, error: "AndroidFileSaver plugin not available" };
}
async saveAs(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }> {
logger.warn(
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
{
fileName: options.fileName,
mimeType: options.mimeType,
contentLength: options.content.length,
},
);
return { success: false, error: "AndroidFileSaver plugin not available" };
}
}
// Use fallback if plugin is not available
const AndroidFileSaverImpl = AndroidFileSaver || new AndroidFileSaverFallback();
import { runMigrations } from "@/db-sql/migration";
import { QueryExecResult } from "@/interfaces/database";
import {
@@ -468,7 +527,7 @@ export class CapacitorPlatformService implements PlatformService {
* ## Logging:
*
* Detailed logging is provided throughout the process using emoji-tagged
* console messages that appear in the Electron DevTools console. This
* log messages that appear in the Electron DevTools. This
* includes:
* - SQL statement execution details
* - Parameter values for debugging
@@ -1119,6 +1178,280 @@ export class CapacitorPlatformService implements PlatformService {
}
}
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "saveToDevice",
fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Validate JSON content
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
if (this.getCapabilities().isIOS) {
// iOS: Use Filesystem to save to Documents directory
const { uri } = await Filesystem.writeFile({
path: uniqueFileName,
data: content,
directory: Directory.Documents,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
uri,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
});
} else {
// Android: Try to use native MediaStore/SAF implementation
const result = await AndroidFileSaverImpl.saveToDownloads({
fileName: uniqueFileName,
content,
mimeType: this.getMimeType(uniqueFileName),
});
if (result.success) {
logger.log(
"[CapacitorPlatformService] File saved to Android Downloads:",
{
path: result.path,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
},
);
} else {
throw new Error(`Failed to save to Downloads: ${result.error}`);
}
}
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error saving file to device:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to save file to device: ${err.message}`);
}
}
/**
* Opens the system file picker to let the user choose where to save a file.
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "saveAs",
fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Validate JSON content
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
if (this.getCapabilities().isIOS) {
// iOS: Use Filesystem to save to Documents directory with user choice
const { uri } = await Filesystem.writeFile({
path: uniqueFileName,
data: content,
directory: Directory.Documents,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
uri,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
});
} else {
// Android: Use SAF for user-chosen location
const result = await AndroidFileSaverImpl.saveAs({
fileName: uniqueFileName,
content,
mimeType: this.getMimeType(uniqueFileName),
});
if (result.success) {
logger.log(
"[CapacitorPlatformService] File saved via Android SAF:",
{
path: result.path,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
},
);
} else {
throw new Error(`Failed to save via SAF: ${result.error}`);
}
}
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error in saveAs:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to save file: ${err.message}`);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileName(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifier();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifier(): string {
try {
const deviceInfo = this.getDeviceInfo();
return this.hashString(deviceInfo);
} catch (error) {
return 'mobile';
}
}
/**
* Gets device info string
*/
private getDeviceInfo(): string {
try {
// For mobile platforms, use device info
const capabilities = this.getCapabilities();
if (capabilities.isIOS) {
return 'ios_mobile';
} else if (capabilities.isAndroid) {
return 'android_mobile';
} else {
return 'mobile';
}
} catch (error) {
return 'mobile';
}
}
/**
* Simple hash function for device ID
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* Determines the MIME type for a given filename based on its extension.
*
* @param fileName - The filename to determine MIME type for
* @returns The MIME type string
*/
private getMimeType(fileName: string): string {
const extension = fileName.split(".").pop()?.toLowerCase();
switch (extension) {
case "json":
return "application/json";
case "txt":
return "text/plain";
case "csv":
return "text/csv";
case "pdf":
return "application/pdf";
case "xml":
return "application/xml";
case "html":
return "text/html";
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
default:
return "application/octet-stream";
}
}
/**
* Deletes a file from the app's data directory.
* @param path - Relative path to the file to delete

View File

@@ -146,6 +146,236 @@ export class ElectronPlatformService extends CapacitorPlatformService {
return true;
}
/**
* Saves content directly to the device's Downloads folder (Electron platform).
* Uses Electron's IPC to save files directly to the Downloads directory.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
logger.info(
`[ElectronPlatformService] Using native IPC for direct file save`,
uniqueFileName,
);
// Check if we're running in Electron with the API available
if (typeof window !== "undefined" && window.electronAPI) {
// Use the native Electron IPC API for file exports
const result = await window.electronAPI.exportData(uniqueFileName, content);
if (result.success) {
logger.info(
`[ElectronPlatformService] File saved successfully to`,
result.path,
);
logger.info(
`[ElectronPlatformService] File saved to Downloads folder`,
uniqueFileName,
);
} else {
logger.error(
`[ElectronPlatformService] Native save failed`,
result.error,
);
throw new Error(`Native file save failed: ${result.error}`);
}
} else {
// Fallback to web-style download if Electron API is not available
logger.warn(
"[ElectronPlatformService] Electron API not available, falling back to web download",
);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = uniqueFileName;
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
setTimeout(() => URL.revokeObjectURL(url), 1000);
logger.info(
`[ElectronPlatformService] Fallback download initiated`,
uniqueFileName,
);
}
} catch (error) {
logger.error("[ElectronPlatformService] File save failed", error);
throw new Error(`Failed to save file: ${error}`);
}
}
/**
* Opens the system file picker to let the user choose where to save a file (Electron platform).
* Uses Electron's IPC to show the native save dialog.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
logger.info(
`[ElectronPlatformService] Using native IPC for save as dialog`,
uniqueFileName,
);
// Check if we're running in Electron with the API available
if (typeof window !== "undefined" && window.electronAPI) {
// Use the native Electron IPC API for file exports (same as saveToDevice for now)
// TODO: Implement native save dialog when available
const result = await window.electronAPI.exportData(uniqueFileName, content);
if (result.success) {
logger.info(
`[ElectronPlatformService] File saved successfully to`,
result.path,
);
logger.info(
`[ElectronPlatformService] File saved via save as`,
uniqueFileName,
);
} else {
logger.error(
`[ElectronPlatformService] Native save as failed`,
result.error,
);
throw new Error(`Native file save as failed: ${result.error}`);
}
} else {
// Fallback to web-style download if Electron API is not available
logger.warn(
"[ElectronPlatformService] Electron API not available, falling back to web download",
);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = uniqueFileName;
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
setTimeout(() => URL.revokeObjectURL(url), 1000);
logger.info(
`[ElectronPlatformService] Fallback download initiated`,
uniqueFileName,
);
}
} catch (error) {
logger.error("[ElectronPlatformService] File save as failed", error);
throw new Error(`Failed to save file as: ${error}`);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileNameElectron(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifierElectron();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifierElectron(): string {
try {
const deviceInfo = this.getDeviceInfoElectron();
return this.hashStringElectron(deviceInfo);
} catch (error) {
return 'electron';
}
}
/**
* Gets device info string
*/
private getDeviceInfoElectron(): string {
try {
// Use machine-specific information
const os = require('os');
const hostname = os.hostname() || 'unknown';
const platform = os.platform() || 'unknown';
const arch = os.arch() || 'unknown';
// Create device info string
return `${platform}_${hostname}_${arch}`;
} catch (error) {
return 'electron';
}
}
/**
* Simple hash function for device ID
*/
private hashStringElectron(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* Checks if running on Capacitor platform.
*

View File

@@ -97,9 +97,7 @@ export class WebPlatformService implements PlatformService {
}
} else {
// We're in a worker context - skip initBackend call
// Use console for critical startup message to avoid circular dependency
// eslint-disable-next-line no-console
console.log(
logger.info(
"[WebPlatformService] Skipping initBackend call in worker context",
);
}
@@ -594,6 +592,157 @@ export class WebPlatformService implements PlatformService {
}
}
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
// Web platform: Use the same download mechanism as writeAndShareFile
await this.writeAndShareFile(uniqueFileName, content);
logger.log("[WebPlatformService] File saved to device", uniqueFileName);
} catch (error) {
logger.error("[WebPlatformService] Error saving file to device", error);
throw new Error(
`Failed to save file to device: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Opens the system file picker to let the user choose where to save a file (web platform).
* Uses the browser's download mechanism with a suggested filename.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
// Web platform: Use the same download mechanism as writeAndShareFile
await this.writeAndShareFile(uniqueFileName, content);
logger.log("[WebPlatformService] File saved as", uniqueFileName);
} catch (error) {
logger.error("[WebPlatformService] Error saving file as", error);
throw new Error(
`Failed to save file as: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileName(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifier();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifier(): string {
try {
const deviceInfo = this.getDeviceInfo();
return this.hashString(deviceInfo);
} catch (error) {
return 'web';
}
}
/**
* Gets device info string
*/
private getDeviceInfo(): string {
try {
// Use browser fingerprint or fallback
const userAgent = navigator.userAgent;
const language = navigator.language || 'unknown';
const platform = navigator.platform || 'unknown';
let browser = 'unknown';
let os = 'unknown';
if (userAgent.includes('Chrome')) browser = 'chrome';
else if (userAgent.includes('Firefox')) browser = 'firefox';
else if (userAgent.includes('Safari')) browser = 'safari';
else if (userAgent.includes('Edge')) browser = 'edge';
if (userAgent.includes('Windows')) os = 'win';
else if (userAgent.includes('Mac')) os = 'mac';
else if (userAgent.includes('Linux')) os = 'linux';
else if (userAgent.includes('Android')) os = 'android';
else if (userAgent.includes('iOS')) os = 'ios';
return `${browser}_${os}_${platform}_${language}`;
} catch (error) {
return 'web';
}
}
/**
* Simple hash function for device ID
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* @see PlatformService.dbQuery
*/