Skip to content

Commit 7cb4b18

Browse files
committed
feat: implement expo device token registration
1 parent e5b69ac commit 7cb4b18

File tree

6 files changed

+172
-7
lines changed

6 files changed

+172
-7
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ Build real-time, collaborative mobile apps that work seamlessly offline and auto
4444
- [`@react-native-community/netinfo`](https://github.com/react-native-netinfo/react-native-netinfo) **^11.0.0**
4545
- [SQLite Cloud](https://sqlitecloud.io/) account
4646
- **Optional (for push mode):**
47-
- [`expo-notifications`](https://docs.expo.dev/versions/latest/sdk/notifications/)
48-
- [`expo-constants`](https://docs.expo.dev/versions/latest/sdk/constants/)
47+
- [`expo-notifications`](https://docs.expo.dev/versions/latest/sdk/notifications/) - Push notification handling
48+
- [`expo-constants`](https://docs.expo.dev/versions/latest/sdk/constants/) - EAS project ID for push tokens
49+
- [`expo-application`](https://docs.expo.dev/versions/latest/sdk/application/) - Device ID for push token registration
50+
- [`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/) - Persisting background sync config and push token state
51+
- [`expo-task-manager`](https://docs.expo.dev/versions/latest/sdk/task-manager/) - Background/terminated notification sync
4952

5053
> **Note:** This library is **native-only** (iOS/Android). Web is not supported.
5154
@@ -62,7 +65,7 @@ yarn add @sqliteai/sqlite-sync-react-native @op-engineering/op-sqlite @react-nat
6265
**Optional: For push mode (Expo projects only)**
6366

6467
```bash
65-
npx expo install expo-notifications expo-constants
68+
npx expo install expo-notifications expo-constants expo-application expo-secure-store expo-task-manager
6669
```
6770

6871
### 2. Platform Setup

examples/sync-demo-expo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@react-native-community/netinfo": "^11.4.1",
2323
"@sqliteai/sqlite-sync-react-native": "*",
2424
"expo": "~54.0.25",
25+
"expo-application": "~7.0.8",
2526
"expo-build-properties": "~1.0.0",
2627
"expo-constants": "~18.0.13",
2728
"expo-dev-client": "~6.0.20",

src/core/common/optionalDependencies.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ try {
3838
// Not available
3939
}
4040

41+
// Load expo-application
42+
export let ExpoApplication: any = null;
43+
try {
44+
ExpoApplication = require('expo-application');
45+
} catch {
46+
// Not available
47+
}
48+
4149
// Compound availability check
4250
export const isBackgroundSyncAvailable = () =>
4351
ExpoNotifications !== null &&
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
ExpoSecureStore,
3+
ExpoApplication,
4+
} from '../common/optionalDependencies';
5+
import type { Logger } from '../common/logger';
6+
7+
const TOKEN_REGISTERED_KEY = 'sqlite_sync_push_token_registered';
8+
const CLOUDSYNC_BASE_URL = 'https://cloudsync-staging.fly.dev/v2';
9+
10+
async function getDeviceId(): Promise<string> {
11+
if (!ExpoApplication) {
12+
throw new Error(
13+
'expo-application is required for push notification token registration. Install it with: npx expo install expo-application'
14+
);
15+
}
16+
17+
const { Platform } = require('react-native');
18+
if (Platform.OS === 'ios') {
19+
return await ExpoApplication.getIosIdForVendorAsync();
20+
}
21+
// Android
22+
return ExpoApplication.getAndroidId();
23+
}
24+
25+
interface RegisterPushTokenParams {
26+
expoToken: string;
27+
databaseName: string;
28+
siteId?: string;
29+
platform: string;
30+
connectionString: string;
31+
apiKey?: string;
32+
accessToken?: string;
33+
logger: Logger;
34+
}
35+
36+
/**
37+
* Register an Expo push token with the SQLite Cloud backend.
38+
* Only sends the token once per installation (persisted via SecureStore).
39+
*/
40+
export async function registerPushToken(
41+
params: RegisterPushTokenParams
42+
): Promise<void> {
43+
const {
44+
expoToken,
45+
databaseName,
46+
siteId,
47+
platform,
48+
connectionString,
49+
apiKey,
50+
accessToken,
51+
logger,
52+
} = params;
53+
54+
// Check if token was already registered
55+
if (ExpoSecureStore) {
56+
try {
57+
const registered = await ExpoSecureStore.getItemAsync(
58+
TOKEN_REGISTERED_KEY
59+
);
60+
if (registered === expoToken) {
61+
logger.info('📱 Push token already registered, skipping');
62+
return;
63+
}
64+
} catch {
65+
// Continue with registration
66+
}
67+
}
68+
69+
const headers: Record<string, string> = {
70+
'Content-Type': 'application/json',
71+
};
72+
73+
if (accessToken) {
74+
headers.Authorization = `Bearer ${accessToken}`;
75+
} else if (apiKey) {
76+
headers.Authorization = `Bearer ${connectionString}?apikey=${apiKey}`;
77+
}
78+
79+
const deviceId = await getDeviceId();
80+
81+
const body = {
82+
expoToken,
83+
deviceId,
84+
database: databaseName,
85+
siteId: siteId ?? '',
86+
platform,
87+
};
88+
89+
const url = `${CLOUDSYNC_BASE_URL}/cloudsync/notifications/tokens`;
90+
logger.info(
91+
'📱 Registering push token with backend...',
92+
url,
93+
JSON.stringify(body)
94+
);
95+
96+
const response = await fetch(url, {
97+
method: 'POST',
98+
headers,
99+
body: JSON.stringify(body),
100+
});
101+
102+
if (!response.ok) {
103+
const text = await response.text().catch(() => '');
104+
throw new Error(
105+
`Failed to register push token: ${response.status} ${text}`
106+
);
107+
}
108+
109+
logger.info('📱 Push token registered successfully');
110+
111+
// Persist that this token has been registered
112+
if (ExpoSecureStore) {
113+
try {
114+
await ExpoSecureStore.setItemAsync(TOKEN_REGISTERED_KEY, expoToken);
115+
} catch {
116+
// Non-critical
117+
}
118+
}
119+
}

src/core/pushNotifications/usePushNotificationSync.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
unregisterBackgroundSync,
2323
} from '../background/backgroundSyncRegistry';
2424
import { setForegroundSyncCallback } from './pushNotificationSyncCallbacks';
25+
import { Platform } from 'react-native';
26+
import { isForegroundSqliteCloudNotification } from './isSqliteCloudNotification';
27+
import { registerPushToken } from './registerPushToken';
2528

2629
/**
2730
* Parameters for usePushNotificationSync hook
@@ -105,8 +108,6 @@ export interface PushNotificationSyncParams {
105108
}) => ReactNode;
106109
}
107110

108-
import { isForegroundSqliteCloudNotification } from './isSqliteCloudNotification';
109-
110111
export function usePushNotificationSync(params: PushNotificationSyncParams): {
111112
permissionPromptNode: ReactNode;
112113
} {
@@ -268,7 +269,38 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
268269

269270
if (token?.data) {
270271
logger.info('📱 Expo Push Token:', token.data);
271-
// TODO: Send token to backend
272+
273+
let siteId: string | undefined;
274+
try {
275+
const firstTable = tablesToBeSynced[0];
276+
if (firstTable && writeDbRef.current) {
277+
const initResult = await writeDbRef.current.execute(
278+
'SELECT cloudsync_init(?);',
279+
[firstTable.name]
280+
);
281+
const firstRow = initResult.rows?.[0];
282+
siteId = firstRow
283+
? String(Object.values(firstRow)[0])
284+
: undefined;
285+
}
286+
} catch {
287+
logger.warn('⚠️ Could not retrieve siteId');
288+
}
289+
290+
try {
291+
await registerPushToken({
292+
expoToken: token.data,
293+
databaseName,
294+
siteId,
295+
platform: Platform.OS,
296+
connectionString,
297+
apiKey,
298+
accessToken,
299+
logger,
300+
});
301+
} catch (registerError) {
302+
logger.warn('⚠️ Failed to register push token:', registerError);
303+
}
272304
}
273305
} catch (error) {
274306
// Network errors and other temporary failures - don't fallback
@@ -277,7 +309,8 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): {
277309
};
278310

279311
requestPermissions();
280-
}, [isSyncReady, syncMode, writeDbRef, logger]);
312+
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per mount, guarded by hasRequestedPermissionsRef
313+
}, [isSyncReady, syncMode]);
281314

282315
/** NOTIFICATION LISTENERS */
283316
useEffect(() => {

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3768,6 +3768,7 @@ __metadata:
37683768
"@react-native-community/netinfo": "npm:^11.4.1"
37693769
"@sqliteai/sqlite-sync-react-native": "npm:*"
37703770
expo: "npm:~54.0.25"
3771+
expo-application: "npm:~7.0.8"
37713772
expo-build-properties: "npm:~1.0.0"
37723773
expo-constants: "npm:~18.0.13"
37733774
expo-dev-client: "npm:~6.0.20"

0 commit comments

Comments
 (0)