adjust the URL for sharing a photo with the app (since it was going to the root "/" home route for sharing and that caused conflicts when we actually want to deep-link to the home page)
This commit is contained in:
@@ -30,6 +30,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
private static final String KEY_BASE64 = "shared_image_base64";
|
private static final String KEY_BASE64 = "shared_image_base64";
|
||||||
private static final String KEY_FILE_NAME = "shared_image_file_name";
|
private static final String KEY_FILE_NAME = "shared_image_file_name";
|
||||||
private static final String KEY_READY = "shared_image_ready";
|
private static final String KEY_READY = "shared_image_ready";
|
||||||
|
private static final Uri SHARED_PHOTO_DEEP_LINK = Uri.parse("timesafari://shared-photo");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
@@ -121,7 +122,9 @@ public class MainActivity extends BridgeActivity {
|
|||||||
}
|
}
|
||||||
if (imageUri != null) {
|
if (imageUri != null) {
|
||||||
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
|
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||||
processSharedImage(imageUri, fileName);
|
if (processSharedImage(imageUri, fileName)) {
|
||||||
|
notifySharedPhotoDeepLink();
|
||||||
|
}
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +141,9 @@ public class MainActivity extends BridgeActivity {
|
|||||||
imageUris = uris;
|
imageUris = uris;
|
||||||
}
|
}
|
||||||
if (imageUris != null && !imageUris.isEmpty()) {
|
if (imageUris != null && !imageUris.isEmpty()) {
|
||||||
processSharedImage(imageUris.get(0), null);
|
if (processSharedImage(imageUris.get(0), null)) {
|
||||||
|
notifySharedPhotoDeepLink();
|
||||||
|
}
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,10 +162,12 @@ public class MainActivity extends BridgeActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a shared image: read it, convert to base64, and write to temp file
|
* Process a shared image: read it, convert to base64, and write it to SharedPreferences
|
||||||
* Uses try-with-resources to ensure proper stream cleanup and prevent network issues
|
* Uses try-with-resources to ensure proper stream cleanup and prevent network issues
|
||||||
|
*
|
||||||
|
* @return true when the image is available to the SharedImage plugin
|
||||||
*/
|
*/
|
||||||
private void processSharedImage(Uri imageUri, String fileName) {
|
private boolean processSharedImage(Uri imageUri, String fileName) {
|
||||||
// Extract filename from URI or use default (do this before opening streams)
|
// Extract filename from URI or use default (do this before opening streams)
|
||||||
String actualFileName = fileName;
|
String actualFileName = fileName;
|
||||||
if (actualFileName == null || actualFileName.isEmpty()) {
|
if (actualFileName == null || actualFileName.isEmpty()) {
|
||||||
@@ -183,7 +190,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
|
|
||||||
if (inputStream == null) {
|
if (inputStream == null) {
|
||||||
Log.e(TAG, "Failed to open input stream for shared image");
|
Log.e(TAG, "Failed to open input stream for shared image");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read image bytes
|
// Read image bytes
|
||||||
@@ -199,21 +206,36 @@ public class MainActivity extends BridgeActivity {
|
|||||||
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
|
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
|
||||||
|
|
||||||
// Store in SharedPreferences for plugin to read
|
// Store in SharedPreferences for plugin to read
|
||||||
storeSharedImageInPreferences(base64String, actualFileName);
|
if (!storeSharedImageInPreferences(base64String, actualFileName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Successfully processed shared image: " + actualFileName);
|
Log.d(TAG, "Successfully processed shared image: " + actualFileName);
|
||||||
|
return true;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.e(TAG, "Error processing shared image", e);
|
Log.e(TAG, "Error processing shared image", e);
|
||||||
|
return false;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Unexpected error processing shared image", e);
|
Log.e(TAG, "Unexpected error processing shared image", e);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver an App-plugin URL event after the shared image is ready. The App plugin retains
|
||||||
|
* URL-open events until JavaScript registers its listener, covering both cold and warm starts.
|
||||||
|
*/
|
||||||
|
private void notifySharedPhotoDeepLink() {
|
||||||
|
Intent deepLinkIntent = new Intent(Intent.ACTION_VIEW, SHARED_PHOTO_DEEP_LINK);
|
||||||
|
getBridge().onNewIntent(deepLinkIntent);
|
||||||
|
Log.d(TAG, "Delivered shared-photo deep link to Capacitor");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store shared image data in SharedPreferences for plugin to read
|
* Store shared image data in SharedPreferences for plugin to read
|
||||||
* Plugin will read and clear the data when called
|
* Plugin will read and clear the data when called
|
||||||
*/
|
*/
|
||||||
private void storeSharedImageInPreferences(String base64, String fileName) {
|
private boolean storeSharedImageInPreferences(String base64, String fileName) {
|
||||||
try {
|
try {
|
||||||
SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
|
SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
|
||||||
SharedPreferences.Editor editor = prefs.edit();
|
SharedPreferences.Editor editor = prefs.edit();
|
||||||
@@ -223,9 +245,11 @@ public class MainActivity extends BridgeActivity {
|
|||||||
editor.apply();
|
editor.apply();
|
||||||
|
|
||||||
Log.d(TAG, "Stored shared image data in SharedPreferences");
|
Log.d(TAG, "Stored shared image data in SharedPreferences");
|
||||||
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error storing shared image in SharedPreferences", e);
|
Log.e(TAG, "Error storing shared image in SharedPreferences", e);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||||
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
|
// Note: Share Extension opens app with timesafari://shared-photo, which is handled by JavaScript
|
||||||
// via the appUrlOpen listener in main.capacitor.ts
|
// via the appUrlOpen listener in main.capacitor.ts
|
||||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class ShareViewController: UIViewController {
|
|||||||
if success {
|
if success {
|
||||||
// Set flag that shared photo is ready
|
// Set flag that shared photo is ready
|
||||||
self.setSharedPhotoReadyFlag()
|
self.setSharedPhotoReadyFlag()
|
||||||
// Open the main app (using minimal URL - app will detect shared data on activation)
|
// Open the main app at its shared-photo deep link.
|
||||||
self.openMainApp()
|
self.openMainApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,8 +186,7 @@ class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openMainApp() {
|
private func openMainApp() {
|
||||||
// Open the main app with minimal URL - app will detect shared data on activation
|
guard let url = URL(string: "timesafari://shared-photo") else {
|
||||||
guard let url = URL(string: "timesafari://") else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export const deepLinkPathSchemas = {
|
|||||||
project: z.object({
|
project: z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
}),
|
}),
|
||||||
|
// Internal handoff used by the native image-share targets.
|
||||||
|
"shared-photo": z.object({}),
|
||||||
"user-profile": z.object({
|
"user-profile": z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+65
-183
@@ -39,6 +39,7 @@ import { logger, safeStringify } from "./utils/logger";
|
|||||||
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
|
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
|
||||||
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
|
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
|
||||||
import { SharedImage } from "./plugins/SharedImagePlugin";
|
import { SharedImage } from "./plugins/SharedImagePlugin";
|
||||||
|
import { createSharedImageHandoff } from "./services/sharedImageHandoff";
|
||||||
import "./utils/safeAreaInset";
|
import "./utils/safeAreaInset";
|
||||||
|
|
||||||
// Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery)
|
// Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery)
|
||||||
@@ -89,9 +90,6 @@ window.addEventListener("unhandledrejection", (event) => {
|
|||||||
|
|
||||||
const deepLinkHandler = new DeepLinkHandler(router);
|
const deepLinkHandler = new DeepLinkHandler(router);
|
||||||
|
|
||||||
// Lock to prevent duplicate processing of shared images
|
|
||||||
let isProcessingSharedImage = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores shared image data in temp database
|
* Stores shared image data in temp database
|
||||||
* Handles clearing old data, converting base64 to data URL, and storing
|
* Handles clearing old data, converting base64 to data URL, and storing
|
||||||
@@ -138,87 +136,80 @@ async function storeSharedImageInTempDB(
|
|||||||
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
|
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function navigateToSharedPhoto(fileName: string): Promise<void> {
|
||||||
* Handles deep link routing for the application
|
await router.isReady();
|
||||||
* Processes URLs in the format timesafari://<route>/<param>
|
|
||||||
* Maps incoming deep links to corresponding router paths with parameters
|
if (router.currentRoute.value.path === "/shared-photo") {
|
||||||
*
|
await router.replace({
|
||||||
* @param {Object} data - Deep link data object
|
path: "/shared-photo",
|
||||||
* @param {string} data.url - The full deep link URL to process
|
query: { fileName, _refresh: Date.now().toString() },
|
||||||
* @returns {Promise<void>}
|
});
|
||||||
*
|
} else {
|
||||||
* @example
|
await router.push({
|
||||||
* // Handles URLs like:
|
path: "/shared-photo",
|
||||||
* // timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H
|
query: { fileName },
|
||||||
* // timesafari://project/abc123
|
});
|
||||||
*
|
|
||||||
* @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;
|
logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const sharedImageHandoff = createSharedImageHandoff({
|
||||||
if (
|
getSharedImage: async () => {
|
||||||
!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 {
|
try {
|
||||||
result = await SharedImage.getSharedImage();
|
return await SharedImage.getSharedImage();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[Main] Error calling SharedImage.getSharedImage():", {
|
logger.error("[Main] Error calling SharedImage.getSharedImage():", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
isProcessingSharedImage = false;
|
return null;
|
||||||
return { success: false };
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
storeSharedImage: async (base64, fileName) => {
|
||||||
|
logger.info("[Main] Native shared image found, storing in temp DB");
|
||||||
|
await storeSharedImageInTempDB(base64, fileName);
|
||||||
|
},
|
||||||
|
navigateToSharedPhoto,
|
||||||
|
});
|
||||||
|
|
||||||
// Check if we have valid image data (base64 must be non-null and non-empty)
|
/**
|
||||||
if (result && result.base64 && result.base64.trim().length > 0) {
|
* Consume a pending native shared image and route to the share screen. The
|
||||||
const fileName = result.fileName || "shared-image.jpg";
|
* in-flight handoff is shared by URL, launch, and activation callbacks because
|
||||||
|
* the native plugin removes an image after it is read.
|
||||||
|
*/
|
||||||
|
async function checkForSharedImageAndNavigate(): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
!Capacitor.isNativePlatform() ||
|
||||||
|
(Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Store in temp database using extracted method
|
try {
|
||||||
logger.info(
|
const navigated = await sharedImageHandoff.process();
|
||||||
"[Main] Native shared image found (via plugin), storing in temp DB",
|
if (!navigated) {
|
||||||
);
|
logger.debug("[Main] ℹ️ No native shared image found");
|
||||||
await storeSharedImageInTempDB(result.base64, fileName);
|
|
||||||
|
|
||||||
isProcessingSharedImage = false;
|
|
||||||
return { success: true, fileName };
|
|
||||||
}
|
}
|
||||||
|
return navigated;
|
||||||
// No shared image found
|
|
||||||
logger.debug("[Main] No shared image found via plugin");
|
|
||||||
isProcessingSharedImage = false;
|
|
||||||
return { success: false };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[Main] Error checking for native shared image:", error);
|
logger.error("[Main] ❌ Error processing native shared image:", error);
|
||||||
isProcessingSharedImage = false;
|
return false;
|
||||||
return { success: false };
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSharedPhotoDeepLink(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return (
|
||||||
|
parsed.protocol === "timesafari:" &&
|
||||||
|
parsed.hostname === "shared-photo" &&
|
||||||
|
(parsed.pathname === "" || parsed.pathname === "/") &&
|
||||||
|
!parsed.search &&
|
||||||
|
!parsed.hash
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,67 +218,10 @@ const handleDeepLink = async (data: { url: string }) => {
|
|||||||
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Handle empty path URLs from share extension (timesafari://)
|
if (isSharedPhotoDeepLink(url) && Capacitor.isNativePlatform()) {
|
||||||
// These are used to open the app, and we should check for shared images
|
logger.debug("[Main] 📸 Shared-photo handoff received");
|
||||||
const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///";
|
await checkForSharedImageAndNavigate();
|
||||||
|
return;
|
||||||
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
|
// Wait for router to be ready
|
||||||
@@ -377,58 +311,6 @@ 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");
|
logger.log("[Capacitor] 🚀 Mounting app");
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
logger.info(`[Main] ✅ App mounted successfully`);
|
logger.info(`[Main] ✅ App mounted successfully`);
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ROUTE_MAP: Record<string, { name: string; paramKey?: string }> =
|
|||||||
const ROUTE_NAME_RESOLVERS: Record<string, () => string> = {
|
const ROUTE_NAME_RESOLVERS: Record<string, () => string> = {
|
||||||
// An empty path is the app's front door.
|
// An empty path is the app's front door.
|
||||||
"": () => "home",
|
"": () => "home",
|
||||||
|
// The native handoff stores its image before the route is opened. The normal
|
||||||
|
// handler keeps this route valid for the web and desktop implementations.
|
||||||
|
"shared-photo": () => "shared-photo",
|
||||||
// Both QR paths mean "show my QR code and scan theirs". Which view provides
|
// Both QR paths mean "show my QR code and scan theirs". Which view provides
|
||||||
// that differs by platform: a full-screen camera view where a native scanner
|
// that differs by platform: a full-screen camera view where a native scanner
|
||||||
// is available, an ordinary page where the in-page web reader is used.
|
// is available, an ordinary page where the in-page web reader is used.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Coordinates consuming a native shared image and opening the shared-photo view.
|
||||||
|
*
|
||||||
|
* Native image storage is consumed when read. Several lifecycle events can arrive
|
||||||
|
* for one share, so callers must share one in-flight operation.
|
||||||
|
*/
|
||||||
|
export interface NativeSharedImage {
|
||||||
|
base64: string | null | undefined;
|
||||||
|
fileName: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedImageHandoffDependencies {
|
||||||
|
getSharedImage: () => Promise<NativeSharedImage | null>;
|
||||||
|
storeSharedImage: (base64: string, fileName: string) => Promise<void>;
|
||||||
|
navigateToSharedPhoto: (fileName: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedImageHandoff {
|
||||||
|
process: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSharedImageHandoff({
|
||||||
|
getSharedImage,
|
||||||
|
storeSharedImage,
|
||||||
|
navigateToSharedPhoto,
|
||||||
|
}: SharedImageHandoffDependencies): SharedImageHandoff {
|
||||||
|
let inFlight: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
const processOnce = async (): Promise<boolean> => {
|
||||||
|
const image = await getSharedImage();
|
||||||
|
if (!image?.base64?.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = image.fileName || "shared-image.jpg";
|
||||||
|
await storeSharedImage(image.base64, fileName);
|
||||||
|
await navigateToSharedPhoto(fileName);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async process(): Promise<boolean> {
|
||||||
|
if (inFlight) {
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = processOnce();
|
||||||
|
inFlight = operation;
|
||||||
|
try {
|
||||||
|
return await operation;
|
||||||
|
} finally {
|
||||||
|
if (inFlight === operation) {
|
||||||
|
inFlight = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
jest.mock("../../services/QRNavigationService", () => ({
|
||||||
|
QRNavigationService: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getQRScannerRoute: () => ({ name: "contact-qr" }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../utils/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DeepLinkHandler } from "../../services/deepLinks";
|
||||||
|
|
||||||
|
function routerSpy() {
|
||||||
|
return {
|
||||||
|
replace: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DeepLinkHandler share routes", () => {
|
||||||
|
it("routes a bare URL to home", async () => {
|
||||||
|
const router = routerSpy();
|
||||||
|
const handler = new DeepLinkHandler(router as never);
|
||||||
|
|
||||||
|
await handler.handleDeepLink("timesafari://");
|
||||||
|
|
||||||
|
expect(router.replace).toHaveBeenCalledWith({
|
||||||
|
name: "home",
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes the native shared-photo handoff route", async () => {
|
||||||
|
const router = routerSpy();
|
||||||
|
const handler = new DeepLinkHandler(router as never);
|
||||||
|
|
||||||
|
await handler.handleDeepLink("timesafari://shared-photo");
|
||||||
|
|
||||||
|
expect(router.replace).toHaveBeenCalledWith({
|
||||||
|
name: "shared-photo",
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { createSharedImageHandoff } from "../../services/sharedImageHandoff";
|
||||||
|
|
||||||
|
describe("createSharedImageHandoff", () => {
|
||||||
|
it("shares one native read and navigation across concurrent callers", async () => {
|
||||||
|
let releaseRead: (image: { base64: string; fileName: string }) => void;
|
||||||
|
const getSharedImage = jest.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<{ base64: string; fileName: string }>((resolve) => {
|
||||||
|
releaseRead = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const storeSharedImage = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const navigateToSharedPhoto = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const handoff = createSharedImageHandoff({
|
||||||
|
getSharedImage,
|
||||||
|
storeSharedImage,
|
||||||
|
navigateToSharedPhoto,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = handoff.process();
|
||||||
|
const second = handoff.process();
|
||||||
|
releaseRead!({ base64: "image-data", fileName: "photo.jpg" });
|
||||||
|
|
||||||
|
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||||
|
expect(getSharedImage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storeSharedImage).toHaveBeenCalledWith("image-data", "photo.jpg");
|
||||||
|
expect(navigateToSharedPhoto).toHaveBeenCalledWith("photo.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not navigate when native storage has no image", async () => {
|
||||||
|
const storeSharedImage = jest.fn();
|
||||||
|
const navigateToSharedPhoto = jest.fn();
|
||||||
|
const handoff = createSharedImageHandoff({
|
||||||
|
getSharedImage: async () => null,
|
||||||
|
storeSharedImage,
|
||||||
|
navigateToSharedPhoto,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(handoff.process()).resolves.toBe(false);
|
||||||
|
expect(storeSharedImage).not.toHaveBeenCalled();
|
||||||
|
expect(navigateToSharedPhoto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a later share after the first operation completes", async () => {
|
||||||
|
const getSharedImage = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ base64: "first", fileName: "first.jpg" })
|
||||||
|
.mockResolvedValueOnce({ base64: "second", fileName: "second.jpg" });
|
||||||
|
const storeSharedImage = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const navigateToSharedPhoto = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const handoff = createSharedImageHandoff({
|
||||||
|
getSharedImage,
|
||||||
|
storeSharedImage,
|
||||||
|
navigateToSharedPhoto,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handoff.process();
|
||||||
|
await handoff.process();
|
||||||
|
|
||||||
|
expect(getSharedImage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(navigateToSharedPhoto).toHaveBeenNthCalledWith(1, "first.jpg");
|
||||||
|
expect(navigateToSharedPhoto).toHaveBeenNthCalledWith(2, "second.jpg");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user