@applicaster/quick-brick-core 16.0.0-alpha.1334507601 → 16.0.0-alpha.1444784746
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/ActionsProvider/LegacyActionsRegistryAdapter.tsx +16 -0
- package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
- package/App/ModalProvider/ModalContent.tsx +3 -16
- package/App/ModalProvider/__tests__/ModalContent.test.tsx +4 -9
- package/App/ModalProvider/__tests__/index.test.tsx +24 -0
- package/App/ModalProvider/index.tsx +10 -3
- package/App/NavigationProvider/NavigationProvider.tsx +6 -4
- package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
- package/App/NetworkStatusProvider/NetworkStatusProvider.tsx +32 -4
- package/App/NetworkStatusProvider/ResumeVerificationController.ts +282 -0
- package/App/NetworkStatusProvider/__tests__/NetworkStatusProvider.test.tsx +152 -1
- package/App/NetworkStatusProvider/__tests__/ResumeVerificationController.test.ts +471 -0
- package/App/NetworkStatusProvider/__tests__/resumeVerification.test.ts +126 -0
- package/App/NetworkStatusProvider/__tests__/useResumeVerifiedNetInfo.test.ts +189 -0
- package/App/NetworkStatusProvider/resumeVerification.ts +54 -0
- package/App/NetworkStatusProvider/useResumeVerifiedNetInfo.ts +33 -0
- package/package.json +8 -8
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
3
|
import { View, StyleSheet } from "react-native";
|
|
4
|
-
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
5
4
|
|
|
6
5
|
import { ScreenResolver } from "@applicaster/zapp-react-native-ui-components/Components/ScreenResolver";
|
|
7
6
|
|
|
@@ -18,8 +17,6 @@ const styles = StyleSheet.create({
|
|
|
18
17
|
export function ModalContent(props: Props) {
|
|
19
18
|
const { modalScreenProps } = props;
|
|
20
19
|
|
|
21
|
-
const insets = useSafeAreaInsets();
|
|
22
|
-
|
|
23
20
|
if (modalScreenProps?.screenData?.modalBottomSheetContentProps) {
|
|
24
21
|
const { ModalBottomSheetContent, modalBottomSheetContentProps } =
|
|
25
22
|
modalScreenProps?.screenData || {};
|
|
@@ -34,19 +31,9 @@ export function ModalContent(props: Props) {
|
|
|
34
31
|
|
|
35
32
|
return (
|
|
36
33
|
// A presented screen fills the window, including the status bar and home
|
|
37
|
-
// indicator.
|
|
38
|
-
//
|
|
39
|
-
<View
|
|
40
|
-
style={[
|
|
41
|
-
styles.container,
|
|
42
|
-
{
|
|
43
|
-
paddingTop: insets.top,
|
|
44
|
-
paddingBottom: insets.bottom,
|
|
45
|
-
paddingLeft: insets.left,
|
|
46
|
-
paddingRight: insets.right,
|
|
47
|
-
},
|
|
48
|
-
]}
|
|
49
|
-
>
|
|
34
|
+
// indicator. The modal wraps it in its own SafeAreaProvider, so the screen
|
|
35
|
+
// reads the real insets itself and places its own chrome inside them.
|
|
36
|
+
<View style={styles.container}>
|
|
50
37
|
<ScreenResolver {...modalScreenProps} />
|
|
51
38
|
</View>
|
|
52
39
|
);
|
|
@@ -6,10 +6,6 @@ import { ModalContent } from "../ModalContent";
|
|
|
6
6
|
|
|
7
7
|
jest.mock("../ModalBottomSheet", () => ({ ModalBottomSheet: () => null }));
|
|
8
8
|
|
|
9
|
-
jest.mock("react-native-safe-area-context", () => ({
|
|
10
|
-
useSafeAreaInsets: () => ({ top: 59, bottom: 34, left: 0, right: 0 }),
|
|
11
|
-
}));
|
|
12
|
-
|
|
13
9
|
jest.mock(
|
|
14
10
|
"@applicaster/zapp-react-native-ui-components/Components/ScreenResolver",
|
|
15
11
|
() => ({ ScreenResolver: () => null })
|
|
@@ -18,15 +14,14 @@ jest.mock(
|
|
|
18
14
|
const flatStyle = (tree: any) => StyleSheet.flatten(tree.props.style);
|
|
19
15
|
|
|
20
16
|
describe("ModalContent", () => {
|
|
21
|
-
it("
|
|
17
|
+
it("lets a presented screen fill the modal, leaving the safe area to the screen", () => {
|
|
22
18
|
const tree = render(
|
|
23
19
|
<ModalContent modalScreenProps={{ screenId: "river-1" }} />
|
|
24
20
|
).toJSON();
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
22
|
+
// The modal provides a SafeAreaProvider instead, so the presented screen
|
|
23
|
+
// reads the real insets itself rather than being padded from the outside.
|
|
24
|
+
expect(flatStyle(tree)).toEqual({ flex: 1 });
|
|
30
25
|
});
|
|
31
26
|
|
|
32
27
|
it("leaves a bottom sheet full-bleed", () => {
|
|
@@ -11,6 +11,9 @@ import { ModalProvider } from "../";
|
|
|
11
11
|
*/
|
|
12
12
|
const mockSeenScreenData: any[] = [];
|
|
13
13
|
|
|
14
|
+
/** Records the `initialMetrics` each SafeAreaProvider the modal renders got. */
|
|
15
|
+
const mockSafeAreaProviderMetrics: any[] = [];
|
|
16
|
+
|
|
14
17
|
jest.mock(
|
|
15
18
|
"@applicaster/zapp-react-native-ui-components/Components/ScreenResolver",
|
|
16
19
|
() => {
|
|
@@ -43,6 +46,14 @@ jest.mock("../ModalBottomSheet", () => ({ ModalBottomSheet: () => null }));
|
|
|
43
46
|
|
|
44
47
|
jest.mock("react-native-safe-area-context", () => ({
|
|
45
48
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
49
|
+
// A modal renders in its own native window, so it needs its own provider
|
|
50
|
+
// seeded with the window metrics; without them insets read as zero inside.
|
|
51
|
+
initialWindowMetrics: { insets: { top: 59, bottom: 34, left: 0, right: 0 } },
|
|
52
|
+
SafeAreaProvider: ({ children, initialMetrics }: any) => {
|
|
53
|
+
mockSafeAreaProviderMetrics.push(initialMetrics);
|
|
54
|
+
|
|
55
|
+
return children;
|
|
56
|
+
},
|
|
46
57
|
}));
|
|
47
58
|
|
|
48
59
|
const river = { id: "river-parent-lock", type: "parent_lock" } as any;
|
|
@@ -54,6 +65,7 @@ const seen = () => mockSeenScreenData[mockSeenScreenData.length - 1];
|
|
|
54
65
|
describe("ModalProvider", () => {
|
|
55
66
|
beforeEach(() => {
|
|
56
67
|
mockSeenScreenData.length = 0;
|
|
68
|
+
mockSafeAreaProviderMetrics.length = 0;
|
|
57
69
|
modalStore.getState().dismissModal();
|
|
58
70
|
});
|
|
59
71
|
|
|
@@ -72,4 +84,16 @@ describe("ModalProvider", () => {
|
|
|
72
84
|
|
|
73
85
|
expect(seen()).toMatchObject({ screen: river });
|
|
74
86
|
});
|
|
87
|
+
|
|
88
|
+
it("seeds the presented screen's safe area with the window metrics", () => {
|
|
89
|
+
const { initialWindowMetrics } = require("react-native-safe-area-context");
|
|
90
|
+
|
|
91
|
+
modalStore.getState().openModal({ item: river });
|
|
92
|
+
|
|
93
|
+
render(<ModalProvider />);
|
|
94
|
+
|
|
95
|
+
expect(mockSafeAreaProviderMetrics).toEqual(
|
|
96
|
+
expect.arrayContaining([initialWindowMetrics])
|
|
97
|
+
);
|
|
98
|
+
});
|
|
75
99
|
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
initialWindowMetrics,
|
|
5
|
+
SafeAreaProvider,
|
|
6
|
+
} from "react-native-safe-area-context";
|
|
7
|
+
|
|
3
8
|
import { ModalPresenter } from "./ModalPresenter";
|
|
4
9
|
import { ModalContent } from "./ModalContent";
|
|
5
10
|
import { ModalChildrenWrapper } from "./ModalChildrenWrapper";
|
|
@@ -62,9 +67,11 @@ export function ModalProvider() {
|
|
|
62
67
|
{...modalState?.options}
|
|
63
68
|
statusBarTranslucent
|
|
64
69
|
>
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
70
|
+
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
|
71
|
+
<ModalChildrenWrapper>
|
|
72
|
+
<ModalContent modalScreenProps={modalScreenProps} />
|
|
73
|
+
</ModalChildrenWrapper>
|
|
74
|
+
</SafeAreaProvider>
|
|
68
75
|
</ModalPresenter>
|
|
69
76
|
</ScreenContextProvider>
|
|
70
77
|
</ScreenDataContext.Provider>
|
|
@@ -243,6 +243,10 @@ export function NavigationProvider({ children }: Props) {
|
|
|
243
243
|
playNextOverlayState.current = state;
|
|
244
244
|
};
|
|
245
245
|
|
|
246
|
+
// The play-next overlay owns remote focus while it is visible, so key
|
|
247
|
+
// handling (InteractionManager) needs to be able to ask for it.
|
|
248
|
+
const getPlayNextOverlay = () => playNextOverlayState.current ?? null;
|
|
249
|
+
|
|
246
250
|
const setNestedScreenContent = (entry: ZappEntry, screenId: string) => {
|
|
247
251
|
const screen = rivers[screenId];
|
|
248
252
|
const parent = findParent(context, navigator.currentRoute);
|
|
@@ -349,10 +353,7 @@ export function NavigationProvider({ children }: Props) {
|
|
|
349
353
|
state: firstRoute.state,
|
|
350
354
|
};
|
|
351
355
|
|
|
352
|
-
if (
|
|
353
|
-
stateForBack?.state?.entry &&
|
|
354
|
-
"home" in stateForBack?.state?.entry
|
|
355
|
-
) {
|
|
356
|
+
if (stateForBack?.state?.entry?.home) {
|
|
356
357
|
goHome();
|
|
357
358
|
} else {
|
|
358
359
|
dispatch(back(stateForBack));
|
|
@@ -777,6 +778,7 @@ export function NavigationProvider({ children }: Props) {
|
|
|
777
778
|
replace: replaceItem,
|
|
778
779
|
setNestedScreenContent,
|
|
779
780
|
setPlayNextOverlay,
|
|
781
|
+
getPlayNextOverlay,
|
|
780
782
|
goHome,
|
|
781
783
|
canGoBack,
|
|
782
784
|
goBack,
|
|
@@ -410,6 +410,62 @@ describe("<NavigationProvider />", () => {
|
|
|
410
410
|
});
|
|
411
411
|
});
|
|
412
412
|
|
|
413
|
+
describe("when backToTop is requested from a non-home tab", () => {
|
|
414
|
+
const listenRiver = {
|
|
415
|
+
id: "LISTEN",
|
|
416
|
+
home: false,
|
|
417
|
+
navigations: [],
|
|
418
|
+
ui_components: [],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
beforeEach(() => {
|
|
422
|
+
wrapper = renderContextConsumer({
|
|
423
|
+
...store,
|
|
424
|
+
rivers: { ...rivers, LISTEN: listenRiver },
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
act(() => {
|
|
428
|
+
getNavigatorValues(wrapper).navigator.replace(listenRiver);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("returns to the tab root from the second level", () => {
|
|
433
|
+
act(() => {
|
|
434
|
+
getNavigatorValues(wrapper).navigator.push(rivers.A1234);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
act(() => {
|
|
438
|
+
getNavigatorValues(wrapper).navigator.goBack(false, false, true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const { navigator, state } = getNavigatorValues(wrapper);
|
|
442
|
+
|
|
443
|
+
expect(navigator.currentRoute).toEqual("/river/LISTEN");
|
|
444
|
+
expect(navigator.activeRiver).toEqual(listenRiver);
|
|
445
|
+
expect(state.stack.mainStack).toHaveLength(1);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("returns to the tab root from the third level", () => {
|
|
449
|
+
act(() => {
|
|
450
|
+
getNavigatorValues(wrapper).navigator.push(rivers.A1234);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
act(() => {
|
|
454
|
+
getNavigatorValues(wrapper).navigator.push(rivers.C0987);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
act(() => {
|
|
458
|
+
getNavigatorValues(wrapper).navigator.goBack(false, false, true);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const { navigator, state } = getNavigatorValues(wrapper);
|
|
462
|
+
|
|
463
|
+
expect(navigator.currentRoute).toEqual("/river/LISTEN");
|
|
464
|
+
expect(navigator.activeRiver).toEqual(listenRiver);
|
|
465
|
+
expect(state.stack.mainStack).toHaveLength(1);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
413
469
|
describe("when there is a previous route and there are hooks", () => {
|
|
414
470
|
beforeEach(() => {
|
|
415
471
|
wrapper = renderContextConsumer();
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
unsubscribeToDeviceNetworkChanges,
|
|
13
13
|
} from "./utils";
|
|
14
14
|
import { usePrevious } from "@applicaster/zapp-react-native-utils/reactHooks/utils";
|
|
15
|
+
import { coreAppLogger } from "../logger";
|
|
16
|
+
import { useResumeVerifiedNetInfo } from "./useResumeVerifiedNetInfo";
|
|
17
|
+
import { describeNetInfo } from "./resumeVerification";
|
|
18
|
+
|
|
19
|
+
const logger = coreAppLogger.addSubsystem("NetworkStatusProvider");
|
|
15
20
|
|
|
16
21
|
// Use default netInfo config for all platforms (important for web platform which otherwise
|
|
17
22
|
// will use backend root which is not responding for HEAD request)
|
|
@@ -36,10 +41,23 @@ export const NetworkStatusProvider = ({
|
|
|
36
41
|
// NetInfo library does not have accurate information for Tizen SmartTVs, and possibly WebOS
|
|
37
42
|
// This combined info helps us gather data from the native connection status APIs
|
|
38
43
|
// and includes it in our definition of... online.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
deviceStatus
|
|
42
|
-
|
|
44
|
+
// stable reference between real changes: every new reference is a network update
|
|
45
|
+
const liveNetInfo = React.useMemo<
|
|
46
|
+
NetInfoState & { deviceStatus?: NetStatus }
|
|
47
|
+
>(
|
|
48
|
+
() => ({ ...currentNetInfo, deviceStatus }),
|
|
49
|
+
[currentNetInfo, deviceStatus]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// NetInfo reports stale offline status right after resume/unlock (iOS),
|
|
53
|
+
// publish the network status verified after the app returns to foreground
|
|
54
|
+
const { netInfo: verifiedNetInfo, stillOfflineAfterResumeCount } =
|
|
55
|
+
useResumeVerifiedNetInfo(liveNetInfo);
|
|
56
|
+
|
|
57
|
+
const combinedNetInfo: NetInfoState & {
|
|
58
|
+
deviceStatus?: NetStatus;
|
|
59
|
+
stillOfflineAfterResumeCount: number;
|
|
60
|
+
} = { ...verifiedNetInfo, stillOfflineAfterResumeCount };
|
|
43
61
|
|
|
44
62
|
const { onConnectionTypeChanged, onConnectionLost, onConnectionRestored } =
|
|
45
63
|
React.useMemo<NetworkHooks>(() => getNetworkHooks(plugins), []);
|
|
@@ -57,6 +75,16 @@ export const NetworkStatusProvider = ({
|
|
|
57
75
|
if (!combinedNetInfo.details) return;
|
|
58
76
|
|
|
59
77
|
if (previousOnline !== undefined && previousOnline !== online) {
|
|
78
|
+
logger.info({
|
|
79
|
+
message: "NetworkStatus: online status changed",
|
|
80
|
+
data: {
|
|
81
|
+
online,
|
|
82
|
+
previousOnline,
|
|
83
|
+
published: describeNetInfo(combinedNetInfo),
|
|
84
|
+
live: describeNetInfo(liveNetInfo),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
60
88
|
online
|
|
61
89
|
? onConnectionRestored &&
|
|
62
90
|
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 { coreAppLogger } from "../logger";
|
|
5
|
+
import { isOnline } from "./utils";
|
|
6
|
+
import {
|
|
7
|
+
RESUME_VERIFICATION_DELAY_MS,
|
|
8
|
+
describeNetInfo,
|
|
9
|
+
getResumeDecision,
|
|
10
|
+
isBackgroundAppState,
|
|
11
|
+
isResumeVerificationEnabled,
|
|
12
|
+
} from "./resumeVerification";
|
|
13
|
+
|
|
14
|
+
const logger = coreAppLogger.addSubsystem("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
|
+
}
|