Merge pull request 'web-share-target-native-implementation' (#227) from web-share-target-native-implementation into master

Reviewed-on: #227
This commit was merged in pull request #227.
This commit is contained in:
2025-12-16 06:44:55 +00:00
33 changed files with 4020 additions and 9 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 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 { SharedImage } from "./plugins/SharedImagePlugin";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -51,6 +55,55 @@ window.addEventListener("unhandledrejection", (event) => {
const deepLinkHandler = new DeepLinkHandler(router);
// Lock to prevent duplicate processing of shared images
let isProcessingSharedImage = false;
/**
* Stores shared image data in temp database
* Handles clearing old data, converting base64 to data URL, and storing
*
* @param base64 - Raw base64 string of the image
* @param fileName - Optional filename for logging
* @returns Promise<void>
*/
async function storeSharedImageInTempDB(
base64: string,
fileName?: string,
): Promise<void> {
const platformService = PlatformServiceFactory.getInstance();
// Clear old image from temp DB first to ensure we get the new one
try {
await platformService.dbExec("DELETE FROM temp WHERE id = ?", [
SHARED_PHOTO_BASE64_KEY,
]);
logger.debug("[Main] Cleared old shared image from temp DB");
} catch (clearError) {
logger.debug(
"[Main] No old image to clear (or error clearing):",
clearError,
);
}
// 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],
);
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
}
/**
* Handles deep link routing for the application
* Processes URLs in the format timesafari://<route>/<param>
@@ -67,11 +120,142 @@ const deepLinkHandler = new DeepLinkHandler(router);
*
* @throws {Error} If URL format is invalid
*/
/**
* Check for native shared image using SharedImage plugin
* Reads from native layer (App Group UserDefaults on iOS, SharedPreferences on Android)
* and stores in temp database before routing to shared-photo view
*/
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
// Prevent duplicate processing
if (isProcessingSharedImage) {
logger.debug(
"[Main] ⏸️ Shared image processing already in progress, skipping",
);
return { success: false };
}
isProcessingSharedImage = true;
try {
if (
!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" &&
Capacitor.getPlatform() !== "android")
) {
isProcessingSharedImage = false;
return { success: false };
}
// Use SharedImage plugin to get shared image data directly from native layer
// No file I/O or polling needed - direct native-to-JS communication
let result;
try {
result = await SharedImage.getSharedImage();
} catch (error) {
logger.error("[Main] Error calling SharedImage.getSharedImage():", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
isProcessingSharedImage = false;
return { success: false };
}
// Check if we have valid image data (base64 must be non-null and non-empty)
if (result && result.base64 && result.base64.trim().length > 0) {
const fileName = result.fileName || "shared-image.jpg";
// Store in temp database using extracted method
logger.info(
"[Main] Native shared image found (via plugin), storing in temp DB",
);
await storeSharedImageInTempDB(result.base64, fileName);
isProcessingSharedImage = false;
return { success: true, fileName };
}
// No shared image found
logger.debug("[Main] No shared image found via plugin");
isProcessingSharedImage = false;
return { success: false };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
isProcessingSharedImage = false;
return { success: false };
}
}
const handleDeepLink = async (data: { url: string }) => {
const { url } = data;
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
try {
// Handle empty path URLs from share extension (timesafari://)
// These are used to open the app, and we should check for shared images
const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///";
if (
isEmptyPathUrl &&
Capacitor.isNativePlatform() &&
Capacitor.getPlatform() === "ios"
) {
logger.debug(
"[Main] 📸 Empty path URL from share extension, checking for native shared image",
);
// Try to get shared image from App Group and store in temp database
// AppDelegate writes the file when the deep link is received, so we may need to retry
// The checkAndStoreNativeSharedImage function now uses polling internally, so we just call it once
try {
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Native shared image found, navigating to shared-photo",
);
// Wait for router to be ready
await router.isReady();
// Navigate directly to shared-photo route
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
await router.push({
path: "/shared-photo",
query: { fileName },
});
}
logger.info(
`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`,
);
return; // Exit early, don't process as deep link
} else {
logger.debug(
"[Main] No native shared image found, ignoring empty path URL",
);
return; // Exit early, don't process empty path as deep link
}
} catch (error) {
logger.error("[Main] Error processing native shared image:", error);
// If check fails, don't process as deep link (empty path would fail validation anyway)
return;
}
}
// Wait for router to be ready
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
await router.isReady();
@@ -159,10 +343,96 @@ const registerDeepLinkListener = async () => {
}
};
/**
* Check for shared image and navigate to shared-photo route if found
* This is called when app becomes active (from share extension or app launch)
*/
async function checkForSharedImageAndNavigate() {
if (
!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android")
) {
return;
}
try {
logger.debug("[Main] 🔍 Checking for shared image on app activation");
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Shared image found, navigating to shared-photo route",
);
// Wait for router to be ready
await router.isReady();
// Navigate to shared-photo route with fileName if available
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
const route = imageResult.fileName
? `/shared-photo?fileName=${encodeURIComponent(imageResult.fileName)}`
: "/shared-photo";
await router.push(route);
}
logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`);
} else {
logger.debug("[Main] No shared image found");
}
} catch (error) {
logger.error("[Main] ❌ Error checking for shared image:", error);
}
}
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 sheet)
// On Android, share intents are processed in MainActivity.onCreate, so we need to check
// after a delay to ensure the native code has finished processing
if (
Capacitor.isNativePlatform() &&
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
) {
// Use multiple checks with increasing delays to handle timing issues
// Android share intent processing happens in onCreate, which may complete after JS loads
const checkDelays =
Capacitor.getPlatform() === "android"
? [500, 1500, 3000] // Android needs more time for share intent processing
: [1000]; // iOS is faster
checkDelays.forEach((delay) => {
setTimeout(async () => {
await checkForSharedImageAndNavigate();
}, delay);
});
}
// Listen for app state changes to detect when app becomes active
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");
await checkForSharedImageAndNavigate();
}
});
}
// Register deeplink listener after app is mounted
setTimeout(async () => {
try {

View File

@@ -0,0 +1,15 @@
/**
* SharedImage Capacitor Plugin
* Provides access to shared image data from native Share Extension/Intent
*/
import { registerPlugin } from "@capacitor/core";
import type { SharedImagePlugin } from "./definitions";
const SharedImage = registerPlugin<SharedImagePlugin>("SharedImage", {
web: () =>
import("./SharedImagePlugin.web").then((m) => new m.SharedImagePluginWeb()),
});
export * from "./definitions";
export { SharedImage };

View File

@@ -0,0 +1,21 @@
/**
* Web implementation of SharedImagePlugin
* Returns null/false for web platform (no native sharing support)
*/
import { WebPlugin } from "@capacitor/core";
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
export class SharedImagePluginWeb
extends WebPlugin
implements SharedImagePlugin
{
async getSharedImage(): Promise<SharedImageResult | null> {
// Web platform doesn't support native sharing
return null;
}
async hasSharedImage(): Promise<{ hasImage: boolean }> {
return { hasImage: false };
}
}

View File

@@ -0,0 +1,23 @@
/**
* Type definitions for SharedImage plugin
*/
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
/**
* Get shared image data from native layer
* Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading
*/
getSharedImage(): Promise<SharedImageResult | null>;
/**
* Check if shared image exists without reading it
* Useful for quick checks before calling getSharedImage()
*/
hasSharedImage(): Promise<{ hasImage: boolean }>;
}

View File

@@ -120,7 +120,7 @@
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { Component, Vue, Watch } from "vue-facing-decorator";
import {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
@@ -191,7 +191,30 @@ export default class SharedPhotoView extends Vue {
*/
async mounted() {
this.notify = createNotifyHelpers(this.$notify);
await this.loadSharedImage();
}
/**
* Watches for route query changes to reload image when navigating
* to the same route with different query parameters (e.g., new fileName or _refresh)
* This handles both new navigations and refreshes when already on the route
*/
@Watch("$route.query", { deep: true })
async onRouteQueryChange() {
// Reload image when any query parameter changes (fileName or _refresh)
// This ensures the image refreshes when a new image is shared while already on this view
await this.loadSharedImage();
}
/**
* Loads the shared image from temporary storage
*
* Retrieves the shared image data from the temp database, converts it to a blob,
* and updates component state. Cleans up temporary storage after successful loading.
*
* @async
*/
private async loadSharedImage() {
try {
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -209,6 +232,9 @@ export default class SharedPhotoView extends Vue {
this.imageFileName = this.$route.query["fileName"] as string;
} else {
logger.error("No appropriate image found in temp storage.", temp);
// Clear image state if no temp data found
this.imageBlob = undefined;
this.imageFileName = undefined;
}
} catch (err: unknown) {
logger.error("Got an error loading an identifier:", err);
@@ -216,6 +242,9 @@ export default class SharedPhotoView extends Vue {
NOTIFY_SHARED_PHOTO_LOAD_ERROR.message,
TIMEOUTS.STANDARD,
);
// Clear image state on error
this.imageBlob = undefined;
this.imageFileName = undefined;
}
}