@applicaster/quick-brick-core 16.0.0-rc.98 → 17.0.0-alpha.1984136194

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/App/ActionSetters/index.ts +2 -2
  2. package/App/ActionsProvider/ActionsProvider.tsx +1 -1
  3. package/App/ActionsProvider/LegacyActionsRegistryAdapter.tsx +17 -1
  4. package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
  5. package/App/AssetCache/logger.ts +1 -1
  6. package/App/DeepLinking/SamsungAppControl/__tests__/samsungAppControl.test.ts +255 -0
  7. package/App/DeepLinking/SamsungAppControl/index.ts +223 -0
  8. package/App/DeepLinking/URLSchemeHandler/SchemeHandlerHooks/usePresentSchemeHandler.ts +3 -0
  9. package/App/DeepLinking/URLSchemeListener/URLSchemeContextProvider.tsx +31 -1
  10. package/App/DeepLinking/logger.ts +1 -1
  11. package/App/ErrorBoundary/index.tsx +5 -2
  12. package/App/NavigationProvider/NavigationProvider.tsx +17 -11
  13. package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
  14. package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
  15. package/App/NavigationProvider/navToolbox.ts +224 -0
  16. package/App/NavigationProvider/utils.ts +23 -2
  17. package/App/NetworkStatusProvider/NetworkStatusProvider.tsx +40 -4
  18. package/App/NetworkStatusProvider/ResumeVerificationController.ts +282 -0
  19. package/App/NetworkStatusProvider/__tests__/NetworkStatusProvider.test.tsx +167 -1
  20. package/App/NetworkStatusProvider/__tests__/ResumeVerificationController.test.ts +486 -0
  21. package/App/NetworkStatusProvider/__tests__/resumeVerification.test.ts +126 -0
  22. package/App/NetworkStatusProvider/__tests__/useResumeVerifiedNetInfo.test.ts +204 -0
  23. package/App/NetworkStatusProvider/resumeVerification.ts +54 -0
  24. package/App/NetworkStatusProvider/useResumeVerifiedNetInfo.ts +33 -0
  25. package/App/ThemeManager/index.tsx +6 -1
  26. package/App/appLifeCycleManager/index.tsx +2 -2
  27. package/App/appRemoteDataLoader/index.tsx +2 -2
  28. package/App/appRemoteDataLoader/remoteDataLoader.ts +2 -2
  29. package/App/index.tsx +8 -2
  30. package/App/layoutPreview/LayoutPreviewReminder.tsx +136 -0
  31. package/App/layoutPreview/__tests__/persistence.test.ts +63 -0
  32. package/App/layoutPreview/persistence.ts +228 -0
  33. package/App/logger.js +2 -2
  34. package/App/remoteContextReloader/__tests__/getNativeRemoteContextData.test.ts +148 -0
  35. package/App/remoteContextReloader/__tests__/getPromiseForType.test.js +31 -0
  36. package/App/remoteContextReloader/getRemoteContextData/getNativeRemoteContextData.ts +107 -29
  37. package/App/remoteContextReloader/helpers.ts +2 -1
  38. package/App/uiActions/index.ts +13 -0
  39. package/App/uiActions/muteUnmute/__tests__/assetResolver.test.ts +226 -0
  40. package/App/uiActions/muteUnmute/__tests__/binding.test.ts +51 -0
  41. package/App/uiActions/muteUnmute/__tests__/defaultAssets.test.ts +47 -0
  42. package/App/uiActions/muteUnmute/__tests__/entryState.test.ts +57 -0
  43. package/App/uiActions/muteUnmute/__tests__/labelResolver.test.ts +35 -0
  44. package/App/uiActions/muteUnmute/assetResolver.ts +87 -0
  45. package/App/uiActions/muteUnmute/defaultAssets.ts +44 -0
  46. package/App/uiActions/muteUnmute/entryState.ts +28 -0
  47. package/App/uiActions/muteUnmute/index.ts +79 -0
  48. package/App/uiActions/muteUnmute/labelResolver.ts +15 -0
  49. package/App/uiActions/muteUnmute/types.ts +18 -0
  50. package/package.json +8 -8
