@teardown/react-native 1.2.39 → 2.0.0

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 (73) hide show
  1. package/README.md +75 -7
  2. package/package.json +65 -47
  3. package/src/clients/api/api.client.ts +55 -0
  4. package/src/clients/api/index.ts +1 -0
  5. package/src/clients/device/device.adpater-interface.ts +57 -0
  6. package/src/clients/device/device.client.test.ts +195 -0
  7. package/src/clients/device/device.client.ts +69 -0
  8. package/src/clients/device/expo-adapter.ts +128 -0
  9. package/src/clients/device/index.ts +4 -0
  10. package/src/clients/force-update/force-update.client.test.ts +296 -0
  11. package/src/clients/force-update/force-update.client.ts +224 -0
  12. package/src/clients/force-update/index.ts +1 -0
  13. package/src/clients/identity/identity.client.test.ts +454 -0
  14. package/src/clients/identity/identity.client.ts +249 -0
  15. package/src/clients/identity/index.ts +1 -0
  16. package/src/clients/logging/index.ts +1 -0
  17. package/src/clients/logging/logging.client.ts +92 -0
  18. package/src/clients/notifications/notifications.client.ts +10 -0
  19. package/src/clients/storage/index.ts +1 -0
  20. package/src/clients/storage/mmkv-adapter.ts +23 -0
  21. package/src/clients/storage/storage.client.ts +75 -0
  22. package/src/clients/utils/index.ts +1 -0
  23. package/src/clients/utils/utils.client.ts +75 -0
  24. package/src/components/ui/button.tsx +0 -0
  25. package/src/components/ui/input.tsx +0 -0
  26. package/src/contexts/index.ts +1 -0
  27. package/src/contexts/teardown.context.ts +17 -0
  28. package/src/exports/expo.ts +1 -0
  29. package/src/exports/index.ts +16 -0
  30. package/src/exports/mmkv.ts +1 -0
  31. package/src/hooks/use-force-update.ts +38 -0
  32. package/src/hooks/use-session.ts +26 -0
  33. package/src/providers/teardown.provider.tsx +28 -0
  34. package/src/teardown.core.ts +76 -0
  35. package/dist/components/index.d.ts +0 -1
  36. package/dist/components/index.js +0 -3
  37. package/dist/components/index.js.map +0 -1
  38. package/dist/components/teardown-logo.d.ts +0 -4
  39. package/dist/components/teardown-logo.js +0 -35
  40. package/dist/components/teardown-logo.js.map +0 -1
  41. package/dist/containers/index.d.ts +0 -1
  42. package/dist/containers/index.js +0 -18
  43. package/dist/containers/index.js.map +0 -1
  44. package/dist/containers/teardown.container.d.ts +0 -8
  45. package/dist/containers/teardown.container.js +0 -26
  46. package/dist/containers/teardown.container.js.map +0 -1
  47. package/dist/index.d.ts +0 -2
  48. package/dist/index.js +0 -22
  49. package/dist/index.js.map +0 -1
  50. package/dist/plugins/http.plugin.d.ts +0 -23
  51. package/dist/plugins/http.plugin.js +0 -145
  52. package/dist/plugins/http.plugin.js.map +0 -1
  53. package/dist/plugins/index.d.ts +0 -2
  54. package/dist/plugins/index.js +0 -20
  55. package/dist/plugins/index.js.map +0 -1
  56. package/dist/plugins/logging.plugin.d.ts +0 -9
  57. package/dist/plugins/logging.plugin.js +0 -36
  58. package/dist/plugins/logging.plugin.js.map +0 -1
  59. package/dist/plugins/websocket.plugin.d.ts +0 -1
  60. package/dist/plugins/websocket.plugin.js +0 -108
  61. package/dist/plugins/websocket.plugin.js.map +0 -1
  62. package/dist/services/index.d.ts +0 -1
  63. package/dist/services/index.js +0 -18
  64. package/dist/services/index.js.map +0 -1
  65. package/dist/services/teardown.service.d.ts +0 -10
  66. package/dist/services/teardown.service.js +0 -22
  67. package/dist/services/teardown.service.js.map +0 -1
  68. package/dist/teardown.client.d.ts +0 -41
  69. package/dist/teardown.client.js +0 -60
  70. package/dist/teardown.client.js.map +0 -1
  71. package/dist/utils/log.d.ts +0 -5
  72. package/dist/utils/log.js +0 -9
  73. package/dist/utils/log.js.map +0 -1
