@umituz/react-native-design-system 2.9.20 → 2.9.22
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/package.json +1 -1
- package/src/device/domain/types/AnonymousUserTypes.ts +47 -0
- package/src/device/index.ts +2 -1
- package/src/device/infrastructure/repositories/SecureDeviceIdRepository.ts +29 -5
- package/src/device/infrastructure/services/DeviceExtrasCollector.ts +10 -1
- package/src/device/infrastructure/services/PersistentDeviceIdService.ts +64 -24
- package/src/device/presentation/hooks/useAnonymousUser.ts +13 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-design-system",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.22",
|
|
4
4
|
"description": "Universal design system for React Native apps - Consolidated package with atoms, molecules, organisms, theme, typography, responsive, safe area, exception, infinite scroll, UUID, image, timezone, offline, onboarding, and loading utilities",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous User Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for device-based anonymous user identification.
|
|
5
|
+
* Device ID is persistent across app reinstalls but device-specific.
|
|
6
|
+
*
|
|
7
|
+
* @domain device
|
|
8
|
+
* @layer domain/types
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Anonymous user data with persistent device-based identification
|
|
13
|
+
*/
|
|
14
|
+
export interface AnonymousUser {
|
|
15
|
+
/** Persistent device-based user ID (stable across sessions and reinstalls) */
|
|
16
|
+
userId: string;
|
|
17
|
+
/** User-friendly device name (e.g., "iPhone13-A8F2") */
|
|
18
|
+
deviceName: string;
|
|
19
|
+
/** Display name for the anonymous user */
|
|
20
|
+
displayName: string;
|
|
21
|
+
/** Always true for anonymous users */
|
|
22
|
+
isAnonymous: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for anonymous user hook
|
|
27
|
+
*/
|
|
28
|
+
export interface UseAnonymousUserOptions {
|
|
29
|
+
/** Custom display name for anonymous user */
|
|
30
|
+
anonymousDisplayName?: string;
|
|
31
|
+
/** Fallback user ID if device ID generation fails */
|
|
32
|
+
fallbackUserId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Return type for useAnonymousUser hook
|
|
37
|
+
*/
|
|
38
|
+
export interface UseAnonymousUserResult {
|
|
39
|
+
/** Anonymous user data with persistent device-based ID */
|
|
40
|
+
anonymousUser: AnonymousUser | null;
|
|
41
|
+
/** Loading state */
|
|
42
|
+
isLoading: boolean;
|
|
43
|
+
/** Error message if ID generation failed */
|
|
44
|
+
error: string | null;
|
|
45
|
+
/** Refresh function to reload user data */
|
|
46
|
+
refresh: () => Promise<void>;
|
|
47
|
+
}
|
package/src/device/index.ts
CHANGED
|
@@ -108,7 +108,8 @@ export {
|
|
|
108
108
|
export type {
|
|
109
109
|
AnonymousUser,
|
|
110
110
|
UseAnonymousUserOptions,
|
|
111
|
-
|
|
111
|
+
UseAnonymousUserResult,
|
|
112
|
+
} from './domain/types/AnonymousUserTypes';
|
|
112
113
|
|
|
113
114
|
export { useDeviceFeatures } from './presentation/hooks/useDeviceFeatures';
|
|
114
115
|
|
|
@@ -1,12 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure Device ID Repository
|
|
3
|
+
*
|
|
4
|
+
* Uses iOS Keychain with AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY for:
|
|
5
|
+
* - Persistence across app reinstalls
|
|
6
|
+
* - Device-specific (no backup migration)
|
|
7
|
+
* - Accessible after first unlock (background tasks)
|
|
8
|
+
*
|
|
9
|
+
* @domain device
|
|
10
|
+
* @layer infrastructure/repositories
|
|
11
|
+
*/
|
|
12
|
+
|
|
1
13
|
import * as SecureStore from 'expo-secure-store';
|
|
2
14
|
|
|
3
15
|
const DEVICE_ID_KEY = '@device/persistent_id';
|
|
4
16
|
const MIGRATION_FLAG_KEY = '@device/migration_completed';
|
|
5
17
|
|
|
18
|
+
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
|
|
19
|
+
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
20
|
+
};
|
|
21
|
+
|
|
6
22
|
export class SecureDeviceIdRepository {
|
|
7
23
|
async get(): Promise<string | null> {
|
|
8
24
|
try {
|
|
9
|
-
return await SecureStore.getItemAsync(DEVICE_ID_KEY);
|
|
25
|
+
return await SecureStore.getItemAsync(DEVICE_ID_KEY, KEYCHAIN_OPTIONS);
|
|
10
26
|
} catch {
|
|
11
27
|
return null;
|
|
12
28
|
}
|
|
@@ -14,15 +30,15 @@ export class SecureDeviceIdRepository {
|
|
|
14
30
|
|
|
15
31
|
async set(deviceId: string): Promise<void> {
|
|
16
32
|
try {
|
|
17
|
-
await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId);
|
|
33
|
+
await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId, KEYCHAIN_OPTIONS);
|
|
18
34
|
} catch {
|
|
19
|
-
// Silent fail
|
|
35
|
+
// Silent fail
|
|
20
36
|
}
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
async hasMigrated(): Promise<boolean> {
|
|
24
40
|
try {
|
|
25
|
-
const flag = await SecureStore.getItemAsync(MIGRATION_FLAG_KEY);
|
|
41
|
+
const flag = await SecureStore.getItemAsync(MIGRATION_FLAG_KEY, KEYCHAIN_OPTIONS);
|
|
26
42
|
return flag === 'true';
|
|
27
43
|
} catch {
|
|
28
44
|
return false;
|
|
@@ -31,7 +47,15 @@ export class SecureDeviceIdRepository {
|
|
|
31
47
|
|
|
32
48
|
async setMigrated(): Promise<void> {
|
|
33
49
|
try {
|
|
34
|
-
await SecureStore.setItemAsync(MIGRATION_FLAG_KEY, 'true');
|
|
50
|
+
await SecureStore.setItemAsync(MIGRATION_FLAG_KEY, 'true', KEYCHAIN_OPTIONS);
|
|
51
|
+
} catch {
|
|
52
|
+
// Silent fail
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async remove(): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
await SecureStore.deleteItemAsync(DEVICE_ID_KEY, KEYCHAIN_OPTIONS);
|
|
35
59
|
} catch {
|
|
36
60
|
// Silent fail
|
|
37
61
|
}
|
|
@@ -12,6 +12,7 @@ import { Dimensions } from 'react-native';
|
|
|
12
12
|
import * as Localization from 'expo-localization';
|
|
13
13
|
import { DeviceInfoService } from './DeviceInfoService';
|
|
14
14
|
import { ApplicationInfoService } from './ApplicationInfoService';
|
|
15
|
+
import { DeviceIdService } from './DeviceIdService';
|
|
15
16
|
import { PersistentDeviceIdService } from './PersistentDeviceIdService';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -21,7 +22,12 @@ import { PersistentDeviceIdService } from './PersistentDeviceIdService';
|
|
|
21
22
|
*/
|
|
22
23
|
export interface DeviceExtras {
|
|
23
24
|
[key: string]: string | number | boolean | undefined;
|
|
25
|
+
/** The stable ID stored in Keychain/SecureStore */
|
|
24
26
|
deviceId?: string;
|
|
27
|
+
/** Alias for deviceId for clarity in Firestore */
|
|
28
|
+
persistentDeviceId?: string;
|
|
29
|
+
/** The raw native platform ID (IDFV on iOS, Android ID on Android) */
|
|
30
|
+
nativeDeviceId?: string;
|
|
25
31
|
platform?: string;
|
|
26
32
|
deviceModel?: string;
|
|
27
33
|
deviceBrand?: string;
|
|
@@ -77,10 +83,11 @@ function getDeviceLocale(): string | undefined {
|
|
|
77
83
|
*/
|
|
78
84
|
export async function collectDeviceExtras(): Promise<DeviceExtras> {
|
|
79
85
|
try {
|
|
80
|
-
const [deviceInfo, appInfo, deviceId] = await Promise.all([
|
|
86
|
+
const [deviceInfo, appInfo, deviceId, nativeDeviceId] = await Promise.all([
|
|
81
87
|
DeviceInfoService.getDeviceInfo(),
|
|
82
88
|
ApplicationInfoService.getApplicationInfo(),
|
|
83
89
|
PersistentDeviceIdService.getDeviceId(),
|
|
90
|
+
DeviceIdService.getDeviceId(),
|
|
84
91
|
]);
|
|
85
92
|
|
|
86
93
|
const locale = getDeviceLocale();
|
|
@@ -88,6 +95,8 @@ export async function collectDeviceExtras(): Promise<DeviceExtras> {
|
|
|
88
95
|
|
|
89
96
|
return {
|
|
90
97
|
deviceId,
|
|
98
|
+
persistentDeviceId: deviceId, // Explicitly named for Firestore clarity
|
|
99
|
+
nativeDeviceId: nativeDeviceId || undefined,
|
|
91
100
|
platform: deviceInfo.platform,
|
|
92
101
|
deviceModel: deviceInfo.modelName || undefined,
|
|
93
102
|
deviceBrand: deviceInfo.brand || undefined,
|
|
@@ -1,32 +1,27 @@
|
|
|
1
|
+
import { generateUUID } from '../../../uuid';
|
|
2
|
+
import { SecureDeviceIdRepository } from '../repositories/SecureDeviceIdRepository';
|
|
3
|
+
import { LegacyDeviceIdRepository } from '../repositories/LegacyDeviceIdRepository';
|
|
4
|
+
import { DeviceIdService } from './DeviceIdService';
|
|
5
|
+
|
|
6
|
+
let cachedDeviceId: string | null = null;
|
|
7
|
+
let initializationPromise: Promise<string> | null = null;
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Persistent Device ID Service
|
|
3
11
|
*
|
|
4
12
|
* Provides a stable device identifier that survives app reinstalls.
|
|
5
|
-
* Priority: SecureStore (
|
|
13
|
+
* Priority: SecureStore (true keychain persistence) > Migration > Native ID > Generated UUID
|
|
6
14
|
*
|
|
7
15
|
* @domain device
|
|
8
16
|
* @layer infrastructure/services
|
|
9
17
|
*/
|
|
10
|
-
|
|
11
|
-
import { SecureDeviceIdRepository } from '../repositories/SecureDeviceIdRepository';
|
|
12
|
-
import { LegacyDeviceIdRepository } from '../repositories/LegacyDeviceIdRepository';
|
|
13
|
-
import { DeviceIdService } from './DeviceIdService';
|
|
14
|
-
|
|
15
|
-
let cachedDeviceId: string | null = null;
|
|
16
|
-
let initializationPromise: Promise<string> | null = null;
|
|
17
|
-
|
|
18
|
-
function generateUUID(): string {
|
|
19
|
-
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
20
|
-
const r = (Math.random() * 16) | 0;
|
|
21
|
-
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
22
|
-
return v.toString(16);
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
18
|
export class PersistentDeviceIdService {
|
|
27
19
|
private static secureRepo = new SecureDeviceIdRepository();
|
|
28
20
|
private static legacyRepo = new LegacyDeviceIdRepository();
|
|
29
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Get device ID with caching and concurrent request handling
|
|
24
|
+
*/
|
|
30
25
|
static async getDeviceId(): Promise<string> {
|
|
31
26
|
if (cachedDeviceId) {
|
|
32
27
|
return cachedDeviceId;
|
|
@@ -40,34 +35,60 @@ export class PersistentDeviceIdService {
|
|
|
40
35
|
return initializationPromise;
|
|
41
36
|
}
|
|
42
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Logic to establish a persistent ID
|
|
40
|
+
*/
|
|
43
41
|
private static async initializeDeviceId(): Promise<string> {
|
|
44
42
|
try {
|
|
43
|
+
// 1. Try secure repository (Keychain)
|
|
45
44
|
const secureId = await this.secureRepo.get();
|
|
46
45
|
|
|
47
46
|
if (secureId) {
|
|
47
|
+
if (__DEV__) {
|
|
48
|
+
console.log('[PersistentDeviceIdService] Found secure device ID:', secureId);
|
|
49
|
+
}
|
|
48
50
|
cachedDeviceId = secureId;
|
|
49
51
|
return secureId;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
// 2. Try migration from legacy storage
|
|
52
55
|
const migrated = await this.migrateFromLegacy();
|
|
53
56
|
if (migrated) {
|
|
57
|
+
if (__DEV__) {
|
|
58
|
+
console.log('[PersistentDeviceIdService] Migrated ID from legacy storage:', migrated);
|
|
59
|
+
}
|
|
54
60
|
cachedDeviceId = migrated;
|
|
55
61
|
return migrated;
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
// 3. Create brand new ID
|
|
58
65
|
const newId = await this.createNewDeviceId();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
|
|
67
|
+
// Save to both for safety during transition
|
|
68
|
+
await Promise.all([
|
|
69
|
+
this.secureRepo.set(newId),
|
|
70
|
+
this.legacyRepo.set(newId),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
if (__DEV__) {
|
|
74
|
+
console.log('[PersistentDeviceIdService] Created new persistent ID:', newId);
|
|
75
|
+
}
|
|
62
76
|
|
|
77
|
+
cachedDeviceId = newId;
|
|
63
78
|
return newId;
|
|
64
|
-
} catch {
|
|
65
|
-
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (__DEV__) {
|
|
81
|
+
console.error('[PersistentDeviceIdService] Initialization failed, using fallback:', error);
|
|
82
|
+
}
|
|
83
|
+
const fallbackId = `fallback_${generateUUID()}`;
|
|
66
84
|
cachedDeviceId = fallbackId;
|
|
67
85
|
return fallbackId;
|
|
68
86
|
}
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Handles migration while ensuring it only happens once
|
|
91
|
+
*/
|
|
71
92
|
private static async migrateFromLegacy(): Promise<string | null> {
|
|
72
93
|
try {
|
|
73
94
|
const hasMigrated = await this.secureRepo.hasMigrated();
|
|
@@ -81,6 +102,7 @@ export class PersistentDeviceIdService {
|
|
|
81
102
|
return null;
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
// Upgrade legacy to secure storage
|
|
84
106
|
await this.secureRepo.set(legacyId);
|
|
85
107
|
await this.secureRepo.setMigrated();
|
|
86
108
|
|
|
@@ -90,6 +112,9 @@ export class PersistentDeviceIdService {
|
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Generates a new ID based on platform info if possible
|
|
117
|
+
*/
|
|
93
118
|
private static async createNewDeviceId(): Promise<string> {
|
|
94
119
|
const nativeId = await DeviceIdService.getDeviceId();
|
|
95
120
|
|
|
@@ -97,9 +122,12 @@ export class PersistentDeviceIdService {
|
|
|
97
122
|
return `device_${nativeId}`;
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
return `
|
|
125
|
+
return `gen_${generateUUID()}`;
|
|
101
126
|
}
|
|
102
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Check if an ID exists without initializing
|
|
130
|
+
*/
|
|
103
131
|
static async hasStoredId(): Promise<boolean> {
|
|
104
132
|
try {
|
|
105
133
|
const secureId = await this.secureRepo.get();
|
|
@@ -109,16 +137,28 @@ export class PersistentDeviceIdService {
|
|
|
109
137
|
}
|
|
110
138
|
}
|
|
111
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Fully clear all stored identifiers
|
|
142
|
+
*/
|
|
112
143
|
static async clearStoredId(): Promise<void> {
|
|
113
144
|
try {
|
|
114
|
-
await
|
|
145
|
+
await Promise.all([
|
|
146
|
+
this.secureRepo.remove(),
|
|
147
|
+
this.legacyRepo.remove(),
|
|
148
|
+
]);
|
|
115
149
|
cachedDeviceId = null;
|
|
116
150
|
initializationPromise = null;
|
|
151
|
+
if (__DEV__) {
|
|
152
|
+
console.log('[PersistentDeviceIdService] All device identifiers cleared');
|
|
153
|
+
}
|
|
117
154
|
} catch {
|
|
118
155
|
// Silent fail
|
|
119
156
|
}
|
|
120
157
|
}
|
|
121
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Get current cached ID synchronously
|
|
161
|
+
*/
|
|
122
162
|
static getCachedId(): string | null {
|
|
123
163
|
return cachedDeviceId;
|
|
124
164
|
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Anonymous User Hook
|
|
3
3
|
*
|
|
4
4
|
* Provides persistent device-based user ID for anonymous users.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Uses iOS Keychain with AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY for:
|
|
6
|
+
* - Stable ID across app restarts and reinstalls
|
|
7
|
+
* - Device-specific (no backup migration)
|
|
8
|
+
* - Compatible with Firebase Anonymous Auth
|
|
7
9
|
*
|
|
8
10
|
* @domain device
|
|
9
11
|
* @layer presentation/hooks
|
|
@@ -12,37 +14,18 @@
|
|
|
12
14
|
import { useState, useEffect, useCallback } from 'react';
|
|
13
15
|
import { PersistentDeviceIdService } from '../../infrastructure/services/PersistentDeviceIdService';
|
|
14
16
|
import { DeviceService } from '../../infrastructure/services/DeviceService';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** Persistent device-based user ID (stable across sessions) */
|
|
21
|
-
userId: string;
|
|
22
|
-
/** User-friendly device name (e.g., "iPhone13-A8F2") */
|
|
23
|
-
deviceName: string;
|
|
24
|
-
/** Display name for the anonymous user */
|
|
25
|
-
displayName: string;
|
|
26
|
-
/** Always true for anonymous users */
|
|
27
|
-
isAnonymous: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* useAnonymousUser hook options
|
|
32
|
-
*/
|
|
33
|
-
export interface UseAnonymousUserOptions {
|
|
34
|
-
/** Custom display name for anonymous user */
|
|
35
|
-
anonymousDisplayName?: string;
|
|
36
|
-
/** Fallback user ID if device ID generation fails */
|
|
37
|
-
fallbackUserId?: string;
|
|
38
|
-
}
|
|
17
|
+
import type {
|
|
18
|
+
AnonymousUser,
|
|
19
|
+
UseAnonymousUserOptions,
|
|
20
|
+
UseAnonymousUserResult,
|
|
21
|
+
} from '../../domain/types/AnonymousUserTypes';
|
|
39
22
|
|
|
40
23
|
/**
|
|
41
24
|
* useAnonymousUser hook for persistent device-based user identification
|
|
42
25
|
*
|
|
43
26
|
* USAGE:
|
|
44
27
|
* ```typescript
|
|
45
|
-
* import { useAnonymousUser } from '@umituz/react-native-
|
|
28
|
+
* import { useAnonymousUser } from '@umituz/react-native-design-system';
|
|
46
29
|
*
|
|
47
30
|
* const { anonymousUser, isLoading } = useAnonymousUser();
|
|
48
31
|
*
|
|
@@ -57,7 +40,9 @@ export interface UseAnonymousUserOptions {
|
|
|
57
40
|
* />
|
|
58
41
|
* ```
|
|
59
42
|
*/
|
|
60
|
-
export const useAnonymousUser = (
|
|
43
|
+
export const useAnonymousUser = (
|
|
44
|
+
options?: UseAnonymousUserOptions
|
|
45
|
+
): UseAnonymousUserResult => {
|
|
61
46
|
const [anonymousUser, setAnonymousUser] = useState<AnonymousUser | null>(null);
|
|
62
47
|
const [isLoading, setIsLoading] = useState(true);
|
|
63
48
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -105,13 +90,9 @@ export const useAnonymousUser = (options?: UseAnonymousUserOptions) => {
|
|
|
105
90
|
}, [loadAnonymousUser]);
|
|
106
91
|
|
|
107
92
|
return {
|
|
108
|
-
/** Anonymous user data with persistent device-based ID */
|
|
109
93
|
anonymousUser,
|
|
110
|
-
/** Loading state */
|
|
111
94
|
isLoading,
|
|
112
|
-
/** Error message if ID generation failed */
|
|
113
95
|
error,
|
|
114
|
-
/** Refresh function to reload user data */
|
|
115
96
|
refresh,
|
|
116
97
|
};
|
|
117
98
|
};
|