@applicaster/quick-brick-core 16.0.0-rc.99 → 17.0.0-rc.1
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.
- package/App/ActionSetters/index.ts +2 -2
- package/App/ActionsProvider/ActionsProvider.tsx +1 -1
- package/App/ActionsProvider/LegacyActionsRegistryAdapter.tsx +17 -1
- package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
- package/App/AssetCache/logger.ts +1 -1
- package/App/DeepLinking/URLSchemeHandler/SchemeHandlerHooks/usePresentSchemeHandler.ts +3 -0
- package/App/DeepLinking/URLSchemeListener/URLSchemeContextProvider.tsx +4 -1
- package/App/DeepLinking/logger.ts +1 -1
- package/App/ErrorBoundary/index.tsx +5 -2
- package/App/NavigationProvider/NavigationProvider.tsx +17 -11
- package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
- package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
- package/App/NavigationProvider/navToolbox.ts +224 -0
- package/App/NavigationProvider/utils.ts +23 -2
- package/App/NetworkStatusProvider/NetworkStatusProvider.tsx +40 -4
- package/App/NetworkStatusProvider/ResumeVerificationController.ts +282 -0
- package/App/NetworkStatusProvider/__tests__/NetworkStatusProvider.test.tsx +167 -1
- package/App/NetworkStatusProvider/__tests__/ResumeVerificationController.test.ts +486 -0
- package/App/NetworkStatusProvider/__tests__/resumeVerification.test.ts +126 -0
- package/App/NetworkStatusProvider/__tests__/useResumeVerifiedNetInfo.test.ts +204 -0
- package/App/NetworkStatusProvider/resumeVerification.ts +54 -0
- package/App/NetworkStatusProvider/useResumeVerifiedNetInfo.ts +33 -0
- package/App/ThemeManager/index.tsx +6 -1
- package/App/appLifeCycleManager/index.tsx +2 -2
- package/App/appRemoteDataLoader/index.tsx +2 -2
- package/App/appRemoteDataLoader/remoteDataLoader.ts +2 -2
- package/App/index.tsx +8 -2
- package/App/layoutPreview/LayoutPreviewReminder.tsx +136 -0
- package/App/layoutPreview/__tests__/persistence.test.ts +63 -0
- package/App/layoutPreview/persistence.ts +228 -0
- package/App/logger.js +2 -2
- package/App/remoteContextReloader/__tests__/getNativeRemoteContextData.test.ts +148 -0
- package/App/remoteContextReloader/__tests__/getPromiseForType.test.js +31 -0
- package/App/remoteContextReloader/getRemoteContextData/getNativeRemoteContextData.ts +107 -29
- package/App/remoteContextReloader/helpers.ts +2 -1
- package/App/uiActions/index.ts +13 -0
- package/App/uiActions/muteUnmute/__tests__/assetResolver.test.ts +226 -0
- package/App/uiActions/muteUnmute/__tests__/binding.test.ts +51 -0
- package/App/uiActions/muteUnmute/__tests__/defaultAssets.test.ts +47 -0
- package/App/uiActions/muteUnmute/__tests__/entryState.test.ts +57 -0
- package/App/uiActions/muteUnmute/__tests__/labelResolver.test.ts +35 -0
- package/App/uiActions/muteUnmute/assetResolver.ts +87 -0
- package/App/uiActions/muteUnmute/defaultAssets.ts +44 -0
- package/App/uiActions/muteUnmute/entryState.ts +28 -0
- package/App/uiActions/muteUnmute/index.ts +79 -0
- package/App/uiActions/muteUnmute/labelResolver.ts +15 -0
- package/App/uiActions/muteUnmute/types.ts +18 -0
- package/package.json +8 -8
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Dimensions } from "react-native";
|
|
2
|
+
import {
|
|
3
|
+
registerXrayToolbox,
|
|
4
|
+
XrayToolSpec,
|
|
5
|
+
} from "@applicaster/zapp-react-native-utils/logger/xrayToolbox";
|
|
6
|
+
import { getXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
|
|
7
|
+
import { playerManager } from "@applicaster/zapp-react-native-utils/appUtils/playerManager";
|
|
8
|
+
import { getTopPathname } from "@applicaster/zapp-react-native-ui-components/Decorators/ZappPipesDataConnector/screenToolbox";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* X-Ray `nav.*` toolbox: lets an agent navigate by intent and read or drive the video modal,
|
|
12
|
+
* which is navigation state. The swipes behind some of these (minimising the player, dragging a
|
|
13
|
+
* sheet) are react-native-gesture-handler recognisers that no public API can feed touches to, so
|
|
14
|
+
* the outcome is offered instead. Pairs with `screen.current` to check where an action landed.
|
|
15
|
+
*
|
|
16
|
+
* Registered by the NavigationProvider, which keeps the navigator in a plain module variable
|
|
17
|
+
* (xrayNavigator), so updating it never renders anything.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const requireNavigator = (): QuickBrickAppNavigator => {
|
|
21
|
+
const navigator = getXrayNavigator();
|
|
22
|
+
|
|
23
|
+
if (!navigator) throw new Error("Navigation is not available yet");
|
|
24
|
+
|
|
25
|
+
return navigator;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The player's own fullscreen switch: it also rotates the phone, as the fullscreen button does. */
|
|
29
|
+
const toggleFullscreen = (fullscreen: boolean) => {
|
|
30
|
+
// Only a mounted PlayerContainer handles it: not while the video modal runs its hooks or casts.
|
|
31
|
+
if (!playerManager.hasHandler("toggleFullscreen")) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"The video modal shows no player (hooks running or casting)"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
playerManager.invokeHandler("toggleFullscreen", { fullscreen });
|
|
38
|
+
|
|
39
|
+
return "OK: the player switches on its next tick, check with videoModal";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const requireVideoModal = () => {
|
|
43
|
+
const nav = requireNavigator();
|
|
44
|
+
|
|
45
|
+
if (!nav.videoModalState?.visible) {
|
|
46
|
+
throw new Error("No video modal is open");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return nav;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const tools: XrayToolSpec[] = [
|
|
53
|
+
{
|
|
54
|
+
name: "stack",
|
|
55
|
+
description: "Routes on the navigation stack, root first.",
|
|
56
|
+
handler: () => ({
|
|
57
|
+
routes: requireNavigator()
|
|
58
|
+
.getStack()
|
|
59
|
+
.map(({ route }) => route),
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "back",
|
|
64
|
+
description:
|
|
65
|
+
"Go back one screen, as the nav bar back button does: it pops the stack and does not run " +
|
|
66
|
+
"hardware back listeners (Android back key), so a screen plugin's own back handling is skipped. " +
|
|
67
|
+
"Not while a modal, the video modal (unless docked) or a hook screen is on top: blockedBy says " +
|
|
68
|
+
"which; close it first, or cancel a hook with the device back key. With Play Next showing it " +
|
|
69
|
+
"only dismisses that. Check with screen.current.",
|
|
70
|
+
handler: () => {
|
|
71
|
+
const nav = requireNavigator();
|
|
72
|
+
const from = getTopPathname() ?? nav.getPathname();
|
|
73
|
+
|
|
74
|
+
// A presented layer (hooks-modal/, modal/, video-modal/) is on top: popping the stack would
|
|
75
|
+
// change the screen underneath it, which the user's back never does.
|
|
76
|
+
if (!from.startsWith("/")) {
|
|
77
|
+
return { from, wentBack: false, blockedBy: from.split("/")[0] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (nav.getPlayNextOverlay()) {
|
|
81
|
+
nav.goBack(false);
|
|
82
|
+
|
|
83
|
+
return { from, wentBack: false, dismissed: "playNext" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The hook screen answers the user's back itself (cancels its flow); a pop would skip that.
|
|
87
|
+
if (from.split("/").slice(-2)[0] === "hooks") {
|
|
88
|
+
return { from, wentBack: false, blockedBy: "hook" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!nav.canGoBack()) return { from, wentBack: false };
|
|
92
|
+
|
|
93
|
+
nav.goBack(false);
|
|
94
|
+
|
|
95
|
+
return { from, wentBack: true };
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "home",
|
|
100
|
+
description:
|
|
101
|
+
"Go to the home screen. Not while a modal, the video modal (unless docked) or the hooks modal is " +
|
|
102
|
+
"on top: blockedBy says which; close it first. On TV it does not move focus to the top menu as " +
|
|
103
|
+
"the remote's home does.",
|
|
104
|
+
handler: () => {
|
|
105
|
+
const nav = requireNavigator();
|
|
106
|
+
const from = getTopPathname() ?? nav.getPathname();
|
|
107
|
+
|
|
108
|
+
// goHome only replaces the stack: a presented layer would stay on top of the home screen,
|
|
109
|
+
// and it covers the menu the user would press home in.
|
|
110
|
+
if (!from.startsWith("/")) {
|
|
111
|
+
return { from, wentHome: false, blockedBy: from.split("/")[0] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
nav.goHome();
|
|
115
|
+
|
|
116
|
+
return { from, wentHome: true };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "dismissModal",
|
|
121
|
+
description:
|
|
122
|
+
"Close the open modal screen or bottom sheet, as dragging it down does. Not the video modal: use closeVideoModal.",
|
|
123
|
+
handler: () => {
|
|
124
|
+
const nav = requireNavigator();
|
|
125
|
+
|
|
126
|
+
if (!nav.getModalState()?.visible) throw new Error("No modal is open");
|
|
127
|
+
|
|
128
|
+
nav.dismissModal();
|
|
129
|
+
|
|
130
|
+
return "OK";
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "videoModal",
|
|
135
|
+
description:
|
|
136
|
+
"The video modal: visible, mode (MAXIMIZED, MINIMIZED = mini player, FULLSCREEN, PIP), its entry, " +
|
|
137
|
+
"and the screen orientation.",
|
|
138
|
+
handler: () => {
|
|
139
|
+
const videoModal = requireNavigator().videoModalState;
|
|
140
|
+
const { width, height } = Dimensions.get("window");
|
|
141
|
+
const orientation = width > height ? "landscape" : "portrait";
|
|
142
|
+
|
|
143
|
+
if (!videoModal?.visible) return { visible: false, orientation };
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
visible: true,
|
|
147
|
+
mode: videoModal.mode,
|
|
148
|
+
entry: videoModal.item
|
|
149
|
+
? { id: videoModal.item.id, title: videoModal.item.title }
|
|
150
|
+
: null,
|
|
151
|
+
orientation,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "minimizeVideoModal",
|
|
157
|
+
description:
|
|
158
|
+
"Minimise the maximised video modal to the mini player, as swiping it down does.",
|
|
159
|
+
handler: () => {
|
|
160
|
+
const nav = requireVideoModal();
|
|
161
|
+
const { mode } = nav.videoModalState;
|
|
162
|
+
|
|
163
|
+
// The swipe exists only on the maximised modal; other modes have no way to the mini player.
|
|
164
|
+
if (mode !== "MAXIMIZED") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Only a maximised video modal minimises (mode is ${mode})`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
nav.minimiseVideoModal();
|
|
171
|
+
|
|
172
|
+
return "OK";
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "maximizeVideoModal",
|
|
177
|
+
description: "Expand the mini player or leave fullscreen.",
|
|
178
|
+
handler: () => {
|
|
179
|
+
const nav = requireVideoModal();
|
|
180
|
+
|
|
181
|
+
if (nav.videoModalState.mode === "FULLSCREEN") {
|
|
182
|
+
return toggleFullscreen(false);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
nav.maximiseVideoModal();
|
|
186
|
+
|
|
187
|
+
return "OK";
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "fullscreenVideoModal",
|
|
192
|
+
description:
|
|
193
|
+
"Put the video modal in fullscreen, as the player's fullscreen button does.",
|
|
194
|
+
handler: () => {
|
|
195
|
+
const { mode } = requireVideoModal().videoModalState;
|
|
196
|
+
|
|
197
|
+
// The fullscreen button is on the maximised player, not on the mini player.
|
|
198
|
+
if (mode !== "MAXIMIZED") {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Only a maximised video modal goes fullscreen (mode is ${mode})`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return toggleFullscreen(true);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "closeVideoModal",
|
|
209
|
+
description: "Close the video modal and its player.",
|
|
210
|
+
handler: () => {
|
|
211
|
+
requireVideoModal().closeVideoModal();
|
|
212
|
+
|
|
213
|
+
return "OK";
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
export const registerNavToolbox = () =>
|
|
219
|
+
registerXrayToolbox({
|
|
220
|
+
name: "nav",
|
|
221
|
+
description:
|
|
222
|
+
"Navigate the app (stack, back, home, modals) and drive the video modal.",
|
|
223
|
+
tools,
|
|
224
|
+
});
|
|
@@ -7,16 +7,16 @@ import {
|
|
|
7
7
|
} from "@applicaster/zapp-react-native-utils/navigationUtils";
|
|
8
8
|
import { findPluginByType } from "@applicaster/zapp-react-native-utils/pluginUtils";
|
|
9
9
|
|
|
10
|
-
import { coreAppLogger } from "../logger";
|
|
11
10
|
import { isString } from "@applicaster/zapp-react-native-utils/stringUtils";
|
|
12
11
|
import { SCREEN_TYPES } from "@applicaster/zapp-react-native-utils/navigationUtils/itemTypes";
|
|
13
12
|
import { isNilOrEmpty } from "@applicaster/zapp-react-native-utils/reactUtils/helpers";
|
|
13
|
+
import { subsystemLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
14
14
|
|
|
15
15
|
const EXTERNAL_URL_TYPE = "external_url";
|
|
16
16
|
const EXTERNAL_URL_FLAG = "open_external_url";
|
|
17
17
|
const WEBVIEW_SCREEN_IDENTIFIER = "webview_screen_qb";
|
|
18
18
|
|
|
19
|
-
const logger =
|
|
19
|
+
const logger = subsystemLogger("ui/navigation", "Navigator");
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Drops a leading `/hooks/<screen_id>` segment from a path.
|
|
@@ -183,6 +183,27 @@ export function getNavigationTarget(
|
|
|
183
183
|
data.entry = targetEntry;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
if (data.entry && !data.screen) {
|
|
187
|
+
const entryType = (data.entry.type as ZappEntryType)?.value;
|
|
188
|
+
const mappedScreenId = contentTypes?.[entryType]?.screen_id;
|
|
189
|
+
|
|
190
|
+
const mappedRiver = mappedScreenId ? rivers?.[mappedScreenId] : undefined;
|
|
191
|
+
|
|
192
|
+
if (!mappedScreenId) {
|
|
193
|
+
logger.error({
|
|
194
|
+
message: `getNavigationTarget: mapped screen ID missing for content type "${entryType}". Check your types mapping or screen configuration.`,
|
|
195
|
+
data: { entry: data.entry },
|
|
196
|
+
});
|
|
197
|
+
} else if (!mappedRiver) {
|
|
198
|
+
logger.error({
|
|
199
|
+
message: `getNavigationTarget: cannot map screen ID "${mappedScreenId}" to a river. Check your types mapping or screen configuration.`,
|
|
200
|
+
data: { entry: data.entry, mappedScreenId },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
data.screen = mappedRiver;
|
|
205
|
+
}
|
|
206
|
+
|
|
186
207
|
return data;
|
|
187
208
|
}
|
|
188
209
|
|
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
} from "./utils";
|
|
14
14
|
import { usePrevious } from "@applicaster/zapp-react-native-utils/reactHooks/utils";
|
|
15
15
|
|
|
16
|
+
import { useResumeVerifiedNetInfo } from "./useResumeVerifiedNetInfo";
|
|
17
|
+
import { describeNetInfo } from "./resumeVerification";
|
|
18
|
+
import { subsystemLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
19
|
+
|
|
20
|
+
const logger = subsystemLogger("network/reachability", "NetworkStatusProvider");
|
|
21
|
+
|
|
16
22
|
// Use default netInfo config for all platforms (important for web platform which otherwise
|
|
17
23
|
// will use backend root which is not responding for HEAD request)
|
|
18
24
|
if (["web", "lg_tv", "samsung_tv"].includes(Platform.OS)) {
|
|
@@ -36,10 +42,30 @@ export const NetworkStatusProvider = ({
|
|
|
36
42
|
// NetInfo library does not have accurate information for Tizen SmartTVs, and possibly WebOS
|
|
37
43
|
// This combined info helps us gather data from the native connection status APIs
|
|
38
44
|
// and includes it in our definition of... online.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
deviceStatus
|
|
42
|
-
|
|
45
|
+
// stable reference between real changes: every new reference is a network update
|
|
46
|
+
const liveNetInfo = React.useMemo<
|
|
47
|
+
NetInfoState & { deviceStatus?: NetStatus }
|
|
48
|
+
>(
|
|
49
|
+
() => ({ ...currentNetInfo, deviceStatus }),
|
|
50
|
+
[currentNetInfo, deviceStatus]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// NetInfo reports stale offline status right after resume/unlock (iOS),
|
|
54
|
+
// publish the network status verified after the app returns to foreground
|
|
55
|
+
const { netInfo: verifiedNetInfo, stillOfflineAfterResumeCount } =
|
|
56
|
+
useResumeVerifiedNetInfo(liveNetInfo);
|
|
57
|
+
|
|
58
|
+
// Memoized so consumers only re-render when the published status changes,
|
|
59
|
+
// not on every render of this provider
|
|
60
|
+
const combinedNetInfo = React.useMemo<
|
|
61
|
+
NetInfoState & {
|
|
62
|
+
deviceStatus?: NetStatus;
|
|
63
|
+
stillOfflineAfterResumeCount: number;
|
|
64
|
+
}
|
|
65
|
+
>(
|
|
66
|
+
() => ({ ...verifiedNetInfo, stillOfflineAfterResumeCount }),
|
|
67
|
+
[verifiedNetInfo, stillOfflineAfterResumeCount]
|
|
68
|
+
);
|
|
43
69
|
|
|
44
70
|
const { onConnectionTypeChanged, onConnectionLost, onConnectionRestored } =
|
|
45
71
|
React.useMemo<NetworkHooks>(() => getNetworkHooks(plugins), []);
|
|
@@ -57,6 +83,16 @@ export const NetworkStatusProvider = ({
|
|
|
57
83
|
if (!combinedNetInfo.details) return;
|
|
58
84
|
|
|
59
85
|
if (previousOnline !== undefined && previousOnline !== online) {
|
|
86
|
+
logger.info({
|
|
87
|
+
message: "NetworkStatus: online status changed",
|
|
88
|
+
data: {
|
|
89
|
+
online,
|
|
90
|
+
previousOnline,
|
|
91
|
+
published: describeNetInfo(combinedNetInfo),
|
|
92
|
+
live: describeNetInfo(liveNetInfo),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
60
96
|
online
|
|
61
97
|
? onConnectionRestored &&
|
|
62
98
|
callAllWith(onConnectionRestored, combinedNetInfo)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { AppState, AppStateStatus } from "react-native";
|
|
2
|
+
import { NetInfoState } from "@react-native-community/netinfo";
|
|
3
|
+
|
|
4
|
+
import { isOnline } from "./utils";
|
|
5
|
+
import {
|
|
6
|
+
RESUME_VERIFICATION_DELAY_MS,
|
|
7
|
+
describeNetInfo,
|
|
8
|
+
getResumeDecision,
|
|
9
|
+
isBackgroundAppState,
|
|
10
|
+
isResumeVerificationEnabled,
|
|
11
|
+
} from "./resumeVerification";
|
|
12
|
+
import { subsystemLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
13
|
+
|
|
14
|
+
const logger = subsystemLogger("network/reachability", "NetworkStatusProvider");
|
|
15
|
+
|
|
16
|
+
export type ConnectionInfo = NetInfoState & { deviceStatus?: NetStatus };
|
|
17
|
+
|
|
18
|
+
export type ResumeVerifiedState<T extends ConnectionInfo> = {
|
|
19
|
+
netInfo: T;
|
|
20
|
+
stillOfflineAfterResumeCount: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Phase = "live" | "frozen" | "verifying";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Holds the published network status while the app is in background and for
|
|
27
|
+
* RESUME_VERIFICATION_DELAY_MS after it becomes active again, then publishes the
|
|
28
|
+
* current state. Works around stale iOS NetInfo reachability failures on resume/unlock.
|
|
29
|
+
*
|
|
30
|
+
* start()/stop() manage the AppState subscription and timer (useLayoutEffect),
|
|
31
|
+
* setNetInfo() feeds live NetInfo, subscribe()/getState() back useSyncExternalStore.
|
|
32
|
+
*/
|
|
33
|
+
export class ResumeVerificationController<T extends ConnectionInfo> {
|
|
34
|
+
private phase: Phase = "live";
|
|
35
|
+
private appState: AppStateStatus;
|
|
36
|
+
private live: T;
|
|
37
|
+
private state: ResumeVerifiedState<T>;
|
|
38
|
+
private listeners = new Set<() => void>();
|
|
39
|
+
private subscription: { remove: () => void } | null = null;
|
|
40
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param initialNetInfo - live NetInfo at mount, published as the initial state.
|
|
44
|
+
* Starts "frozen" when the app is created in background (e.g. iOS background launch).
|
|
45
|
+
*/
|
|
46
|
+
constructor(initialNetInfo: T) {
|
|
47
|
+
this.appState = AppState.currentState;
|
|
48
|
+
this.live = initialNetInfo;
|
|
49
|
+
this.state = { netInfo: initialNetInfo, stillOfflineAfterResumeCount: 0 };
|
|
50
|
+
|
|
51
|
+
if (isResumeVerificationEnabled() && isBackgroundAppState(this.appState)) {
|
|
52
|
+
this.phase = "frozen";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.subscribe = this.subscribe.bind(this);
|
|
56
|
+
this.getState = this.getState.bind(this);
|
|
57
|
+
this.applyAppState = this.applyAppState.bind(this);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* useSyncExternalStore subscribe: listener is called whenever getState() changes.
|
|
62
|
+
* @param listener - called with no arguments after each publish
|
|
63
|
+
* @returns unsubscribe function
|
|
64
|
+
*/
|
|
65
|
+
subscribe(listener: () => void): () => void {
|
|
66
|
+
this.listeners.add(listener);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
this.listeners.delete(listener);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* useSyncExternalStore snapshot: same reference until a new state is published.
|
|
75
|
+
* @returns the verified network status and the still-offline-after-resume counter
|
|
76
|
+
*/
|
|
77
|
+
getState(): ResumeVerifiedState<T> {
|
|
78
|
+
return this.state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Subscribes to AppState changes and catches up with the current app state.
|
|
83
|
+
* Idempotent; no-op on platforms without resume verification (web, TV).
|
|
84
|
+
* Call from useLayoutEffect, paired with stop().
|
|
85
|
+
*/
|
|
86
|
+
start(): void {
|
|
87
|
+
if (!isResumeVerificationEnabled() || this.subscription) return;
|
|
88
|
+
|
|
89
|
+
this.subscription = AppState.addEventListener("change", this.applyAppState);
|
|
90
|
+
|
|
91
|
+
// AppState may have changed since construction (e.g. initial "background"
|
|
92
|
+
// corrected to "active"), or stop() interrupted a window: catch up
|
|
93
|
+
this.applyAppState(AppState.currentState);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Removes the AppState subscription and cancels a pending verification.
|
|
98
|
+
* An interrupted window falls back to "frozen" and restarts on the next start() while active.
|
|
99
|
+
*/
|
|
100
|
+
stop(): void {
|
|
101
|
+
this.subscription?.remove();
|
|
102
|
+
this.subscription = null;
|
|
103
|
+
this.clearTimer();
|
|
104
|
+
|
|
105
|
+
// an interrupted window is restarted by the next start() while active
|
|
106
|
+
if (this.phase === "verifying") {
|
|
107
|
+
this.phase = "frozen";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Feeds the latest live NetInfo. Published immediately while "live",
|
|
113
|
+
* held (and logged as ignored) while "frozen" or "verifying".
|
|
114
|
+
* @param netInfo - live NetInfo; the same reference is a no-op
|
|
115
|
+
*/
|
|
116
|
+
setNetInfo(netInfo: T): void {
|
|
117
|
+
if (netInfo === this.live) return;
|
|
118
|
+
|
|
119
|
+
this.live = netInfo;
|
|
120
|
+
|
|
121
|
+
if (this.phase === "live") {
|
|
122
|
+
this.publish(netInfo);
|
|
123
|
+
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
logger.info({
|
|
128
|
+
message: "NetworkStatus: network change ignored until verified",
|
|
129
|
+
data: {
|
|
130
|
+
appState: this.appState,
|
|
131
|
+
phase: this.phase,
|
|
132
|
+
published: describeNetInfo(this.state.netInfo),
|
|
133
|
+
live: describeNetInfo(netInfo),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* AppState change handler: "background" freezes, "active" after a freeze starts verification,
|
|
140
|
+
* anything else ("inactive", "unknown") keeps the current phase.
|
|
141
|
+
* @param nextAppState - new AppState value
|
|
142
|
+
*/
|
|
143
|
+
private applyAppState(nextAppState: AppStateStatus): void {
|
|
144
|
+
const previousAppState = this.appState;
|
|
145
|
+
const nextPhase = this.getNextPhase(nextAppState);
|
|
146
|
+
this.appState = nextAppState;
|
|
147
|
+
|
|
148
|
+
if (previousAppState === nextAppState && nextPhase === this.phase) return;
|
|
149
|
+
|
|
150
|
+
logger.info({
|
|
151
|
+
message: "NetworkStatus: app state changed",
|
|
152
|
+
data: {
|
|
153
|
+
appState: nextAppState,
|
|
154
|
+
previousAppState,
|
|
155
|
+
phase: this.phase,
|
|
156
|
+
nextPhase,
|
|
157
|
+
published: describeNetInfo(this.state.netInfo),
|
|
158
|
+
live: describeNetInfo(this.live),
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (nextPhase === "frozen" && this.phase !== "frozen") {
|
|
163
|
+
this.freeze();
|
|
164
|
+
} else if (nextPhase === "verifying" && this.phase === "frozen") {
|
|
165
|
+
this.startVerification();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param appState - new AppState value
|
|
171
|
+
* @returns the phase the controller moves to for this app state
|
|
172
|
+
*/
|
|
173
|
+
private getNextPhase(appState: AppStateStatus): Phase {
|
|
174
|
+
if (isBackgroundAppState(appState)) return "frozen";
|
|
175
|
+
|
|
176
|
+
if (appState === "active" && this.phase === "frozen") return "verifying";
|
|
177
|
+
|
|
178
|
+
return this.phase;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stops publishing live changes; cancels a running verification window.
|
|
183
|
+
*/
|
|
184
|
+
private freeze(): void {
|
|
185
|
+
if (this.phase === "verifying") {
|
|
186
|
+
this.clearTimer();
|
|
187
|
+
|
|
188
|
+
logger.info({
|
|
189
|
+
message: "NetworkStatus: resume verification cancelled",
|
|
190
|
+
data: {
|
|
191
|
+
appState: this.appState,
|
|
192
|
+
snapshot: describeNetInfo(this.state.netInfo),
|
|
193
|
+
live: describeNetInfo(this.live),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.phase = "frozen";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Opens the RESUME_VERIFICATION_DELAY_MS window. When it ends, compares the state published
|
|
203
|
+
* before background with the current live state, publishes the live state and bumps
|
|
204
|
+
* stillOfflineAfterResumeCount when the app is still offline.
|
|
205
|
+
*/
|
|
206
|
+
private startVerification(): void {
|
|
207
|
+
this.phase = "verifying";
|
|
208
|
+
|
|
209
|
+
const snapshot = this.state.netInfo;
|
|
210
|
+
const startedAt = Date.now();
|
|
211
|
+
|
|
212
|
+
logger.info({
|
|
213
|
+
message: "NetworkStatus: resume verification started",
|
|
214
|
+
data: {
|
|
215
|
+
delayMs: RESUME_VERIFICATION_DELAY_MS,
|
|
216
|
+
appState: this.appState,
|
|
217
|
+
snapshot: describeNetInfo(snapshot),
|
|
218
|
+
live: describeNetInfo(this.live),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
this.timer = setTimeout(() => {
|
|
223
|
+
this.timer = null;
|
|
224
|
+
|
|
225
|
+
if (this.phase !== "verifying") return;
|
|
226
|
+
|
|
227
|
+
const decision = getResumeDecision(
|
|
228
|
+
isOnline(snapshot) === true,
|
|
229
|
+
isOnline(this.live) === true
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
logger.info({
|
|
233
|
+
message: "NetworkStatus: resume verification finished",
|
|
234
|
+
data: {
|
|
235
|
+
decision,
|
|
236
|
+
elapsedMs: Date.now() - startedAt,
|
|
237
|
+
appState: this.appState,
|
|
238
|
+
snapshot: describeNetInfo(snapshot),
|
|
239
|
+
live: describeNetInfo(this.live),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
this.phase = "live";
|
|
244
|
+
|
|
245
|
+
this.publish(
|
|
246
|
+
this.live,
|
|
247
|
+
this.state.stillOfflineAfterResumeCount +
|
|
248
|
+
(decision === "still_offline" ? 1 : 0)
|
|
249
|
+
);
|
|
250
|
+
}, RESUME_VERIFICATION_DELAY_MS);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Cancels the pending verification timer, if any.
|
|
255
|
+
*/
|
|
256
|
+
private clearTimer(): void {
|
|
257
|
+
if (this.timer !== null) {
|
|
258
|
+
clearTimeout(this.timer);
|
|
259
|
+
this.timer = null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Replaces the snapshot and notifies subscribers; no-op when nothing changed.
|
|
265
|
+
* @param netInfo - NetInfo to publish
|
|
266
|
+
* @param stillOfflineAfterResumeCount - counter to publish, defaults to the current one
|
|
267
|
+
*/
|
|
268
|
+
private publish(
|
|
269
|
+
netInfo: T,
|
|
270
|
+
stillOfflineAfterResumeCount = this.state.stillOfflineAfterResumeCount
|
|
271
|
+
): void {
|
|
272
|
+
if (
|
|
273
|
+
netInfo === this.state.netInfo &&
|
|
274
|
+
stillOfflineAfterResumeCount === this.state.stillOfflineAfterResumeCount
|
|
275
|
+
) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.state = { netInfo, stillOfflineAfterResumeCount };
|
|
280
|
+
this.listeners.forEach((listener) => listener());
|
|
281
|
+
}
|
|
282
|
+
}
|