@@ -4,8 +4,8 @@ import { allSettled } from "promise";
4
4
  import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
5
5
 
6
6
  import * as React from "react";
7
- import { coreAppLogger } from "../logger";
8
- const logger = coreAppLogger.addSubsystem("AppLifeCycleManager");
7
+ import { subsystemLogger } from "@applicaster/zapp-react-native-utils/logger";
8
+ const logger = subsystemLogger("app/lifecycle", "AppLifeCycleManager");
9
9
 
10
10
  const debugActions = (actions) =>
11
11
  logger.debug({
@@ -8,7 +8,7 @@ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
8
8
  import { LegacyActionsRegistryAdapter } from "./LegacyActionsRegistryAdapter";
9
9
 
10
10
  const { log_debug } = createLogger({
11
- subsystem: "ActionsProvider",
11
+ subsystem: "core/actions",
12
12
  category: "ActionRegistration",
13
13
  });
14
14
 
@@ -5,7 +5,7 @@ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiAction
5
5
  import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
6
6
 
7
7
  const { log_debug, log_warning } = createLogger({
8
- subsystem: "LegacyActionsRegistryAdapter",
8
+ subsystem: "core/actions/legacy_registry",
9
9
  category: "ActionRegistration",
10
10
  });
11
11
 
@@ -66,6 +66,22 @@ function LegacyActionBridge({ plugin }: { plugin: LegacyActionPlugin }) {
66
66
  };
67
67
  }, [plugin.identifier]);
68
68
 
69
+ // Consumers resolve a snapshot of `value`; when the plugin publishes a new
70
+ // one (e.g. cast devices found), make them re-resolve so availability
71
+ // predicates are not read from a stale value. Registration already
72
+ // notifies, so the first render is skipped.
73
+ const isFirstValueRef = React.useRef(true);
74
+
75
+ React.useEffect(() => {
76
+ if (isFirstValueRef.current) {
77
+ isFirstValueRef.current = false;
78
+
79
+ return;
80
+ }
81
+
82
+ uiActionsRegistry.notifyProviderChanged(plugin.identifier);
83
+ }, [value, plugin.identifier]);
84
+
69
85
  return null;
70
86
  }
71
87
 
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react-native";
3
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
4
+ import { LegacyActionsRegistryAdapter } from "../LegacyActionsRegistryAdapter";
5
+
6
+ const CastContext = React.createContext<any>(null);
7
+
8
+ const plugin = {
9
+ identifier: "cast-action-test",
10
+ module: { context: CastContext },
11
+ };
12
+
13
+ const renderWithValue = (value) => (
14
+ <CastContext.Provider value={value}>
15
+ <LegacyActionsRegistryAdapter actionPlugins={{ cast: plugin } as any} />
16
+ </CastContext.Provider>
17
+ );
18
+
19
+ describe("LegacyActionsRegistryAdapter", () => {
20
+ it("makes consumers re-resolve when the plugin publishes a new value", () => {
21
+ const noDevices = { isActionAvailable: () => false };
22
+ const devicesFound = { isActionAvailable: () => true };
23
+
24
+ const { rerender, unmount } = render(renderWithValue(noDevices));
25
+
26
+ // what a consumer resolved at first paint
27
+ expect(
28
+ uiActionsRegistry
29
+ .getAction("cast-action-test")
30
+ ?.isActionAvailable?.({} as any)
31
+ ).toBe(false);
32
+
33
+ const listener = jest.fn();
34
+ const unsubscribe = uiActionsRegistry.subscribe(listener);
35
+
36
+ rerender(renderWithValue(devicesFound));
37
+
38
+ expect(listener).toHaveBeenCalledTimes(1);
39
+
40
+ expect(
41
+ uiActionsRegistry
42
+ .getAction("cast-action-test")
43
+ ?.isActionAvailable?.({} as any)
44
+ ).toBe(true);
45
+
46
+ unsubscribe();
47
+ unmount();
48
+ });
49
+
50
+ it("does not notify again when the value is unchanged", () => {
51
+ const stable = { isActionAvailable: () => true };
52
+ const { rerender, unmount } = render(renderWithValue(stable));
53
+
54
+ const listener = jest.fn();
55
+ const unsubscribe = uiActionsRegistry.subscribe(listener);
56
+
57
+ rerender(renderWithValue(stable));
58
+
59
+ expect(listener).not.toHaveBeenCalled();
60
+
61
+ unsubscribe();
62
+ unmount();
63
+ });
64
+ });
@@ -5,6 +5,6 @@ import { coreAppLogger } from "../logger";
5
5
  export const { log_verbose, log_debug, log_warning, log_info, log_error } =
