@ateam-ai-mobile/expo 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/android/build.gradle +16 -0
  2. package/android/src/main/java/expo/modules/adavision/AdaVisionModule.kt +75 -0
  3. package/dist/attachmentCache-DhQTqi2E.d.mts +18 -0
  4. package/dist/attachmentCache-DhQTqi2E.d.ts +18 -0
  5. package/dist/bootstrap.d.mts +95 -0
  6. package/dist/bootstrap.d.ts +95 -0
  7. package/dist/bootstrap.js +2473 -0
  8. package/dist/bootstrap.js.map +1 -0
  9. package/dist/bootstrap.mjs +64 -0
  10. package/dist/bootstrap.mjs.map +1 -0
  11. package/dist/chunk-4BMWVN25.mjs +91 -0
  12. package/dist/chunk-4BMWVN25.mjs.map +1 -0
  13. package/dist/chunk-4XYXJYN5.mjs +1513 -0
  14. package/dist/chunk-4XYXJYN5.mjs.map +1 -0
  15. package/dist/chunk-5HM6UA2K.mjs +571 -0
  16. package/dist/chunk-5HM6UA2K.mjs.map +1 -0
  17. package/dist/chunk-6LXF3JVX.mjs +96 -0
  18. package/dist/chunk-6LXF3JVX.mjs.map +1 -0
  19. package/dist/chunk-FF6ZZZXH.mjs +71 -0
  20. package/dist/chunk-FF6ZZZXH.mjs.map +1 -0
  21. package/dist/chunk-KOXF2FZM.mjs +92 -0
  22. package/dist/chunk-KOXF2FZM.mjs.map +1 -0
  23. package/dist/chunk-L4V7OCJ3.mjs +71 -0
  24. package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
  25. package/dist/chunk-TZXWVYYR.mjs +67 -0
  26. package/dist/chunk-TZXWVYYR.mjs.map +1 -0
  27. package/dist/chunk-VQJPSLHP.mjs +1801 -0
  28. package/dist/chunk-VQJPSLHP.mjs.map +1 -0
  29. package/dist/chunk-WMJKH4XE.mjs +34 -0
  30. package/dist/chunk-WMJKH4XE.mjs.map +1 -0
  31. package/dist/chunk-XQSY6MHP.mjs +213 -0
  32. package/dist/chunk-XQSY6MHP.mjs.map +1 -0
  33. package/dist/createBridge.mjs +2 -0
  34. package/dist/createBridge.mjs.map +1 -1
  35. package/dist/device/createDeviceProvider.d.mts +23 -0
  36. package/dist/device/createDeviceProvider.d.ts +23 -0
  37. package/dist/device/createDeviceProvider.js +967 -0
  38. package/dist/device/createDeviceProvider.js.map +1 -0
  39. package/dist/device/createDeviceProvider.mjs +17 -0
  40. package/dist/device/createDeviceProvider.mjs.map +1 -0
  41. package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
  42. package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
  43. package/dist/device-bridge/collectors/appEngagement.js +128 -0
  44. package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
  45. package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
  46. package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
  47. package/dist/device-bridge/collectors/health.d.mts +24 -0
  48. package/dist/device-bridge/collectors/health.d.ts +24 -0
  49. package/dist/device-bridge/collectors/health.js +238 -0
  50. package/dist/device-bridge/collectors/health.js.map +1 -0
  51. package/dist/device-bridge/collectors/health.mjs +18 -0
  52. package/dist/device-bridge/collectors/health.mjs.map +1 -0
  53. package/dist/device-bridge/cookies.d.mts +49 -0
  54. package/dist/device-bridge/cookies.d.ts +49 -0
  55. package/dist/device-bridge/cookies.js +90 -0
  56. package/dist/device-bridge/cookies.js.map +1 -0
  57. package/dist/device-bridge/cookies.mjs +14 -0
  58. package/dist/device-bridge/cookies.mjs.map +1 -0
  59. package/dist/device-bridge/index.d.mts +483 -0
  60. package/dist/device-bridge/index.d.ts +483 -0
  61. package/dist/device-bridge/index.js +2084 -0
  62. package/dist/device-bridge/index.js.map +1 -0
  63. package/dist/device-bridge/index.mjs +32 -0
  64. package/dist/device-bridge/index.mjs.map +1 -0
  65. package/dist/device-bridge/nfc.d.mts +30 -0
  66. package/dist/device-bridge/nfc.d.ts +30 -0
  67. package/dist/device-bridge/nfc.js +109 -0
  68. package/dist/device-bridge/nfc.js.map +1 -0
  69. package/dist/device-bridge/nfc.mjs +14 -0
  70. package/dist/device-bridge/nfc.mjs.map +1 -0
  71. package/dist/index.d.mts +493 -18
  72. package/dist/index.d.ts +493 -18
  73. package/dist/index.js +1409 -6
  74. package/dist/index.js.map +1 -1
  75. package/dist/index.mjs +23 -194
  76. package/dist/index.mjs.map +1 -1
  77. package/dist/oauth/expoOAuth.d.mts +18 -0
  78. package/dist/oauth/expoOAuth.d.ts +18 -0
  79. package/dist/oauth/expoOAuth.js +102 -0
  80. package/dist/oauth/expoOAuth.js.map +1 -0
  81. package/dist/oauth/expoOAuth.mjs +8 -0
  82. package/dist/oauth/expoOAuth.mjs.map +1 -0
  83. package/dist/types-BRanJYTn.d.mts +138 -0
  84. package/dist/types-BRanJYTn.d.ts +138 -0
  85. package/dist/vision/index.d.mts +81 -0
  86. package/dist/vision/index.d.ts +81 -0
  87. package/dist/vision/index.js +101 -0
  88. package/dist/vision/index.js.map +1 -0
  89. package/dist/vision/index.mjs +28 -0
  90. package/dist/vision/index.mjs.map +1 -0
  91. package/expo-module.config.json +9 -0
  92. package/ios/AdaVision.podspec +28 -0
  93. package/ios/AdaVisionModule.swift +432 -0
  94. package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
  95. package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
  96. package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
  97. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
  98. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
  99. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
  100. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
  101. package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
  102. package/package.json +107 -7
  103. package/src/bootstrap.ts +159 -0
  104. package/src/camera/CameraTaskHost.tsx +175 -0
  105. package/src/camera/cameraTasks.ts +129 -0
  106. package/src/device/createDeviceProvider.ts +832 -0
  107. package/src/device-bridge/actions/calendar-actions.ts +217 -0
  108. package/src/device-bridge/actions/dnd.ts +74 -0
  109. package/src/device-bridge/actions/health-actions.ts +58 -0
  110. package/src/device-bridge/actions/index.ts +12 -0
  111. package/src/device-bridge/actions/messaging.ts +74 -0
  112. package/src/device-bridge/actions/navigation.ts +88 -0
  113. package/src/device-bridge/actions/queue-poller.ts +151 -0
  114. package/src/device-bridge/background.ts +137 -0
  115. package/src/device-bridge/biometrics.ts +64 -0
  116. package/src/device-bridge/capabilities.ts +218 -0
  117. package/src/device-bridge/collectors/appEngagement.ts +120 -0
  118. package/src/device-bridge/collectors/battery.ts +83 -0
  119. package/src/device-bridge/collectors/calendar.ts +162 -0
  120. package/src/device-bridge/collectors/camera.ts +27 -0
  121. package/src/device-bridge/collectors/connectivity.ts +60 -0
  122. package/src/device-bridge/collectors/contacts.ts +200 -0
  123. package/src/device-bridge/collectors/health.ts +299 -0
  124. package/src/device-bridge/collectors/index.ts +12 -0
  125. package/src/device-bridge/collectors/location.ts +224 -0
  126. package/src/device-bridge/collectors/mood.ts +141 -0
  127. package/src/device-bridge/collectors/notifications.ts +70 -0
  128. package/src/device-bridge/cookies.ts +89 -0
  129. package/src/device-bridge/dynamic-connector.ts +422 -0
  130. package/src/device-bridge/index.ts +467 -0
  131. package/src/device-bridge/nfc.ts +85 -0
  132. package/src/device-bridge/plugin.ts +71 -0
  133. package/src/device-bridge/relay-client.ts +237 -0
  134. package/src/device-bridge/scanner.ts +42 -0
  135. package/src/device-bridge/sharing.ts +35 -0
  136. package/src/device-bridge/types.ts +326 -0
  137. package/src/device-state/activityClassifier.ts +151 -0
  138. package/src/device-state/activityEngine.ts +252 -0
  139. package/src/device-state/collectors.ts +31 -0
  140. package/src/device-state/domains/activity.ts +93 -0
  141. package/src/device-state/domains/capability.ts +60 -0
  142. package/src/device-state/domains/connectivity.ts +60 -0
  143. package/src/device-state/domains/deviceContext.ts +55 -0
  144. package/src/device-state/domains/interaction.ts +59 -0
  145. package/src/device-state/domains/spatial.ts +283 -0
  146. package/src/device-state/domains/visual.ts +100 -0
  147. package/src/device-state/headingSource.ts +84 -0
  148. package/src/device-state/index.ts +77 -0
  149. package/src/device-state/quality.ts +60 -0
  150. package/src/device-state/replay.ts +220 -0
  151. package/src/device-state/turnDetector.ts +121 -0
  152. package/src/device-state/visualEngine.ts +341 -0
  153. package/src/haptics.ts +4 -0
  154. package/src/index.ts +37 -0
  155. package/src/oauth/expoOAuth.tsx +113 -0
  156. package/src/vision/analysis.js +80 -0
  157. package/src/vision/index.ts +144 -0
  158. package/src/vision/ring.js +35 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Calendar Collector — reads device calendar via expo-calendar.
