Skip to content

Commit 44d2d43

Browse files
author
Lalit Sharma
committed
feat: add share intake and shared map link parsing functionality
- Implemented share intake service with tests for normalizing external links. - Added shared map link parser with support for various map providers and coordinate extraction. - Integrated share intent handling for incoming shared links in the mobile app. - Updated planning document to reflect progress on location share links feature. - Added patch for xcode dependency to resolve known issues.
1 parent b063036 commit 44d2d43

13 files changed

Lines changed: 1054 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.41] — 2026-02-27
9+
10+
### Added
11+
- Added shared map-link parsing support for Apple Maps and Google Maps links, including coordinate extraction from query params and Google `@lat,lon` path tokens.
12+
- Added short-link redirect expansion support for `maps.app.goo.gl` with timeout-safe fallback behavior.
13+
- Added shared-link intake utilities and tests to normalize incoming linking/share payloads and keep parsing behavior deterministic.
14+
- Added Android `ACTION_SEND` (`text/*`) intent filter to accept shared text/map URLs in the tracked native manifest.
15+
16+
### Changed
17+
- Integrated incoming map-link handling into `RootNavigator` so supported links navigate to `Timer`, jump the pin to parsed coordinates, and show `Location loaded from shared map link`.
18+
- Added shared-link dedupe guarding to avoid duplicate processing during cold-start intake (`getInitialURL` + runtime URL events).
19+
- Added `expo-share-intent` integration and wiring so share-target payloads feed the same parser/navigation pipeline as deep links.
20+
- Added pnpm `patchedDependencies` entry and `xcode@3.0.1` patch hardening to avoid known share-extension config-sync failures during native config generation.
21+
- Bumped `apps/mobile` version to `1.1.41`.
22+
823
## [1.1.40] — 2026-02-27
924

1025
### Fixed

apps/mobile/android/app/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
<category android:name="android.intent.category.BROWSABLE"/>
3232
<data android:scheme="eclipsetimer"/>
3333
</intent-filter>
34+
<intent-filter>
35+
<action android:name="android.intent.action.SEND"/>
36+
<category android:name="android.intent.category.DEFAULT"/>
37+
<data android:mimeType="text/*"/>
38+
</intent-filter>
3439
</activity>
3540
<service android:name=".wearable.WearDataLayerListenerService" android:exported="true">
3641
<intent-filter>