@@ -0,0 +1,128 @@
1
+ import type {
2
+ ApplicationInfo,
3
+ HardwareInfo,
4
+ NotificationsInfo,
5
+ OSInfo,
6
+ } from "@teardown/schemas";
7
+ import * as Application from "expo-application";
8
+ import * as Device from "expo-device";
9
+ import * as Notifications from "expo-notifications";
10
+ import { Platform } from "react-native";
11
+
12
+ import { DeviceInfoAdapter } from "./device.adpater-interface";
13
+ import { DevicePlatformEnum, NotificationPlatformEnum } from "./device.client";
14
+
15
+ /**
16
+ * Maps expo-device DeviceType to a string representation
17
+ */
18
+ function mapDeviceType(deviceType: Device.DeviceType | null): string {
19
+ switch (deviceType) {
20
+ case Device.DeviceType.PHONE:
21
+ return "phone";
22
+ case Device.DeviceType.TABLET:
23
+ return "tablet";
24
+ case Device.DeviceType.DESKTOP:
25
+ return "desktop";
26
+ case Device.DeviceType.TV:
27
+ return "tv";
28
+ default:
29
+ return "unknown";
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Maps React Native Platform.OS to DevicePlatformEnum
35
+ */
36
+ function mapPlatform(platform: typeof Platform.OS): DevicePlatformEnum {
37
+ switch (platform) {
38
+ case "ios":
39
+ return DevicePlatformEnum.IOS;
40
+ case "android":
41
+ return DevicePlatformEnum.ANDROID;
42
+ case "web":
43
+ return DevicePlatformEnum.WEB;
44
+ case "macos":
45
+ return DevicePlatformEnum.MACOS;
46
+ case "windows":
47
+ return DevicePlatformEnum.WINDOWS;
48
+ default:
49
+ return DevicePlatformEnum.UNKNOWN;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Determines the notification platform based on the device platform
55
+ */
56
+ function getNotificationPlatform(): NotificationPlatformEnum {
57
+ return NotificationPlatformEnum.EXPO;
58
+ }
59
+
60
+ export class ExpoDeviceAdapter extends DeviceInfoAdapter {
61
+ get applicationInfo(): ApplicationInfo {
62
+ return {
63
+ version: Application.nativeApplicationVersion ?? "0.0.0",
64
+ build_number: Number.parseInt(
65
+ Application.nativeBuildVersion ?? "0",
66
+ 10
67
+ ),
68
+ };
69
+ }
70
+
71
+ get hardwareInfo(): HardwareInfo {
72
+ return {
73
+ device_name: Device.deviceName ?? "Unknown Device",
74
+ device_brand: Device.brand ?? "Unknown Brand",
75
+ device_type: mapDeviceType(Device.deviceType),
76
+ };
77
+ }
78
+
79
+ get osInfo(): OSInfo {
80
+ return {
81
+ platform: mapPlatform(Platform.OS),
82
+ name: Device.osName ?? Platform.OS,
83
+ version: Device.osVersion ?? "0.0.0",
84
+ };
85
+ }
86
+
87
+ get notificationsInfo(): NotificationsInfo {
88
+ // Note: This returns a synchronous snapshot.
89
+ // For accurate permission status, use getNotificationsInfoAsync()
90
+ return {
91
+ push: {
92
+ enabled: false,
93
+ granted: false,
94
+ token: null,
95
+ platform: getNotificationPlatform(),
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Async method to get accurate notification permissions and token.
102
+ * Use this when you need real-time notification status.
103
+ */
104
+ async getNotificationsInfoAsync(): Promise<NotificationsInfo> {
105
+ const { status: existingStatus } =
106
+ await Notifications.getPermissionsAsync();
107
+ const granted = existingStatus === "granted";
108
+
109
+ let token: string | null = null;
110
+ if (granted) {
111
+ try {
112
+ const tokenData = await Notifications.getExpoPushTokenAsync();
113
+ token = tokenData.data;
114
+ } catch {
115
+ // Token retrieval failed, keep as null
116
+ }
117
+ }
118
+
119
+ return {
120
+ push: {
121
+ enabled: granted,
122
+ granted,
123
+ token,
124
+ platform: getNotificationPlatform(),
125
+ },
126
+ };
127
+ }
128
+ }
@@ -0,0 +1,4 @@
1
+ // export * from "./adapters/device-info.adapter";
2
+ // export * from "./adapters/expo-device.adapter";
3
+ export * from "./device.adpater-interface";
4
+ export * from "./device.client";
@@ -0,0 +1,296 @@
1
+ import { describe, test, expect, beforeEach, mock } from "bun:test";
2
+ import { EventEmitter } from "eventemitter3";
3
+
4
+ // Must mock react-native BEFORE any imports that use it
5
+ const mockAppStateListeners: ((state: string) => void)[] = [];
6
+ mock.module("react-native", () => ({
7
+ AppState: {
8
+ addEventListener: (_event: string, handler: (state: string) => void) => {
9
+ mockAppStateListeners.push(handler);
10
+ return { remove: () => mockAppStateListeners.splice(mockAppStateListeners.indexOf(handler), 1) };
11
+ },
12
+ },
13
+ }));
14
+
15
+ // Import after mock
16
+ const { ForceUpdateClient, IdentifyVersionStatusEnum } = await import("./force-update.client");
17
+ type IdentifyState = import("../identity").IdentifyState;
18
+ type IdentifyStateChangeEvents = import("../identity").IdentifyStateChangeEvents;
19
+ type VersionStatus = import("./force-update.client").VersionStatus;
20
+
21
+ function createMockIdentityClient() {
22
+ const emitter = new EventEmitter<IdentifyStateChangeEvents>();
23
+ let identifyCallCount = 0;
24
+ let nextIdentifyResult: { success: boolean; data?: { version_info: { status: IdentifyVersionStatusEnum } } } = {
25
+ success: true,
26
+ data: { version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE } },
27
+ };
28
+
29
+ return {
30
+ emitter,
31
+ onIdentifyStateChange: (listener: (state: IdentifyState) => void) => {
32
+ emitter.addListener("IDENTIFY_STATE_CHANGED", listener);
33
+ return () => emitter.removeListener("IDENTIFY_STATE_CHANGED", listener);
34
+ },
35
+ identify: async () => {
36
+ identifyCallCount++;
37
+ emitter.emit("IDENTIFY_STATE_CHANGED", { type: "identifying" });
38
+ emitter.emit("IDENTIFY_STATE_CHANGED", {
39
+ type: "identified",
40
+ session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
41
+ version_info: { status: nextIdentifyResult.data?.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
42
+ });
43
+ return nextIdentifyResult;
44
+ },
45
+ getIdentifyCallCount: () => identifyCallCount,
46
+ setNextIdentifyResult: (result: typeof nextIdentifyResult) => {
47
+ nextIdentifyResult = result;
48
+ },
49
+ };
50
+ }
51
+
52
+ function createMockLoggingClient() {
53
+ return {
54
+ createLogger: () => ({
55
+ info: () => {},
56
+ warn: () => {},
57
+ error: () => {},
58
+ debug: () => {},
59
+ }),
60
+ };
61
+ }
62
+
63
+ function createMockStorageClient() {
64
+ const storage = new Map<string, string>();
65
+ return {
66
+ createStorage: () => ({
67
+ getItem: (key: string) => storage.get(key) ?? null,
68
+ setItem: (key: string, value: string) => storage.set(key, value),
69
+ removeItem: (key: string) => storage.delete(key),
70
+ }),
71
+ };
72
+ }
73
+
74
+ describe("ForceUpdateClient", () => {
75
+ beforeEach(() => {
76
+ mockAppStateListeners.length = 0;
77
+ });
78
+
79
+ describe("updateFromVersionStatus single emission", () => {
80
+ test("emits VERSION_STATUS_CHANGED exactly once per identify cycle", async () => {
81
+ const mockIdentity = createMockIdentityClient();
82
+ const mockLogging = createMockLoggingClient();
83
+ const mockStorage = createMockStorageClient();
84
+
85
+ const client = new ForceUpdateClient(
86
+ mockLogging as never,
87
+ mockStorage as never,
88
+ mockIdentity as never,
89
+ { throttleMs: 0, checkCooldownMs: 0 }
90
+ );
91
+
92
+ const statusChanges: VersionStatus[] = [];
93
+ client.onVersionStatusChange((status) => statusChanges.push(status));
94
+
95
+ // Trigger identify via state change
96
+ mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", { type: "identifying" });
97
+ mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", {
98
+ type: "identified",
99
+ session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
100
+ version_info: { status: IdentifyVersionStatusEnum.UPDATE_AVAILABLE, update: null },
101
+ });
102
+
103
+ // Should have: checking (from identifying) + update_available (from identified)
104
+ expect(statusChanges).toHaveLength(2);
105
+ expect(statusChanges[0]).toEqual({ type: "checking" });
106
+ expect(statusChanges[1]).toEqual({ type: "update_available" });
107
+
108
+ client.shutdown();
109
+ });
110
+
111
+ test("foreground check triggers only one status update via subscription", async () => {
112
+ const mockIdentity = createMockIdentityClient();
113
+ const mockLogging = createMockLoggingClient();
114
+ const mockStorage = createMockStorageClient();
115
+
116
+ mockIdentity.setNextIdentifyResult({
117
+ success: true,
118
+ data: { version_info: { status: IdentifyVersionStatusEnum.UPDATE_REQUIRED } },
119
+ });
120
+
121
+ const client = new ForceUpdateClient(
122
+ mockLogging as never,
123
+ mockStorage as never,
124
+ mockIdentity as never,
125
+ { throttleMs: 0, checkCooldownMs: 0 }
126
+ );
127
+
128
+ const statusChanges: VersionStatus[] = [];
129
+ client.onVersionStatusChange((status) => statusChanges.push(status));
130
+
131
+ // Simulate app coming to foreground
132
+ const foregroundHandler = mockAppStateListeners[0];
133
+ expect(foregroundHandler).toBeDefined();
134
+
135
+ await foregroundHandler("active");
136
+
137
+ // Wait for async identify to complete
138
+ await new Promise((r) => setTimeout(r, 10));
139
+
140
+ // Should have: checking + update_required (NOT duplicated)
141
+ expect(statusChanges).toHaveLength(2);
142
+ expect(statusChanges[0]).toEqual({ type: "checking" });
143
+ expect(statusChanges[1]).toEqual({ type: "update_required" });
144
+
145
+ client.shutdown();
146
+ });
147
+ });
148
+
149
+ describe("cleanup on shutdown", () => {
150
+ test("removes all listeners on shutdown", () => {
151
+ const mockIdentity = createMockIdentityClient();
152
+ const mockLogging = createMockLoggingClient();
153
+ const mockStorage = createMockStorageClient();
154
+
155
+ const client = new ForceUpdateClient(
156
+ mockLogging as never,
157
+ mockStorage as never,
158
+ mockIdentity as never
159
+ );
160
+
161
+ const statusChanges: VersionStatus[] = [];
162
+ client.onVersionStatusChange((status) => statusChanges.push(status));
163
+
164
+ client.shutdown();
165
+
166
+ // After shutdown, emitting should not trigger listener
167
+ mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", {
168
+ type: "identified",
169
+ session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
170
+ version_info: { status: IdentifyVersionStatusEnum.UPDATE_AVAILABLE, update: null },
171
+ });
172
+
173
+ // No new status changes after shutdown
174
+ expect(statusChanges).toHaveLength(0);
175
+ });
176
+
177
+ test("removes AppState listener on shutdown", () => {
178
+ const mockIdentity = createMockIdentityClient();
179
+ const mockLogging = createMockLoggingClient();
180
+ const mockStorage = createMockStorageClient();
181
+
182
+ const client = new ForceUpdateClient(
183
+ mockLogging as never,
184
+ mockStorage as never,
185
+ mockIdentity as never
186
+ );
187
+
188
+ expect(mockAppStateListeners).toHaveLength(1);
189
+
190
+ client.shutdown();
191
+
192
+ expect(mockAppStateListeners).toHaveLength(0);
193
+ });
194
+ });
195
+
196
+ describe("throttle and cooldown", () => {
197
+ test("throttle prevents rapid foreground checks", async () => {
198
+ const mockIdentity = createMockIdentityClient();
199
+ const mockLogging = createMockLoggingClient();
200
+ const mockStorage = createMockStorageClient();
201
+
202
+ const client = new ForceUpdateClient(
203
+ mockLogging as never,
204
+ mockStorage as never,
205
+ mockIdentity as never,
206
+ { throttleMs: 1000, checkCooldownMs: 0 }
207
+ );
208
+
209
+ const foregroundHandler = mockAppStateListeners[0];
210
+
211
+ // First foreground
212
+ await foregroundHandler("active");
213
+ await new Promise((r) => setTimeout(r, 10));
214
+
215
+ const callsAfterFirst = mockIdentity.getIdentifyCallCount();
216
+
217
+ // Second foreground immediately (within throttle window)
218
+ await foregroundHandler("active");
219
+ await new Promise((r) => setTimeout(r, 10));
220
+
221
+ const callsAfterSecond = mockIdentity.getIdentifyCallCount();
222
+
223
+ // Should only have one identify call due to throttle
224
+ expect(callsAfterFirst).toBe(1);
225
+ expect(callsAfterSecond).toBe(1);
226
+
227
+ client.shutdown();
228
+ });
229
+
230
+ test("cooldown prevents redundant checks after recent success", async () => {
231
+ const mockIdentity = createMockIdentityClient();
232
+ const mockLogging = createMockLoggingClient();
233
+ const mockStorage = createMockStorageClient();
234
+
235
+ const client = new ForceUpdateClient(
236
+ mockLogging as never,
237
+ mockStorage as never,
238
+ mockIdentity as never,
239
+ { throttleMs: 0, checkCooldownMs: 5000 }
240
+ );
241
+
242
+ const foregroundHandler = mockAppStateListeners[0];
243
+
244
+ // First foreground - should trigger check
245
+ await foregroundHandler("active");
246
+ await new Promise((r) => setTimeout(r, 10));
247
+
248
+ const callsAfterFirst = mockIdentity.getIdentifyCallCount();
249
+
250
+ // Second foreground (within cooldown window)
251
+ await foregroundHandler("active");
252
+ await new Promise((r) => setTimeout(r, 10));
253
+
254
+ const callsAfterSecond = mockIdentity.getIdentifyCallCount();
255
+
256
+ // Only first call should have triggered identify (cooldown blocks second)
257
+ expect(callsAfterFirst).toBe(1);
258
+ expect(callsAfterSecond).toBe(1);
259
+
260
+ client.shutdown();
261
+ });
262
+ });
263
+
264
+ describe("version status mapping", () => {
265
+ test.each([
266
+ [IdentifyVersionStatusEnum.UP_TO_DATE, "up_to_date"],
267
+ [IdentifyVersionStatusEnum.UPDATE_AVAILABLE, "update_available"],
268
+ [IdentifyVersionStatusEnum.UPDATE_REQUIRED, "update_required"],
269
+ [IdentifyVersionStatusEnum.UPDATE_RECOMMENDED, "up_to_date"], // Falls through to default
270
+ [IdentifyVersionStatusEnum.DISABLED, "up_to_date"], // Falls through to default
271
+ ])("maps %s to %s", (apiStatus, expectedType) => {
272
+ const mockIdentity = createMockIdentityClient();
273
+ const mockLogging = createMockLoggingClient();
274
+ const mockStorage = createMockStorageClient();
275
+
276
+ const client = new ForceUpdateClient(
277
+ mockLogging as never,
278
+ mockStorage as never,
279
+ mockIdentity as never
280
+ );
281
+
282
+ const statusChanges: VersionStatus[] = [];
283
+ client.onVersionStatusChange((status) => statusChanges.push(status));
284
+
285
+ mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", {
286
+ type: "identified",
287
+ session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
288
+ version_info: { status: apiStatus, update: null },
289
+ });
290
+
291
+ expect(statusChanges[statusChanges.length - 1]?.type).toBe(expectedType);
292
+
293
+ client.shutdown();
294
+ });
295
+ });
296
+ });
@@ -0,0 +1,224 @@
1
+ import { EventEmitter } from "eventemitter3";
2
+ import { AppState, type AppStateStatus, type NativeEventSubscription } from "react-native";
3
+ import { z } from "zod";
4
+ import type { IdentityClient } from "../identity";
5
+ import type { Logger, LoggingClient } from "../logging";
6
+ import type { StorageClient, SupportedStorage } from "../storage";
7
+
8
+ // TODO: sort out why importing these enuims from schemas is not working - @teardown/schemas
9
+ export enum IdentifyVersionStatusEnum {
10
+ /**
11
+ * A new version is available
12
+ */
13
+ UPDATE_AVAILABLE = "UPDATE_AVAILABLE",
14
+ /**
15
+ * An update is recommended
16
+ */
17
+ UPDATE_RECOMMENDED = "UPDATE_RECOMMENDED",
18
+ /**
19
+ * An update is required
20
+ */
21
+ UPDATE_REQUIRED = "UPDATE_REQUIRED",
22
+ /**
23
+ * The current version is valid & up to date
24
+ */
25
+ UP_TO_DATE = "UP_TO_DATE",
26
+ /**
27
+ * The version or build has been disabled
28
+ */
29
+ DISABLED = "DISABLED",
30
+ }
31
+
32
+
33
+ export const InitializingVersionStatusSchema = z.object({ type: z.literal("initializing") });
34
+ export const CheckingVersionStatusSchema = z.object({ type: z.literal("checking") });
35
+ export const UpToDateVersionStatusSchema = z.object({ type: z.literal("up_to_date") });
36
+ export const UpdateAvailableVersionStatusSchema = z.object({ type: z.literal("update_available") });
37
+ export const UpdateRecommendedVersionStatusSchema = z.object({ type: z.literal("update_recommended") });
38
+ export const UpdateRequiredVersionStatusSchema = z.object({ type: z.literal("update_required") });
39
+ export const DisabledVersionStatusSchema = z.object({ type: z.literal("disabled") });
40
+ /**
41
+ * The version status schema.
42
+ * - "initializing" - The version status is initializing.
43
+ * - "checking" - The version status is being checked.
44
+ * - "up_to_date" - The version is up to date.
45
+ * - "update_available" - The version is available for update.
46
+ * - "update_required" - The version is required for update.
47
+ */
48
+ export const VersionStatusSchema = z.discriminatedUnion("type", [
49
+ InitializingVersionStatusSchema,
50
+ CheckingVersionStatusSchema,
51
+ UpToDateVersionStatusSchema,
52
+ UpdateAvailableVersionStatusSchema,
53
+ UpdateRecommendedVersionStatusSchema,
54
+ UpdateRequiredVersionStatusSchema,
55
+ DisabledVersionStatusSchema,
56
+ ]);
57
+
58
+ export type InitializingVersionStatus = z.infer<typeof InitializingVersionStatusSchema>;
59
+ export type CheckingVersionStatus = z.infer<typeof CheckingVersionStatusSchema>;
60
+ export type UpToDateVersionStatus = z.infer<typeof UpToDateVersionStatusSchema>;
61
+ export type UpdateAvailableVersionStatus = z.infer<typeof UpdateAvailableVersionStatusSchema>;
62
+ export type UpdateRecommendedVersionStatus = z.infer<typeof UpdateRecommendedVersionStatusSchema>;
63
+ export type UpdateRequiredVersionStatus = z.infer<typeof UpdateRequiredVersionStatusSchema>;
64
+ export type DisabledVersionStatus = z.infer<typeof DisabledVersionStatusSchema>;
65
+ export type VersionStatus = z.infer<typeof VersionStatusSchema>;
66
+
67
+ export type VersionStatusChangeEvents = {
68
+ VERSION_STATUS_CHANGED: (status: VersionStatus) => void;
69
+ };
70
+
71
+ export type ForceUpdateClientOptions = {
72
+ /** Min ms between foreground checks (default: 30000) */
73
+ throttleMs?: number;
74
+ /** Min ms since last successful check before re-checking (default: 300000 = 5min) */
75
+ checkCooldownMs?: number;
76
+ };
77
+
78
+ const DEFAULT_OPTIONS: Required<ForceUpdateClientOptions> = {
79
+ throttleMs: 30_000, // 30 seconds
80
+ checkCooldownMs: 300_000, // 5 minutes
81
+ };
82
+
83
+ export const VERSION_STATUS_STORAGE_KEY = "VERSION_STATUS";
84
+
85
+ export class ForceUpdateClient {
86
+ private emitter = new EventEmitter<VersionStatusChangeEvents>();
87
+ private versionStatus: VersionStatus;
88
+ private unsubscribe: (() => void) | null = null;
89
+ private appStateSubscription: NativeEventSubscription | null = null;
90
+ private lastCheckTime: number | null = null;
91
+ private lastForegroundTime: number | null = null;
92
+
93
+ private readonly logger: Logger;
94
+ private readonly storage: SupportedStorage;
95
+ private readonly options: Required<ForceUpdateClientOptions>;
96
+
97
+ constructor(
98
+ logging: LoggingClient,
99
+ storage: StorageClient,
100
+ private readonly identity: IdentityClient,
101
+ options: ForceUpdateClientOptions = {}
102
+ ) {
103
+ this.logger = logging.createLogger({ name: "ForceUpdateClient" });
104
+ this.storage = storage.createStorage("version");
105
+ this.options = { ...DEFAULT_OPTIONS, ...options };
106
+ this.versionStatus = this.getVersionStatusFromStorage();
107
+ this.subscribeToIdentity();
108
+ this.subscribeToAppState();
109
+ }
110
+
111
+ private getVersionStatusFromStorage(): VersionStatus {
112
+ const stored = this.storage.getItem(VERSION_STATUS_STORAGE_KEY);
113
+ if (stored == null) {
114
+ return InitializingVersionStatusSchema.parse({ type: "initializing" });
115
+ }
116
+
117
+ return VersionStatusSchema.parse(JSON.parse(stored));
118
+ }
119
+
120
+ private saveVersionStatusToStorage(status: VersionStatus): void {
121
+ this.storage.setItem(VERSION_STATUS_STORAGE_KEY, JSON.stringify(status));
122
+ }
123
+
124
+ private subscribeToIdentity() {
125
+ this.unsubscribe = this.identity.onIdentifyStateChange((state) => {
126
+ if (state.type === "identifying") {
127
+ this.setVersionStatus({ type: "checking" });
128
+ } else if (state.type === "identified") {
129
+ this.updateFromVersionStatus(state.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE);
130
+ }
131
+ });
132
+ }
133
+
134
+ private updateFromVersionStatus(status?: IdentifyVersionStatusEnum) {
135
+ if (!status) {
136
+ this.setVersionStatus({ type: "up_to_date" });
137
+ return;
138
+ }
139
+
140
+ switch (status) {
141
+ case "UPDATE_AVAILABLE":
142
+ this.setVersionStatus({ type: "update_available" });
143
+ break;
144
+ case "UPDATE_RECOMMENDED":
145
+ this.setVersionStatus({ type: "update_recommended" });
146
+ break;
147
+ case "UPDATE_REQUIRED":
148
+ this.setVersionStatus({ type: "update_required" });
149
+ break;
150
+ case "UP_TO_DATE":
151
+ this.setVersionStatus({ type: "up_to_date" });
152
+ break;
153
+ case "DISABLED":
154
+ this.setVersionStatus({ type: "disabled" });
155
+ break;
156
+ default:
157
+ this.setVersionStatus({ type: "up_to_date" });
158
+ }
159
+ }
160
+
161
+ private subscribeToAppState() {
162
+ this.appStateSubscription = AppState.addEventListener("change", this.handleAppStateChange);
163
+ }
164
+
165
+ private handleAppStateChange = (nextState: AppStateStatus) => {
166
+ if (nextState === "active") {
167
+ const now = Date.now();
168
+ const throttleOk = !this.lastForegroundTime || now - this.lastForegroundTime >= this.options.throttleMs;
169
+ const cooldownOk = !this.lastCheckTime || now - this.lastCheckTime >= this.options.checkCooldownMs;
170
+
171
+ this.lastForegroundTime = now;
172
+
173
+ if (throttleOk && cooldownOk) {
174
+ this.checkVersionOnForeground();
175
+ }
176
+ }
177
+ };
178
+
179
+ private async checkVersionOnForeground() {
180
+ this.logger.info("Checking version status on foreground");
181
+ const result = await this.identity.identify();
182
+
183
+ if (!result) {
184
+ this.logger.info("Skipping version check - not identified");
185
+ return;
186
+ }
187
+
188
+ if (result.success) {
189
+ this.lastCheckTime = Date.now();
190
+ // Version status is handled by subscribeToIdentity() listener
191
+ }
192
+ }
193
+
194
+ public onVersionStatusChange(listener: (status: VersionStatus) => void) {
195
+ this.emitter.addListener("VERSION_STATUS_CHANGED", listener);
196
+ return () => {
197
+ this.emitter.removeListener("VERSION_STATUS_CHANGED", listener);
198
+ };
199
+ }
200
+
201
+ public getVersionStatus(): VersionStatus {
202
+ return this.versionStatus;
203
+ }
204
+
205
+ private setVersionStatus(newStatus: VersionStatus) {
206
+ this.logger.info(`Version status changing: ${this.versionStatus.type} -> ${newStatus.type}`);
207
+ this.versionStatus = newStatus;
208
+ this.saveVersionStatusToStorage(newStatus);
209
+ this.emitter.emit("VERSION_STATUS_CHANGED", newStatus);
210
+ }
211
+
212
+ public shutdown() {
213
+ if (this.unsubscribe) {
214
+ this.unsubscribe();
215
+ this.unsubscribe = null;
216
+ }
217
+ if (this.appStateSubscription) {
218
+ this.appStateSubscription.remove();
219
+ this.appStateSubscription = null;
220
+ }
221
+ this.emitter.removeAllListeners("VERSION_STATUS_CHANGED");
222
+ }
223
+
224
+ }
@@ -0,0 +1 @@
1
+ export * from "./force-update.client";