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.
This commit is contained in:
Jose Olarte III
2025-11-26 20:01:14 +08:00
parent 09a230f43e
commit e1eb91f26d
3 changed files with 180 additions and 11 deletions

View File

@@ -27,6 +27,20 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
<!-- Share Target Intent Filter - Single Image -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Share Target Intent Filter - Multiple Images (optional, we'll handle first image) -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<provider

View File

@@ -1,6 +1,10 @@
package app.timesafari;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Base64;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowInsetsController;
@@ -13,7 +17,17 @@ import com.getcapacitor.BridgeActivity;
import app.timesafari.safearea.SafeAreaPlugin;
//import com.getcapacitor.community.sqlite.SQLite;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import org.json.JSONObject;
public class MainActivity extends BridgeActivity {
private static final String TAG = "MainActivity";
private static final String TEMP_FILE_NAME = "timesafari_shared_photo.json";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -50,7 +64,124 @@ public class MainActivity extends BridgeActivity {
// Initialize SQLite
//registerPlugin(SQLite.class);
// Handle share intent if app was launched from share sheet
handleShareIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleShareIntent(intent);
}
/**
* Handle share intents (ACTION_SEND or ACTION_SEND_MULTIPLE)
* Processes shared images and writes them to a temp file for JavaScript to read
*/
private void handleShareIntent(Intent intent) {
if (intent == null) {
return;
}
String action = intent.getAction();
String type = intent.getType();
// Handle single image share
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (imageUri != null) {
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
processSharedImage(imageUri, fileName);
}
}
// Handle multiple images share (we'll just process the first one)
else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
java.util.ArrayList<Uri> 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);
}
}
}

View File

@@ -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<boolean> - 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<boolean> {
@@ -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");