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.
- package/client/csrTypes.ts +10 -1
- package/client/native.ts +2 -0
- package/fetch/client/wsClient.ts +6 -1
- package/package.json +1 -1
- package/server/akanApp.ts +4 -2
- package/server/resolver/signal.resolver.ts +11 -5
- package/service/predefinedAdaptor/sql/QueryCompiler.ts +3 -3
- package/store/liveInsert.ts +35 -13
- package/types/client/native.d.ts +2 -0
- package/types/ui/Data/Item.d.ts +3 -3
- package/types/vendor/@akanjs/native/plugins/contacts/src/index.d.ts +43 -0
- package/types/vendor/@akanjs/native/plugins/contacts/src.d.ts +1 -0
- package/ui/Data/Item.tsx +6 -3
- package/vendor/@akanjs/native/plugins/contacts/android/ContactsPlugin.kt +84 -0
- package/vendor/@akanjs/native/plugins/contacts/ios/ContactsPlugin.swift +86 -0
- package/vendor/@akanjs/native/plugins/contacts/native-plugin.json +22 -0
- package/vendor/@akanjs/native/plugins/contacts/src/index.ts +43 -0
package/client/csrTypes.ts
CHANGED
|
@@ -288,7 +288,16 @@ export type UseCsrTransition = CsrTransitionStyles & {
|
|
|
288
288
|
};
|
|
289
289
|
|
|
290
290
|
export type CsrContextType = RouteState & UseCsrTransition;
|
|
291
|
-
|
|
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
|
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -107,7 +107,12 @@ export class WsClient {
|
|
|
107
107
|
#connect() {
|
|
108
108
|
if (this.#destroyed) return;
|
|
109
109
|
|
|
110
|
-
|
|
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
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:
|
|
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 (${
|
|
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 =
|
|
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
|
|
494
|
-
`
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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) {
|
package/store/liveInsert.ts
CHANGED
|
@@ -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]) ===
|
|
34
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
if (
|
|
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 })
|
|
79
|
+
const valued: unknown = (value as { valueOf?: () => unknown } | undefined)?.valueOf?.();
|
|
58
80
|
if (typeof valued === "number" || typeof valued === "string") return valued;
|
|
59
|
-
return
|
|
81
|
+
return undefined;
|
|
60
82
|
};
|
package/types/client/native.d.ts
CHANGED
|
@@ -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. */
|
package/types/ui/Data/Item.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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)
|
|
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
|
|
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
|
+
});
|