Skip to content

Commit 0faef12

Browse files
committed
feat: add streaming delta helpers and simplify getAll
1 parent 1785bcb commit 0faef12

9 files changed

Lines changed: 441 additions & 168 deletions

File tree

README.md

Lines changed: 68 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,14 @@ type ContactChange = Contact & {
8888
} | null;
8989
};
9090

91+
type UpdatedPageBase = {
92+
nextSince: string;
93+
totalContacts: number;
94+
};
95+
9196
type UpdatedPage =
92-
| { mode: 'full'; items: Contact[]; nextSince: string }
93-
| { mode: 'delta'; items: ContactChange[]; nextSince: string };
97+
| (UpdatedPageBase & { mode: 'full'; items: Contact[] })
98+
| (UpdatedPageBase & { mode: 'delta'; items: ContactChange[] });
9499
```
95100

96101
API reference & examples
@@ -103,6 +108,7 @@ API reference & examples
103108
| `type PhoneNumberUpdate` | Represents an individual phone number that changed within a contact delta (`previous``current`). |
104109
| `type PhoneNumberChanges` | Buckets the numbers added/removed/updated in a `ContactChange`. Useful when reconciling diffs. |
105110
| `type ContactChange` | Extends `Contact` with delta metadata (`changeType`, `isDeleted`, `phoneNumberChanges`, and an optional `previous` snapshot). |
111+
| `type UpdatedPage` | Union returned by paging APIs (`mode`, `items`, `nextSince`, and `totalContacts` so you know the device book size). |
106112

107113
### Functions (promise / async)
108114

@@ -131,12 +137,12 @@ API reference & examples
131137
await commitPersisted(nextToken);
132138
```
133139

134-
- `getAll(options?: { offset?: number; limit?: number; pageSize?: number }): Promise<Contact[]>`
135-
- Convenience wrapper for the native `getAll`. When `limit` is provided it returns that specific page. Otherwise it loops until all contacts are fetched (respecting `pageSize`, default 500).
140+
- `getAll(): Promise<Contact[]>`
141+
- Convenience helper that returns the full native contact list in one call.
136142

137143
```ts
138-
const everyone = await getAll({ pageSize: 400 });
139-
const pageTwo = await getAll({ offset: 400, limit: 200 });
144+
const everyone = await getAll();
145+
console.log('Fetched contacts', everyone.length);
140146
```
141147

142148
- `getUpdatedSincePaged(since: string, offset: number, limit: number): Promise<UpdatedPage>`
@@ -149,15 +155,24 @@ API reference & examples
149155
} else {
150156
page.items.forEach((change) => console.log(change.changeType, change.id));
151157
}
158+
console.log('Total contacts on device', page.totalContacts);
152159
```
153160

154-
- `getUpdatedFromPersistedPaged(offset: number, limit: number): Promise<UpdatedPage>`
155-
- Same as above but the native layer provides the starting token (useful when you previously called `commitPersisted`). If the native token is missing the call yields `{ mode: 'full' }` so you can rebuild state from the full contacts list.
161+
- Need to walk every page? Use `getUpdatedSincePaged.listen` to stream until exhaustion (return `false` from the handler to stop early). `pageSize` is optional and defaults to `300`.
162+
156163
```ts
157-
const page = await getUpdatedFromPersistedPaged(0, 300);
158-
console.log(page.mode, page.items.length);
164+
await getUpdatedSincePaged.listen(
165+
{ since: lastToken, pageSize: 250 },
166+
async (page) => {
167+
console.log(
168+
`Page mode=${page.mode} size=${page.items.length} total=${page.totalContacts}`
169+
);
170+
}
171+
);
159172
```
160173

174+
- Streaming signature: `getUpdatedSincePaged.listen(handler, options?)` or `getUpdatedSincePaged.listen(options, handler)`. The handler can be async and should return `false` to stop fetching. `options` accepts `{ since?: string; offset?: number; pageSize?: number }`.
175+
161176
> iOS tokens:
162177
>
163178
> - Real change-history tokens look like long base64 strings (`YnBsaXN0MDD…`).
@@ -176,22 +191,29 @@ import { ensureContactsPermission } from './permissions'; // from snippet above
176191
// Delta or baseline sync (falls back to full pages when native tokens are unavailable)
177192
if (await ensureContactsPermission()) {
178193
const persistedSince = await getPersistedSince();
179-
let offset = 0;
180-
let nextSince: string | undefined;
194+
const pageSize = 300;
195+
let nextSince: string | undefined = persistedSince;
181196
let usedFullFallback = false;
182-
for (;;) {
183-
const page = await getUpdatedSincePaged(persistedSince, offset, 300);
184-
if (page.nextSince) nextSince = page.nextSince;
185-
if (!page.items.length) break;
186-
const label = page.mode === 'full' ? 'Contacts page' : 'Delta page';
187-
console.log(label, page.items.length);
188-
if (page.mode === 'full') usedFullFallback = true;
189-
offset += page.items.length;
190-
if (page.items.length < 300) break;
191-
}
192-
if (nextSince && nextSince !== persistedSince) await commitPersisted(nextSince);
193-
if (usedFullFallback && !nextSince)
197+
198+
await getUpdatedSincePaged.listen(
199+
{ since: persistedSince, pageSize },
200+
(page) => {
201+
if (page.nextSince) nextSince = page.nextSince;
202+
if (!page.items.length) return false;
203+
const label = page.mode === 'full' ? 'Contacts page' : 'Delta page';
204+
console.log(
205+
`${label}: ${page.items.length} items (total contacts ${page.totalContacts})`
206+
);
207+
if (page.mode === 'full') usedFullFallback = true;
208+
return page.items.length >= pageSize;
209+
}
210+
);
211+
212+
if (nextSince && nextSince !== persistedSince) {
213+
await commitPersisted(nextSince);
214+
} else if (usedFullFallback && !nextSince) {
194215
console.log('Full snapshot processed; no token persisted yet.');
216+
}
195217
}
196218
```
197219

