@teardown/react-native 1.2.38 → 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.
- package/README.md +75 -7
- package/package.json +65 -47
- package/src/clients/api/api.client.ts +55 -0
- package/src/clients/api/index.ts +1 -0
- package/src/clients/device/device.adpater-interface.ts +57 -0
- package/src/clients/device/device.client.test.ts +195 -0
- package/src/clients/device/device.client.ts +69 -0
- package/src/clients/device/expo-adapter.ts +128 -0
- package/src/clients/device/index.ts +4 -0
- package/src/clients/force-update/force-update.client.test.ts +296 -0
- package/src/clients/force-update/force-update.client.ts +224 -0
- package/src/clients/force-update/index.ts +1 -0
- package/src/clients/identity/identity.client.test.ts +454 -0
- package/src/clients/identity/identity.client.ts +249 -0
- package/src/clients/identity/index.ts +1 -0
- package/src/clients/logging/index.ts +1 -0
- package/src/clients/logging/logging.client.ts +92 -0
- package/src/clients/notifications/notifications.client.ts +10 -0
- package/src/clients/storage/index.ts +1 -0
- package/src/clients/storage/mmkv-adapter.ts +23 -0
- package/src/clients/storage/storage.client.ts +75 -0
- package/src/clients/utils/index.ts +1 -0
- package/src/clients/utils/utils.client.ts +75 -0
- package/src/components/ui/button.tsx +0 -0
- package/src/components/ui/input.tsx +0 -0
- package/src/contexts/index.ts +1 -0
- package/src/contexts/teardown.context.ts +17 -0
- package/src/exports/expo.ts +1 -0
- package/src/exports/index.ts +16 -0
- package/src/exports/mmkv.ts +1 -0
- package/src/hooks/use-force-update.ts +38 -0
- package/src/hooks/use-session.ts +26 -0
- package/src/providers/teardown.provider.tsx +28 -0
- package/src/teardown.core.ts +76 -0
- package/dist/components/index.js +0 -2
- package/dist/components/teardown-logo.js +0 -34
- package/dist/containers/index.js +0 -17
- package/dist/containers/teardown.container.js +0 -25
- package/dist/index.js +0 -21
- package/dist/plugins/http.plugin.js +0 -144
- package/dist/plugins/index.js +0 -19
- package/dist/plugins/logging.plugin.js +0 -35
- package/dist/plugins/websocket.plugin.js +0 -107
- package/dist/services/index.js +0 -17
- package/dist/services/teardown.service.js +0 -21
- package/dist/teardown.client.js +0 -59
- package/dist/utils/log.js +0 -8
|
@@ -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,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";
|