diff --git a/.github/scripts/bump-version.mjs b/.github/scripts/bump-version.mjs new file mode 100755 index 0000000..9e46dd2 --- /dev/null +++ b/.github/scripts/bump-version.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const args = process.argv.slice(2); +let explicitVersion; +let bumpType = 'patch'; + +for (let i = 0; i < args.length; i += 1) { + const value = args[i]; + if (value === '--set') { + explicitVersion = args[i + 1]; + if (!explicitVersion) { + console.error('Expected value after --set'); + process.exit(1); + } + i += 1; + } else { + bumpType = value.toLowerCase(); + } +} + +const pkgPath = resolve(process.cwd(), 'package.json'); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + +const nextVersion = explicitVersion + ? normalizeVersion(explicitVersion) + : bumpVersion(pkg.version, bumpType); + +pkg.version = nextVersion; +writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); + +console.log(nextVersion); + +function normalizeVersion(candidate) { + const clean = String(candidate).trim().replace(/^v/, ''); + if (!/^\d+\.\d+\.\d+$/.test(clean)) { + throw new Error(`Invalid semantic version: ${candidate}`); + } + return clean; +} + +function bumpVersion(version, type) { + const allowed = new Set(['major', 'minor', 'patch']); + if (!allowed.has(type)) { + throw new Error(`Unsupported bump type: ${type}`); + } + + const [core] = String(version).split('-'); + const parts = core.split('.').map((part) => { + const value = Number.parseInt(part, 10); + if (Number.isNaN(value)) { + throw new Error(`Invalid semver component: ${part}`); + } + return value; + }); + + if (parts.length !== 3) { + throw new Error(`Version must have three numeric parts (got "${version}")`); + } + + if (type === 'major') { + parts[0] += 1; + parts[1] = 0; + parts[2] = 0; + } else if (type === 'minor') { + parts[1] += 1; + parts[2] = 0; + } else { + parts[2] += 1; + } + + return parts.join('.'); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c7db0a..f8bd0f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,12 @@ on: merge_group: types: - checks_requested + release: + types: + - published + +permissions: + contents: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -198,6 +204,65 @@ jobs: npm version "$CANARY_VERSION" --no-git-tag-version npm publish --access public --tag canary + publish-release: + name: Publish release to npm + if: github.event_name == 'release' && github.event.release.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + ref: ${{ github.event.release.target_commitish }} + + - name: Setup + uses: ./.github/actions/setup + + - name: Bump package version + id: bump + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + if [ -z "$RELEASE_TAG" ]; then + echo "Release tag is required to set package version" >&2 + exit 1 + fi + NEW_VERSION=$(node .github/scripts/bump-version.mjs --set "$RELEASE_TAG") + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + - name: Commit version bump + id: commit + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git diff --quiet; then + echo "did_commit=false" >> "$GITHUB_OUTPUT" + echo "Package version already up to date" + exit 0 + fi + git commit -am "chore: bump version to ${VERSION}" + echo "did_commit=true" >> "$GITHUB_OUTPUT" + + - name: Push version bump + if: steps.commit.outputs.did_commit == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git push + + - name: Build package + run: yarn prepare + + - name: Configure npm auth + run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish release + run: npm publish --access public + publish: name: Publish to npm (push to main) if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/README.md b/README.md index ac3e1d7..2c0857d 100644 --- a/README.md +++ b/README.md @@ -31,17 +31,19 @@ Permissions ``` - Runtime request (JS): + ```ts - import {PermissionsAndroid, Platform} from 'react-native' + import { PermissionsAndroid, Platform } from 'react-native'; export async function ensureContactsPermission() { - if (Platform.OS !== 'android') return true + if (Platform.OS !== 'android') return true; const res = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.READ_CONTACTS - ) - return res === PermissionsAndroid.RESULTS.GRANTED + ); + return res === PermissionsAndroid.RESULTS.GRANTED; } ``` + - iOS: add usage description to your app `Info.plist` and rebuild pods. - Info.plist: ```xml @@ -50,65 +52,220 @@ Permissions ``` - iOS will show the permission prompt the first time you access contacts. -Type shape +Type shapes ```ts type Contact = { - id: string - displayName: string - phoneNumbers: string[] - givenName?: string | null - familyName?: string | null + id: string; + displayName: string; + phoneNumbers: string[]; + givenName?: string | null; + familyName?: string | null; // Android only; iOS sets null - lastUpdatedAt?: number | null -} + lastUpdatedAt?: number | null; +}; + +type PhoneNumberUpdate = { + previous: string; + current: string; +}; + +type PhoneNumberChanges = { + created: string[]; + deleted: string[]; + updated: PhoneNumberUpdate[]; +}; + +type ContactChange = Contact & { + changeType: 'created' | 'updated' | 'deleted'; + isDeleted: boolean; + phoneNumberChanges: PhoneNumberChanges; + previous?: { + displayName?: string | null; + givenName?: string | null; + familyName?: string | null; + phoneNumbers: string[]; + } | null; +}; ``` -API reference (JS) +API reference & examples + +### Types + +| Type | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `type Contact` | Normalised contact record returned by all APIs. Includes optional `givenName`, `familyName`, and `lastUpdatedAt` (Android only). | +| `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). | + +### Functions (synchronous) + +- `getById(id: string): Contact | null` + - Look up a single contact by its native identifier. Returns `null` if the contact no longer exists or if the identifier was empty. + + ```ts + const maybeAlice = getById('42'); + if (maybeAlice) { + console.log('Found contact', maybeAlice.displayName); + } + ``` -- `getAllPaged(offset: number, limit: number): Contact[]` - - Paged full fetch. Android is sorted by last updated desc. iOS order is undefined. -- `getUpdatedSincePaged(since: string, offset: number, limit: number): { items: Contact[], nextSince: string }` - - Paged delta since provided token. - - Android token: millisecond timestamp string. - - iOS token: base64 CNChangeHistory token (or synthetic `fp:` when change history doesn’t advance). - `getPersistedSince(): string` - - Returns the current native‑persisted token (empty string if none). -- `getUpdatedFromPersistedPaged(offset: number, limit: number): { items: Contact[], nextSince: string }` - - Paged delta using the native‑persisted token without passing it from JS. + - Reads the last token that was committed natively (empty string if nothing has been stored yet). Handy when resuming delta sync in a fresh JS session. + + ```ts + const lastToken = getPersistedSince(); + console.log('Native token:', lastToken); + ``` + - `commitPersisted(nextSince: string): void` - - Commits the token at the end of a delta session and advances the iOS snapshot baseline. + - Persists the supplied token on the native side so subsequent delta calls start from that point. On iOS this also rebuilds the fingerprint snapshot. + + ```ts + commitPersisted(nextToken); + ``` + +- `multiply(a: number, b: number): number` + - A simple sample used in the template (kept for backwards-compatibility with the RN library scaffold). Not used by the contacts flows. + ```ts + multiply(2, 3); // => 6 + ``` + +### Functions (promise / async) + +- `getAllPaged(offset: number, limit: number): Contact[]` + - Low-level paged fetch that mirrors the native call. On Android the result is sorted by `lastUpdatedAt` descending; iOS order is undefined. + + ```ts + const first500 = await getAllPaged(0, 500); + const next500 = await getAllPaged(500, 500); + ``` + +- `getAll(options?: { offset?: number; limit?: number; pageSize?: number }): Promise` + - Convenience wrapper for `getAll`. When `limit` is provided it behaves like `getAllPaged`. Otherwise it will loop until all contacts are fetched (respecting `pageSize`). + + ```ts + const everyone = await getAll({ pageSize: 400 }); + const pageTwo = await getAll({ offset: 400, limit: 200 }); + ``` + +- `getUpdatedSincePaged(since: string, offset: number, limit: number): { items: ContactChange[]; nextSince: string }` + - Fetch a delta page using an explicit token. Returns changed contacts plus the token you should persist after processing all pages. Token format differs by platform (millisecond timestamp on Android, CNChangeHistory token or `fp:` on iOS). + + ```ts + const { items, nextSince } = await getUpdatedSincePaged(lastToken, 0, 200); + items.forEach((change) => console.log(change.changeType, change.id)); + ``` + +- `getUpdatedFromPersistedPaged(offset: number, limit: number): { items: ContactChange[]; nextSince: string }` + - Same as above but the native layer provides the starting token (useful when you previously called `commitPersisted`). + ```ts + const page = await getUpdatedFromPersistedPaged(0, 300); + ``` + +### Generators + - `streamAll(pageSize?: number)` - - Async generator yielding pages of `Contact[]` until exhausted. + - Async generator that yields `Contact[]` pages until the address book is exhausted. Under the hood it repeatedly calls `getAll`. + + ```ts + for await (const contacts of streamAll(250)) { + console.log('Received', contacts.length); + } + ``` + - `streamUpdatedSince(since: string, pageSize?: number)` - - Async generator yielding `{ items: Contact[] }` pages; returns the final token when done. + - Async generator that yields `{ items: ContactChange[] }` based on a provided token and returns the final token after the loop completes. + + ```ts + let token = lastToken; + for await (const { items } of streamUpdatedSince(token, 200)) { + // process items + } + ``` + - `streamUpdatedFromPersisted(pageSize?: number)` - - Async generator using the native token; returns the committed token when done. + - Async generator that uses the native persisted token and commits the new token automatically when finished. Returns the committed token. + ```ts + const committedToken = await (async () => { + let finalToken = ''; + for await (const { items } of streamUpdatedFromPersisted(200)) { + finalToken = items.length ? finalToken : finalToken; + } + return finalToken; + })(); + ``` Quick start ```ts -import { streamAll, streamUpdatedFromPersisted } from '@omarsdev/react-native-contacts' -import { ensureContactsPermission } from './permissions' // from snippet above +import { + streamAll, + streamUpdatedFromPersisted, +} from '@omarsdev/react-native-contacts'; +import { ensureContactsPermission } from './permissions'; // from snippet above // First run: baseline in chunks (paged) if (await ensureContactsPermission()) { for await (const page of streamAll(300)) { // page is Contact[] - console.log('All page', page.length) + console.log('All page', page.length); } } // Next runs: delta in chunks (token stored natively) if (await ensureContactsPermission()) { for await (const { items } of streamUpdatedFromPersisted(300)) { - // items is Contact[] changed since last commit - console.log('Delta page', items.length) + // items is ContactChange[] describing created/updated/deleted contacts + console.log('Delta page', items.length); } // streamUpdatedFromPersisted commits the new token automatically } ``` +## Example scenarios + +The example app in `example/src/screens/ContactsDemoScreen.tsx` walks through the most common flows. The snippets below highlight the key cases in isolation: + +```ts +import { + commitPersisted, + getAll, + getById, + getUpdatedFromPersistedPaged, +} from '@omarsdev/react-native-contacts'; + +// 1. Request permission on Android before touching contacts. +await ensureContactsPermission(); + +// 2a. Fetch the entire address book in batches (first run / re-baseline). +const allContacts: Contact[] = await getAll({ pageSize: 500 }); + +// 2b. Or fetch an explicit page (offset + limit) for infinite-scroll UI. +const pageTwo: Contact[] = await getAll({ offset: 500, limit: 300 }); + +// 3. Pull the delta since the last committed token and persist progress. +let offset = 0; +let sessionToken = ''; +const delta: ContactChange[] = []; +for (;;) { + const { items, nextSince } = await getUpdatedFromPersistedPaged(offset, 300); + if (!sessionToken) sessionToken = nextSince; + if (!items.length) break; + delta.push(...items); + offset += items.length; + if (items.length < 300) break; +} +if (sessionToken) { + commitPersisted(sessionToken); +} + +// 4. Look up a single contact by identifier (helpful after `getAll`). +const singleContact = getById('12345'); // returns `null` if the contact was deleted +``` + Platform details - Android @@ -118,7 +275,7 @@ Platform details - Change history: uses CNContactStore change-history tokens when available. - Fingerprint fallback: when change history is unavailable or returns no events, a native snapshot (id → fingerprint of name + normalized numbers) detects adds/edits; snapshot updates on `commitPersisted`. - Synthetic tokens: when the system token doesn’t advance, we synthesize `fp:` to ensure forward progress. - - Note: deleted contacts aren’t returned as items (no data to fetch by ID). If you need `deletedIds` surfaced, open an issue. + - Delta payloads include `changeType`, a `previous` snapshot (when available), and `phoneNumberChanges` summarising added, updated, and deleted numbers. Recommended paging & usage diff --git a/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt b/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt index 1714101..206cf02 100644 --- a/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt +++ b/android/src/main/java/com/contactslastupdated/ContactsLastUpdatedModule.kt @@ -12,6 +12,10 @@ import android.content.ContentResolver import android.os.Build import android.content.SharedPreferences import android.content.Context +import java.io.File +import org.json.JSONArray +import org.json.JSONObject +import java.util.LinkedHashMap @Suppress("unused") @@ -30,11 +34,60 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : } // Data class for internal mapping + data class PhoneEntry( + val id: String?, + val number: String + ) + data class Contact( val id: String, val displayName: String, + val phoneNumbers: List, + val lastUpdatedAt: Long?, + val givenName: String? = null, + val familyName: String? = null + ) + + data class SnapshotContact( + val id: String, + val displayName: String, + val phoneNumbers: List, + val lastUpdatedAt: Long?, + val givenName: String?, + val familyName: String? + ) + + enum class ChangeType { + CREATED, + UPDATED, + DELETED + } + + data class PhoneNumberChanges( + val created: List, + val updated: List>, + val deleted: List + ) + + data class PreviousState( + val displayName: String?, + val givenName: String?, + val familyName: String?, + val phoneNumbers: List + ) + + data class ContactDelta( + val id: String, + val displayName: String?, + val givenName: String?, + val familyName: String?, val phoneNumbers: List, - val lastUpdatedAt: Long? + val lastUpdatedAt: Long?, + val changeType: ChangeType, + val phoneNumberChanges: PhoneNumberChanges, + val previous: PreviousState?, + val isDeleted: Boolean, + val sortTimestamp: Long ) override fun getAll(offset: Double, limit: Double): WritableArray { @@ -45,25 +98,21 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : return contactsToWritableArray(contacts) } + override fun getById(id: String): WritableMap? { + if (id.isEmpty()) return null + val contact = queryContactById(id) ?: return null + return contactToWritableMap(contact) + } + override fun getUpdatedSince( since: String, offset: Double, limit: Double ): WritableMap { - val off = offset.toInt().coerceAtLeast(0) - val lim = limit.toInt().coerceAtLeast(0) - if (lim <= 0) { - val map = Arguments.createMap() - map.putArray("items", Arguments.createArray()) - map.putString("nextSince", System.currentTimeMillis().toString()) - return map - } - val sinceMs = since.toLongOrNull() ?: 0L - val contacts = queryContacts(off, lim, sinceMs) + val delta = computeDelta(since.toLongOrNull() ?: 0L, offset, limit) val result = Arguments.createMap() - result.putArray("items", contactsToWritableArray(contacts)) - // Use current time as next checkpoint token - result.putString("nextSince", System.currentTimeMillis().toString()) + result.putArray("items", deltasToWritableArray(delta.items)) + result.putString("nextSince", delta.nextSince) return result } @@ -77,19 +126,157 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : } override fun getUpdatedFromPersisted(offset: Double, limit: Double): WritableMap { - val off = offset.toInt().coerceAtLeast(0) - val lim = limit.toInt().coerceAtLeast(0) - val start = prefs.getLong("since", 0L) - val contacts = if (lim > 0) queryContacts(off, lim, start) else emptyList() + val delta = computeDelta(prefs.getLong("since", 0L), offset, limit) val map = Arguments.createMap() - map.putArray("items", contactsToWritableArray(contacts)) - map.putString("nextSince", System.currentTimeMillis().toString()) + map.putArray("items", deltasToWritableArray(delta.items)) + map.putString("nextSince", delta.nextSince) return map } override fun commitPersisted(nextSince: String) { val v = nextSince.toLongOrNull() ?: return prefs.edit().putLong("since", v).apply() + rebuildSnapshot() + } + + private data class DeltaResult( + val items: List, + val nextSince: String + ) + + private fun computeDelta(sinceMs: Long, offset: Double, limit: Double): DeltaResult { + val off = offset.toInt().coerceAtLeast(0) + val lim = limit.toInt().coerceAtLeast(0) + val nextSince = System.currentTimeMillis().toString() + if (lim <= 0) { + return DeltaResult(emptyList(), nextSince) + } + + val snapshot = loadSnapshot() + val updatedContacts = queryContactsForDelta(sinceMs, off + lim) + val updatedIds = updatedContacts.map { it.id }.toSet() + val updatedDeltas = updatedContacts.map { contact -> + val previous = snapshot[contact.id] + buildDelta(contact, previous, if (previous == null) ChangeType.CREATED else ChangeType.UPDATED, contact.lastUpdatedAt ?: System.currentTimeMillis()) + } + + val deletedCandidates = queryDeletedContacts(sinceMs, off + lim) + val deletedDeltas = deletedCandidates + .mapNotNull { deleted -> + val previous = snapshot[deleted.contactId] ?: return@mapNotNull null + // Skip if contact still present in updated list (was resurrected) + if (updatedIds.contains(deleted.contactId)) return@mapNotNull null + buildDelta(null, previous, ChangeType.DELETED, deleted.deletedAt) + } + + val combined = (updatedDeltas + deletedDeltas) + .sortedByDescending { it.sortTimestamp } + + val page = if (off >= combined.size) emptyList() else combined.drop(off).take(lim) + return DeltaResult(page, nextSince) + } + + private data class DeletedContact( + val contactId: String, + val deletedAt: Long + ) + + 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) + } + + private fun queryDeletedContacts(sinceMs: Long, desiredCount: Int): List { + if (desiredCount <= 0 || sinceMs <= 0) return emptyList() + val cr: ContentResolver = reactApplicationContext.contentResolver + val uri: Uri = ContactsContract.DeletedContacts.CONTENT_URI + val projection = arrayOf( + ContactsContract.DeletedContacts.CONTACT_ID, + ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP + ) + val selection = ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?" + val selectionArgs = arrayOf(sinceMs.toString()) + val sortOrder = ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP + " DESC" + val results = ArrayList() + val cursor = cr.query(uri, projection, selection, selectionArgs, sortOrder) + cursor?.use { c -> + var count = 0 + while (c.moveToNext() && count < desiredCount) { + val contactId = c.getLong(c.getColumnIndexOrThrow(ContactsContract.DeletedContacts.CONTACT_ID)).toString() + val deletedAt = c.getLong(c.getColumnIndexOrThrow(ContactsContract.DeletedContacts.CONTACT_DELETED_TIMESTAMP)) + results.add(DeletedContact(contactId, deletedAt)) + count++ + } + } + val unique = LinkedHashMap() + for (entry in results) { + if (!unique.containsKey(entry.contactId)) { + unique[entry.contactId] = entry + } + } + return unique.values.toList() + } + + private fun buildDelta( + current: Contact?, + previous: SnapshotContact?, + change: ChangeType, + sortTimestamp: Long + ): ContactDelta { + val currentMap = current?.phoneNumbers?.associateBy { it.id } + val previousMap = previous?.phoneNumbers?.associateBy { it.id } + + val createdNumbers = ArrayList() + val updatedNumbers = ArrayList>() + val deletedNumbers = ArrayList() + + if (previousMap != null) { + for ((_, entry) in previousMap) { + val matching = if (entry.id != null) currentMap?.get(entry.id) else null + if (matching == null) { + deletedNumbers.add(entry.number) + } else if (matching.number != entry.number) { + updatedNumbers.add(Pair(entry.number, matching.number)) + } + } + } + + if (currentMap != null) { + for ((id, entry) in currentMap) { + val existed = if (id != null) previousMap?.containsKey(id) == true else previous?.phoneNumbers?.any { it.number == entry.number } == true + if (!existed) { + createdNumbers.add(entry.number) + } + } + } + + val phoneNumbers = current?.phoneNumbers?.map { it.number } ?: emptyList() + val previousPhones = previous?.phoneNumbers?.map { it.number } ?: emptyList() + + val previousState = if (previous != null) { + PreviousState(previous.displayName, previous.givenName, previous.familyName, previousPhones) + } else null + + val changeType = when { + change == ChangeType.DELETED -> ChangeType.DELETED + previous == null -> ChangeType.CREATED + else -> ChangeType.UPDATED + } + + return ContactDelta( + id = current?.id ?: previous?.id.orEmpty(), + displayName = current?.displayName ?: previous?.displayName, + givenName = current?.givenName ?: previous?.givenName, + familyName = current?.familyName ?: previous?.familyName, + phoneNumbers = phoneNumbers, + lastUpdatedAt = current?.lastUpdatedAt ?: previous?.lastUpdatedAt, + changeType = changeType, + phoneNumberChanges = PhoneNumberChanges(createdNumbers, updatedNumbers, deletedNumbers), + previous = previousState, + isDeleted = changeType == ChangeType.DELETED, + sortTimestamp = sortTimestamp + ) } private fun queryContacts(offset: Int, limit: Int, sinceMs: Long?): List { @@ -136,40 +323,228 @@ class ContactsLastUpdatedModule(reactContext: ReactApplicationContext) : return items } - private fun getPhoneNumbersForContact(contactId: String): List { + private fun queryContactById(contactId: String): Contact? { + val cr: ContentResolver = reactApplicationContext.contentResolver + val uri: Uri = ContactsContract.Contacts.CONTENT_URI + val projection = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, + ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP, + ContactsContract.Contacts.HAS_PHONE_NUMBER + ) + val selection = ContactsContract.Contacts._ID + " = ?" + val selectionArgs = arrayOf(contactId) + val cursor = cr.query(uri, projection, selection, selectionArgs, null) + cursor?.use { c -> + if (c.moveToFirst()) { + val id = c.getLong(c.getColumnIndexOrThrow(ContactsContract.Contacts._ID)).toString() + val name = c.getString(c.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)) ?: "" + val hasPhone = c.getInt(c.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) + val updatedIdx = c.getColumnIndex(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP) + val updatedAt = if (updatedIdx >= 0 && !c.isNull(updatedIdx)) c.getLong(updatedIdx) else null + val phones = if (hasPhone > 0) getPhoneNumbersForContact(id) else emptyList() + return Contact(id, name, phones, updatedAt) + } + } + return null + } + + private fun getPhoneNumbersForContact(contactId: String): List { val cr: ContentResolver = reactApplicationContext.contentResolver val phonesUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI - val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone._ID, + ContactsContract.CommonDataKinds.Phone.NUMBER + ) val selection = ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?" val selectionArgs = arrayOf(contactId) - val numbers = ArrayList() + val numbers = ArrayList() val cursor = cr.query(phonesUri, projection, selection, selectionArgs, null) cursor?.use { c -> while (c.moveToNext()) { val number = c.getString(c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)) - if (!number.isNullOrEmpty()) numbers.add(number) + if (!number.isNullOrEmpty()) { + val idIdx = c.getColumnIndex(ContactsContract.CommonDataKinds.Phone._ID) + val dataId = if (idIdx >= 0 && !c.isNull(idIdx)) c.getLong(idIdx).toString() else null + numbers.add(PhoneEntry(dataId, number)) + } } } return numbers } + private fun contactToWritableMap(contact: Contact): WritableMap { + val map = Arguments.createMap() + map.putString("id", contact.id) + map.putString("displayName", contact.displayName) + val phones = Arguments.createArray() + for (p in contact.phoneNumbers) phones.pushString(p.number) + map.putArray("phoneNumbers", phones) + if (contact.givenName != null) map.putString("givenName", contact.givenName) else map.putNull("givenName") + if (contact.familyName != null) map.putString("familyName", contact.familyName) else map.putNull("familyName") + if (contact.lastUpdatedAt != null) map.putDouble("lastUpdatedAt", contact.lastUpdatedAt.toDouble()) else map.putNull("lastUpdatedAt") + return map + } + private fun contactsToWritableArray(contacts: List): WritableArray { val array = Arguments.createArray() for (c in contacts) { + array.pushMap(contactToWritableMap(c)) + } + return array + } + + private fun deltasToWritableArray(deltas: List): WritableArray { + val array = Arguments.createArray() + for (delta in deltas) { val map = Arguments.createMap() - map.putString("id", c.id) - map.putString("displayName", c.displayName) + map.putString("id", delta.id) + map.putString("displayName", delta.displayName ?: "") val phones = Arguments.createArray() - for (p in c.phoneNumbers) phones.pushString(p) + for (p in delta.phoneNumbers) phones.pushString(p) map.putArray("phoneNumbers", phones) map.putNull("givenName") map.putNull("familyName") - if (c.lastUpdatedAt != null) map.putDouble("lastUpdatedAt", c.lastUpdatedAt.toDouble()) else map.putNull("lastUpdatedAt") + if (delta.lastUpdatedAt != null) map.putDouble("lastUpdatedAt", delta.lastUpdatedAt.toDouble()) else map.putNull("lastUpdatedAt") + map.putString("changeType", delta.changeType.name.lowercase()) + map.putBoolean("isDeleted", delta.isDeleted) + + val changes = Arguments.createMap() + val createdArray = Arguments.createArray() + delta.phoneNumberChanges.created.forEach { createdArray.pushString(it) } + val deletedArray = Arguments.createArray() + delta.phoneNumberChanges.deleted.forEach { deletedArray.pushString(it) } + val updatedArray = Arguments.createArray() + delta.phoneNumberChanges.updated.forEach { pair -> + val updatedMap = Arguments.createMap() + updatedMap.putString("previous", pair.first) + updatedMap.putString("current", pair.second) + updatedArray.pushMap(updatedMap) + } + changes.putArray("created", createdArray) + changes.putArray("deleted", deletedArray) + changes.putArray("updated", updatedArray) + map.putMap("phoneNumberChanges", changes) + + if (delta.previous != null) { + val prev = Arguments.createMap() + if (delta.previous.displayName != null) prev.putString("displayName", delta.previous.displayName) else prev.putNull("displayName") + if (delta.previous.givenName != null) prev.putString("givenName", delta.previous.givenName) else prev.putNull("givenName") + if (delta.previous.familyName != null) prev.putString("familyName", delta.previous.familyName) else prev.putNull("familyName") + val prevPhones = Arguments.createArray() + delta.previous.phoneNumbers.forEach { prevPhones.pushString(it) } + prev.putArray("phoneNumbers", prevPhones) + map.putMap("previous", prev) + } else { + map.putNull("previous") + } + array.pushMap(map) } return array } + private fun snapshotDir(): File { + val dir = File(reactApplicationContext.filesDir, "ContactsLastUpdated") + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + private fun snapshotFile(): File = File(snapshotDir(), "snapshot.json") + + private fun loadSnapshot(): MutableMap { + val file = snapshotFile() + if (!file.exists()) { + rebuildSnapshot() + if (!file.exists()) return mutableMapOf() + } + return try { + val text = file.readText() + if (text.isEmpty()) mutableMapOf() else { + val root = JSONObject(text) + val contactsJson = root.optJSONObject("contacts") ?: return mutableMapOf() + val result = mutableMapOf() + val keys = contactsJson.keys() + while (keys.hasNext()) { + val id = keys.next() + val obj = contactsJson.optJSONObject(id) ?: continue + val displayName = obj.optString("displayName", "") + val givenName = if (obj.has("givenName") && !obj.isNull("givenName")) obj.optString("givenName") else null + val familyName = if (obj.has("familyName") && !obj.isNull("familyName")) obj.optString("familyName") else null + val lastUpdatedAt = if (obj.has("lastUpdatedAt") && !obj.isNull("lastUpdatedAt")) obj.optLong("lastUpdatedAt") else null + val phoneNumbersJson = obj.optJSONArray("phoneNumbers") + val phones = mutableListOf() + if (phoneNumbersJson != null) { + for (i in 0 until phoneNumbersJson.length()) { + val pn = phoneNumbersJson.optJSONObject(i) ?: continue + val pid = if (pn.has("id") && !pn.isNull("id")) pn.optString("id") else null + val number = pn.optString("value", "") + phones.add(PhoneEntry(pid, number)) + } + } + result[id] = SnapshotContact(id, displayName, phones, lastUpdatedAt, givenName, familyName) + } + result + } + } catch (_: Exception) { + mutableMapOf() + } + } + + private fun saveSnapshot(snapshot: Map) { + try { + val contactsObj = JSONObject() + for ((id, contact) in snapshot) { + val contactObj = JSONObject() + contactObj.put("displayName", contact.displayName) + contactObj.put("givenName", contact.givenName ?: JSONObject.NULL) + contactObj.put("familyName", contact.familyName ?: JSONObject.NULL) + contactObj.put("lastUpdatedAt", contact.lastUpdatedAt ?: JSONObject.NULL) + val phoneArray = JSONArray() + contact.phoneNumbers.forEach { entry -> + val pn = JSONObject() + pn.put("id", entry.id ?: JSONObject.NULL) + pn.put("value", entry.number) + phoneArray.put(pn) + } + contactObj.put("phoneNumbers", phoneArray) + contactsObj.put(id, contactObj) + } + val root = JSONObject() + root.put("contacts", contactsObj) + snapshotFile().writeText(root.toString()) + } catch (_: Exception) { + // best effort persistence + } + } + + private fun rebuildSnapshot() { + val cr: ContentResolver = reactApplicationContext.contentResolver + val uri: Uri = ContactsContract.Contacts.CONTENT_URI + val projection = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.DISPLAY_NAME_PRIMARY, + ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP, + ContactsContract.Contacts.HAS_PHONE_NUMBER + ) + val snapshot = mutableMapOf() + val cursor = cr.query(uri, projection, null, null, null) + cursor?.use { c -> + while (c.moveToNext()) { + val id = c.getLong(c.getColumnIndexOrThrow(ContactsContract.Contacts._ID)).toString() + val name = c.getString(c.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)) ?: "" + val hasPhone = c.getInt(c.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) + val updatedIdx = c.getColumnIndex(ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMP) + val updatedAt = if (updatedIdx >= 0 && !c.isNull(updatedIdx)) c.getLong(updatedIdx) else null + val phones = if (hasPhone > 0) getPhoneNumbersForContact(id) else emptyList() + snapshot[id] = SnapshotContact(id, name, phones, updatedAt, null, null) + } + } + saveSnapshot(snapshot) + } + companion object { const val NAME = "ContactsLastUpdated" } diff --git a/example/ios/ContactsLastUpdatedExample.xcodeproj/project.pbxproj b/example/ios/ContactsLastUpdatedExample.xcodeproj/project.pbxproj index 4293d3f..07ab40a 100644 --- a/example/ios/ContactsLastUpdatedExample.xcodeproj/project.pbxproj +++ b/example/ios/ContactsLastUpdatedExample.xcodeproj/project.pbxproj @@ -191,14 +191,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ContactsLastUpdatedExample/Pods-ContactsLastUpdatedExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ContactsLastUpdatedExample/Pods-ContactsLastUpdatedExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ContactsLastUpdatedExample/Pods-ContactsLastUpdatedExample-frameworks.sh\"\n"; @@ -234,14 +230,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ContactsLastUpdatedExample/Pods-ContactsLastUpdatedExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ContactsLastUpdatedExample/Pods-ContactsLastUpdatedExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ContactsLastUpdatedExample/Pods-ContactsLastUpdatedExample-resources.sh\"\n"; diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index da8f924..f173426 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - ContactsLastUpdated (1.0.0): + - ContactsLastUpdated (1.0.1): - boost - DoubleConversion - fast_float @@ -2603,7 +2603,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - ContactsLastUpdated: 83516242985c110e649eedca83e647cdff750ad9 + ContactsLastUpdated: e089b3529e71e6952413dd3c6e156a37337d768b 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 7fcf2bf..126d555 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,315 +1,5 @@ -import React from 'react'; -import { - Text, - View, - StyleSheet, - Button, - FlatList, - PermissionsAndroid, - Platform, - useColorScheme, - StatusBar, - SafeAreaView, - ActivityIndicator, -} from 'react-native'; -import { - getAllPaged, - getPersistedSince, - getUpdatedFromPersistedPaged, - commitPersisted, - type Contact, -} from '@omarsdev/react-native-contacts'; - -type Theme = 'light' | 'dark'; - -const createStyles = (theme: Theme) => { - const colors = - theme === 'dark' - ? { - bg: '#121212', - text: '#FFFFFF', - sub: '#BBBBBB', - border: '#333333', - logBg: '#1E1E1E', - logText: '#8f8', - } - : { - bg: '#FFFFFF', - text: '#111111', - sub: '#555555', - border: '#E0E0E0', - logBg: '#F5F5F5', - logText: '#0a601a', - }; - - return StyleSheet.create({ - container: { - flex: 1, - paddingTop: 48, - paddingHorizontal: 16, - backgroundColor: colors.bg, - }, - controls: { - flexDirection: 'column', - }, - info: { - marginVertical: 12, - }, - spacer: { height: 8 }, - text: { - color: colors.text, - }, - row: { - paddingVertical: 8, - borderBottomWidth: StyleSheet.hairlineWidth, - borderColor: colors.border, - }, - name: { - fontSize: 16, - fontWeight: '500', - color: colors.text, - }, - id: { - fontSize: 16, - fontWeight: '600', - color: colors.text, - }, - sub: { - color: colors.sub, - fontSize: 12, - }, - logBox: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - backgroundColor: colors.logBg, - maxHeight: 160, - padding: 8, - }, - logText: { - color: colors.logText, - fontSize: 10, - }, - listContent: { - paddingBottom: 40, - }, - loadingBox: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - }, - loadingText: { marginLeft: 8 }, - }); -}; +import ContactsDemoScreen from './screens/ContactsDemoScreen'; export default function App() { - const systemScheme = useColorScheme(); - const theme: Theme = systemScheme === 'dark' ? 'dark' : 'light'; - const styles = React.useMemo(() => createStyles(theme), [theme]); - const [granted, setGranted] = React.useState(Platform.OS === 'ios'); - const [loading, setLoading] = React.useState(false); - const [contacts, setContacts] = React.useState([]); - const [delta, setDelta] = React.useState([]); - const [since, setSince] = React.useState(''); - const [log, setLog] = React.useState(''); - const [status, setStatus] = React.useState('Idle'); - - React.useEffect(() => { - const ensurePermission = async () => { - if (Platform.OS === 'android') { - const res = await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.READ_CONTACTS - ); - setGranted(res === PermissionsAndroid.RESULTS.GRANTED); - } - try { - const s = await getPersistedSince(); - setSince(s || ''); - } catch {} - }; - ensurePermission(); - }, []); - - const appendLog = (msg: string) => - setLog((prev) => `${new Date().toISOString()} ${msg}\n${prev}`); - - const onFetchAll = async () => { - if (!granted) { - appendLog('Permission not granted'); - return; - } - setLoading(true); - setStatus('Fetching all (paged)…'); - setDelta([]); - try { - const pageSize = 300; - let offset = 0; - const acc: Contact[] = []; - const t0 = Date.now(); - for (;;) { - const page = await getAllPaged(offset, pageSize); - appendLog(`Fetched page: offset=${offset} size=${page.length}`); - if (!page || page.length === 0) break; - acc.push(...page); - offset += page.length; - if (page.length < pageSize) break; - } - setContacts(acc); - appendLog( - `Completed full fetch. Total=${acc.length} in ${Date.now() - t0}ms` - ); - setStatus(`All contacts: ${acc.length}`); - } catch (e: any) { - appendLog(`Error: ${e?.message || String(e)}`); - setStatus('Error while fetching all'); - } finally { - setLoading(false); - } - }; - - const onFetchDelta = async () => { - if (!granted) { - appendLog('Permission not granted'); - return; - } - setLoading(true); - setStatus('Fetching delta…'); - setContacts([]); - try { - const pageSize = 300; - let offset = 0; - const acc: Contact[] = []; - let sessionToken = ''; - const t0 = Date.now(); - for (;;) { - const resp = await getUpdatedFromPersistedPaged(offset, pageSize); - const items = resp.items ?? []; - if (!sessionToken) sessionToken = resp.nextSince || ''; - appendLog( - `Fetched delta page: offset=${offset} size=${items.length} session=${sessionToken}` - ); - if (items.length === 0) break; - acc.push(...items); - offset += items.length; - if (items.length < pageSize) break; - } - setDelta(acc); - if (sessionToken) { - await commitPersisted(sessionToken); - setSince(sessionToken); - } - appendLog( - `Completed delta fetch. Items=${acc.length} committedSince=${sessionToken} in ${Date.now() - t0}ms` - ); - setStatus( - `Delta: ${acc.length} (since: ${sessionToken || since || 'n/a'})` - ); - } catch (e: any) { - appendLog(`Error: ${e?.message || String(e)}`); - setStatus('Error while fetching delta'); - } finally { - setLoading(false); - } - }; - - const onReset = () => { - setContacts([]); - setDelta([]); - setSince(''); - appendLog('State reset; since cleared'); - setStatus('Idle'); - }; - - const onBaselineNow = async () => { - try { - const current = since || (await getPersistedSince()) || ''; - await commitPersisted(current); - const after = await getPersistedSince(); - setSince(after); - appendLog(`Baseline snapshot rebuilt. since=${after}`); - setStatus('Baseline rebuilt'); - } catch (e: any) { - appendLog(`Baseline error: ${e?.message || String(e)}`); - } - }; - - const renderItem = ({ item }: { item: Contact }) => ( - - {item.id} - {item.displayName || '(no name)'} - {item.lastUpdatedAt ? ( - - {new Date(item.lastUpdatedAt).toLocaleString()} - - ) : null} - - {item.phoneNumbers?.join(', ')} - - - ); - - const data = contacts.length > 0 ? contacts : delta; - const header = contacts.length > 0 ? 'All Contacts' : 'Delta Contacts'; - - return ( - - - -