From e1eb91f26d428d91a96df9d0bf167fd4d83b1441 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Wed, 26 Nov 2025 20:01:14 +0800 Subject: [PATCH] feat: Add Android share target support for image sharing Implement native Android share functionality to allow users to share images from other apps directly to TimeSafari. This mirrors the iOS share extension functionality and provides a seamless cross-platform experience. Changes: - Add ACTION_SEND and ACTION_SEND_MULTIPLE intent filters to AndroidManifest.xml to register the app as a share target for images - Implement share intent handling in MainActivity.java: - Process incoming share intents in onCreate() and onNewIntent() - Read shared image data from content URI using ContentResolver - Convert image to Base64 encoding - Write image data to temporary JSON file in app's internal storage - Use getFilesDir() which maps to Capacitor's Directory.Data - Update src/main.capacitor.ts to support Android platform: - Extend checkAndStoreNativeSharedImage() to check for Android platform - Use Directory.Data for Android file operations (vs Directory.Documents for iOS) - Add Android to initial load and app state change listeners - Ensure shared image detection works on app launch and activation The implementation follows the same pattern as iOS: - Native layer (MainActivity) writes shared image to temp file - JavaScript layer polls for file existence with exponential backoff - Image is stored in temp database and user is navigated to SharedPhotoView This enables users to share images from Gallery, Photos, and other apps directly to TimeSafari on Android devices. --- android/app/src/main/AndroidManifest.xml | 14 ++ .../java/app/timesafari/MainActivity.java | 131 ++++++++++++++++++ src/main.capacitor.ts | 46 ++++-- 3 files changed, 180 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1d8ad70d11..46e22c040a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,20 @@ + + + + + + + + + + + + + + imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (imageUris != null && !imageUris.isEmpty()) { + processSharedImage(imageUris.get(0), null); + } + } + } + + /** + * Process a shared image: read it, convert to base64, and write to temp file + */ + private void processSharedImage(Uri imageUri, String fileName) { + try { + // Read image data from URI + InputStream inputStream = getContentResolver().openInputStream(imageUri); + if (inputStream == null) { + Log.e(TAG, "Failed to open input stream for shared image"); + return; + } + + // Read image bytes + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[8192]; + int nRead; + while ((nRead = inputStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + buffer.flush(); + byte[] imageBytes = buffer.toByteArray(); + inputStream.close(); + + // Convert to base64 + String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP); + + // Extract filename from URI or use default + String actualFileName = fileName; + if (actualFileName == null || actualFileName.isEmpty()) { + String path = imageUri.getPath(); + if (path != null) { + int lastSlash = path.lastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < path.length() - 1) { + actualFileName = path.substring(lastSlash + 1); + } + } + if (actualFileName == null || actualFileName.isEmpty()) { + actualFileName = "shared-image.jpg"; + } + } + + // Write to temp file in app's internal files directory + // JavaScript will read this file using Capacitor's Filesystem plugin + writeSharedImageToTempFile(base64String, actualFileName); + + Log.d(TAG, "Successfully processed shared image: " + actualFileName); + } catch (IOException e) { + Log.e(TAG, "Error processing shared image", e); + } catch (Exception e) { + Log.e(TAG, "Unexpected error processing shared image", e); + } + } + + /** + * Write shared image data to temp JSON file for JavaScript to read + * File is written to app's internal files directory (accessible via Capacitor Filesystem plugin) + */ + private void writeSharedImageToTempFile(String base64, String fileName) { + try { + // Get app's internal files directory + File filesDir = getFilesDir(); + File tempFile = new File(filesDir, TEMP_FILE_NAME); + + // Create JSON object + JSONObject jsonData = new JSONObject(); + jsonData.put("base64", base64); + jsonData.put("fileName", fileName); + + // Write to file + FileWriter writer = new FileWriter(tempFile); + writer.write(jsonData.toString()); + writer.close(); + + Log.d(TAG, "Wrote shared image data to temp file: " + tempFile.getAbsolutePath()); + } catch (Exception e) { + Log.e(TAG, "Error writing shared image to temp file", e); + } + } } \ No newline at end of file diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index 402c7fbbf4..1bc7f08ef7 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -63,12 +63,14 @@ let isProcessingSharedImage = false; * More reliable than hardcoded timeout - checks if file actually exists * * @param filePath - Path to the file to check + * @param directory - Directory to check (default: Directory.Documents) * @param maxRetries - Maximum number of retry attempts (default: 5) * @param initialDelay - Initial delay in milliseconds (default: 100) * @returns Promise - true if file exists, false if max retries reached */ async function pollForFileExistence( filePath: string, + directory: Directory = Directory.Documents, maxRetries: number = 5, initialDelay: number = 100, ): Promise { @@ -76,7 +78,7 @@ async function pollForFileExistence( try { await Filesystem.stat({ path: filePath, - directory: Directory.Documents, + directory: directory, }); // File exists return true; @@ -176,12 +178,19 @@ async function checkAndStoreNativeSharedImage(): Promise<{ isProcessingSharedImage = true; try { - if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") { + if ( + !Capacitor.isNativePlatform() || + (Capacitor.getPlatform() !== "ios" && + Capacitor.getPlatform() !== "android") + ) { isProcessingSharedImage = false; return { success: false }; } - logger.debug("[Main] Checking for iOS shared image from App Group"); + const platform = Capacitor.getPlatform(); + logger.debug( + `[Main] Checking for ${platform} shared image from native layer`, + ); // Use Capacitor's native bridge to call the ShareImagePlugin // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -194,16 +203,22 @@ async function checkAndStoreNativeSharedImage(): Promise<{ } // WORKAROUND: Since the plugin isn't being auto-discovered, use a temp file bridge - // AppDelegate writes the shared image data to a temp file, and we read it here + // Native layer (AppDelegate on iOS, MainActivity on Android) writes the shared image data to a temp file, and we read it here const tempFilePath = "timesafari_shared_photo.json"; + // Use platform-specific directory: + // - iOS: Directory.Documents (AppDelegate writes to Documents directory) + // - Android: Directory.Data (MainActivity writes to getFilesDir() which maps to Data) + const fileDirectory = + platform === "android" ? Directory.Data : Directory.Documents; + // Check if file exists first (more reliable than hardcoded timeout) - const fileExists = await pollForFileExistence(tempFilePath); + const fileExists = await pollForFileExistence(tempFilePath, fileDirectory); if (fileExists) { try { const fileContent = await Filesystem.readFile({ path: tempFilePath, - directory: Directory.Documents, + directory: fileDirectory, encoding: Encoding.UTF8, }); @@ -223,7 +238,7 @@ async function checkAndStoreNativeSharedImage(): Promise<{ try { await Filesystem.deleteFile({ path: tempFilePath, - directory: Directory.Documents, + directory: fileDirectory, }); logger.debug("[Main] Deleted temp file after reading"); } catch (deleteError) { @@ -492,7 +507,10 @@ const registerDeepLinkListener = async () => { * This is called when app becomes active (from share extension or app launch) */ async function checkForSharedImageAndNavigate() { - if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") { + if ( + !Capacitor.isNativePlatform() || + (Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android") + ) { return; } @@ -540,15 +558,21 @@ logger.log("[Capacitor] 🚀 Mounting app"); app.mount("#app"); logger.info(`[Main] ✅ App mounted successfully`); -// Check for shared image on initial load (in case app was launched from share extension) -if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") { +// Check for shared image on initial load (in case app was launched from share sheet) +if ( + Capacitor.isNativePlatform() && + (Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android") +) { setTimeout(async () => { await checkForSharedImageAndNavigate(); }, 1000); // Small delay to ensure router is ready } // Listen for app state changes to detect when app becomes active -if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") { +if ( + Capacitor.isNativePlatform() && + (Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android") +) { CapacitorApp.addListener("appStateChange", async ({ isActive }) => { if (isActive) { logger.debug("[Main] 📱 App became active, checking for shared image");