Skip to content

Commit 119ff10

Browse files
forrest57Vangaorth
andauthored
feat:[IOCOM-2544] SEND AAR UAL + pre-login UAL storage (#7328)
## Short description [DEPENDS ON pagopa/io-dev-api-server/pull/530] this PR introduces the logic required to both handle an Universal/AppLink and to store a link of said kind in case the app is currently closed and can't process it due to remote configurations. ## List of changes proposed in this pull request - addition of deepLinking reducer to store said link - addition of logic that opens the SEND flow if the app was in foreground when a SEND AAR link wakes it up - required tests ## How to test using both an android and iOS device, and while running the dev-server on the branch related to the PR this depends on, make sure that opening an URL like `https://cittadini.dev.notifichedigitali.it/io?aar=asd123` (either via another app, like "reminders", or when scanning a QR containing said URL) has the following behaviour: - if the app is killed (neither in background nor in foreground), then it should simply open and run like normal - if it is in backgound, but not killed , it will go to foreground and display the SEND AAR flow, even if it requests biometri authentication before so. automated tests should also pass --------- Co-authored-by: Andrea <[email protected]>
1 parent 6045af9 commit 119ff10

File tree

13 files changed

+327
-9
lines changed

13 files changed

+327
-9
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as REMOTE_CONFIG from "../../../../../store/reducers/backendStatus/remoteConfig";
2+
import { GlobalState } from "../../../../../store/reducers/types";
3+
import { isSendAARLink } from "../deepLinking";
4+
const testRegex = "^\\s*https:\\/\\/example\\.com\\/aar\\/.*";
5+
describe("DeepLinking utils", () => {
6+
describe("isSendAARLink", () => {
7+
[true, false].forEach(isValid => {
8+
it(`should return ${isValid} for a ${
9+
isValid ? "valid" : "invalid"
10+
} AAR link`, () => {
11+
jest
12+
.spyOn(REMOTE_CONFIG, "pnAARQRCodeRegexSelector")
13+
.mockImplementation(() => testRegex);
14+
const url = `https://example.com/${isValid ? "aar" : "INVALID"}/12345`;
15+
const result = isSendAARLink({} as GlobalState, url);
16+
expect(result).toBe(isValid);
17+
});
18+
});
19+
20+
it("should return false if the regex is not available", () => {
21+
jest
22+
.spyOn(REMOTE_CONFIG, "pnAARQRCodeRegexSelector")
23+
.mockImplementation(() => undefined);
24+
const url = "https://example.com/aar/12345";
25+
const result = isSendAARLink({} as GlobalState, url);
26+
expect(result).toBe(false);
27+
});
28+
});
29+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as O from "fp-ts/Option";
2+
import { pipe } from "fp-ts/lib/function";
3+
import { GlobalState } from "../../../../store/reducers/types";
4+
import { pnAARQRCodeRegexSelector } from "../../../../store/reducers/backendStatus/remoteConfig";
5+
6+
export const isSendAARLink = (state: GlobalState, url: string) =>
7+
pipe(
8+
state,
9+
pnAARQRCodeRegexSelector,
10+
O.fromNullable,
11+
O.map(aarQRCodeRegexString => new RegExp(aarQRCodeRegexString, "i")),
12+
O.fold(
13+
() => false,
14+
regExp => regExp.test(url)
15+
)
16+
);

ts/navigation/AppStackNavigator.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ import { cgnLinkingOptions } from "../features/bonus/cgn/navigation/navigator";
2121
import { fciLinkingOptions } from "../features/fci/navigation/FciStackNavigator";
2222
import { idPayLinkingOptions } from "../features/idpay/common/navigation/linking";
2323
import { IngressScreen } from "../features/ingress/screens/IngressScreen";
24+
import { ITW_ROUTES } from "../features/itwallet/navigation/routes";
2425
import { useItwLinkingOptions } from "../features/itwallet/navigation/useItwLinkingOptions";
2526
import { MESSAGES_ROUTES } from "../features/messages/navigation/routes";
2627
import { SERVICES_ROUTES } from "../features/services/common/navigation/routes";
28+
import { SETTINGS_ROUTES } from "../features/settings/common/navigation/routes";
2729
import { processUtmLink } from "../features/utmLink";
2830
import { startApplicationInitialization } from "../store/actions/application";
2931
import { setDebugCurrentRouteName } from "../store/actions/debug";
32+
import { storeLinkingUrl } from "../store/actions/linking";
3033
import { useIODispatch, useIOSelector, useIOStore } from "../store/hooks";
3134
import { trackScreen } from "../store/middlewares/navigation";
3235
import { isCGNEnabledAfterLoadSelector } from "../store/reducers/backendStatus/remoteConfig";
@@ -41,18 +44,16 @@ import {
4144
IO_INTERNAL_LINK_PREFIX,
4245
IO_UNIVERSAL_LINK_PREFIX
4346
} from "../utils/navigation";
44-
import { ITW_ROUTES } from "../features/itwallet/navigation/routes";
45-
import { SETTINGS_ROUTES } from "../features/settings/common/navigation/routes";
4647
import AuthenticatedStackNavigator from "./AuthenticatedStackNavigator";
47-
import { linkingSubscription } from "./linkingSubscription";
4848
import NavigationService, {
4949
navigationRef,
5050
setMainNavigatorReady
5151
} from "./NavigationService";
5252
import NotAuthenticatedStackNavigator from "./NotAuthenticatedStackNavigator";
53+
import OfflineStackNavigator from "./OfflineStackNavigator";
54+
import { linkingSubscription } from "./linkingSubscription";
5355
import { AppParamsList } from "./params/AppParamsList";
5456
import ROUTES from "./routes";
55-
import OfflineStackNavigator from "./OfflineStackNavigator";
5657

5758
type OnStateChangeStateType = Parameters<
5859
NonNullable<NavigationContainerProps["onStateChange"]>
@@ -162,6 +163,12 @@ const InnerNavigationContainer = (props: InnerNavigationContainerProps) => {
162163
void Linking.getInitialURL().then(initialUrl => {
163164
if (initialUrl) {
164165
processUtmLink(initialUrl, dispatch);
166+
/**
167+
* We store the initialUrl in the redux store so that
168+
* it can be processed in case we need any kind of data
169+
* that would be accessible after the app's startup for that
170+
*/
171+
dispatch(storeLinkingUrl(initialUrl));
165172
}
166173
});
167174
});
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import * as LINKING from "react-native";
2+
import configureMockStore from "redux-mock-store";
3+
import { applicationChangeState } from "../../store/actions/application";
4+
import { appReducer } from "../../store/reducers";
5+
import { GlobalState } from "../../store/reducers/types";
6+
import { linkingSubscription } from "../linkingSubscription";
7+
import * as ARCHIVING_SELECTORS from "../../features/messages/store/reducers/archiving";
8+
import { resetMessageArchivingAction } from "../../features/messages/store/actions/archiving";
9+
import * as UTIL_GUARDS from "../../features/authentication/common/store/utils/guards";
10+
import * as DEEP_LINKING from "../../features/pn/aar/utils/deepLinking";
11+
import { storeLinkingUrl } from "../../store/actions/linking";
12+
import * as NAVIGATION_SRV from "../NavigationService";
13+
import { MESSAGES_ROUTES } from "../../features/messages/navigation/routes";
14+
import PN_ROUTES from "../../features/pn/navigation/routes";
15+
import * as UTM_LINK from "../../features/utmLink";
16+
17+
describe("linkingSubscription", () => {
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
jest.resetAllMocks();
21+
});
22+
23+
it("should add and remove an event listener", () => {
24+
const { addEventListenerSpy, mockCurrySubscription } = initializeTests();
25+
26+
const removeMock = jest.fn();
27+
addEventListenerSpy.mockImplementation(
28+
() => ({ remove: removeMock } as any)
29+
);
30+
31+
const mockUnsubscribe = mockCurrySubscription(jest.fn());
32+
expect(addEventListenerSpy).toHaveBeenCalledWith(
33+
"url",
34+
expect.any(Function)
35+
);
36+
mockUnsubscribe();
37+
expect(removeMock).toHaveBeenCalled();
38+
});
39+
it("should call the listener and 'processUtmLink'", () => {
40+
const { mockCurrySubscription, addEventListenerSpy } = initializeTests();
41+
const mockListener = jest.fn();
42+
const mockProcessUtmLink = jest.fn();
43+
44+
jest
45+
.spyOn(UTM_LINK, "processUtmLink")
46+
.mockImplementation(mockProcessUtmLink);
47+
48+
mockCurrySubscription(mockListener);
49+
50+
const testUrl = "https://example.com";
51+
runEventListenerCallback(addEventListenerSpy, { url: testUrl });
52+
53+
expect(mockListener).toHaveBeenCalledTimes(1);
54+
expect(mockListener).toHaveBeenCalledWith(testUrl);
55+
expect(mockProcessUtmLink).toHaveBeenCalledTimes(1);
56+
expect(mockProcessUtmLink).toHaveBeenCalledWith(
57+
testUrl,
58+
expect.any(Function)
59+
);
60+
});
61+
62+
it.each([false, true])(
63+
`should dispatch 'resetMessageArchivingAction' if archiving is not disabled; archiving : %d`,
64+
isDisabled => {
65+
const { mockDispatch, mockCurrySubscription, addEventListenerSpy } =
66+
initializeTests();
67+
68+
jest
69+
.spyOn(ARCHIVING_SELECTORS, "isArchivingDisabledSelector")
70+
.mockImplementation(() => isDisabled);
71+
72+
mockCurrySubscription(jest.fn());
73+
74+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
75+
76+
const testUrl = "https://example.com";
77+
runEventListenerCallback(addEventListenerSpy, { url: testUrl });
78+
if (isDisabled) {
79+
expect(mockDispatch).not.toHaveBeenCalledWith(
80+
resetMessageArchivingAction(undefined)
81+
);
82+
} else {
83+
expect(mockDispatch).toHaveBeenCalledWith(
84+
resetMessageArchivingAction(undefined)
85+
);
86+
}
87+
}
88+
);
89+
90+
[true, false].forEach(isLoggedIn => {
91+
[true, false].forEach(isAARLink => {
92+
it(`should handle a URL event when${
93+
isLoggedIn ? "" : " not"
94+
} logged in, and the link passed ${
95+
isAARLink ? "is" : "isn't"
96+
} a valid AAR link`, () => {
97+
const { mockDispatch, mockCurrySubscription, addEventListenerSpy } =
98+
initializeTests();
99+
const mockNav = jest.fn();
100+
const testUrl = `https://example.com/${isAARLink}/${isLoggedIn}`;
101+
102+
jest
103+
.spyOn(UTIL_GUARDS, "isLoggedIn")
104+
.mockImplementation(() => isLoggedIn);
105+
jest
106+
.spyOn(DEEP_LINKING, "isSendAARLink")
107+
.mockImplementation(() => isAARLink);
108+
jest
109+
.spyOn(NAVIGATION_SRV.default, "navigate")
110+
.mockImplementation(mockNav);
111+
112+
mockCurrySubscription(jest.fn());
113+
114+
runEventListenerCallback(addEventListenerSpy, { url: testUrl });
115+
116+
if (isLoggedIn) {
117+
// When logged in, we do not store the URL for later processing
118+
expect(mockDispatch).not.toHaveBeenCalledWith(
119+
expect.objectContaining({ type: "STORE_LINKING_URL" })
120+
);
121+
122+
if (isAARLink) {
123+
expect(mockNav).toHaveBeenCalledWith(
124+
MESSAGES_ROUTES.MESSAGES_NAVIGATOR,
125+
{
126+
screen: PN_ROUTES.MAIN,
127+
params: {
128+
screen: PN_ROUTES.QR_SCAN_FLOW,
129+
params: { aarUrl: testUrl }
130+
}
131+
}
132+
);
133+
} else {
134+
expect(mockNav).not.toHaveBeenCalled();
135+
}
136+
} else {
137+
expect(mockNav).not.toHaveBeenCalled();
138+
expect(mockDispatch).toHaveBeenCalledWith(storeLinkingUrl(testUrl));
139+
}
140+
});
141+
});
142+
});
143+
});
144+
145+
// --------------------- UTILS ---------------------
146+
147+
const runEventListenerCallback = (
148+
eventListenerSpy: jest.SpyInstance,
149+
event: { url: string }
150+
) => {
151+
const callback = eventListenerSpy.mock.calls[0][1] as (event: {
152+
url: string;
153+
}) => void;
154+
callback(event);
155+
};
156+
157+
const initializeTests = () => {
158+
const mockStore = configureMockStore<GlobalState>();
159+
const defaultState = appReducer(undefined, applicationChangeState("active"));
160+
const store = mockStore(defaultState);
161+
const addEventListenerSpy = jest.spyOn(LINKING.Linking, "addEventListener");
162+
const mockDispatch = jest.fn();
163+
const mockCurrySubscription = linkingSubscription(mockDispatch, store);
164+
165+
return {
166+
store,
167+
addEventListenerSpy,
168+
mockCurrySubscription,
169+
mockDispatch
170+
};
171+
};
Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { Linking } from "react-native";
22
import { Action, Dispatch, Store } from "redux";
3-
import { GlobalState } from "../store/reducers/types";
4-
import { isArchivingDisabledSelector } from "../features/messages/store/reducers/archiving";
3+
import { isLoggedIn } from "../features/authentication/common/store/utils/guards";
4+
import { MESSAGES_ROUTES } from "../features/messages/navigation/routes";
55
import { resetMessageArchivingAction } from "../features/messages/store/actions/archiving";
6+
import { isArchivingDisabledSelector } from "../features/messages/store/reducers/archiving";
7+
import { isSendAARLink } from "../features/pn/aar/utils/deepLinking";
8+
import PN_ROUTES from "../features/pn/navigation/routes";
69
import { processUtmLink } from "../features/utmLink";
10+
import { storeLinkingUrl } from "../store/actions/linking";
11+
import { GlobalState } from "../store/reducers/types";
12+
import NavigationService from "./NavigationService";
713