6
6
  createLogger({
7
7
  category: "AssetCache",
8
- subsystem: "quick-brick-core",
8
+ subsystem: "storage/asset_cache",
9
9
  parent: coreAppLogger,
10
10
  });
@@ -0,0 +1,255 @@
1
+ import {
2
+ actionDataToUrl,
3
+ addSamsungAppControlListener,
4
+ getSamsungAppControlUrl,
5
+ resetSamsungAppControlState,
6
+ } from "..";
7
+
8
+ jest.mock("@applicaster/zapp-react-native-utils/appDataUtils", () => ({
9
+ getAppUrlScheme: () => "self",
10
+ }));
11
+
12
+ const QUERY =
13
+ "feed_locator=https%3A%2F%2Fzapp-pf-axnm.web.app%2Faxinom%2Fmovies%2F488059942906&id=488059942906";
14
+
15
+ const DEEP_LINK = `pureflix://open?${QUERY}`;
16
+
17
+ const mockTizen = (data?: { key: string; value: string[] }[]) => {
18
+ const getRequestedAppControl = jest.fn(() =>
19
+ data
20
+ ? {
21
+ appControl: {
22
+ operation: "http://tizen.org/appcontrol/operation/default",
23
+ data,
24
+ },
25
+ }
26
+ : null
27
+ );
28
+
29
+ // @ts-ignore
30
+ window.tizen = {
31
+ application: { getCurrentApplication: () => ({ getRequestedAppControl }) },
32
+ };
33
+
34
+ return getRequestedAppControl;
35
+ };
36
+
37
+ const payload = (values: unknown) => [
38
+ { key: "PAYLOAD", value: [JSON.stringify({ values })] },
39
+ ];
40
+
41
+ // The RN jest environment has no DOM events on window
42
+ const target = new EventTarget();
43
+ // @ts-ignore
44
+ window.addEventListener = target.addEventListener.bind(target);
45
+ // @ts-ignore
46
+ window.removeEventListener = target.removeEventListener.bind(target);
47
+ // @ts-ignore
48
+ window.dispatchEvent = target.dispatchEvent.bind(target);
49
+
50
+ const documentTarget = new EventTarget();
51
+ let documentHidden = false;
52
+
53
+ // @ts-ignore
54
+ global.document = {
55
+ get hidden() {
56
+ return documentHidden;
57
+ },
58
+ addEventListener: documentTarget.addEventListener.bind(documentTarget),
59
+ removeEventListener: documentTarget.removeEventListener.bind(documentTarget),
60
+ dispatchEvent: documentTarget.dispatchEvent.bind(documentTarget),
61
+ };
62
+
63
+ const setVisibility = (hidden: boolean) => {
64
+ documentHidden = hidden;
65
+ document.dispatchEvent(new Event("visibilitychange"));
66
+ };
67
+
68
+ afterEach(() => {
69
+ // @ts-ignore
70
+ delete window.tizen;
71
+ documentHidden = false;
72
+ resetSamsungAppControlState();
73
+ });
74
+
75
+ describe("actionDataToUrl", () => {
76
+ it("keeps a full deep link", () => {
77
+ expect(actionDataToUrl(DEEP_LINK)).toBe(DEEP_LINK);
78
+ });
79
+
80
+ it("builds an open deep link from a query string", () => {
81
+ expect(actionDataToUrl(QUERY)).toBe(`self://open?${QUERY}`);
82
+ expect(actionDataToUrl(`?${QUERY}`)).toBe(`self://open?${QUERY}`);
83
+ });
84
+
85
+ it("reads url / deep_link / values from JSON", () => {
86
+ expect(actionDataToUrl(JSON.stringify({ url: DEEP_LINK }))).toBe(DEEP_LINK);
87
+ expect(actionDataToUrl({ deep_link: DEEP_LINK })).toBe(DEEP_LINK);
88
+
89
+ expect(actionDataToUrl(JSON.stringify({ values: DEEP_LINK }))).toBe(
90
+ DEEP_LINK
91
+ );
92
+
93
+ expect(
94
+ actionDataToUrl(
95
+ JSON.stringify({ values: JSON.stringify({ url: DEEP_LINK }) })
96
+ )
97
+ ).toBe(DEEP_LINK);
98
+ });
99
+
100
+ it("returns null for non deep link data", () => {
101
+ expect(actionDataToUrl("")).toBeNull();
102
+ expect(actionDataToUrl("hello")).toBeNull();
103
+ expect(actionDataToUrl(undefined)).toBeNull();
104
+ expect(actionDataToUrl("{not json")).toBeNull();
105
+ });
106
+ });
107
+
108
+ describe("getSamsungAppControlUrl", () => {
109
+ it("returns null outside Tizen", () => {
110
+ expect(getSamsungAppControlUrl()).toBeNull();
111
+ });
112
+
113
+ it("returns null when the app was launched without PAYLOAD", () => {
114
+ mockTizen([]);
115
+ expect(getSamsungAppControlUrl()).toBeNull();
116
+ });
117
+
118
+ it("returns null when there is no requested app control", () => {
119
+ mockTizen();
120
+ expect(getSamsungAppControlUrl()).toBeNull();
121
+ });
122
+
123
+ it("returns the deep link from PAYLOAD", () => {
124
+ mockTizen(payload(DEEP_LINK));
125
+ expect(getSamsungAppControlUrl()).toBe(DEEP_LINK);
126
+ });
127
+
128
+ it("does not throw when getRequestedAppControl throws", () => {
129
+ // @ts-ignore
130
+ window.tizen = {
131
+ application: {
132
+ getCurrentApplication: () => ({
133
+ getRequestedAppControl: () => {
134
+ throw new Error("NotFoundError");
135
+ },
136
+ }),
137
+ },
138
+ };
139
+
140
+ expect(getSamsungAppControlUrl()).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("addSamsungAppControlListener", () => {
145
+ it("is a no-op outside Tizen", () => {
146
+ const onUrl = jest.fn();
147
+ const remove = addSamsungAppControlListener(onUrl);
148
+ window.dispatchEvent(new Event("appcontrol"));
149
+ expect(onUrl).not.toHaveBeenCalled();
150
+ remove();
151
+ });
152
+
153
+ it("calls onUrl with the deep link on appcontrol event and unsubscribes", () => {
154
+ mockTizen(payload(DEEP_LINK));
155
+ const onUrl = jest.fn();
156
+ const remove = addSamsungAppControlListener(onUrl);
157
+
158
+ window.dispatchEvent(new Event("appcontrol"));
159
+ expect(onUrl).toHaveBeenCalledWith(DEEP_LINK);
160
+
161
+ remove();
162
+ window.dispatchEvent(new Event("appcontrol"));
163
+ expect(onUrl).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it("ignores appcontrol events without a deep link", () => {
167
+ mockTizen([]);
168
+ const onUrl = jest.fn();
169
+ const remove = addSamsungAppControlListener(onUrl);
170
+ window.dispatchEvent(new Event("appcontrol"));
171
+ expect(onUrl).not.toHaveBeenCalled();
172
+ remove();
173
+ });
174
+ });
175
+
176
+ describe("deduplication", () => {
177
+ it("returns the same PAYLOAD only once until a launch without PAYLOAD", () => {
178
+ const tizenData = payload(DEEP_LINK);
179
+ const getRequested = mockTizen(tizenData);
180
+
181
+ expect(getSamsungAppControlUrl()).toBe(DEEP_LINK);
182
+ expect(getSamsungAppControlUrl()).toBeNull();
183
+
184
+ // App opened from the icon: app control without PAYLOAD
185
+ getRequested.mockReturnValueOnce({
186
+ appControl: { operation: "default", data: [] },
187
+ });
188
+
189
+ expect(getSamsungAppControlUrl()).toBeNull();
190
+ expect(getSamsungAppControlUrl()).toBe(DEEP_LINK);
191
+ });
192
+
193
+ it("handles a different PAYLOAD right away", () => {
194
+ const getRequested = mockTizen(payload(DEEP_LINK));
195
+ expect(getSamsungAppControlUrl()).toBe(DEEP_LINK);
196
+
197
+ const other = DEEP_LINK.replace(/488059942906/g, "111");
198
+
199
+ getRequested.mockReturnValue({
200
+ appControl: { operation: "default", data: payload(other) },
201
+ });
202
+
203
+ expect(getSamsungAppControlUrl()).toBe(other);
204
+ });
205
+ });
206
+
207
+ describe("visibilitychange (app brought back to foreground)", () => {
208
+ it("calls onUrl when the page becomes visible with a new PAYLOAD", () => {
209
+ mockTizen(payload(DEEP_LINK));
210
+ const onUrl = jest.fn();
211
+ const remove = addSamsungAppControlListener(onUrl);
212
+
213
+ setVisibility(true);
214
+ expect(onUrl).not.toHaveBeenCalled();
215
+
216
+ setVisibility(false);
217
+ expect(onUrl).toHaveBeenCalledWith(DEEP_LINK);
218
+
219
+ remove();
220
+ });
221
+
222
+ it("does not reopen the same deep link on a plain return to the app", () => {
223
+ mockTizen(payload(DEEP_LINK));
224
+ const onUrl = jest.fn();
225
+ const remove = addSamsungAppControlListener(onUrl);
226
+
227
+ setVisibility(false);
228
+ setVisibility(true);
229
+ setVisibility(false);
230
+ expect(onUrl).toHaveBeenCalledTimes(1);
231
+
232
+ remove();
233
+ });
234
+
235
+ it("handles appcontrol + visibilitychange for one launch only once", () => {
236
+ mockTizen(payload(DEEP_LINK));
237
+ const onUrl = jest.fn();
238
+ const remove = addSamsungAppControlListener(onUrl);
239
+
240
+ window.dispatchEvent(new Event("appcontrol"));
241
+ setVisibility(false);
242
+ expect(onUrl).toHaveBeenCalledTimes(1);
243
+
244
+ remove();
245
+ });
246
+
247
+ it("does not react after unsubscribe", () => {
248
+ mockTizen(payload(DEEP_LINK));
249
+ const onUrl = jest.fn();
250
+ addSamsungAppControlListener(onUrl)();
251
+
252
+ setVisibility(false);
253
+ expect(onUrl).not.toHaveBeenCalled();
254
+ });
255
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Samsung (Tizen) deep links.
3
+ *
4
+ * Samsung delivers deep links (Smart Hub Preview, Universal Guide, search, voice,
5
+ * partner launchers) as an app control with a `PAYLOAD` data key:
6
+ * PAYLOAD = '{"values":"<action data>"}'
7
+ *
8
+ * - Cold start: the app control that launched the app is available through
9
+ * tizen.application.getCurrentApplication().getRequestedAppControl().
10
+ * - App already running (multitasking): the platform fires a window "appcontrol"
11
+ * event and getRequestedAppControl() returns the new app control.
12
+ *
13
+ * The action data may be a full deep link (`<scheme>://open?feed_locator=...&id=...`),
14
+ * a bare query string (`feed_locator=...&id=...`) or a JSON object with a `url`,
15
+ * `deep_link` or `deeplink` field. It is normalized to a URL handled by
16
+ * URLSchemeListener (routing is by host, e.g. "open" / "present").
17
+ */
18
+ import { getAppUrlScheme } from "@applicaster/zapp-react-native-utils/appDataUtils";
19
+
20
+ import { log_debug, log_error, log_info } from "../logger";
21
+
22
+ export const PAYLOAD_KEY = "PAYLOAD";
23
+
24
+ export const APP_CONTROL_EVENT = "appcontrol";
25
+
26
+ type AppControlData = { key: string; value?: string[] };
27
+
28
+ const SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:\/\//i;
29
+
30
+ const getTizenApplication = () => {
31
+ try {
32
+ // @ts-ignore tizen is a global injected by the Tizen web runtime
33
+ const tizenApi = typeof window !== "undefined" ? window.tizen : undefined;
34
+
35
+ return tizenApi?.application?.getCurrentApplication?.() ?? null;
36
+ } catch (error) {
37
+ log_debug("SamsungAppControl: Tizen application API is not available", {
38
+ error,
39
+ });
40
+
41
+ return null;
42
+ }
43
+ };
44
+
45
+ const parseJSON = (value: string): unknown => {
46
+ try {
47
+ return JSON.parse(value);
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /**
54
+ * Converts Samsung action data into a deep link URL, or null if it is not one.
55
+ */
56
+ export const actionDataToUrl = (raw: unknown, depth = 0): string | null => {
57
+ if (depth > 6 || raw === null || raw === undefined) return null;
58
+
59
+ if (typeof raw === "object") {
60
+ const data = raw as Record<string, unknown>;
61
+
62
+ return actionDataToUrl(
63
+ data.url ?? data.deep_link ?? data.deeplink ?? data.values,
64
+ depth + 1
65
+ );
66
+ }
67
+
68
+ if (typeof raw !== "string") return null;
69
+
70
+ const value = raw.trim();
71
+
72
+ if (!value) return null;
73
+
74
+ if (value.startsWith("{")) {
75
+ const parsed = parseJSON(value);
76
+
77
+ if (parsed) return actionDataToUrl(parsed, depth + 1);
78
+ }
79
+
80
+ if (SCHEME_REGEX.test(value)) {
81
+ // Already a deep link (<scheme>://<host>?...). http(s) links are not deep
82
+ // links: only their query string is used below.
83
+ if (!/^https?:\/\//i.test(value)) return value;
84
+ }
85
+
86
+ const queryIndex = value.indexOf("?");
87
+ const query = queryIndex >= 0 ? value.slice(queryIndex + 1) : value;
88
+
89
+ if (!query.includes("=")) return null;
90
+
91
+ return `${getAppUrlScheme()}://open?${query}`;
92
+ };
93
+
94
+ /**
95
+ * Key of the last app control whose PAYLOAD was handed to the app. Used to avoid
96
+ * opening the same deep link again when the app is merely brought back to the
97
+ * foreground. A launch without PAYLOAD (e.g. from the app icon) replaces the
98
+ * requested app control and resets this key, so the same link can be sent again.
99
+ */
100
+ let lastHandledPayloadKey: string | null = null;
101
+
102
+ /** Test helper */
103
+ export const resetSamsungAppControlState = () => {
104
+ lastHandledPayloadKey = null;
105
+ };
106
+
107
+ /**
108
+ * Returns the deep link carried by the app control that launched / resumed the
109
+ * app, or null if there is none or it was already handled.
110
+ */
111
+ export const getSamsungAppControlUrl = (source = "launch"): string | null => {
112
+ const application = getTizenApplication();
113
+
114
+ if (!application) return null;
115
+
116
+ try {
117
+ const requested = application.getRequestedAppControl?.();
118
+ const data: AppControlData[] = requested?.appControl?.data ?? [];
119
+ const payload = data.find((item) => item?.key === PAYLOAD_KEY);
120
+
121
+ if (!payload) {
122
+ lastHandledPayloadKey = null;
123
+
124
+ log_debug(
125
+ `SamsungAppControl[${source}]: no PAYLOAD in requested app control`,
126
+ {
127
+ operation: requested?.appControl?.operation,
128
+ keys: data.map((item) => item?.key),
129
+ }
130
+ );
131
+
132
+ return null;
133
+ }
134
+
135
+ const payloadKey = JSON.stringify(payload.value ?? null);
136
+
137
+ if (payloadKey === lastHandledPayloadKey) {
138
+ log_debug(
139
+ `SamsungAppControl[${source}]: PAYLOAD already handled, skipping`,
140
+ {
141
+ payload: payload.value,
142
+ }
143
+ );
144
+
145
+ return null;
146
+ }
147
+
148
+ lastHandledPayloadKey = payloadKey;
149
+
150
+ const url = actionDataToUrl(payload.value?.[0]);
151
+
152
+ if (!url) {
153
+ log_error(`SamsungAppControl[${source}]: PAYLOAD is not a deep link`, {
154
+ payload: payload.value,
155
+ });
156
+
157
+ return null;
158
+ }
159
+
160
+ log_info(
161
+ `SamsungAppControl[${source}]: deep link from app control: ${url}`,
162
+ {
163
+ payload: payload.value,
164
+ }
165
+ );
166
+
167
+ return url;
168
+ } catch (error) {
169
+ log_error(
170
+ `SamsungAppControl[${source}]: failed to read requested app control`,
171
+ {
172
+ error,
173
+ }
174
+ );
175
+
176
+ return null;
177
+ }
178
+ };
179
+
180
+ /**
181
+ * Listens for deep links sent while the app is already running.
182
+ *
183
+ * On Samsung TVs (verified on a 2023 Tizen 7 model) a running app relaunched with
184
+ * an app control is only brought to the foreground: the new app control is
185
+ * available through getRequestedAppControl(), but no "appcontrol" event is fired.
186
+ * So the app control is checked both on "appcontrol" (fired by some runtimes /
187
+ * configurations) and when the page becomes visible again.
188
+ *
189
+ * Returns an unsubscribe function.
190
+ */
191
+ export const addSamsungAppControlListener = (
192
+ onUrl: (url: string) => void
193
+ ): (() => void) => {
194
+ if (
195
+ typeof window === "undefined" ||
196
+ typeof document === "undefined" ||
197
+ typeof window.addEventListener !== "function" ||
198
+ typeof document.addEventListener !== "function" ||
199
+ !getTizenApplication()
200
+ ) {
201
+ return () => {};
202
+ }
203
+
204
+ const check = (source: string) => {
205
+ const url = getSamsungAppControlUrl(source);
206
+
207
+ if (url) onUrl(url);
208
+ };
209
+
210
+ const onAppControl = () => check("appcontrol");
211
+
212
+ const onVisibilityChange = () => {
213
+ if (!document.hidden) check("visibilitychange");
214
+ };
215
+
216
+ window.addEventListener(APP_CONTROL_EVENT, onAppControl);
217
+ document.addEventListener("visibilitychange", onVisibilityChange);
218
+
219
+ return () => {
220
+ window.removeEventListener(APP_CONTROL_EVENT, onAppControl);
221
+ document.removeEventListener("visibilitychange", onVisibilityChange);
222
+ };
223
+ };
@@ -28,6 +28,7 @@ import {
28
28
 
29
29
  import { log_error, log_info } from "../../logger";
30
30
  import { useRivers } from "@applicaster/zapp-react-native-utils/reactHooks/state";
31
+ import { rememberLayoutPreview } from "../../../layoutPreview/persistence";
31
32
 
32
33
  const getFeed = (query) => ({
33
34
  content: { src: query.data_source },
@@ -155,6 +156,8 @@ export function usePresentSchemeHandler({
155
156
  rivers_configuration_id
156
157
  );
157
158
 
159
+ await rememberLayoutPreview(rivers_configuration_id);
160
+
158
161
  onFinish((done) => {
159
162
  actions?.setAppReady();
160
163
  navigator.replace(homeRiver);
@@ -3,6 +3,7 @@ import React, {
3
3
  createContext,
4
4
  useEffect,
5
5
  useCallback,
6
+ useMemo,
6
7
  ComponentType,
7
8
  ReactChild,
8
9
  } from "react";
@@ -11,6 +12,10 @@ import * as ReactNative from "react-native";
11
12
  import { isWeb } from "@applicaster/zapp-react-native-utils/reactUtils";
12
13
  import { log_debug, log_error, log_info } from "../logger";
13
14
  import { parseUrl } from "../helpers";
15
+ import {
16
+ addSamsungAppControlListener,
17
+ getSamsungAppControlUrl,
18
+ } from "../SamsungAppControl";
14
19
  import { getAppUrlScheme } from "@applicaster/zapp-react-native-utils/appDataUtils";
15
20
  import {
16
21
  getZappPlatform,
@@ -86,6 +91,17 @@ function URLSchemeContextProvider(props: Props) {
86
91
  const url = await Linking.getInitialURL();
87
92
 
88
93
  if (isWeb()) {
94
+ // Samsung: deep link from the app control that launched the app
95
+ const samsungDeepLink = getSamsungAppControlUrl();
96
+
97
+ if (samsungDeepLink) {
98
+ log_info(
99
+ `URLSchemeContextProvider: Retrieved initial Samsung deep link: ${samsungDeepLink}`
100
+ );
101
+
102
+ return setState(samsungDeepLink);
103
+ }
104
+
89
105
  const presentDeepLink = getPresentDeepLink(url);
90
106
 
91
107
  if (presentDeepLink) {
@@ -179,10 +195,22 @@ function URLSchemeContextProvider(props: Props) {
179
195
  handleOpenURL
180
196
  );
181
197
 
198
+ // Samsung: deep links sent while the app is running ("appcontrol" event)
199
+ const removeSamsungAppControlListener = addSamsungAppControlListener(
200
+ (appControlUrl) => {
201
+ log_info(
202
+ `URLSchemeContextProvider: Samsung appcontrol deep link received — url=${appControlUrl}`
203
+ );
204
+
205
+ setState(appControlUrl);
206
+ }
207
+ );
208
+
182
209
  return () => {
183
210
  log_debug("URLSchemeContextProvider: Remove URL event listeners");
184
211
  listener.remove();
185
212
  linkingListener.remove();
213
+ removeSamsungAppControlListener();
186
214
  };
187
215
  }
188
216
 
@@ -196,9 +224,11 @@ function URLSchemeContextProvider(props: Props) {
196
224
  };
197
225
  }, []);
198
226
 
227
+ const contextValue = useMemo(() => ({ url: state, done }), [state, done]);
228
+
199
229
  return (
200
230
  <View style={fillStyles.container}>
201
- <URLSchemeContext.Provider value={{ url: state, done }}>
231
+ <URLSchemeContext.Provider value={contextValue}>
202
232
  {props.children}
203
233
  </URLSchemeContext.Provider>
204
234
  </View>
@@ -5,6 +5,6 @@ import { coreAppLogger } from "../logger";
5
5
  export const { log_verbose, log_debug, log_warning, log_info, log_error } =
6
6
  createLogger({
7
7
  category: "DeepLinking",
8
- subsystem: "General",
8
+ subsystem: "app/deep_linking",
9
9
  parent: coreAppLogger,
10
10
  });