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:
@@ -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 {
|
||||
|
||||
15
src/plugins/SharedImagePlugin.ts
Normal file
15
src/plugins/SharedImagePlugin.ts
Normal 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 };
|
||||
21
src/plugins/SharedImagePlugin.web.ts
Normal file
21
src/plugins/SharedImagePlugin.web.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
23
src/plugins/definitions.ts
Normal file
23
src/plugins/definitions.ts
Normal 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 }>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user