Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 71 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -131,12 +138,12 @@ API reference & examples
await commitPersisted(nextToken);
```

- `getAll(options?: { offset?: number; limit?: number; pageSize?: number }): Promise<Contact[]>`
- 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<Contact[]>`
- 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<UpdatedPage>`
Expand All @@ -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<UpdatedPage>`
- 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…`).
Expand All @@ -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.');
}
}
```

Expand All @@ -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
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

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

data class ContactPage(
val items: List<Contact>,
val total: Int
)

data class SnapshotContact(
val id: String,
val displayName: String,
Expand Down Expand Up @@ -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))
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -223,7 +225,7 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
private fun queryContactsForDelta(sinceMs: Long, desiredCount: Int): List<Contact> {
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<DeletedContact> {
Expand Down Expand Up @@ -318,7 +320,7 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
)
}

private fun queryContacts(offset: Int, limit: Int, sinceMs: Long?): List<Contact> {
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(
Expand All @@ -340,12 +342,17 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) :
}

val sortOrder = ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " DESC"
val items = ArrayList<Contact>(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<Contact>(capacity)
var count = 0
do {
val id = c.getLong(c.getColumnIndexOrThrow(ContactsContract.Contacts._ID)).toString()
Expand All @@ -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<Contact> {
return queryContactsPage(offset, limit, sinceMs).items
}

private fun queryContactById(contactId: String): Contact? {
Expand Down
6 changes: 3 additions & 3 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
PODS:
- boost (1.84.0)
- ContactsLastUpdated (1.2.2):
- ContactsLastUpdated (1.2.3):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2603,7 +2603,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
ContactsLastUpdated: 50a1a6488109fbd2b74ff59371786c19d72d09ba
ContactsLastUpdated: 768294728346641ddb82925d13cb878fb4f87bae
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
FBLazyVector: b8f1312d48447cca7b4abc21ed155db14742bd03
Expand Down Expand Up @@ -2679,4 +2679,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: 0c430fe8ae9178ec32f81dd9670b0ce1bf39a157

COCOAPODS: 1.15.2
COCOAPODS: 1.16.2
43 changes: 42 additions & 1 deletion example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 <ContactsDemoScreen />;
const [totalContacts, setTotalContacts] = React.useState<number | null>(null);

return (
<SafeAreaProvider>
<View style={styles.container}>
<View style={styles.body}>
<ContactsDemoScreen onTotalContactsChange={setTotalContacts} />
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>
Total device contacts:{' '}
{typeof totalContacts === 'number' ? totalContacts : '—'}
</Text>
</View>
</View>
</SafeAreaProvider>
);
}

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',
},
});
3 changes: 2 additions & 1 deletion example/src/components/InfoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Props = {
since: string;
listLabel: string;
listCount: number;
totalCount?: number | null;
};

const InfoPanel = React.memo(
Expand All @@ -28,7 +29,7 @@ const InfoPanel = React.memo(
<Text style={styles.text}>
Granted: {String(granted)} • Loading: {String(loading)}
</Text>
<Text style={styles.text}>
<Text style={styles.text} numberOfLines={1}>
Since: {since || '(empty)'}
{since?.startsWith('fp:') ? ' • fingerprint' : ''}
</Text>
Expand Down
Loading
Loading