814
export const linkingSubscription =
915
(dispatch: Dispatch<Action>, store: Store<Readonly<GlobalState>>) =>
1016
(listener: (url: string) => void) => {
11-
const linkingSubscription = Linking.addEventListener("url", ({ url }) => {
17+
const subscription = Linking.addEventListener("url", ({ url }) => {
1218
// Message archiving/restoring hides the bottom tab bar so we must make
1319
// sure that either it is disabled or we manually deactivate it, otherwise
1420
// a deep link may initiate a navigation flow that will later deliver the
@@ -19,13 +25,30 @@ export const linkingSubscription =
1925
// Auto-reset does not provide feedback to the user
2026
dispatch(resetMessageArchivingAction(undefined));
2127
}
28+
29+
if (isLoggedIn(state.authentication)) {
30+
// only when logged in we can navigate to the AAR screen.
31+
if (isSendAARLink(state, url)) {
32+
NavigationService.navigate(MESSAGES_ROUTES.MESSAGES_NAVIGATOR, {
33+
screen: PN_ROUTES.MAIN,
34+
params: {
35+
screen: PN_ROUTES.QR_SCAN_FLOW,
36+
params: { aarUrl: url }
37+
}
38+
});
39+
}
40+
} else {
41+
// If we are not logged in, we store the URL to be processed later
42+
dispatch(storeLinkingUrl(url));
43+
}
44+
2245
// If we have a deep link with utm_medium and utm_source parameters, we want to track it
2346
// We don't enter this point if the app is opened from scratch with a deep link,
2447
// but we track it in the `useOnFirstRender` hook on the AppStackNavigator
2548
processUtmLink(url, dispatch);
2649
listener(url);
2750
});
2851
return () => {
29-
linkingSubscription.remove();
52+
subscription.remove();
3053
};
3154
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`linking actions should create an action to store a linking url 1`] = `
4+
{
5+
"meta": undefined,
6+
"payload": "https://example.com",
7+
"type": "STORE_LINKING_URL",
8+
}
9+
`;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { storeLinkingUrl } from "../linking";
2+
3+
describe("linking actions", () => {
4+
it("should create an action to store a linking url", () => {
5+
const url = "https://example.com";
6+
const action = storeLinkingUrl(url);
7+
expect(action).toMatchSnapshot();
8+
});
9+
});

ts/store/actions/linking.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { ActionType, createStandardAction } from "typesafe-actions";
2+
3+
export const storeLinkingUrl =
4+
createStandardAction("STORE_LINKING_URL")<string>();
5+
6+
export type BackgroundLinkingActions = ActionType<typeof storeLinkingUrl>;

ts/store/actions/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { PersistedPreferencesActions } from "./persistedPreferences";
6565
import { PreferencesActions } from "./preferences";
6666
import { SearchActions } from "./search";
6767
import { StartupActions } from "./startup";
68+
import { BackgroundLinkingActions } from "./linking";
6869

6970
export type Action =
7071
| AnalyticsActions
@@ -121,7 +122,8 @@ export type Action =
121122
| UtmLinkActions
122123
| ConnectivityActions
123124
| LoginPreferencesActions
124-
| AARFlowStateActions;
125+
| AARFlowStateActions
126+
| BackgroundLinkingActions;
125127

126128
export type Dispatch = DispatchAPI<Action>;
127129

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { storeLinkingUrl } from "../../actions/linking";
2+
import { backgroundLinkingReducer } from "../linking";
3+
4+
describe("test for linking reducer", () => {
5+
it("should return the initial state", () => {
6+
const expected = {};
7+
const reducer = backgroundLinkingReducer(undefined, {} as any);
8+
expect(reducer).toEqual(expected);
9+
});
10+
11+
it("should handle storing a linking url", () => {
12+
const url = "https://example.com";
13+
const expected = {
14+
linkingUrl: url
15+
};
16+
const reducer = backgroundLinkingReducer(undefined, storeLinkingUrl(url));
17+
expect(reducer).toEqual(expected);
18+
});
19+
});

0 commit comments

Comments
 (0)