akanjs 3.0.0-beta.21 → 3.0.0-beta.23

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.
@@ -288,7 +288,16 @@ export type UseCsrTransition = CsrTransitionStyles & {
288
288
  };
289
289
 
290
290
  export type CsrContextType = RouteState & UseCsrTransition;
291
- export const csrContext = sharedContext<CsrContextType>("csr", {} as unknown as CsrContextType);
291
+
292
+ export const csrContext = sharedContext<CsrContextType>("csr", {
293
+ history: { current: null },
294
+ topSafeAreaRef: { current: null },
295
+ bottomSafeAreaRef: { current: null },
296
+ prevPageContentRef: { current: null },
297
+ pageContentRef: { current: null },
298
+ frameRootRef: { current: null },
299
+ onBack: { current: {} },
300
+ } as unknown as CsrContextType);
292
301
  export const useCsr = () => {
293
302
  const contextValues = useContext(csrContext);
294
303
  return contextValues;
package/client/native.ts CHANGED
@@ -6,6 +6,7 @@ export { AkanNativeError, fileBlob, releaseFile } from "../vendor/@akanjs/native
6
6
  export { app } from "../vendor/@akanjs/native/plugins/app/src/index.ts";
7
7
  export { appState } from "../vendor/@akanjs/native/plugins/app-state/src/index.ts";
8
8
  export type { Photo } from "../vendor/@akanjs/native/plugins/camera/src/index.ts";
9
+ export type { Contact } from "../vendor/@akanjs/native/plugins/contacts/src/index.ts";
9
10
  export type { DeviceInfo } from "../vendor/@akanjs/native/plugins/device/src/index.ts";
10
11
  export { device } from "../vendor/@akanjs/native/plugins/device/src/index.ts";
11
12
  export { dialog } from "../vendor/@akanjs/native/plugins/dialog/src/index.ts";
@@ -25,6 +26,7 @@ export { secureStorage } from "../vendor/@akanjs/native/plugins/secure-storage/s
25
26
  export { browser, opener };
26
27
 
27
28
  export const loadCamera = () => import("../vendor/@akanjs/native/plugins/camera/src/index.ts");
29
+ export const loadContacts = () => import("../vendor/@akanjs/native/plugins/contacts/src/index.ts");
28
30
  export const loadGeolocation = () => import("../vendor/@akanjs/native/plugins/geolocation/src/index.ts");
29
31
  export const loadIap = () => import("../vendor/@akanjs/native/plugins/iap/src/index.ts");
30
32
 
@@ -107,7 +107,12 @@ export class WsClient {
107
107
  #connect() {
108
108
  if (this.#destroyed) return;
109
109
 
110
- this.#ws = new WebSocket(this.url);
110
+ try {
111
+ this.#ws = new WebSocket(this.url);
112
+ } catch (error) {
113
+ this.logger.error(`Cannot open a WebSocket to ${this.url}: ${error instanceof Error ? error.message : error}`);
114
+ return;
115
+ }
111
116
  this.#ws.binaryType = "arraybuffer";
112
117
  this.#ws.onopen = (e) => {
113
118
  this.#reconnectAttempts = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.21",
3
+ "version": "3.0.0-beta.23",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -741,6 +741,8 @@ export class AkanApp {
741
741
  async #proxyHttp(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response> {
742
742
  const child = await this.#pickReadyFederationChild(req);
743
743
  if (!child?.upstream || child.upstream.type !== "unix") return this.#respondWithUnavailable(req);
744
+
745
+ const { socketPath } = child.upstream;
744
746
  const url = new URL(req.url);
745
747
  const upstreamUrl = `http://akan-child${url.pathname}${url.search}`;
746
748
  const headers = makeAkanChildProxyHeaders(req, child.idx, server.requestIP(req));
@@ -750,7 +752,7 @@ export class AkanApp {
750
752
  const hopStart = traced ? performance.now() : 0;
751
753
  try {
752
754
  const upstreamRes = await fetch(upstreamUrl, {
753
- unix: child.upstream.socketPath,
755
+ unix: socketPath,
754
756
  method: req.method,
755
757
  headers,
756
758
  body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
@@ -761,7 +763,7 @@ export class AkanApp {
761
763
  } catch (error) {
762
764
  if (AkanApp.#isUpstreamOpenFailure(error)) {
763
765
  this.logger.error(
764
- `Child ${child.idx}/${child.role} upstream is unreachable (${child.upstream.socketPath}); restarting`,
766
+ `Child ${child.idx}/${child.role} upstream is unreachable (${socketPath}); restarting`,
765
767
  );
766
768
  this.#scheduleChildRestart(child, child.proc, "upstream-open-failed");
767
769
  return AkanApp.#unavailableResponse(req, "Federation child upstream is unreachable; restarting");
@@ -5,6 +5,7 @@ import {
5
5
  type Cls,
6
6
  ENDPOINT_META,
7
7
  FIELD_META,
8
+ Float,
8
9
  getEnv,
9
10
  ID,
10
11
  INTERNAL_META,
@@ -14,7 +15,7 @@ import {
14
15
  SLICE_META,
15
16
  } from "akanjs/base";
16
17
  import { capitalize, cookieHeaderHasAuthToken, Logger } from "akanjs/common";
17
- import { deserialize, resolvePageLimit, resolvePageSkip, serialize } from "akanjs/constant";
18
+ import { type ConstantField, deserialize, resolvePageLimit, resolvePageSkip, serialize } from "akanjs/constant";
18
19
  import { baseDocumentColumns, documentQueryHelper, getFilterSortByKey, type QueryFieldMap } from "akanjs/document";
19
20
  import {
20
21
  type AkanJob,
@@ -475,9 +476,11 @@ export class SignalResolver {
475
476
  }) {}
476
477
  return SliceEndpoint;
477
478
  }
478
-
479
+
480
+ static readonly #clientOrderableTypes = new Set<unknown>([Int, Float, String, ID, Boolean, Date]);
479
481
  static #assertLiveSort(refName: string, key: string, sliceInfo: SliceInfo) {
480
- const lightFields = (sliceInfo.light as unknown as { [FIELD_META]?: Record<string, unknown> })[FIELD_META] ?? {};
482
+ const lightFields =
483
+ (sliceInfo.light as unknown as { [FIELD_META]?: Record<string, ConstantField> })[FIELD_META] ?? {};
481
484
  for (const sortKey of sliceInfo.liveOption?.sort ?? []) {
482
485
  const sort = getFilterSortByKey(sliceInfo.filter, sortKey);
483
486
  if (!sort)
@@ -489,9 +492,12 @@ export class SignalResolver {
489
492
  `Live slice "${refName}.${key}" declares sort "${sortKey}" on "${path}", which is not in ` +
490
493
  `Light${capitalize(refName)}. A subscriber cannot order by a field it never receives.`,
491
494
  );
495
+ const { modelRef, isArray, isMap } = lightFields[path].getProps();
496
+ if (!isArray && !isMap && SignalResolver.#clientOrderableTypes.has(modelRef)) continue;
492
497
  SignalResolver.logger.warn(
493
- `Live slice "${refName}.${key}" sorts by "${path}", which is stored inside the document rather than in a ` +
494
- `column. SQLite and Postgres order those differently, so a client placing a row may disagree with the server.`,
498
+ `Live slice "${refName}.${key}" sorts "${sortKey}" by "${path}", which is not an Int, Float, String, ID, ` +
499
+ `Boolean or Date field, so a client placing a new row may disagree with the server. Sort by one of ` +
500
+ `those, or leave "${sortKey}" out of .live({ sort }) so the list refetches when a row arrives.`,
495
501
  );
496
502
  }
497
503
  }
@@ -37,9 +37,9 @@ export class QueryCompiler {
37
37
  }
38
38
 
39
39
  orderBy(sort: Record<string, 1 | -1> = { createdAt: -1 }) {
40
- return Object.entries(sort)
41
- .map(([path, direction]) => this.dialect.orderTerm(this.fieldExpr(path), direction))
42
- .join(", ");
40
+ const terms = Object.entries(sort);
41
+ if (!terms.some(([path]) => path === "id")) terms.push(["id", terms.at(-1)?.[1] ?? -1]);
42
+ return terms.map(([path, direction]) => this.dialect.orderTerm(this.fieldExpr(path), direction)).join(", ");
43
43
  }
44
44
 
45
45
  fieldExpr(path: string) {
@@ -30,31 +30,53 @@ export const livePlacementIndex = ({
30
30
  const sort = sorts?.[sortKey];
31
31
  if (!sort || !Object.keys(sort).length) return null;
32
32
  const paths = Object.entries(sort);
33
- if (paths.some(([path]) => comparableOf(row[path]) === null)) return null;
34
- const index = list.findIndex((item) => compareRows(row, item, paths) < 0);
33
+ if (paths.some(([path]) => comparableOf(row[path]) === undefined)) return null;
34
+ let orderable = true;
35
+ const index = list.findIndex((item) => {
36
+ const order = compareRows(row, item, paths);
37
+ orderable = order !== null;
38
+ return order === null || order < 0;
39
+ });
40
+ if (!orderable) return null;
35
41
  if (index !== -1) return index;
36
42
 
37
43
  if (cumulative) return hasMore ? null : list.length;
38
44
  return list.length < limit ? list.length : null;
39
45
  };
40
46
 
41
- const compareRows = (left: LiveSortableRow, right: LiveSortableRow, paths: [string, 1 | -1][]): number => {
47
+ const compareRows = (left: LiveSortableRow, right: LiveSortableRow, paths: [string, 1 | -1][]): number | null => {
42
48
  for (const [path, direction] of paths) {
43
- const a = comparableOf(left[path]);
44
- const b = comparableOf(right[path]);
45
- if (a === null || b === null) continue;
46
- if (a === b) continue;
47
- return (a < b ? -1 : 1) * direction;
49
+ const order = compareValues(comparableOf(left[path]), comparableOf(right[path]));
50
+ if (order !== 0) return order === null ? null : order * direction;
48
51
  }
49
- return 0;
52
+ const order = compareValues(comparableOf(left.id), comparableOf(right.id));
53
+ return order === null ? null : order * (paths.at(-1)?.[1] ?? -1);
50
54
  };
51
55
 
52
- const comparableOf = (value: unknown): number | string | null => {
53
- if (value === null || value === undefined) return null;
56
+ const compareValues = (a: Comparable | undefined, b: Comparable | undefined): number | null => {
57
+ if (a === undefined || b === undefined) return null;
58
+ if (a === b) return 0;
59
+ if (a === null) return -1;
60
+ if (b === null) return 1;
61
+ if (typeof a === "string" && typeof b === "string") return compareText(a, b);
62
+ if (typeof a === "number" && typeof b === "number") return a < b ? -1 : 1;
63
+ return null;
64
+ };
65
+
66
+ const compareText = (a: string, b: string) => {
67
+ let idx = 0;
68
+ while (idx < a.length && idx < b.length && a[idx] === b[idx]) idx += 1;
69
+ return (a.codePointAt(idx) ?? -1) < (b.codePointAt(idx) ?? -1) ? -1 : 1;
70
+ };
71
+
72
+ type Comparable = number | string | null;
73
+
74
+ const comparableOf = (value: unknown): Comparable | undefined => {
75
+ if (value === null) return null;
54
76
  if (typeof value === "number" || typeof value === "string") return value;
55
77
  if (typeof value === "boolean") return value ? 1 : 0;
56
78
  if (value instanceof Date) return value.getTime();
57
- const valued: unknown = (value as { valueOf?: () => unknown }).valueOf?.();
79
+ const valued: unknown = (value as { valueOf?: () => unknown } | undefined)?.valueOf?.();
58
80
  if (typeof valued === "number" || typeof valued === "string") return valued;
59
- return null;
81
+ return undefined;
60
82
  };
@@ -4,6 +4,7 @@ export { AkanNativeError, fileBlob, releaseFile } from "../vendor/@akanjs/native
4
4
  export { app } from "../vendor/@akanjs/native/plugins/app/src/index.d.ts";
5
5
  export { appState } from "../vendor/@akanjs/native/plugins/app-state/src/index.d.ts";
6
6
  export type { Photo } from "../vendor/@akanjs/native/plugins/camera/src/index.d.ts";
7
+ export type { Contact } from "../vendor/@akanjs/native/plugins/contacts/src/index.d.ts";
7
8
  export type { DeviceInfo } from "../vendor/@akanjs/native/plugins/device/src/index.d.ts";
8
9
  export { device } from "../vendor/@akanjs/native/plugins/device/src/index.d.ts";
9
10
  export { dialog } from "../vendor/@akanjs/native/plugins/dialog/src/index.d.ts";
@@ -17,6 +18,7 @@ export { push } from "../vendor/@akanjs/native/plugins/push/src/index.d.ts";
17
18
  export { secureStorage } from "../vendor/@akanjs/native/plugins/secure-storage/src/index.d.ts";
18
19
  export { browser, opener };
19
20
  export declare const loadCamera: () => Promise<typeof import("../vendor/@akanjs/native/plugins/camera/src/index.d.ts")>;
21
+ export declare const loadContacts: () => Promise<typeof import("../vendor/@akanjs/native/plugins/contacts/src/index.d.ts")>;
20
22
  export declare const loadGeolocation: () => Promise<typeof import("../vendor/@akanjs/native/plugins/geolocation/src/index.d.ts")>;
21
23
  export declare const loadIap: () => Promise<typeof import("../vendor/@akanjs/native/plugins/iap/src/index.d.ts")>;
22
24
  /** Inside an iOS or Android shell of the native runtime; a mobile target opened in a browser is not. */
@@ -1,4 +1,4 @@
1
- import type { Dayjs } from "akanjs/base";
1
+ import { type Dayjs } from "akanjs/base";
2
2
  import { type DataAction, type DataColumn } from "akanjs/client";
3
3
  import type { SliceMeta } from "akanjs/fetch";
4
4
  import type { ReactNode } from "react";
@@ -7,7 +7,7 @@ export declare const convToAntdColumn: (column: DataColumn<any>) => {
7
7
  dataIndex: string;
8
8
  title: string;
9
9
  responsive: readonly ["md", "lg", "xl"] | undefined;
10
- render: ((value: any, model: any) => ReactNode) | undefined;
10
+ render: ((value: unknown) => string | import("react/jsx-runtime").JSX.Element) | ((value: any, model: any) => ReactNode);
11
11
  } | {
12
12
  key: string;
13
13
  dataIndex: string;
@@ -24,8 +24,8 @@ export declare const convToAntdColumn: (column: DataColumn<any>) => {
24
24
  key: string;
25
25
  dataIndex: string;
26
26
  title: string;
27
+ render: (value: unknown) => string | import("react/jsx-runtime").JSX.Element;
27
28
  responsive?: undefined;
28
- render?: undefined;
29
29
  };
30
30
  interface ItemProps<T extends string, Full extends {
31
31
  id: string;
@@ -0,0 +1,43 @@
1
+ /** "prompt-with-rationale" is Android's: asked and refused once, so the app should explain before asking again. */
2
+ export type PermissionState = "granted" | "denied" | "prompt" | "prompt-with-rationale";
3
+ export interface ContactPhone {
4
+ /** As the address book stores it: no normalization, so "010-1234-5678" and "+82 10 1234 5678" both occur. */
5
+ number: string;
6
+ /** The label shown in the system Contacts app ("mobile", "home", a custom one), localized; null when unlabeled. */
7
+ label: string | null;
8
+ }
9
+ export interface Contact {
10
+ /** Stable for this contact on this device only. */
11
+ id: string;
12
+ /** The display name the system Contacts app shows; null when not projected or the contact has none. */
13
+ name: string | null;
14
+ /** Empty when not projected. */
15
+ phones: ContactPhone[];
16
+ }
17
+ /** What to read for each contact. Both default to true; leave out what the screen does not need. */
18
+ export interface GetContactsOptions {
19
+ projection?: {
20
+ name?: boolean;
21
+ phones?: boolean;
22
+ };
23
+ }
24
+ /**
25
+ * Reads the device's address book (iOS Contacts, Android ContactsContract); there is no web or desktop
26
+ * implementation, so every call there rejects with UNSUPPORTED.
27
+ * - "granted" on iOS 18 also covers limited access: getContacts returns only the contacts the user shared.
28
+ * - getContacts asks for permission first when it was never asked, and rejects with PERMISSION_DENIED when
29
+ * denied. INTERNAL when NSContactsUsageDescription is missing (iOS kills an app that asks without it).
30
+ */
31
+ export interface ContactsApi {
32
+ checkPermission(): Promise<{
33
+ contacts: PermissionState;
34
+ }>;
35
+ /** Shows the system prompt when the state is "prompt" or "prompt-with-rationale"; otherwise returns the state. */
36
+ requestPermission(): Promise<{
37
+ contacts: PermissionState;
38
+ }>;
39
+ getContacts(options?: GetContactsOptions): Promise<{
40
+ contacts: Contact[];
41
+ }>;
42
+ }
43
+ export declare const contacts: import("../../../packages/core/src/plugin.ts").Plugin<ContactsApi, {}>;
@@ -0,0 +1 @@
1
+ export * from "./src/index.d.ts";
package/ui/Data/Item.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import type { Dayjs } from "akanjs/base";
2
+ import { type Dayjs, dayjs } from "akanjs/base";
3
3
  import { cn, type DataAction, type DataColumn, usePage } from "akanjs/client";
4
4
  import { capitalize } from "akanjs/common";
5
5
  import type { BaseObject } from "akanjs/constant";
@@ -15,6 +15,9 @@ import { Popconfirm } from "../Popconfirm";
15
15
  import { RecentTime } from "../RecentTime";
16
16
  import { dictLabel, formatCell } from "./dataText";
17
17
 
18
+ const renderCell = (value: unknown) =>
19
+ value instanceof Date || dayjs.isDayjs(value) ? <RecentTime date={value} /> : formatCell(value);
20
+
18
21
  export const convToAntdColumn = (column: DataColumn<any>) => {
19
22
  if (typeof column !== "string")
20
23
  return {
@@ -22,7 +25,7 @@ export const convToAntdColumn = (column: DataColumn<any>) => {
22
25
  dataIndex: column.key as string,
23
26
  title: capitalize(column.key as string),
24
27
  responsive: column.responsive ? (["md", "lg", "xl"] as const) : undefined,
25
- render: column.render,
28
+ render: column.render ?? renderCell,
26
29
  };
27
30
  else if (
28
31
  [
@@ -62,7 +65,7 @@ export const convToAntdColumn = (column: DataColumn<any>) => {
62
65
  title: capitalize(column),
63
66
  render: (role: string) => <RoleTags role={role} />,
64
67
  };
65
- else return { key: column, dataIndex: column, title: capitalize(column) };
68
+ else return { key: column, dataIndex: column, title: capitalize(column), render: renderCell };
66
69
  };
67
70
 
68
71
  interface ItemProps<T extends string, Full extends { id: string }, Light extends { id: string }> {
@@ -0,0 +1,84 @@
1
+ package com.akanjs.plugins.contacts
2
+
3
+ import android.Manifest
4
+ import android.provider.ContactsContract.CommonDataKinds.Phone
5
+ import android.provider.ContactsContract.Contacts
6
+ import com.akanjs.runtime.AkanNativeErrorCode
7
+ import com.akanjs.runtime.AkanNativePluginContext
8
+ import com.akanjs.runtime.AkanNativeReply
9
+
10
+ /**
11
+ * The address book through ContactsContract, read-only. Two queries (contacts, then every phone row) joined in
12
+ * memory rather than one query per contact; they run on a worker thread because a large book takes seconds.
13
+ * Arguments arrive decoded and checked by the generated ContactsPluginSpec (PL-10).
14
+ */
15
+ class ContactsPlugin(private val context: AkanNativePluginContext) : ContactsPluginSpec {
16
+ override fun checkPermission(reply: AkanNativeReply<ContactsCheckPermissionResult>) =
17
+ reply.resolve(ContactsCheckPermissionResult(contacts = permission()))
18
+
19
+ override fun requestPermission(reply: AkanNativeReply<ContactsRequestPermissionResult>) =
20
+ withPermission { reply.resolve(ContactsRequestPermissionResult(contacts = it)) }
21
+
22
+ override fun getContacts(args: ContactsGetContactsOptions, reply: AkanNativeReply<ContactsGetContactsResult>) {
23
+ val withName = args.projection?.name ?: true
24
+ val withPhones = args.projection?.phones ?: true
25
+ withPermission { state ->
26
+ if (state != ContactsPermissionState.GRANTED) {
27
+ reply.reject(AkanNativeErrorCode.PERMISSION_DENIED, "contacts access was denied")
28
+ return@withPermission
29
+ }
30
+ Thread {
31
+ try {
32
+ reply.resolve(ContactsGetContactsResult(contacts = read(withName, withPhones)))
33
+ } catch (e: Exception) {
34
+ reply.reject(AkanNativeErrorCode.INTERNAL, "could not read contacts: ${e.message}")
35
+ }
36
+ }.start()
37
+ }
38
+ }
39
+
40
+ /** context.permissionState answers with one of the four AkanNativePermission strings. */
41
+ private fun permission(): ContactsPermissionState {
42
+ val state = context.permissionState(Manifest.permission.READ_CONTACTS)
43
+ return ContactsPermissionState.entries.first { it.json == state }
44
+ }
45
+
46
+ /** "denied" is a refusal the system will not show a dialog for again, so only the two prompt states ask. */
47
+ private fun withPermission(then: (ContactsPermissionState) -> Unit) {
48
+ val current = permission()
49
+ if (current != ContactsPermissionState.PROMPT && current != ContactsPermissionState.PROMPT_WITH_RATIONALE) {
50
+ then(current)
51
+ return
52
+ }
53
+ context.requestPermissions(arrayOf(Manifest.permission.READ_CONTACTS)) { then(permission()) }
54
+ }
55
+
56
+ private fun read(withName: Boolean, withPhones: Boolean): List<ContactsContact> {
57
+ val resolver = context.activity.contentResolver
58
+ val names = linkedMapOf<String, String?>()
59
+ resolver.query(
60
+ Contacts.CONTENT_URI,
61
+ arrayOf(Contacts._ID, Contacts.DISPLAY_NAME_PRIMARY),
62
+ null,
63
+ null,
64
+ Contacts.SORT_KEY_PRIMARY,
65
+ )?.use { cursor ->
66
+ while (cursor.moveToNext()) {
67
+ names[cursor.getLong(0).toString()] = if (withName) cursor.getString(1)?.takeIf { it.isNotEmpty() } else null
68
+ }
69
+ }
70
+ val phones = mutableMapOf<String, MutableList<ContactsContactPhone>>()
71
+ if (withPhones) {
72
+ resolver.query(Phone.CONTENT_URI, arrayOf(Phone.CONTACT_ID, Phone.NUMBER, Phone.TYPE, Phone.LABEL), null, null, null)
73
+ ?.use { cursor ->
74
+ val resources = context.activity.resources
75
+ while (cursor.moveToNext()) {
76
+ val number = cursor.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
77
+ val label = if (cursor.isNull(2)) cursor.getString(3) else Phone.getTypeLabel(resources, cursor.getInt(2), cursor.getString(3)).toString()
78
+ phones.getOrPut(cursor.getLong(0).toString()) { mutableListOf() }.add(ContactsContactPhone(number = number, label = label))
79
+ }
80
+ }
81
+ }
82
+ return names.map { (id, name) -> ContactsContact(id = id, name = name, phones = phones[id] ?: emptyList()) }
83
+ }
84
+ }
@@ -0,0 +1,86 @@
1
+ import Contacts
2
+ import Foundation
3
+
4
+ /// The address book through CNContactStore, read-only.
5
+ /// - Enumeration blocks until every contact is read, so it runs on a background queue with a store of its own.
6
+ /// - iOS 18 limited access reports as "granted": the store then enumerates only the contacts the user shared.
7
+ /// Arguments arrive decoded and checked by the generated ContactsPluginSpec (PL-10).
8
+ final class ContactsPlugin: ContactsPluginSpec {
9
+ static let id = "contacts"
10
+ private let context: AkanNativePluginContext
11
+
12
+ init(context: AkanNativePluginContext) {
13
+ self.context = context
14
+ }
15
+
16
+ private static var permission: ContactsPermissionState {
17
+ switch CNContactStore.authorizationStatus(for: .contacts) {
18
+ case .authorized: .granted
19
+ case .denied, .restricted: .denied
20
+ case .notDetermined: .prompt
21
+ case .limited: .granted
22
+ @unknown default: .denied
23
+ }
24
+ }
25
+
26
+ /// Asking for contacts without NSContactsUsageDescription gets the app killed by TCC.
27
+ private static var hasUsageDescription: Bool {
28
+ Bundle.main.object(forInfoDictionaryKey: "NSContactsUsageDescription") != nil
29
+ }
30
+
31
+ func checkPermission(_ reply: AkanNativeReply<ContactsCheckPermissionResult>) {
32
+ reply.resolve(ContactsCheckPermissionResult(contacts: Self.permission))
33
+ }
34
+
35
+ func requestPermission(_ reply: AkanNativeReply<ContactsRequestPermissionResult>) {
36
+ withPermission(reply) { reply.resolve(ContactsRequestPermissionResult(contacts: $0)) }
37
+ }
38
+
39
+ func getContacts(_ args: ContactsGetContactsOptions, _ reply: AkanNativeReply<ContactsGetContactsResult>) {
40
+ let withName = args.projection?.name ?? true
41
+ let withPhones = args.projection?.phones ?? true
42
+ withPermission(reply) { state in
43
+ guard state == .granted else { return reply.reject(.permissionDenied, "contacts access was denied") }
44
+ DispatchQueue.global(qos: .userInitiated).async {
45
+ do {
46
+ reply.resolve(ContactsGetContactsResult(contacts: try Self.read(name: withName, phones: withPhones)))
47
+ } catch {
48
+ reply.reject(.internalError, "could not read contacts: \(error.localizedDescription)")
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /// Resolves with the state after the prompt, or the current one when there is nothing to ask.
55
+ private func withPermission<T>(_ reply: AkanNativeReply<T>, _ then: @escaping @Sendable (ContactsPermissionState) -> Void) {
56
+ guard Self.permission == .prompt else { return then(Self.permission) }
57
+ guard Self.hasUsageDescription else {
58
+ return reply.reject(.internalError, "Info.plist has no NSContactsUsageDescription")
59
+ }
60
+ CNContactStore().requestAccess(for: .contacts) { _, _ in
61
+ DispatchQueue.main.async { then(Self.permission) }
62
+ }
63
+ }
64
+
65
+ nonisolated private static func read(name: Bool, phones: Bool) throws -> [ContactsContact] {
66
+ var keys: [CNKeyDescriptor] = []
67
+ if name { keys.append(CNContactFormatter.descriptorForRequiredKeys(for: .fullName)) }
68
+ if phones { keys.append(CNContactPhoneNumbersKey as CNKeyDescriptor) }
69
+ let request = CNContactFetchRequest(keysToFetch: keys)
70
+ request.sortOrder = .userDefault
71
+ var result: [ContactsContact] = []
72
+ try CNContactStore().enumerateContacts(with: request) { contact, _ in
73
+ let display = name ? CNContactFormatter.string(from: contact, style: .fullName) : nil
74
+ let numbers = phones
75
+ ? contact.phoneNumbers.map { labeled in
76
+ ContactsContactPhone(
77
+ number: labeled.value.stringValue,
78
+ label: labeled.label.map { CNLabeledValue<CNPhoneNumber>.localizedString(forLabel: $0) }
79
+ )
80
+ }
81
+ : []
82
+ result.append(ContactsContact(id: contact.identifier, name: display?.isEmpty == false ? display : nil, phones: numbers))
83
+ }
84
+ return result
85
+ }
86
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "contacts",
3
+ "apiVersion": 1,
4
+ "methods": ["checkPermission", "requestPermission", "getContacts"],
5
+ "events": [],
6
+ "codegen": true,
7
+ "web": null,
8
+ "desktop": null,
9
+ "ios": {
10
+ "sources": ["ios/*.swift"],
11
+ "class": "ContactsPlugin",
12
+ "frameworks": ["Contacts"],
13
+ "infoPlist": {
14
+ "NSContactsUsageDescription": "Read your contacts."
15
+ }
16
+ },
17
+ "android": {
18
+ "sources": ["android/*.kt"],
19
+ "class": "com.akanjs.plugins.contacts.ContactsPlugin",
20
+ "permissions": ["android.permission.READ_CONTACTS"]
21
+ }
22
+ }
@@ -0,0 +1,43 @@
1
+ import { definePlugin } from "../../../packages/core/src/index.ts";
2
+
3
+ /** "prompt-with-rationale" is Android's: asked and refused once, so the app should explain before asking again. */
4
+ export type PermissionState = "granted" | "denied" | "prompt" | "prompt-with-rationale";
5
+
6
+ export interface ContactPhone {
7
+ /** As the address book stores it: no normalization, so "010-1234-5678" and "+82 10 1234 5678" both occur. */
8
+ number: string;
9
+ /** The label shown in the system Contacts app ("mobile", "home", a custom one), localized; null when unlabeled. */
10
+ label: string | null;
11
+ }
12
+
13
+ export interface Contact {
14
+ /** Stable for this contact on this device only. */
15
+ id: string;
16
+ /** The display name the system Contacts app shows; null when not projected or the contact has none. */
17
+ name: string | null;
18
+ /** Empty when not projected. */
19
+ phones: ContactPhone[];
20
+ }
21
+
22
+ /** What to read for each contact. Both default to true; leave out what the screen does not need. */
23
+ export interface GetContactsOptions {
24
+ projection?: { name?: boolean; phones?: boolean };
25
+ }
26
+
27
+ /**
28
+ * Reads the device's address book (iOS Contacts, Android ContactsContract); there is no web or desktop
29
+ * implementation, so every call there rejects with UNSUPPORTED.
30
+ * - "granted" on iOS 18 also covers limited access: getContacts returns only the contacts the user shared.
31
+ * - getContacts asks for permission first when it was never asked, and rejects with PERMISSION_DENIED when
32
+ * denied. INTERNAL when NSContactsUsageDescription is missing (iOS kills an app that asks without it).
33
+ */
34
+ export interface ContactsApi {
35
+ checkPermission(): Promise<{ contacts: PermissionState }>;
36
+ /** Shows the system prompt when the state is "prompt" or "prompt-with-rationale"; otherwise returns the state. */
37
+ requestPermission(): Promise<{ contacts: PermissionState }>;
38
+ getContacts(options?: GetContactsOptions): Promise<{ contacts: Contact[] }>;
39
+ }
40
+
41
+ export const contacts = definePlugin<ContactsApi>("contacts", {
42
+ methods: ["checkPermission", "requestPermission", "getContacts"],
43
+ });