feat(ios): implement native share target for images

Implement iOS Share Extension to enable native image sharing from Photos
and other apps directly into TimeSafari. Users can now share images from
the iOS share sheet, which will open in SharedPhotoView for use as gifts
or profile pictures.

iOS Native Implementation:
- Add TimeSafariShareExtension target with ShareViewController
- Configure App Groups for data sharing between extension and main app
- Implement ShareViewController to process shared images and convert to base64
- Store shared image data in App Group UserDefaults
- Add ShareImageBridge utility to read shared data from App Group
- Update AppDelegate to handle shared-photo deep link and bridge data to JS

JavaScript Integration:
- Add checkAndStoreNativeSharedImage() in main.capacitor.ts to read shared
  images from native layer via temporary file bridge
- Convert base64 data to data URL format for compatibility with base64ToBlob
- Integrate with existing SharedPhotoView component
- Add "shared-photo" to deep link validation schema

Build System:
- Integrate Xcode 26 / CocoaPods compatibility workaround into build-ios.sh
- Add run_pod_install_with_workaround() for explicit pod install
- Add run_cap_sync_with_workaround() for Capacitor sync (which runs pod
  install internally)
- Automatically detect project format version 70 and apply workaround
- Remove standalone pod-install-workaround.sh script

Code Cleanup:
- Remove verbose debug logs from ShareViewController, AppDelegate, and
  main.capacitor.ts
- Retain essential logger calls for production debugging

Documentation:
- Add ios-share-extension-setup.md with manual Xcode setup instructions
- Add ios-share-extension-git-commit-guide.md for version control best practices
- Add ios-share-implementation-status.md tracking implementation progress
- Add native-share-target-implementation.md with overall architecture
- Add xcode-26-cocoapods-workaround.md documenting the compatibility issue

The implementation uses a temporary file bridge (AppDelegate writes to Documents
directory, JS reads via Capacitor Filesystem plugin) as a workaround for
Capacitor plugin auto-discovery issues. This can be improved in the future by
properly registering ShareImagePlugin in Capacitor's plugin registry.
This commit is contained in:
Jose Olarte III
2025-11-24 20:46:58 +08:00
parent 1b4ab7a500
commit ae49c0e907
16 changed files with 1839 additions and 4 deletions

View File