3
+ * Output format matches device-mock-mcp's calendar.today / calendar.upcoming schemas.
4
+ */
5
+
6
+ import * as ExpoCalendar from 'expo-calendar';
7
+ // Type-only alias: the value namespace above cannot be used in type
8
+ // position, so `ExpoCalendar.Calendar`-style annotations need this.
9
+ // expo-calendar's types are not visible through the package entry under
10
+ // bundler resolution, so derive them from the APIs that return them —
11
+ // the shape then always matches the installed version.
12
+ type Calendar = Awaited<ReturnType<typeof ExpoCalendar.getCalendarsAsync>>[number];
13
+ type Event = Awaited<ReturnType<typeof ExpoCalendar.getEventsAsync>>[number];
14
+ import type { CalendarEvent, CalendarTodayData, CalendarUpcomingData } from '../types';
15
+
16
+ /**
17
+ * Request calendar permissions.
18
+ * @returns true if granted
19
+ */
20
+ export async function requestPermission(): Promise<boolean> {
21
+ const { status } = await ExpoCalendar.requestCalendarPermissionsAsync();
22
+ return status === 'granted';
23
+ }
24
+
25
+ /**
26
+ * Check if calendar permission is granted.
27
+ */
28
+ export async function hasPermission(): Promise<boolean> {
29
+ const { status } = await ExpoCalendar.getCalendarPermissionsAsync();
30
+ return status === 'granted';
31
+ }
32
+
33
+ /**
34
+ * Get all calendar events for today.
35
+ * Matches the device.calendar.today tool output schema.
36
+ */
37
+ export async function collectToday(): Promise<CalendarTodayData> {
38
+ const now = new Date();
39
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
40
+ const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
41
+
42
+ const calendars = await ExpoCalendar.getCalendarsAsync(ExpoCalendar.EntityTypes.EVENT);
43
+ const userCalendarIds = getUserCalendarIds(calendars);
44
+
45
+ const events = await ExpoCalendar.getEventsAsync(userCalendarIds, startOfDay, endOfDay);
46
+ const filtered = filterHolidays(events);
47
+
48
+ const mapped: CalendarEvent[] = filtered.map(e => ({
49
+ id: e.id,
50
+ title: e.title || 'Untitled',
51
+ date: startOfDay.toISOString().slice(0, 10),
52
+ start: formatTime(new Date(e.startDate)),
53
+ end: formatTime(new Date(e.endDate)),
54
+ location: e.location || '',
55
+ attendees: ((e as any).attendees || []).map((a: { name?: string; email?: string }) => a.name || a.email || 'Unknown'),
56
+ notes: e.notes || '',
57
+ }));
58
+
59
+ // Sort by start time
60
+ mapped.sort((a, b) => a.start.localeCompare(b.start));
61
+
62
+ return {
63
+ ok: true,
64
+ date: startOfDay.toISOString().slice(0, 10),
65
+ day_of_week: now.toLocaleDateString('en-US', { weekday: 'long' }),
66
+ event_count: mapped.length,
67
+ events: mapped,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Get upcoming calendar events for the next N days (tomorrow and beyond).
73
+ * Matches the device.calendar.upcoming tool output schema.
74
+ */
75
+ export async function collectUpcoming(daysAhead: number = 3): Promise<CalendarUpcomingData> {
76
+ const now = new Date();
77
+ const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
78
+ const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + daysAhead + 1);
79
+
80
+ const calendars = await ExpoCalendar.getCalendarsAsync(ExpoCalendar.EntityTypes.EVENT);
81
+ const userCalendarIds = getUserCalendarIds(calendars);
82
+
83
+ const events = await ExpoCalendar.getEventsAsync(userCalendarIds, tomorrow, endDate);
84
+ const filtered = filterHolidays(events);
85
+
86
+ const mapped: CalendarEvent[] = filtered.map(e => {
87
+ const startDt = new Date(e.startDate);
88
+ return {
89
+ id: e.id,
90
+ title: e.title || 'Untitled',
91
+ date: startDt.toISOString().slice(0, 10),
92
+ start: formatTime(startDt),
93
+ end: formatTime(new Date(e.endDate)),
94
+ location: e.location || '',
95
+ attendees: ((e as any).attendees || []).map((a: { name?: string; email?: string }) => a.name || a.email || 'Unknown'),
96
+ notes: e.notes || '',
97
+ };
98
+ });
99
+
100
+ // Sort by date then start time
101
+ mapped.sort((a, b) =>
102
+ a.date === b.date ? a.start.localeCompare(b.start) : a.date.localeCompare(b.date)
103
+ );
104
+
105
+ return {
106
+ ok: true,
107
+ from: tomorrow.toISOString().slice(0, 10),
108
+ days_ahead: daysAhead,
109
+ event_count: mapped.length,
110
+ events: mapped,
111
+ };
112
+ }
113
+
114
+ // ─── Helpers ───
115
+
116
+ /**
117
+ * Filter out holiday/subscription calendars — only keep user-owned calendars
118
+ * (local, CalDAV, Exchange, Google). Subscription calendars are read-only
119
+ * holiday feeds that clutter the real schedule.
120
+ */
121
+ function getUserCalendarIds(calendars: Calendar[]): string[] {
122
+ const SKIP_SOURCES = ['birthdays', 'other']; // 'other' = subscribed holiday feeds on iOS
123
+ const SKIP_TYPES = ['birthdays', 'other'];
124
+
125
+ return calendars
126
+ .filter(c => {
127
+ const sourceType = ((c.source as any)?.type || '').toLowerCase();
128
+ const calType = (c.type || '').toLowerCase();
129
+ // Skip subscription/holiday calendars
130
+ if (SKIP_SOURCES.includes(sourceType)) return false;
131
+ if (SKIP_TYPES.includes(calType)) return false;
132
+ return true;
133
+ })
134
+ .map(c => c.id);
135
+ }
136
+
137
+ /**
138
+ * Extra safety: filter out all-day events with holiday-like patterns
139
+ * in case they slip through from non-subscription calendars.
140
+ */
141
+ function filterHolidays(events: Event[]): Event[] {
142
+ const HOLIDAY_PATTERNS = [
143
+ /holiday/i, /observance/i, /eve\b.*\btentative\b/i, /\btentative\b/i,
144
+ ];
145
+
146
+ return events.filter(e => {
147
+ // All-day events spanning exactly one day with "tentative" or "observance" in notes → holiday
148
+ const isAllDay = (e as any).allDay === true;
149
+ const notes = (e.notes || '').toLowerCase();
150
+ if (isAllDay && HOLIDAY_PATTERNS.some(p => p.test(notes))) return false;
151
+ if (isAllDay && HOLIDAY_PATTERNS.some(p => p.test(e.title || ''))) return false;
152
+ return true;
153
+ });
154
+ }
155
+
156
+ function formatTime(date: Date): string {
157
+ return date.toLocaleTimeString('en-US', {
158
+ hour: '2-digit',
159
+ minute: '2-digit',
160
+ hour12: false,
161
+ });
162
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Camera capability — permission only, NO collector.
3
+ *
4
+ * Unlike calendar/contacts/health, the camera is a LIVE capability: frames are
5
+ * used in the moment (deviceState.visual, a widget's camera preview) and are
6
+ * analysed on-device — never collected to the relay. So this module carries only
7
+ * the OS-permission check/request that the capability toggle drives; there is no
8
+ * `collect`. The user's grant (this toggle) is what gates access, on the same
9
+ * path as location/contacts.
10
+ */
11
+ import { Camera } from 'expo-camera';
12
+
13
+ export async function hasPermission(): Promise<boolean> {
14
+ try {
15
+ return (await Camera.getCameraPermissionsAsync()).granted;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export async function requestPermission(): Promise<boolean> {
22
+ try {
23
+ return (await Camera.requestCameraPermissionsAsync()).granted;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Connectivity Collector — reads network state via expo-network.
3
+ * Output format matches device-mock-mcp's device.connectivity schema.
4
+ */
5
+
6
+ import * as ExpoNetwork from 'expo-network';
7
+ import type { ConnectivityData } from '../types';
8
+
9
+ // No special permission needed for network state
10
+
11
+ /**
12
+ * Collect current connectivity status.
13
+ * Matches the device.connectivity tool output schema.
14
+ */
15
+ export async function collectConnectivity(): Promise<ConnectivityData> {
16
+ const networkState = await ExpoNetwork.getNetworkStateAsync();
17
+
18
+ let type: 'wifi' | 'cellular' | 'none' = 'none';
19
+ let wifiName: string | undefined;
20
+ let cellularType: string | undefined;
21
+
22
+ if (networkState.type === ExpoNetwork.NetworkStateType.WIFI) {
23
+ type = 'wifi';
24
+ // Wi-Fi SSID requires additional permissions on newer OS versions
25
+ // and may not always be available
26
+ try {
27
+ wifiName = await getWifiName();
28
+ } catch {
29
+ wifiName = undefined;
30
+ }
31
+ } else if (networkState.type === ExpoNetwork.NetworkStateType.CELLULAR) {
32
+ type = 'cellular';
33
+ cellularType = getCellularGeneration();
34
+ }
35
+
36
+ return {
37
+ ok: true,
38
+ timestamp: new Date().toISOString(),
39
+ connectivity: {
40
+ type,
41
+ is_connected: networkState.isConnected ?? false,
42
+ ...(wifiName && { wifi_name: wifiName }),
43
+ ...(cellularType && { cellular_type: cellularType }),
44
+ },
45
+ };
46
+ }
47
+
48
+ // ─── Helpers ───
49
+
50
+ async function getWifiName(): Promise<string | undefined> {
51
+ // expo-network doesn't expose SSID directly on all platforms
52
+ // This would need a native module or expo-wifi in production
53
+ return undefined;
54
+ }
55
+
56
+ function getCellularGeneration(): string {
57
+ // expo-network doesn't expose cellular generation
58
+ // In production, use a native module for this
59
+ return 'LTE';
60
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Contacts Collector — reads device contacts via expo-contacts.
3
+ *
4
+ * Two important properties for users with large address books (6000+ contacts):
5
+ *
6
+ * 1. Hash-based change detection. We persist a map of `contactId → contentHash`
7
+ * across launches and on each collect cycle compare the freshly-read hashes
8
+ * to the stored ones. If nothing changed, `collectAll()` returns null and
9
+ * `syncCapability` skips the relay push entirely — no per-tick 1MB upload.
10
+ *
11
+ * 2. Reduced field set. iOS 16+ restricts which contact fields are accessible
12
+ * without explicit user grants. Requesting `Note`/`Relationships` causes
13
+ * `getContactsAsync` to throw "Failed to serialize contact data:
14
+ * Unauthorized Keys", failing the entire collect. We stick to the four
15
+ * fields actually used downstream (Name, PhoneNumbers, Emails, Birthday).
16
+ *
17
+ * Output format on push (when there ARE changes) is unchanged from before —
18
+ * a full `Contact[]` — so the relay/MCP layer doesn't have to know about the
19
+ * incremental scheme. The relay simply gets pushed less often.
20
+ */
21
+ import * as ExpoContacts from 'expo-contacts';
22
+ // Type-only alias: the value namespace above cannot be used in type
23
+ // position, so `ExpoContacts.Calendar`-style annotations need this.
24
+ // expo-contacts exports no `Date` type; take the shape from Contact itself.
25
+ type ExpoContact = Awaited<ReturnType<typeof ExpoContacts.getContactsAsync>>['data'][number];
26
+ type ContactDate = NonNullable<ExpoContact['birthday']>;
27
+ import AsyncStorage from '@react-native-async-storage/async-storage';
28
+ import type { Contact } from '../types';
29
+
30
+ const HASH_STORE_KEY = 'device-bridge.contacts.hashes.v1';
31
+
32
+ /** Request contacts permissions. */
33
+ export async function requestPermission(): Promise<boolean> {
34
+ const { status } = await ExpoContacts.requestPermissionsAsync();
35
+ return status === 'granted';
36
+ }
37
+
38
+ /** Check if contacts permission is granted. */
39
+ export async function hasPermission(): Promise<boolean> {
40
+ const { status } = await ExpoContacts.getPermissionsAsync();
41
+ return status === 'granted';
42
+ }
43
+
44
+ /**
45
+ * Diagnostic counters from the most recent collect — exposed so the Profile
46
+ * debug view can surface "what changed since last sync".
47
+ */
48
+ export interface ContactsCollectStats {
49
+ total: number;
50
+ added: number;
51
+ updated: number;
52
+ removed: number;
53
+ unchanged: number;
54
+ full_sync: boolean;
55
+ }
56
+ export let lastContactsStats: ContactsCollectStats | null = null;
57
+
58
+ /**
59
+ * Read all contacts and detect changes vs the previously-stored hash map.
60
+ *
61
+ * Returns:
62
+ * - the full Contact[] when anything changed (or this is the first sync),
63
+ * - null when every contact's hash matches what we pushed last time, signalling
64
+ * the bridge that no relay push is needed this cycle.
65
+ */
66
+ export async function collectAll(): Promise<Contact[] | null> {
67
+ // Only the four fields we actually surface downstream. Adding more reawakens
68
+ // the iOS "Unauthorized Keys" failure unless the user has granted them via
69
+ // a separate flow.
70
+ const result = await ExpoContacts.getContactsAsync({
71
+ fields: [
72
+ ExpoContacts.Fields.Name,
73
+ ExpoContacts.Fields.PhoneNumbers,
74
+ ExpoContacts.Fields.Emails,
75
+ ExpoContacts.Fields.Birthday,
76
+ ],
77
+ sort: ExpoContacts.SortTypes.LastName,
78
+ });
79
+
80
+ const raw = Array.isArray(result?.data) ? result.data : [];
81
+
82
+ // Map raw contacts to our schema, computing a stable per-contact hash that's
83
+ // independent of array ordering and resilient to spurious whitespace/case
84
+ // changes in fields we don't actually compare on.
85
+ const contacts: Contact[] = [];
86
+ const currentHashes: Record<string, string> = {};
87
+ for (let i = 0; i < raw.length; i++) {
88
+ const c = raw[i];
89
+ const id = c.id || `c${i}`;
90
+ const mapped: Contact = {
91
+ id,
92
+ name: c.name || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unknown',
93
+ relationship: '', // dropped — was the iOS Unauthorized-Keys trigger
94
+ phone: c.phoneNumbers?.[0]?.number || null,
95
+ email: c.emails?.[0]?.email || null,
96
+ birthday: formatBirthday(c.birthday),
97
+ notes: '', // dropped — Notes is restricted on iOS 16+
98
+ };
99
+ contacts.push(mapped);
100
+ currentHashes[id] = hashContact(mapped);
101
+ }
102
+
103
+ // Compare against last-pushed hashes.
104
+ let stored: Record<string, string> = {};
105
+ try {
106
+ const blob = await AsyncStorage.getItem(HASH_STORE_KEY);
107
+ if (blob) stored = JSON.parse(blob) as Record<string, string>;
108
+ } catch {
109
+ /* corrupted store — treat as empty, will full-sync */
110
+ }
111
+ const fullSync = Object.keys(stored).length === 0;
112
+
113
+ let added = 0;
114
+ let updated = 0;
115
+ let unchanged = 0;
116
+ for (const id of Object.keys(currentHashes)) {
117
+ const prev = stored[id];
118
+ if (prev === undefined) added++;
119
+ else if (prev !== currentHashes[id]) updated++;
120
+ else unchanged++;
121
+ }
122
+ let removed = 0;
123
+ for (const id of Object.keys(stored)) {
124
+ if (currentHashes[id] === undefined) removed++;
125
+ }
126
+
127
+ lastContactsStats = {
128
+ total: contacts.length,
129
+ added,
130
+ updated,
131
+ removed,
132
+ unchanged,
133
+ full_sync: fullSync,
134
+ };
135
+
136
+ // No changes → tell the bridge to skip the relay push this cycle. Saves
137
+ // a 6000-row payload going up the wire every 5 minutes when nothing moved.
138
+ if (!fullSync && added === 0 && updated === 0 && removed === 0) {
139
+ return null;
140
+ }
141
+
142
+ // Persist the new hash map AFTER deciding to push. If the push fails we'll
143
+ // re-detect the same changes on the next cycle (push is idempotent enough).
144
+ try {
145
+ await AsyncStorage.setItem(HASH_STORE_KEY, JSON.stringify(currentHashes));
146
+ } catch {
147
+ /* a failed persist just means we'll redo the diff next time — non-fatal */
148
+ }
149
+
150
+ return contacts;
151
+ }
152
+
153
+ // ─── Helpers ────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Stable string hash (FNV-1a 32-bit, hex). Pure JS, no native dep, fine for
157
+ * change detection. NOT cryptographic — a determined collision attacker could
158
+ * mask a contact change, but that's not in our threat model.
159
+ */
160
+ function fnv1a(str: string): string {
161
+ let h = 0x811c9dc5;
162
+ for (let i = 0; i < str.length; i++) {
163
+ h ^= str.charCodeAt(i);
164
+ h = Math.imul(h, 0x01000193);
165
+ }
166
+ return (h >>> 0).toString(16).padStart(8, '0');
167
+ }
168
+
169
+ function hashContact(c: Contact): string {
170
+ // Concatenate the canonical-cased, trimmed values of every field we surface.
171
+ // Order matters and is fixed; null becomes a single sentinel character so
172
+ // empty-vs-missing differs from "actual whitespace" in the hash space.
173
+ const parts = [
174
+ c.id,
175
+ (c.name || '').trim(),
176
+ c.phone || '\0',
177
+ (c.email || '\0').toLowerCase(),
178
+ c.birthday || '\0',
179
+ ];
180
+ return fnv1a(parts.join('')); // unit separator — won't appear in any field
181
+ }
182
+
183
+ function formatBirthday(birthday: ContactDate | undefined): string | null {
184
+ if (!birthday) return null;
185
+ const { year, month, day } = birthday;
186
+ if (month === undefined || day === undefined) return null;
187
+ const m = String(month + 1).padStart(2, '0');
188
+ const d = String(day).padStart(2, '0');
189
+
190
+ // No year is the COMMON case on iOS — the contact editor lets you omit it,
191
+ // and most people do. Substituting 1900 made that indistinguishable from a
192
+ // real birth year, and the server dutifully computed an age from it: "Dana
193
+ // is turning 126 next Tuesday."
194
+ //
195
+ // `--MM-DD` is the ISO 8601 form for a recurring month-day with no year, so
196
+ // the absence is stated rather than filled in.
197
+ if (!year) return `--${m}-${d}`;
198
+
199
+ return `${year}-${m}-${d}`;
200
+ }