diff --git a/README.md b/README.md index aae5eb5..493aba8 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,15 @@ type ContactChange = Contact & { } | null; }; -type UpdatedPage = - | { mode: 'full'; items: Contact[]; nextSince: string } - | { mode: 'delta'; items: ContactChange[]; nextSince: string }; +type UpdatedPageBase = { + nextSince: string; + totalContacts: number; +}; + +type UpdatedPage = UpdatedPageBase & { + mode: 'delta' | 'full'; + items: ContactChange[]; +}; ``` API reference & examples @@ -103,6 +109,7 @@ API reference & examples | `type PhoneNumberUpdate` | Represents an individual phone number that changed within a contact delta (`previous` → `current`). | | `type PhoneNumberChanges` | Buckets the numbers added/removed/updated in a `ContactChange`. Useful when reconciling diffs. | | `type ContactChange` | Extends `Contact` with delta metadata (`changeType`, `isDeleted`, `phoneNumberChanges`, and an optional `previous` snapshot). | +| `type UpdatedPage` | Page returned by paging APIs (`mode`, `items`, `nextSince`, and `totalContacts` so you know the device book size). Items are always `ContactChange[]`. | ### Functions (promise / async) @@ -131,12 +138,12 @@ API reference & examples await commitPersisted(nextToken); ``` -- `getAll(options?: { offset?: number; limit?: number; pageSize?: number }): Promise` - - 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). +- `getAll(): Promise` + - Convenience helper that returns the full native contact list in one call. ```ts - const everyone = await getAll({ pageSize: 400 }); - const pageTwo = await getAll({ offset: 400, limit: 200 }); + const everyone = await getAll(); + console.log('Fetched contacts', everyone.length); ``` - `getUpdatedSincePaged(since: string, offset: number, limit: number): Promise` @@ -145,19 +152,28 @@ API reference & examples ```ts const page = await getUpdatedSincePaged(lastToken, 0, 200); if (page.mode === 'full') { - page.items.forEach((contact) => console.log('Full contact', contact.id)); + page.items.forEach((change) => console.log('Full contact', change.id)); } else { page.items.forEach((change) => console.log(change.changeType, change.id)); } + console.log('Total contacts on device', page.totalContacts); ``` -- `getUpdatedFromPersistedPaged(offset: number, limit: number): Promise` - - 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. + - 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`. + ```ts - const page = await getUpdatedFromPersistedPaged(0, 300); - console.log(page.mode, page.items.length); + await getUpdatedSincePaged.listen( + { since: lastToken, pageSize: 250 }, + async (page) => { + console.log( + `Page mode=${page.mode} size=${page.items.length} total=${page.totalContacts}` + ); + } + ); ``` + - 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 }`. + > iOS tokens: > > - Real change-history tokens look like long base64 strings (`YnBsaXN0MDD…`). @@ -176,22 +192,29 @@ import { ensureContactsPermission } from './permissions'; // from snippet above // Delta or baseline sync (falls back to full pages when native tokens are unavailable) if (await ensureContactsPermission()) { const persistedSince = await getPersistedSince(); - let offset = 0; - let nextSince: string | undefined; + const pageSize = 300; + let nextSince: string | undefined = persistedSince; let usedFullFallback = false; - for (;;) { - const page = await getUpdatedSincePaged(persistedSince, offset, 300); - if (page.nextSince) nextSince = page.nextSince; - if (!page.items.length) break; - const label = page.mode === 'full' ? 'Contacts page' : 'Delta page'; - console.log(label, page.items.length); - if (page.mode === 'full') usedFullFallback = true; - offset += page.items.length; - if (page.items.length < 300) break; - } - if (nextSince && nextSince !== persistedSince) await commitPersisted(nextSince); - if (usedFullFallback && !nextSince) + + await getUpdatedSincePaged.listen( + { since: persistedSince, pageSize }, + (page) => { + if (page.nextSince) nextSince = page.nextSince; + if (!page.items.length) return false; + const label = page.mode === 'full' ? 'Contacts page' : 'Delta page'; + console.log( + `${label}: ${page.items.length} items (total contacts ${page.totalContacts})` + ); + if (page.mode === 'full') usedFullFallback = true; + return page.items.length >= pageSize; + } + ); + + if (nextSince && nextSince !== persistedSince) { + await commitPersisted(nextSince); + } else if (usedFullFallback && !nextSince) { console.log('Full snapshot processed; no token persisted yet.'); + } } ``` @@ -213,30 +236,37 @@ await ensureContactsPermission(); // 2. Pull the delta (or fallback full pages) since the last committed token and persist progress. const persistedSince = await getPersistedSince(); -let offset = 0; +const pageSize = 300; let sessionToken = persistedSince; +let totalContacts: number | undefined; const delta: ContactChange[] = []; let fullFallback: Contact[] = []; -for (;;) { - const page = await getUpdatedSincePaged(persistedSince, offset, 300); - if (page.nextSince) sessionToken = page.nextSince; - if (!page.items.length) break; - if (page.mode === 'delta') { - delta.push(...page.items); - } else { - fullFallback = fullFallback.concat(page.items); + +await getUpdatedSincePaged.listen( + { since: persistedSince, pageSize }, + (page) => { + if (page.nextSince) sessionToken = page.nextSince; + if (!page.items.length) return false; + totalContacts = page.totalContacts; + if (page.mode === 'delta') { + delta.push(...page.items); + } else { + fullFallback = fullFallback.concat(page.items); + } + return page.items.length >= pageSize; } - offset += page.items.length; - if (page.items.length < 300) break; -} +); + if (sessionToken && sessionToken !== persistedSince) { await commitPersisted(sessionToken); } -// 3. Full fallback pages can be handled like a baseline rebuild -console.log('Full contacts received', fullFallback.length); +console.log('Total contacts reported by native layer', totalContacts ?? 'unknown'); + +// 3. Full fallback pages can be handled like a baseline rebuild. +console.log('Full snapshot contacts (if fallback)', fullFallback.length); -// 4. Look up a single contact by identifier (helpful after any baseline). +// 4. Look up a single contact by identifier (helpful after any baseline rebuild). const singleContact = await getById('12345'); // returns `null` if the contact was deleted ``` diff --git a/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt b/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt index ca9f24f..f18b98f 100644 --- a/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt +++ b/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt @@ -17,6 +17,7 @@ import java.io.File import org.json.JSONArray import org.json.JSONObject import java.util.LinkedHashMap +import kotlin.math.min @Suppress("unused") @@ -43,6 +44,11 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : val familyName: String? = null ) + data class ContactPage( + val items: List, + val total: Int + ) + data class SnapshotContact( val id: String, val displayName: String, @@ -85,14 +91,8 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : val sortTimestamp: Long ) - override fun getAll(offset: Double, limit: Double, promise: Promise) { - val off = offset.toInt().coerceAtLeast(0) - val lim = limit.toInt().coerceAtLeast(0) - if (lim <= 0) { - promise.resolve(Arguments.createArray()) - return - } - val contacts = queryContacts(off, lim, null) + override fun getAll(promise: Promise) { + val contacts = queryContactsPage(0, Int.MAX_VALUE, null).items promise.resolve(contactsToWritableArray(contacts)) } @@ -114,9 +114,10 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : val off = offset.toInt().coerceAtLeast(0) val lim = limit.toInt().coerceAtLeast(0) if (since.isBlank()) { - val contacts = if (lim <= 0) emptyList() else queryContacts(off, lim, null) + val page = queryContactsPage(off, lim, null) val result = Arguments.createMap() - result.putArray("items", contactsToWritableArray(contacts)) + result.putArray("items", contactsToWritableArray(page.items)) + result.putInt("totalContacts", page.total) result.putString("nextSince", System.currentTimeMillis().toString()) result.putString("mode", "full") promise.resolve(result) @@ -150,9 +151,10 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : val lim = limit.toInt().coerceAtLeast(0) val stored = prefs.getLong("since", 0L) if (stored <= 0L) { - val contacts = if (lim <= 0) emptyList() else queryContacts(off, lim, null) + val page = queryContactsPage(off, lim, null) val map = Arguments.createMap() - map.putArray("items", contactsToWritableArray(contacts)) + map.putArray("items", contactsToWritableArray(page.items)) + map.putInt("totalContacts", page.total) map.putString("nextSince", System.currentTimeMillis().toString()) map.putString("mode", "full") promise.resolve(map) @@ -223,7 +225,7 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : private fun queryContactsForDelta(sinceMs: Long, desiredCount: Int): List { if (desiredCount <= 0) return emptyList() val filter = if (sinceMs > 0) sinceMs else null - return queryContacts(0, desiredCount, filter) + return queryContactsPage(0, desiredCount, filter).items } private fun queryDeletedContacts(sinceMs: Long, desiredCount: Int): List { @@ -318,7 +320,7 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : ) } - private fun queryContacts(offset: Int, limit: Int, sinceMs: Long?): List { + private fun queryContactsPage(offset: Int, limit: Int, sinceMs: Long?): ContactPage { val cr: ContentResolver = reactApplicationContext.contentResolver val uri: Uri = ContactsContract.Contacts.CONTENT_URI val projection = arrayOf( @@ -340,12 +342,17 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : } val sortOrder = ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " DESC" - val items = ArrayList(limit) val cursor: Cursor? = cr.query(uri, projection, selection, selectionArgs, sortOrder) cursor?.use { c -> + val total = c.count + if (limit <= 0) { + return ContactPage(emptyList(), total) + } if (!c.moveToPosition(offset)) { - return emptyList() + return ContactPage(emptyList(), total) } + val capacity = if (total <= offset) 0 else min(limit, total - offset) + val items = ArrayList(capacity) var count = 0 do { val id = c.getLong(c.getColumnIndexOrThrow(ContactsContract.Contacts._ID)).toString() @@ -358,8 +365,13 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : items.add(Contact(id, name, phones, updatedAt)) count++ } while (c.moveToNext() && count < limit) + return ContactPage(items, total) } - return items + return ContactPage(emptyList(), 0) + } + + private fun queryContacts(offset: Int, limit: Int, sinceMs: Long?): List { + return queryContactsPage(offset, limit, sinceMs).items } private fun queryContactById(contactId: String): Contact? { diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 205ea8e..5f99b57 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - ContactsLastUpdated (1.2.2): + - ContactsLastUpdated (1.2.3): - boost - DoubleConversion - fast_float @@ -2603,7 +2603,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - ContactsLastUpdated: 50a1a6488109fbd2b74ff59371786c19d72d09ba + ContactsLastUpdated: 768294728346641ddb82925d13cb878fb4f87bae DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: b8f1312d48447cca7b4abc21ed155db14742bd03 @@ -2679,4 +2679,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0c430fe8ae9178ec32f81dd9670b0ce1bf39a157 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/src/App.tsx b/example/src/App.tsx index 126d555..05c07f6 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,5 +1,46 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + import ContactsDemoScreen from './screens/ContactsDemoScreen'; export default function App() { - return ; + const [totalContacts, setTotalContacts] = React.useState(null); + + return ( + + + + + + + + Total device contacts:{' '} + {typeof totalContacts === 'number' ? totalContacts : '—'} + + + + + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + body: { + flex: 1, + }, + footer: { + paddingVertical: 12, + paddingHorizontal: 16, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#d0d0d0', + backgroundColor: '#ffffff', + }, + footerText: { + textAlign: 'center', + color: '#333333', + }, +}); diff --git a/example/src/components/InfoPanel.tsx b/example/src/components/InfoPanel.tsx index 331fab4..003d442 100644 --- a/example/src/components/InfoPanel.tsx +++ b/example/src/components/InfoPanel.tsx @@ -11,6 +11,7 @@ type Props = { since: string; listLabel: string; listCount: number; + totalCount?: number | null; }; const InfoPanel = React.memo( @@ -28,7 +29,7 @@ const InfoPanel = React.memo( Granted: {String(granted)} • Loading: {String(loading)} - + Since: {since || '(empty)'} {since?.startsWith('fp:') ? ' • fingerprint' : ''} diff --git a/example/src/screens/ContactsDemoScreen.tsx b/example/src/screens/ContactsDemoScreen.tsx index e21b186..d3943ca 100644 --- a/example/src/screens/ContactsDemoScreen.tsx +++ b/example/src/screens/ContactsDemoScreen.tsx @@ -28,7 +28,11 @@ type DeltaTally = { deleted: number; }; -const ContactsDemoScreen = () => { +type Props = { + onTotalContactsChange?: (count: number | null) => void; +}; + +const ContactsDemoScreen = ({ onTotalContactsChange }: Props) => { const systemTheme = useColorScheme(); const theme: Theme = systemTheme === 'dark' ? 'dark' : 'light'; const styles = React.useMemo(() => createStyles(theme), [theme]); @@ -39,6 +43,7 @@ const ContactsDemoScreen = () => { const [since, setSince] = React.useState(''); const [deltaStatus, setDeltaStatus] = React.useState('No delta fetched yet'); const [log, setLog] = React.useState(''); + const [totalContacts, setTotalContacts] = React.useState(null); React.useEffect(() => { if (Platform.OS !== 'android') return; const requestPermission = async () => { @@ -54,6 +59,12 @@ const ContactsDemoScreen = () => { setLog((prev) => `${new Date().toISOString()} ${message}\n${prev}`); }, []); + React.useEffect(() => { + if (onTotalContactsChange) { + onTotalContactsChange(totalContacts); + } + }, [onTotalContactsChange, totalContacts]); + const fetchDelta = React.useCallback(async () => { if (!granted) { appendLog('Permission not granted'); @@ -63,45 +74,45 @@ const ContactsDemoScreen = () => { setDeltaStatus('Fetching delta…'); try { const persistedSince = await getPersistedSince(); - let offset = 0; let nextSince: string | undefined; let usedFullFallback = false; const collected: ContactChange[] = []; const started = Date.now(); - for (;;) { - const response = await getUpdatedSincePaged( - persistedSince, - offset, - PAGE_FETCH_LIMIT - ); - if (response.nextSince) { - nextSince = response.nextSince; - } - appendLog( - `Fetched ${response.mode} page: baseSince=${persistedSince} offset=${offset} size=${response.items.length} nextSince=${response.nextSince || '∅'}` - ); - if (response.items.length === 0) break; - if (response.mode === 'delta') { - collected.push(...response.items); - } else { - usedFullFallback = true; - collected.push( - ...response.items.map((contact) => ({ - ...contact, - changeType: 'created' as ContactChange['changeType'], - isDeleted: false, - phoneNumberChanges: { - created: contact.phoneNumbers, - deleted: [], - updated: [], - }, - previous: null, - })) + let latestTotal: number | null = null; + let pageOffset = 0; + + await getUpdatedSincePaged.listen( + { since: persistedSince, offset: 0, pageSize: PAGE_FETCH_LIMIT }, + async (response) => { + if (response.mode === 'full') { + const nextTotal = + typeof response.totalContacts === 'number' + ? response.totalContacts + : response.items.length; + latestTotal = nextTotal; + setTotalContacts(nextTotal); + } + if (response.nextSince) { + nextSince = response.nextSince; + } + appendLog( + `Fetched ${response.mode} page: baseSince=${persistedSince} offset=${pageOffset} size=${response.items.length} total=${ + typeof response.totalContacts === 'number' + ? response.totalContacts + : '∅' + } nextSince=${response.nextSince || '∅'}` ); + if (response.items.length === 0) { + return false; + } + if (response.mode === 'full') { + usedFullFallback = true; + } + collected.push(...response.items); + pageOffset += response.items.length; + return true; } - offset += response.items.length; - if (response.items.length < PAGE_FETCH_LIMIT) break; - } + ); setChanges(collected); const summary = collected.reduce( (acc, item) => ({ @@ -122,8 +133,12 @@ const ContactsDemoScreen = () => { appendLog( `Completed delta fetch. Items=${collected.length} (created=${summary.created}, updated=${summary.updated}, deleted=${summary.deleted}) committedSince=${committedSince || persistedSince} in ${Date.now() - started}ms${fallbackNote}` ); + const finalTotal = + typeof latestTotal === 'number' ? latestTotal : totalContacts; + const totalNote = + typeof finalTotal === 'number' ? `, total contacts ${finalTotal}` : ''; setDeltaStatus( - `Delta: ${collected.length} (created ${summary.created}, updated ${summary.updated}, deleted ${summary.deleted})${fallbackNote}` + `Delta: ${collected.length} (created ${summary.created}, updated ${summary.updated}, deleted ${summary.deleted})${fallbackNote}${totalNote}` ); } catch (error: any) { appendLog(`Error: ${error?.message || String(error)}`); @@ -131,7 +146,7 @@ const ContactsDemoScreen = () => { } finally { setLoading(false); } - }, [appendLog, granted]); + }, [appendLog, granted, totalContacts]); const listLabel = 'Delta Contacts'; const listData = changes; @@ -167,6 +182,7 @@ const ContactsDemoScreen = () => { since={since} listLabel={listLabel} listCount={listData.length} + totalCount={totalContacts} /> {loading ? ( diff --git a/ios/ContactsLastUpdated.mm b/ios/ContactsLastUpdated.mm index 0f063da..8ca8311 100644 --- a/ios/ContactsLastUpdated.mm +++ b/ios/ContactsLastUpdated.mm @@ -92,9 +92,9 @@ static uint64_t CLUContactFingerprint(CNContact *c) { }; } -static NSArray *CLUFetchContactPage(NSInteger off, NSInteger lim) { - if (lim <= 0) { - return @[]; +static NSDictionary *CLUFetchContactPageInternal(NSInteger off, NSInteger lim, BOOL includeTotal) { + if (lim < 0) { + lim = 0; } CNContactStore *store = [CNContactStore new]; @@ -106,15 +106,66 @@ static uint64_t CLUContactFingerprint(CNContact *c) { CNContactPhoneNumbersKey]; CNContactFetchRequest *request = [[CNContactFetchRequest alloc] initWithKeysToFetch:keys]; - NSMutableArray *results = [NSMutableArray arrayWithCapacity:lim]; + NSMutableArray *results = [NSMutableArray arrayWithCapacity:(NSUInteger)lim]; __block NSInteger index = -1; + __block NSInteger total = 0; BOOL ok = [store enumerateContactsWithFetchRequest:request error:&err usingBlock:^(CNContact * _Nonnull contact, BOOL * _Nonnull stop) { index += 1; + total = index + 1; if (index < off) { return; } - if ((NSInteger)results.count >= lim) { *stop = YES; return; } + if ((NSInteger)results.count < lim) { + [results addObject:CLUContactToContactDict(contact)]; + } + if (!includeTotal && lim > 0 && (NSInteger)results.count >= lim) { + *stop = YES; + } + }]; + + if (!ok || err) { + if (includeTotal) { + return @{ @"items": @[], @"total": @0 }; + } + return @{ @"items": @[], @"total": @(off) }; + } + + if (!includeTotal) { + total = off + (NSInteger)results.count; + } + + return @{ @"items": results ?: @[], @"total": @(total) }; +} + +static NSArray *CLUFetchContactPage(NSInteger off, NSInteger lim) { + NSDictionary *page = CLUFetchContactPageInternal(off, lim, NO); + NSArray *items = page[@"items"]; + return items ?: @[]; +} +static NSDictionary *CLUPagedContactsWithTotal(NSInteger off, NSInteger lim) { + NSDictionary *page = CLUFetchContactPageInternal(off, lim, YES); + NSArray *items = page[@"items"]; + NSNumber *total = page[@"total"]; + NSArray *safeItems = items ?: @[]; + NSNumber *safeTotal = total ?: @(safeItems.count); + return @{ @"items": safeItems, @"total": safeTotal }; +} + +static NSArray *CLUFetchAllContacts(void) { + CNContactStore *store = [CNContactStore new]; + NSError *err = nil; + + NSArray *keys = @[CNContactIdentifierKey, + CNContactGivenNameKey, + CNContactFamilyNameKey, + CNContactPhoneNumbersKey]; + CNContactFetchRequest *request = [[CNContactFetchRequest alloc] initWithKeysToFetch:keys]; + + NSMutableArray *results = [NSMutableArray array]; + BOOL ok = [store enumerateContactsWithFetchRequest:request + error:&err + usingBlock:^(CNContact * _Nonnull contact, BOOL * _Nonnull stop) { [results addObject:CLUContactToContactDict(contact)]; }]; @@ -547,14 +598,21 @@ - (void)getUpdatedFromPersisted:(double)offset } if (sinceStr == nil || sinceStr.length == 0) { - NSArray *page = CLUFetchContactPage(off, lim); + NSDictionary *pageResult = CLUPagedContactsWithTotal(off, lim); + NSArray *page = pageResult[@"items"]; + NSNumber *total = pageResult[@"total"]; NSData *token = store.currentHistoryToken; NSString *nextSince = token != nil ? [token base64EncodedStringWithOptions:0] : @""; if (nextSince.length == 0) { long long ms = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); nextSince = [NSString stringWithFormat:@"fp:%lld", ms]; } - resolve(@{ @"items": page ?: @[], @"nextSince": nextSince ?: @"", @"mode": @"full" }); + NSArray *safePage = page ?: @[]; + NSNumber *safeTotal = total ?: @(safePage.count); + resolve(@{ @"items": safePage, + @"nextSince": nextSince ?: @"", + @"mode": @"full", + @"totalContacts": safeTotal }); return; } @@ -664,16 +722,10 @@ - (void)commitPersisted:(NSString *)nextSince resolve(nil); } -// Paged full fetch. iOS cannot sort by last updated (not exposed), -// so the order is undefined. Use small limits for performance. -- (void)getAll:(double)offset - limit:(double)limit - resolve:(RCTPromiseResolveBlock)resolve +- (void)getAll:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - NSInteger off = (NSInteger)MAX(0, offset); - NSInteger lim = (NSInteger)MAX(0, limit); - resolve(CLUFetchContactPage(off, lim)); + resolve(CLUFetchAllContacts()); } - (void)getById:(NSString *)identifier @@ -712,14 +764,21 @@ - (void)getUpdatedSince:(NSString *)since BOOL initialSync = (since == nil) || (since.length == 0); if (initialSync) { - NSArray *page = CLUFetchContactPage(off, lim); + NSDictionary *pageResult = CLUPagedContactsWithTotal(off, lim); + NSArray *page = pageResult[@"items"]; + NSNumber *total = pageResult[@"total"]; NSData *token = store.currentHistoryToken; NSString *nextSince = token != nil ? [token base64EncodedStringWithOptions:0] : @""; if (nextSince.length == 0) { long long ms = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); nextSince = [NSString stringWithFormat:@"fp:%lld", ms]; } - resolve(@{ @"items": page ?: @[], @"nextSince": nextSince ?: @"", @"mode": @"full" }); + NSArray *safePage = page ?: @[]; + NSNumber *safeTotal = total ?: @(safePage.count); + resolve(@{ @"items": safePage, + @"nextSince": nextSince ?: @"", + @"mode": @"full", + @"totalContacts": safeTotal }); return; } diff --git a/package.json b/package.json index 6810b9c..00994be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@omarsdev/react-native-contacts", - "version": "1.2.3", + "version": "1.3.0", "description": "Access the device address book and track when contacts were last touched.", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/src/NativeContactsLastUpdated.ts b/src/NativeContactsLastUpdated.ts index bb819cf..cd903e8 100644 --- a/src/NativeContactsLastUpdated.ts +++ b/src/NativeContactsLastUpdated.ts @@ -37,24 +37,26 @@ export type ContactChange = Contact & { } | null; }; -type NativeDeltaResult = { - items: ContactChange[]; +type NativeUpdatedBase = { + items: T; nextSince: string; + totalContacts?: number; +}; + +type NativeDeltaResult = NativeUpdatedBase & { mode?: 'delta'; }; -type NativeFullResult = { - items: Contact[]; - nextSince: string; +type NativeFullResult = NativeUpdatedBase & { mode: 'full'; }; export type NativeUpdatedResult = NativeDeltaResult | NativeFullResult; export interface Spec extends TurboModule { - // Paged full fetch; on Android sorted by last updated desc. + // Full fetch; on Android sorted by last updated desc. // iOS order is undefined (CNContacts doesn’t expose last updated). - getAll(offset: number, limit: number): Promise; + getAll(): Promise; // Retrieve a single contact by identifier if it exists. getById(id: string): Promise; @@ -88,12 +90,14 @@ function createFallbackModule(): Spec { items: [] as ContactChange[], nextSince: persistedToken, mode: 'delta', + totalContacts: 0, }), getPersistedSince: async () => persistedToken, getUpdatedFromPersisted: async () => ({ items: [] as ContactChange[], nextSince: persistedToken, mode: 'delta', + totalContacts: 0, }), commitPersisted: async (nextSince: string) => { persistedToken = nextSince; diff --git a/src/index.tsx b/src/index.tsx index 955cdb9..7542a2d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,105 +7,216 @@ export type { PhoneNumberUpdate, } from './NativeContactsLastUpdated'; -type DeltaPage = { - mode: 'delta'; - items: ContactChange[]; +type UpdatedPageBase = { nextSince: string; + totalContacts: number; }; -type FullPage = { - mode: 'full'; - items: Contact[]; - nextSince: string; +export type UpdatedPage = UpdatedPageBase & { + mode: 'delta' | 'full'; + items: ContactChange[]; }; -export type UpdatedPage = DeltaPage | FullPage; +type UpdatedPageHandler = ( + page: UpdatedPage +) => void | boolean | Promise; -// Convenience: fetch contacts either by page or entire list. -// Provide `limit` to fetch a single page; omit to stream until exhaustion starting at optional `offset`. -export async function getAll(options?: { +type SinceListenOptions = { + since?: string; offset?: number; - limit?: number; pageSize?: number; -}): Promise { - const offset = options?.offset ?? 0; - if (typeof options?.limit === 'number') { - return ContactsLastUpdated.getAll(offset, options.limit); +}; + +type ListenWithOptions = { + (handler: UpdatedPageHandler, options?: TOptions): Promise; + (options: TOptions, handler: UpdatedPageHandler): Promise; +}; + +type GetUpdatedSincePagedFn = { + (since: string, offset: number, limit: number): Promise; + listen: ListenWithOptions; +}; + +const DEFAULT_PAGE_SIZE = 300; + +function ensurePositivePageSize(value?: number): number { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.floor(value); } - const pageSize = options?.pageSize ?? 500; - let cursor = offset; - const results: Contact[] = []; - while (true) { - const page = await ContactsLastUpdated.getAll(cursor, pageSize); - if (!page || page.length === 0) break; - results.push(...page); - cursor += page.length; - if (page.length < pageSize) break; + return DEFAULT_PAGE_SIZE; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +function normalizeListenArgs( + arg1: UpdatedPageHandler | TOptions | undefined, + arg2?: UpdatedPageHandler | TOptions +): { handler: UpdatedPageHandler; options: TOptions } { + let handler: UpdatedPageHandler | undefined; + let options: TOptions | undefined; + + if (typeof arg1 === 'function') { + handler = arg1; + } else if (isRecord(arg1)) { + options = arg1 as TOptions; + } + + if (typeof arg2 === 'function') { + handler = arg2; + } else if (isRecord(arg2)) { + options = arg2 as TOptions; } - return results; + + if (!handler) { + throw new TypeError( + 'A page handler function must be provided to listen().' + ); + } + + return { handler, options: options ?? ({} as TOptions) }; +} + +function resolveTotalContacts(nativeResult: { + totalContacts?: number; + items: unknown; +}): number { + if (typeof nativeResult.totalContacts === 'number') { + return nativeResult.totalContacts; + } + return Array.isArray(nativeResult.items) ? nativeResult.items.length : 0; +} + +function synthesizeChangeFromContact(contact: Contact): ContactChange { + const numbers = Array.isArray(contact.phoneNumbers) + ? contact.phoneNumbers + : []; + + return { + ...contact, + changeType: 'created', + isDeleted: false, + phoneNumberChanges: { + created: numbers, + deleted: [], + updated: [], + }, + previous: null, + }; +} + +function normalizeDeltaChange(change: ContactChange): ContactChange { + const changes = change.phoneNumberChanges ?? { + created: [], + deleted: [], + updated: [], + }; + + return { + ...change, + changeType: change.changeType ?? (change.isDeleted ? 'deleted' : 'updated'), + isDeleted: Boolean(change.isDeleted), + phoneNumberChanges: { + created: Array.isArray(changes.created) ? changes.created : [], + deleted: Array.isArray(changes.deleted) ? changes.deleted : [], + updated: Array.isArray(changes.updated) ? changes.updated : [], + }, + previous: + change.previous && typeof change.previous === 'object' + ? change.previous + : null, + }; +} + +// Convenience: fetch the entire contacts list in one call. +export async function getAll(): Promise { + const contacts = await ContactsLastUpdated.getAll(); + return Array.isArray(contacts) ? contacts : []; } // Paged API: Delta list since a token. // Android: token is a millisecond timestamp string. // iOS: token is a base64-encoded CNChangeHistory token. -export async function getUpdatedSincePaged( +const getUpdatedSincePagedImpl = async ( since: string, offset: number, limit: number -): Promise { +): Promise => { const result = await ContactsLastUpdated.getUpdatedSince( since, offset, limit ); const nextSince = result.nextSince ?? ''; - if (result.mode === 'full') { - return { - mode: 'full', - items: result.items, - nextSince, - }; - } - if (!nextSince && since.trim().length === 0) { + const totalContacts = resolveTotalContacts(result); + const shouldTreatAsFull = + result.mode === 'full' || (since.trim().length === 0 && !nextSince); + + if (shouldTreatAsFull) { + const contacts = + result.mode === 'full' + ? result.items + : (result as unknown as { items: Contact[] }).items; return { mode: 'full', - items: (result as unknown as { items: Contact[] }).items, + items: Array.isArray(contacts) + ? contacts.map(synthesizeChangeFromContact) + : [], nextSince, + totalContacts, }; } + + const deltas = + result.mode === 'delta' + ? result.items + : (result as unknown as { items: ContactChange[] }).items; return { mode: 'delta', - items: result.items, + items: Array.isArray(deltas) ? deltas.map(normalizeDeltaChange) : [], nextSince, + totalContacts, }; -} +}; -// Persisted-delta helpers -export async function getPersistedSince(): Promise { - return ContactsLastUpdated.getPersistedSince(); -} +export const getUpdatedSincePaged = + getUpdatedSincePagedImpl as GetUpdatedSincePagedFn; -export async function getUpdatedFromPersistedPaged( - offset: number, - limit: number -): Promise { - const result = await ContactsLastUpdated.getUpdatedFromPersisted( - offset, - limit +getUpdatedSincePaged.listen = async function ( + arg1?: UpdatedPageHandler | SinceListenOptions, + arg2?: UpdatedPageHandler | SinceListenOptions +): Promise { + const { handler, options } = normalizeListenArgs( + arg1, + arg2 ); - const nextSince = result.nextSince ?? ''; - if (result.mode === 'full' || !nextSince) { - return { - mode: 'full', - items: (result as unknown as { items: Contact[] }).items, - nextSince, - }; + const baseSince = options.since ?? ''; + let offset = options.offset ?? 0; + const pageSize = ensurePositivePageSize(options.pageSize); + + while (true) { + const page = await getUpdatedSincePaged(baseSince, offset, pageSize); + const handlerResult = await handler(page); + if (handlerResult === false) { + break; + } + + const length = Array.isArray(page.items) ? page.items.length : 0; + if (length <= 0) { + break; + } + + offset += length; + if (length < pageSize) { + break; + } } - return { - mode: 'delta', - items: result.items, - nextSince, - }; +}; + +// Persisted-delta helpers +export async function getPersistedSince(): Promise { + return ContactsLastUpdated.getPersistedSince(); } export async function commitPersisted(nextSince: string): Promise {