@@ -213,30 +235,37 @@ await ensureContactsPermission();
213235

214236
// 2. Pull the delta (or fallback full pages) since the last committed token and persist progress.
215237
const persistedSince = await getPersistedSince();
216-
let offset = 0;
238+
const pageSize = 300;
217239
let sessionToken = persistedSince;
240+
let totalContacts: number | undefined;
218241
const delta: ContactChange[] = [];
219242
let fullFallback: Contact[] = [];
220-
for (;;) {
221-
const page = await getUpdatedSincePaged(persistedSince, offset, 300);
222-
if (page.nextSince) sessionToken = page.nextSince;
223-
if (!page.items.length) break;
224-
if (page.mode === 'delta') {
225-
delta.push(...page.items);
226-
} else {
227-
fullFallback = fullFallback.concat(page.items);
243+
244+
await getUpdatedSincePaged.listen(
245+
{ since: persistedSince, pageSize },
246+
(page) => {
247+
if (page.nextSince) sessionToken = page.nextSince;
248+
if (!page.items.length) return false;
249+
totalContacts = page.totalContacts;
250+
if (page.mode === 'delta') {
251+
delta.push(...page.items);
252+
} else {
253+
fullFallback = fullFallback.concat(page.items);
254+
}
255+
return page.items.length >= pageSize;
228256
}
229-
offset += page.items.length;
230-
if (page.items.length < 300) break;
231-
}
257+
);
258+
232259
if (sessionToken && sessionToken !== persistedSince) {
233260
await commitPersisted(sessionToken);
234261
}
235262

236-
// 3. Full fallback pages can be handled like a baseline rebuild
237-
console.log('Full contacts received', fullFallback.length);
263+
console.log('Total contacts reported by native layer', totalContacts ?? 'unknown');
264+
265+
// 3. Full fallback pages can be handled like a baseline rebuild.
266+
console.log('Full snapshot contacts (if fallback)', fullFallback.length);
238267

239-
// 4. Look up a single contact by identifier (helpful after any baseline).
268+
// 4. Look up a single contact by identifier (helpful after any baseline rebuild).
240269
const singleContact = await getById('12345'); // returns `null` if the contact was deleted
241270
```
242271

android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import java.io.File
1717
import org.json.JSONArray
1818
import org.json.JSONObject
1919
import java.util.LinkedHashMap
20+
import kotlin.math.min
2021

2122
@Suppress("unused")
2223

@@ -43,6 +44,11 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
4344
val familyName: String? = null
4445
)
4546

47+
data class ContactPage(
48+
val items: List<Contact>,
49+
val total: Int
50+
)
51+
4652
data class SnapshotContact(
4753
val id: String,
4854
val displayName: String,
@@ -85,14 +91,8 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
8591
val sortTimestamp: Long
8692
)
8793

88-
override fun getAll(offset: Double, limit: Double, promise: Promise) {
89-
val off = offset.toInt().coerceAtLeast(0)
90-
val lim = limit.toInt().coerceAtLeast(0)
91-
if (lim <= 0) {
92-
promise.resolve(Arguments.createArray())
93-
return
94-
}
95-
val contacts = queryContacts(off, lim, null)
94+
override fun getAll(promise: Promise) {
95+
val contacts = queryContactsPage(0, Int.MAX_VALUE, null).items
9696
promise.resolve(contactsToWritableArray(contacts))
9797
}
9898

@@ -114,9 +114,10 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
114114
val off = offset.toInt().coerceAtLeast(0)
115115
val lim = limit.toInt().coerceAtLeast(0)
116116
if (since.isBlank()) {
117-
val contacts = if (lim <= 0) emptyList() else queryContacts(off, lim, null)
117+
val page = queryContactsPage(off, lim, null)
118118
val result = Arguments.createMap()
119-
result.putArray("items", contactsToWritableArray(contacts))
119+
result.putArray("items", contactsToWritableArray(page.items))
120+
result.putInt("totalContacts", page.total)
120121
result.putString("nextSince", System.currentTimeMillis().toString())
121122
result.putString("mode", "full")
122123
promise.resolve(result)
@@ -150,9 +151,10 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
150151
val lim = limit.toInt().coerceAtLeast(0)
151152
val stored = prefs.getLong("since", 0L)
152153
if (stored <= 0L) {
153-
val contacts = if (lim <= 0) emptyList() else queryContacts(off, lim, null)
154+
val page = queryContactsPage(off, lim, null)
154155
val map = Arguments.createMap()
155-
map.putArray("items", contactsToWritableArray(contacts))
156+
map.putArray("items", contactsToWritableArray(page.items))
157+
map.putInt("totalContacts", page.total)
156158
map.putString("nextSince", System.currentTimeMillis().toString())
157159
map.putString("mode", "full")
158160
promise.resolve(map)
@@ -223,7 +225,7 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
223225
private fun queryContactsForDelta(sinceMs: Long, desiredCount: Int): List<Contact> {
224226
if (desiredCount <= 0) return emptyList()
225227
val filter = if (sinceMs > 0) sinceMs else null
226-
return queryContacts(0, desiredCount, filter)
228+
return queryContactsPage(0, desiredCount, filter).items
227229
}
228230

229231
private fun queryDeletedContacts(sinceMs: Long, desiredCount: Int): List<DeletedContact> {
@@ -318,7 +320,7 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
318320
)
319321
}
320322

321-
private fun queryContacts(offset: Int, limit: Int, sinceMs: Long?): List<Contact> {
323+
private fun queryContactsPage(offset: Int, limit: Int, sinceMs: Long?): ContactPage {
322324
val cr: ContentResolver = reactApplicationContext.contentResolver
323325
val uri: Uri = ContactsContract.Contacts.CONTENT_URI
324326
val projection = arrayOf(
@@ -340,12 +342,17 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
340342
}
341343

342344
val sortOrder = ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " DESC"
343-
val items = ArrayList<Contact>(limit)
344345
val cursor: Cursor? = cr.query(uri, projection, selection, selectionArgs, sortOrder)
345346
cursor?.use { c ->
347+
val total = c.count
348+
if (limit <= 0) {
349+
return ContactPage(emptyList(), total)
350+
}
346351
if (!c.moveToPosition(offset)) {
347-
return emptyList()
352+
return ContactPage(emptyList(), total)
348353
}
354+
val capacity = if (total <= offset) 0 else min(limit, total - offset)
355+
val items = ArrayList<Contact>(capacity)
349356
var count = 0
350357
do {
351358
val id = c.getLong(c.getColumnIndexOrThrow(ContactsContract.Contacts._ID)).toString()
@@ -358,8 +365,13 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
358365
items.add(Contact(id, name, phones, updatedAt))
359366
count++
360367
} while (c.moveToNext() && count < limit)
368+
return ContactPage(items, total)
361369
}
362-
return items
370+
return ContactPage(emptyList(), 0)
371+
}
372+
373+
private fun queryContacts(offset: Int, limit: Int, sinceMs: Long?): List<Contact> {
374+
return queryContactsPage(offset, limit, sinceMs).items
363375
}
364376

365377
private fun queryContactById(contactId: String): Contact? {

example/ios/Podfile.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
PODS:
22
- boost (1.84.0)
3-
- ContactsLastUpdated (1.2.2):
3+
- ContactsLastUpdated (1.2.3):
44
- boost
55
- DoubleConversion
66
- fast_float
@@ -2603,7 +2603,7 @@ EXTERNAL SOURCES:
26032603

26042604
SPEC CHECKSUMS:
26052605
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
2606-
ContactsLastUpdated: 50a1a6488109fbd2b74ff59371786c19d72d09ba
2606+
ContactsLastUpdated: 768294728346641ddb82925d13cb878fb4f87bae
26072607
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
26082608
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
26092609
FBLazyVector: b8f1312d48447cca7b4abc21ed155db14742bd03
@@ -2679,4 +2679,4 @@ SPEC CHECKSUMS:
26792679

26802680
PODFILE CHECKSUM: 0c430fe8ae9178ec32f81dd9670b0ce1bf39a157
26812681

2682-
COCOAPODS: 1.15.2
2682+
COCOAPODS: 1.16.2

example/src/App.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,62 @@
1+
import React, { useEffect } from 'react';
2+
import { StyleSheet, Text, View } from 'react-native';
3+
import { SafeAreaProvider } from 'react-native-safe-area-context';
4+
15
import ContactsDemoScreen from './screens/ContactsDemoScreen';
6+
import { getUpdatedSincePaged } from '@omarsdev/react-native-contacts';
27

38
export default function App() {
4-
return <ContactsDemoScreen />;
9+
const [totalContacts, setTotalContacts] = React.useState<number | null>(null);
10+
11+
useEffect(() => {
12+
const getContacts = async () => {
13+
await getUpdatedSincePaged.listen(
14+
{ since: '', pageSize: 1 },
15+
async (page) => {
16+
console.log(`Page mode=${page.mode} size=${page.items.length}`, {
17+
items: page.items,
18+
});
19+
}
20+
);
21+
};
22+
23+
getContacts();
24+
});
25+
26+
return (
27+
<SafeAreaProvider>
28+
<View style={styles.container}>
29+
<View style={styles.body}>
30+
<ContactsDemoScreen onTotalContactsChange={setTotalContacts} />
31+
</View>
32+
<View style={styles.footer}>
33+
<Text style={styles.footerText}>
34+
Total device contacts:{' '}
35+
{typeof totalContacts === 'number' ? totalContacts : '—'}
36+
</Text>
37+
</View>
38+
</View>
39+
</SafeAreaProvider>
40+
);
541
}
42+
43+
const styles = StyleSheet.create({
44+
container: {
45+
flex: 1,
46+
backgroundColor: '#f5f5f5',
47+
},
48+
body: {
49+
flex: 1,
50+
},
51+
footer: {
52+
paddingVertical: 12,
53+
paddingHorizontal: 16,
54+
borderTopWidth: StyleSheet.hairlineWidth,
55+
borderTopColor: '#d0d0d0',
56+
backgroundColor: '#ffffff',
57+
},
58+
footerText: {
59+
textAlign: 'center',
60+
color: '#333333',
61+
},
62+
});

example/src/components/InfoPanel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Props = {
1111
since: string;
1212
listLabel: string;
1313
listCount: number;
14+
totalCount?: number | null;
1415
};
1516

1617
const InfoPanel = React.memo(
@@ -22,6 +23,7 @@ const InfoPanel = React.memo(
2223
since,
2324
listLabel,
2425
listCount,
26+
totalCount,
2527
}: Props) => (
2628
<View style={styles.info}>
2729
<Text style={styles.text}>Delta status: {deltaStatus}</Text>
@@ -35,6 +37,9 @@ const InfoPanel = React.memo(
3537
<Text style={styles.text}>
3638
Showing {listCount} {listLabel}
3739
</Text>
40+
{typeof totalCount === 'number' ? (
41+
<Text style={styles.text}>Total device contacts: {totalCount}</Text>
42+
) : null}
3843
</View>
3944
)
4045
);

0 commit comments

Comments
 (0)