diff --git a/doc/android-filesaver-plugin.md b/doc/android-filesaver-plugin.md
new file mode 100644
index 00000000..2c1180b2
--- /dev/null
+++ b/doc/android-filesaver-plugin.md
@@ -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
+
+
+```
+
+## 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)
diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue
index 61bf4753..ac894856 100644
--- a/src/components/DataExportSection.vue
+++ b/src/components/DataExportSection.vue
@@ -19,33 +19,46 @@ messages * - Conditional UI based on platform capabilities * * @component *
Backup Identifier Seed
-
+
+
+
+
+
-
- After the export, you can save the file in your preferred storage
- location.
-
+
Choose how you want to export your contacts:
+ -
+ Share Contacts: Opens the system share dialog to send
+ to apps like Gmail, Drive, or messaging apps.
+
+ -
+ Save to Device: Saves directly to your device's
+ Downloads folder (Android) or Documents folder (iOS).
+
-
- 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.
-
- 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.
@@ -139,6 +152,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
*/
@@ -216,6 +243,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 {
+ 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 {
+ 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);
}
diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts
index a3dbff6a..3869f9a9 100644
--- a/src/services/PlatformService.ts
+++ b/src/services/PlatformService.ts
@@ -88,6 +88,24 @@ export interface PlatformService {
*/
writeAndShareFile(fileName: string, content: string): Promise;
+ /**
+ * 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;
+
+ /**
+ * 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;
+
/**
* Deletes a file at the specified path.
* @param path - The path to the file to delete
diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts
index bd22ef8d..ad256867 100644
--- a/src/services/platforms/CapacitorPlatformService.ts
+++ b/src/services/platforms/CapacitorPlatformService.ts
@@ -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("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 {
@@ -460,7 +519,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
@@ -1096,6 +1155,175 @@ 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 {
+ 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 {
+ if (this.getCapabilities().isIOS) {
+ // iOS: Use Filesystem to save to Documents directory
+ const { uri } = await Filesystem.writeFile({
+ path: fileName,
+ data: content,
+ directory: Directory.Documents,
+ encoding: Encoding.UTF8,
+ recursive: true,
+ });
+
+ logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
+ uri,
+ timestamp: new Date().toISOString(),
+ });
+ } else {
+ // Android: Try to use native MediaStore/SAF implementation
+ const result = await AndroidFileSaverImpl.saveToDownloads({
+ fileName,
+ content,
+ mimeType: this.getMimeType(fileName),
+ });
+
+ if (result.success) {
+ logger.log(
+ "[CapacitorPlatformService] File saved to Android Downloads:",
+ {
+ path: result.path,
+ 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 {
+ 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 {
+ if (this.getCapabilities().isIOS) {
+ // iOS: Use Filesystem to save to Documents directory with user choice
+ const { uri } = await Filesystem.writeFile({
+ path: fileName,
+ data: content,
+ directory: Directory.Documents,
+ encoding: Encoding.UTF8,
+ recursive: true,
+ });
+
+ logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
+ uri,
+ timestamp: new Date().toISOString(),
+ });
+ } else {
+ // Android: Use SAF to let user choose location
+ const result = await AndroidFileSaverImpl.saveAs({
+ fileName,
+ content,
+ mimeType: this.getMimeType(fileName),
+ });
+
+ if (result.success) {
+ logger.log("[CapacitorPlatformService] File saved via SAF:", {
+ path: result.path,
+ 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 saving file as:",
+ JSON.stringify(errLog, null, 2),
+ );
+ throw new Error(`Failed to save file as: ${err.message}`);
+ }
+ }
+
+ /**
+ * 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
diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts
index 1a077c65..3d68d987 100644
--- a/src/services/platforms/ElectronPlatformService.ts
+++ b/src/services/platforms/ElectronPlatformService.ts
@@ -146,6 +146,129 @@ 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 {
+ logger.info(
+ `[ElectronPlatformService] Using native IPC for direct file save: ${fileName}`,
+ );
+
+ try {
+ // 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(fileName, content);
+
+ if (result.success) {
+ logger.info(
+ `[ElectronPlatformService] File saved successfully to: ${result.path}`,
+ );
+ logger.info(
+ `[ElectronPlatformService] File saved to Downloads folder: ${fileName}`,
+ );
+ } 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 = fileName;
+ 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: ${fileName}`,
+ );
+ }
+ } 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 {
+ logger.info(
+ `[ElectronPlatformService] Using native IPC for save as dialog: ${fileName}`,
+ );
+
+ try {
+ // 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(fileName, content);
+
+ if (result.success) {
+ logger.info(
+ `[ElectronPlatformService] File saved successfully to: ${result.path}`,
+ );
+ logger.info(
+ `[ElectronPlatformService] File saved via save as: ${fileName}`,
+ );
+ } 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 = fileName;
+ 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: ${fileName}`,
+ );
+ }
+ } catch (error) {
+ logger.error("[ElectronPlatformService] File save as failed:", error);
+ throw new Error(`Failed to save file as: ${error}`);
+ }
+ }
+
/**
* Checks if running on Capacitor platform.
*
diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts
index a731ee22..88951131 100644
--- a/src/services/platforms/WebPlatformService.ts
+++ b/src/services/platforms/WebPlatformService.ts
@@ -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,48 @@ export class WebPlatformService implements PlatformService {
}
}
+ /**
+ * Saves content directly to the device's Downloads folder (web platform).
+ * Uses the browser's download mechanism to save files.
+ *
+ * @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 {
+ try {
+ // Web platform: Use the same download mechanism as writeAndShareFile
+ await this.writeAndShareFile(fileName, content);
+ logger.log("[WebPlatformService] File saved to device:", fileName);
+ } 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 {
+ try {
+ // Web platform: Use the same download mechanism as writeAndShareFile
+ await this.writeAndShareFile(fileName, content);
+ logger.log("[WebPlatformService] File saved as:", fileName);
+ } 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"}`,
+ );
+ }
+ }
+
/**
* @see PlatformService.dbQuery
*/
diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue
index f635df32..eb99665c 100644
--- a/src/views/AccountViewView.vue
+++ b/src/views/AccountViewView.vue
@@ -182,7 +182,6 @@
@change="onLocationCheckboxChange"
/>
-