apps/mobile/app.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@
8383
},
8484
"plugins": [
8585
"expo-notifications",
86+
[
87+
"expo-share-intent",
88+
{
89+
"disableAndroid": true
90+
}
91+
],
8692
[
8793
"expo-location",
8894
{

apps/mobile/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eclipse-timer/mobile",
3-
"version": "1.1.40",
3+
"version": "1.1.41",
44
"private": true,
55
"main": "index.js",
66
"scripts": {
@@ -22,8 +22,11 @@
2222
"@react-navigation/native-stack": "^7.12.0",
2323
"@sentry/react-native": "^7.2.0",
2424
"expo": "~54.0.33",
25+
"expo-constants": "~18.0.10",
26+
"expo-linking": "~8.0.9",
2527
"expo-location": "~19.0.8",
2628
"expo-notifications": "~0.32.16",
29+
"expo-share-intent": "5.1.1",
2730
"expo-speech": "^14.0.8",
2831
"expo-splash-screen": "^31.0.13",
2932
"expo-updates": "^29.0.16",

apps/mobile/src/navigation/RootNavigator.tsx

Lines changed: 156 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
createNativeStackNavigator,
2525
type NativeStackScreenProps,
2626
} from "@react-navigation/native-stack";
27+
import { useShareIntent } from "expo-share-intent";
2728
import * as SplashScreen from "expo-splash-screen";
2829
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2930
import {
@@ -54,12 +55,18 @@ import PhotographyGuideScreen, {
5455
import SettingsScreen from "../screens/SettingsScreen";
5556
import ThemeSettingsScreen from "../screens/ThemeSettingsScreen";
5657
import TimerScreen from "../screens/TimerScreen";
58+
import {
59+
type IncomingExternalLink,
60+
normalizeSharePayloadToIncomingLinks,
61+
subscribeToIncomingExternalLinks,
62+
} from "../services/shareIntake";
5763
import { syncWearPreviewRouteState } from "../services/wearPreviewPublisher";
5864
import { startWearLiveSync } from "../services/wearSync";
5965
import { type FavoriteLocation, useAppState } from "../state/appState";
6066
import { useAppTheme } from "../theme/useAppTheme";
6167
import { localYmdNow } from "../utils/date";
6268
import { kindCodeForRecord, kindLabelFromCode } from "../utils/eclipse";
69+
import { type ParsedSharedMapLink, parseSharedMapLinkAsync } from "../utils/sharedMapLink";
6370
import FirstRunOnboardingOverlay from "./FirstRunOnboardingOverlay";
6471
import { ONBOARDING_WALKTHROUGH_STEPS, type OnboardingRouteName } from "./onboardingWalkthrough";
6572
import SideMenu, { type MenuRouteName } from "./SideMenu";
@@ -104,6 +111,8 @@ type LandingRouteProps = NativeStackScreenProps<RootStackParamList, "Landing"> &
104111
type TimerRouteProps = NativeStackScreenProps<RootStackParamList, "Timer"> & {
105112
catalog: EclipseRecord[];
106113
onOpenMenu: () => void;
114+
pendingSharedLocation: PendingSharedLocation | null;
115+
onConsumePendingSharedLocation: (id: string) => void;
107116
};
108117

109118
type PreviewRouteProps = NativeStackScreenProps<RootStackParamList, "Preview"> & {
@@ -197,6 +206,13 @@ type FeaturedEclipseDeepLinkAction =
197206
| { type: "open_timer"; eclipseId: string }
198207
| { type: "open_preview"; eclipseId: string };
199208

209+
type PendingSharedLocation = ParsedSharedMapLink & {
210+
id: string;
211+
};
212+
213+
const INCOMING_EXTERNAL_LINK_DEDUPE_WINDOW_MS = 5_000;
214+
const MAX_TRACKED_INCOMING_EXTERNAL_LINKS = 20;
215+
200216
const FEATURED_ECLIPSE_BY_SLUG: Record<string, string> = {
201217
"2026-total": "2026-08-12T",
202218
"2027-total": "2027-08-02T",
@@ -322,9 +338,16 @@ function LandingRoute({ navigation, catalog, onOpenMenu }: LandingRouteProps) {
322338
);
323339
}
324340

325-
function TimerRoute({ navigation, catalog, onOpenMenu }: TimerRouteProps) {
341+
function TimerRoute({
342+
navigation,
343+
catalog,
344+
onOpenMenu,
345+
pendingSharedLocation,
346+
onConsumePendingSharedLocation,
347+
}: TimerRouteProps) {
326348
const { state, actions } = useAppState();
327349
const isFocused = useIsFocused();
350+
const lastAppliedSharedLocationIdRef = useRef<string | null>(null);
328351
const [activeEclipse, setActiveEclipse] = useState<EclipseRecord | null>(null);
329352
const [isActiveEclipseLoading, setIsActiveEclipseLoading] = useState(false);
330353
const todayYmd = useMemo(() => localYmdNow(), []);
@@ -383,6 +406,16 @@ function TimerRoute({ navigation, catalog, onOpenMenu }: TimerRouteProps) {
383406
actions.removeEclipseReminderAnchor,
384407
);
385408

409+
useEffect(() => {
410+
if (!pendingSharedLocation) return;
411+
if (lastAppliedSharedLocationIdRef.current === pendingSharedLocation.id) return;
412+
413+
lastAppliedSharedLocationIdRef.current = pendingSharedLocation.id;
414+
timerState.jumpTo(pendingSharedLocation.lat, pendingSharedLocation.lon, 2);
415+
timerState.setStatusMessage("Location loaded from shared map link");
416+
onConsumePendingSharedLocation(pendingSharedLocation.id);
417+
}, [onConsumePendingSharedLocation, pendingSharedLocation, timerState]);
418+
386419
useInAppAlarmEngine({
387420
isRouteFocused: isFocused,
388421
activeEclipseId: state.activeEclipseId,
@@ -575,9 +608,17 @@ function LocationSettingsRoute({ onOpenMenu }: RouteWithMenuProps) {
575608
export default function RootNavigator() {
576609
const { state: appState, hasHydratedPreferences, actions } = useAppState();
577610
const { colors, resolvedTheme } = useAppTheme();
611+
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent({
612+
resetOnBackground: false,
613+
});
578614
const navigationRef = useNavigationContainerRef<RootStackParamList>();
579615
const pendingFeaturedDeepLinkActionRef = useRef<FeaturedEclipseDeepLinkAction | null>(null);
616+
const sharedLocationSequenceRef = useRef(0);
617+
const recentIncomingExternalLinksRef = useRef<Array<{ value: string; receivedAtMs: number }>>([]);
580618
const [catalog, setCatalog] = useState<EclipseRecord[] | null>(null);
619+
const [pendingSharedLocation, setPendingSharedLocation] = useState<PendingSharedLocation | null>(
620+
null,
621+
);
581622
const [isMenuOpen, setIsMenuOpen] = useState(false);
582623
const [onboardingStepIndex, setOnboardingStepIndex] = useState(0);
583624
const [currentRouteName, setCurrentRouteName] = useState<keyof RootStackParamList>("Landing");
@@ -686,29 +727,98 @@ export default function RootNavigator() {
686727
[activateEclipseById, closeMenu, navigationRef],
687728
);
688729

730+
const queuePendingSharedLocation = useCallback(
731+
(link: ParsedSharedMapLink) => {
732+
sharedLocationSequenceRef.current += 1;
733+
const pending: PendingSharedLocation = {
734+
...link,
735+
id: `shared-location-${sharedLocationSequenceRef.current}`,
736+
};
737+
738+
setPendingSharedLocation(pending);
739+
closeMenu();
740+
741+
if (!navigationRef.isReady()) return;
742+
navigationRef.navigate("Timer");
743+
},
744+
[closeMenu, navigationRef],
745+
);
746+
747+
const consumePendingSharedLocation = useCallback((id: string) => {
748+
setPendingSharedLocation((current) => (current?.id === id ? null : current));
749+
}, []);
750+
751+
const shouldProcessIncomingExternalLink = useCallback((value: string) => {
752+
const nowMs = Date.now();
753+
const cutoffMs = nowMs - INCOMING_EXTERNAL_LINK_DEDUPE_WINDOW_MS;
754+
const recent = recentIncomingExternalLinksRef.current.filter(
755+
(entry) => entry.receivedAtMs >= cutoffMs,
756+
);
757+
const isDuplicate = recent.some((entry) => entry.value === value);
758+
if (isDuplicate) {
759+
recentIncomingExternalLinksRef.current = recent;
760+
return false;
761+
}
762+
763+
recent.push({ value, receivedAtMs: nowMs });
764+
if (recent.length > MAX_TRACKED_INCOMING_EXTERNAL_LINKS) {
765+
recent.splice(0, recent.length - MAX_TRACKED_INCOMING_EXTERNAL_LINKS);
766+
}
767+
recentIncomingExternalLinksRef.current = recent;
768+
return true;
769+
}, []);
770+
689771
const handleIncomingUrl = useCallback(
690-
(url: string) => {
691-
const action = parseFeaturedEclipseDeepLink(url);
692-
if (!action) return false;
772+
async (incoming: IncomingExternalLink) => {
773+
if (!shouldProcessIncomingExternalLink(incoming.value)) {
774+
return false;
775+
}
693776

694-
if (!navigationRef.isReady()) {
695-
pendingFeaturedDeepLinkActionRef.current = action;
777+
const action = parseFeaturedEclipseDeepLink(incoming.value);
778+
if (action) {
779+
if (!navigationRef.isReady()) {
780+
pendingFeaturedDeepLinkActionRef.current = action;
781+
return true;
782+
}
783+
784+
runFeaturedDeepLinkAction(action);
696785
return true;
697786
}
698787

699-
runFeaturedDeepLinkAction(action);
788+
const sharedMapLink = await parseSharedMapLinkAsync(incoming.value);
789+
if (!sharedMapLink) return false;
790+
791+
queuePendingSharedLocation(sharedMapLink);
700792
return true;
701793
},
702-
[navigationRef, runFeaturedDeepLinkAction],
794+
[
795+
navigationRef,
796+
queuePendingSharedLocation,
797+
runFeaturedDeepLinkAction,
798+
shouldProcessIncomingExternalLink,
799+
],
703800
);
704801

705802
const onNavigationReady = useCallback(() => {
706803
syncWearPreviewWithRoute();
707804
const pendingAction = pendingFeaturedDeepLinkActionRef.current;
708-
if (!pendingAction) return;
709-
pendingFeaturedDeepLinkActionRef.current = null;
710-
runFeaturedDeepLinkAction(pendingAction);
711-
}, [runFeaturedDeepLinkAction, syncWearPreviewWithRoute]);
805+
if (pendingAction) {
806+
pendingFeaturedDeepLinkActionRef.current = null;
807+
runFeaturedDeepLinkAction(pendingAction);
808+
return;
809+
}
810+
811+
if (pendingSharedLocation) {
812+
closeMenu();
813+
navigationRef.navigate("Timer");
814+
}
815+
}, [
816+
closeMenu,
817+
navigationRef,
818+
pendingSharedLocation,
819+
runFeaturedDeepLinkAction,
820+
syncWearPreviewWithRoute,
821+
]);
712822

713823
const onNavigateFromMenu = useCallback(
714824
(route: MenuRouteName) => {
@@ -807,23 +917,33 @@ export default function RootNavigator() {
807917
}, []);
808918

809919
useEffect(() => {
810-
const subscription = Linking.addEventListener("url", ({ url }) => {
811-
handleIncomingUrl(url);
812-
});
920+
return subscribeToIncomingExternalLinks(
921+
(incoming) => {
922+
void handleIncomingUrl(incoming);
923+
},
924+
{ linking: Linking },
925+
);
926+
}, [handleIncomingUrl]);
813927

814-
void Linking.getInitialURL()
815-
.then((url) => {
816-
if (!url) return;
817-
handleIncomingUrl(url);
818-
})
819-
.catch(() => {
820-
// Ignore URL parsing failures and allow default linking behavior.
821-
});
928+
useEffect(() => {
929+
if (!hasShareIntent) return;
822930

823-
return () => {
824-
subscription.remove();
825-
};
826-
}, [handleIncomingUrl]);
931+
const shareIncomingLinks = normalizeSharePayloadToIncomingLinks({
932+
webUrl: shareIntent.webUrl,
933+
text: shareIntent.text,
934+
});
935+
if (!shareIncomingLinks.length) {
936+
resetShareIntent();
937+
return;
938+
}
939+
940+
void (async () => {
941+
for (const incoming of shareIncomingLinks) {
942+
await handleIncomingUrl(incoming);
943+
}
944+
resetShareIntent();
945+
})();
946+
}, [handleIncomingUrl, hasShareIntent, resetShareIntent, shareIntent.text, shareIntent.webUrl]);
827947

828948
if (!catalog) {
829949
return <StartupLoadingScreen message="Loading eclipse catalog..." colors={colors} />;
@@ -843,7 +963,15 @@ export default function RootNavigator() {
843963
{(props) => <LandingRoute {...props} catalog={catalog} onOpenMenu={openMenu} />}
844964
</Stack.Screen>
845965
<Stack.Screen name="Timer">
846-
{(props) => <TimerRoute {...props} catalog={catalog} onOpenMenu={openMenu} />}
966+
{(props) => (
967+
<TimerRoute
968+
{...props}
969+
catalog={catalog}
970+
onOpenMenu={openMenu}
971+
pendingSharedLocation={pendingSharedLocation}
972+
onConsumePendingSharedLocation={consumePendingSharedLocation}
973+
/>
974+
)}
847975
</Stack.Screen>
848976
<Stack.Screen name="Preview">
849977
{(props) => <PreviewRoute {...props} onOpenMenu={openMenu} />}

0 commit comments

Comments
 (0)