@@ -30,11 +30,15 @@
import { initializeApp } from "./main.common";
import { App as CapacitorApp } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logger, safeStringify } from "./utils/logger";
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -67,11 +71,220 @@ const deepLinkHandler = new DeepLinkHandler(router);
*
* @throws {Error} If URL format is invalid
*/
/**
* Check for native shared image from iOS App Group UserDefaults
* and store in temp database before routing to shared-photo view
*/
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
return { success: false };
}
try {
logger.debug("[Main] Checking for iOS shared image from App Group");
// Use Capacitor's native bridge to call the ShareImagePlugin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const capacitor = (window as any).Capacitor;
if (!capacitor || !capacitor.Plugins) {
logger.debug("[Main] Capacitor plugins not available");
return { success: false };
}
// 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
try {
const tempFilePath = "timesafari_shared_photo.json";
const fileContent = await Filesystem.readFile({
path: tempFilePath,
directory: Directory.Documents,
encoding: Encoding.UTF8,
});
if (fileContent.data) {
const sharedData = JSON.parse(fileContent.data as string);
const base64 = sharedData.base64;
const fileName = sharedData.fileName || "shared-image.jpg";
if (base64) {
// Store in temp database using dbExec directly
logger.info(
"[Main] Native shared image found (via temp file), storing in temp DB",
);
const platformService = PlatformServiceFactory.getInstance();
// Convert raw base64 to data URL format that base64ToBlob expects
// base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..."
// Try to detect image type from base64 or default to jpeg
let mimeType = "image/jpeg"; // default
if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) {
// JPEG or PNG
mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
}
const dataUrl = `data:${mimeType};base64,${base64}`;
// Use INSERT OR REPLACE to handle existing records
await platformService.dbExec(
"INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)",
[SHARED_PHOTO_BASE64_KEY, dataUrl],
);
// Delete the temp file
try {
await Filesystem.deleteFile({
path: tempFilePath,
directory: Directory.Documents,
});
} catch (deleteError) {
logger.error("[Main] Failed to delete temp file:", deleteError);
}
logger.info(`[Main] Stored shared image: ${fileName}`);
return { success: true, fileName };
}
}
} catch (fileError: unknown) {
// File doesn't exist or can't be read - that's okay, try plugin method
logger.debug(
"[Main] Temp file not found or unreadable (this is normal if plugin works)",
);
}
// NOTE: Plugin registration issue - ShareImage plugin is not being auto-discovered
// This is a known issue that needs to be resolved. For now, we use the temp file workaround above.
// Try multiple methods to call the plugin (fallback if temp file method fails)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugins = (capacitor as any).Plugins;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const shareImagePlugin = (plugins as any)?.ShareImage;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any = null;
if (
shareImagePlugin &&
typeof shareImagePlugin.getSharedImageData === "function"
) {
logger.debug("[Main] Using direct plugin method");
try {
result = await shareImagePlugin.getSharedImageData();
} catch (pluginError) {
logger.error("[Main] Plugin call failed:", pluginError);
return { success: false };
}
} else {
// Method 2: Use Capacitor's execute method
logger.debug(
"[Main] Plugin not found directly, trying Capacitor.execute",
);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bridge = (capacitor as any).getBridge?.();
if (bridge) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await bridge.execute({
pluginId: "ShareImage",
methodName: "getSharedImageData",
options: {},
});
} else {
// Method 3: Try execute on Plugins object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((plugins as any)?.execute) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await (plugins as any).execute(
"ShareImage",
"getSharedImageData",
{},
);
}
}
} catch (executeError) {
logger.error("[Main] Execute method failed:", executeError);
return { success: false };
}
}
if (!result || !result.success || !result.data) {
logger.debug("[Main] No shared image data found in result");
return { success: false };
}
const { base64, fileName } = result.data;
if (!base64) {
logger.debug("[Main] Shared image data missing base64");
return { success: false };
}
logger.info("[Main] Native shared image found, storing in temp DB");
// Store in temp database (similar to web flow)
const platformService = PlatformServiceFactory.getInstance();
// $insertEntity is available via PlatformServiceMixin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (platformService as any).$insertEntity(
"temp",
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64 },
["id", "blobB64"],
);
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
return { success: true, fileName: fileName || "shared-image.jpg" };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
return { success: false };
}
}
const handleDeepLink = async (data: { url: string }) => {
const { url } = data;
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
try {
// Check if this is a shared-photo deep link from native share
const isSharedPhotoLink = url.includes("timesafari://shared-photo");
if (
isSharedPhotoLink &&
Capacitor.isNativePlatform() &&
Capacitor.getPlatform() === "ios"
) {
logger.debug(
"[Main] 📸 Shared photo deep link detected, checking for native shared image",
);
// Try to get shared image from App Group and store in temp database
try {
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info("[Main] ✅ Native shared image stored in temp database");
// Add fileName to the URL as a query parameter if we have it
if (imageResult.fileName) {
const urlObj = new URL(url);
urlObj.searchParams.set("fileName", imageResult.fileName);
const modifiedUrl = urlObj.toString();
data.url = modifiedUrl;
logger.debug(`[Main] Added fileName to URL: ${modifiedUrl}`);
}
} else {
logger.debug(
"[Main] No native shared image found (may be from web or already processed)",
);
}
} catch (error) {
logger.error("[Main] Error processing native shared image:", error);
// Continue with normal deep link processing even if native check fails
}
}
// Wait for router to be ready
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
await router.isReady();