@ankhorage/expo-runtime 1.0.0 → 2.1.0
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/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/mediaPicker/createExpoMediaPickerAdapter.d.ts +3 -0
- package/dist/mediaPicker/createExpoMediaPickerAdapter.d.ts.map +1 -0
- package/dist/mediaPicker/createExpoMediaPickerAdapter.js +8 -0
- package/dist/mediaPicker/createExpoMediaPickerAdapter.js.map +1 -0
- package/dist/mediaPicker/documentPicker.d.ts +18 -0
- package/dist/mediaPicker/documentPicker.d.ts.map +1 -0
- package/dist/mediaPicker/documentPicker.js +41 -0
- package/dist/mediaPicker/documentPicker.js.map +1 -0
- package/dist/mediaPicker/index.d.ts +3 -0
- package/dist/mediaPicker/index.d.ts.map +1 -0
- package/dist/mediaPicker/index.js +3 -0
- package/dist/mediaPicker/index.js.map +1 -0
- package/dist/mediaPicker/mediaKinds.d.ts +5 -0
- package/dist/mediaPicker/mediaKinds.d.ts.map +1 -0
- package/dist/mediaPicker/mediaKinds.js +37 -0
- package/dist/mediaPicker/mediaKinds.js.map +1 -0
- package/dist/mediaPicker/photoLibraryPicker.d.ts +24 -0
- package/dist/mediaPicker/photoLibraryPicker.d.ts.map +1 -0
- package/dist/mediaPicker/photoLibraryPicker.js +68 -0
- package/dist/mediaPicker/photoLibraryPicker.js.map +1 -0
- package/dist/mediaPicker/readSelectedBytes.d.ts +9 -0
- package/dist/mediaPicker/readSelectedBytes.d.ts.map +1 -0
- package/dist/mediaPicker/readSelectedBytes.js +13 -0
- package/dist/mediaPicker/readSelectedBytes.js.map +1 -0
- package/dist/mediaPicker/types.d.ts +29 -0
- package/dist/mediaPicker/types.d.ts.map +1 -0
- package/dist/mediaPicker/types.js +2 -0
- package/dist/mediaPicker/types.js.map +1 -0
- package/package.json +26 -2
- package/src/generatedAppOutput.test.ts +7 -0
- package/src/mediaPicker/createExpoMediaPickerAdapter.ts +10 -0
- package/src/mediaPicker/documentPicker.ts +68 -0
- package/src/mediaPicker/index.ts +10 -0
- package/src/mediaPicker/mediaKinds.ts +45 -0
- package/src/mediaPicker/mediaPicker.test.ts +120 -0
- package/src/mediaPicker/photoLibraryPicker.ts +94 -0
- package/src/mediaPicker/readSelectedBytes.ts +22 -0
- package/src/mediaPicker/types.ts +36 -0
- package/src/resolveExpoRuntimePlan.test.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @ankhorage/expo-runtime
|
|
2
2
|
|
|
3
|
+
## 2.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2d4b9c2: Add an isolated Expo media-picker subpath for transient file and photo-library selection, returning canonical media bytes and metadata without leaking local picker URIs into authoring state.
|
|
8
|
+
|
|
9
|
+
## 2.0.0
|
|
10
|
+
|
|
11
|
+
### Major Changes
|
|
12
|
+
|
|
13
|
+
- 3ef194b: Align public manifest planning APIs with Contracts 6 and its canonical Ankhorage module fields. Expo config `plugins` remain unchanged as a separate Expo ecosystem concept.
|
|
14
|
+
|
|
3
15
|
## 1.0.0
|
|
4
16
|
|
|
5
17
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# EXPO-RUNTIME
|
|
5
5
|
|
|
6
|
-
         
|
|
7
7
|
|
|
8
8
|
Declarative runtime integration for Expo apps: maps app capabilities to permissions, packages, config plugins, providers, and adapters
|
|
9
9
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createExpoMediaPickerAdapter.d.ts","sourceRoot":"","sources":["../../src/mediaPicker/createExpoMediaPickerAdapter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC;AAEtD,wBAAgB,4BAA4B,IAAI,sBAAsB,CAKrE"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { pickDocumentMedia } from './documentPicker';
|
|
2
|
+
import { pickPhotoLibraryMedia } from './photoLibraryPicker';
|
|
3
|
+
export function createExpoMediaPickerAdapter() {
|
|
4
|
+
return {
|
|
5
|
+
pick: (input) => input.source === 'photo-library' ? pickPhotoLibraryMedia(input) : pickDocumentMedia(input),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=createExpoMediaPickerAdapter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createExpoMediaPickerAdapter.js","sourceRoot":"","sources":["../../src/mediaPicker/createExpoMediaPickerAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAG7D,MAAM,UAAU,4BAA4B;IAC1C,OAAO;QACL,IAAI,EAAE,CAAC,KAAK,EAAE,EAAE,CACd,KAAK,CAAC,MAAM,KAAK,eAAe,CAAC,CAAC,CAAC,qBAAqB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,iBAAiB,CAAC,KAAK,CAAC;KAC7F,CAAC;AACJ,CAAC","sourcesContent":["import { pickDocumentMedia } from './documentPicker';\nimport { pickPhotoLibraryMedia } from './photoLibraryPicker';\nimport type { ExpoMediaPickerAdapter } from './types';\n\nexport function createExpoMediaPickerAdapter(): ExpoMediaPickerAdapter {\n return {\n pick: (input) =>\n input.source === 'photo-library' ? pickPhotoLibraryMedia(input) : pickDocumentMedia(input),\n };\n}\n"]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
import { type ArrayBufferReadable, type SelectedMediaByteSource } from './readSelectedBytes';
|
|
3
|
+
import type { ExpoMediaPickerInput, ExpoMediaPickerResult } from './types';
|
|
4
|
+
interface DocumentPickerAssetLike extends SelectedMediaByteSource {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly mimeType?: string;
|
|
7
|
+
readonly size?: number;
|
|
8
|
+
readonly file?: ArrayBufferReadable;
|
|
9
|
+
}
|
|
10
|
+
interface DocumentPickerResultLike {
|
|
11
|
+
readonly canceled: boolean;
|
|
12
|
+
readonly assets: readonly DocumentPickerAssetLike[] | null;
|
|
13
|
+
}
|
|
14
|
+
type ByteReader = (source: SelectedMediaByteSource) => Promise<Uint8Array | null>;
|
|
15
|
+
export declare function normalizeDocumentPickerResult(result: DocumentPickerResultLike, mediaKinds: readonly MediaAssetKind[] | undefined, readBytes?: ByteReader): Promise<ExpoMediaPickerResult>;
|
|
16
|
+
export declare function pickDocumentMedia(input: ExpoMediaPickerInput): Promise<ExpoMediaPickerResult>;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=documentPicker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"documentPicker.d.ts","sourceRoot":"","sources":["../../src/mediaPicker/documentPicker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAO3D,OAAO,EACL,KAAK,mBAAmB,EAExB,KAAK,uBAAuB,EAC7B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAE3E,UAAU,uBAAwB,SAAQ,uBAAuB;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,mBAAmB,CAAC;CACrC;AAED,UAAU,wBAAwB;IAChC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,uBAAuB,EAAE,GAAG,IAAI,CAAC;CAC5D;AAED,KAAK,UAAU,GAAG,CAAC,MAAM,EAAE,uBAAuB,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;AAElF,wBAAsB,6BAA6B,CACjD,MAAM,EAAE,wBAAwB,EAChC,UAAU,EAAE,SAAS,cAAc,EAAE,GAAG,SAAS,EACjD,SAAS,GAAE,UAAmC,GAC7C,OAAO,CAAC,qBAAqB,CAAC,CAkBhC;AAED,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,qBAAqB,CAAC,CAahC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { inferMediaAssetKind, isRequestedMediaKind, resolveDocumentPickerMimeTypes, } from './mediaKinds';
|
|
2
|
+
import { readSelectedMediaBytes, } from './readSelectedBytes';
|
|
3
|
+
export async function normalizeDocumentPickerResult(result, mediaKinds, readBytes = readSelectedMediaBytes) {
|
|
4
|
+
if (result.canceled)
|
|
5
|
+
return { ok: false, reason: 'cancelled' };
|
|
6
|
+
const asset = result.assets?.[0];
|
|
7
|
+
if (!asset)
|
|
8
|
+
return { ok: false, reason: 'empty-selection' };
|
|
9
|
+
const kind = inferMediaAssetKind(asset.mimeType, asset.name);
|
|
10
|
+
if (!isRequestedMediaKind(kind, mediaKinds))
|
|
11
|
+
return { ok: false, reason: 'unsupported-kind' };
|
|
12
|
+
const body = await readBytes(asset);
|
|
13
|
+
if (!body)
|
|
14
|
+
return { ok: false, reason: 'read-failed' };
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
selection: {
|
|
18
|
+
kind,
|
|
19
|
+
name: asset.name,
|
|
20
|
+
body,
|
|
21
|
+
contentType: asset.mimeType,
|
|
22
|
+
sizeBytes: asset.size ?? body.byteLength,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function pickDocumentMedia(input) {
|
|
27
|
+
try {
|
|
28
|
+
const DocumentPicker = await import('expo-document-picker');
|
|
29
|
+
const result = await DocumentPicker.getDocumentAsync({
|
|
30
|
+
base64: false,
|
|
31
|
+
copyToCacheDirectory: true,
|
|
32
|
+
multiple: false,
|
|
33
|
+
type: resolveDocumentPickerMimeTypes(input.mediaKinds),
|
|
34
|
+
});
|
|
35
|
+
return await normalizeDocumentPickerResult(result, input.mediaKinds);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { ok: false, reason: 'picker-failed' };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=documentPicker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"documentPicker.js","sourceRoot":"","sources":["../../src/mediaPicker/documentPicker.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,8BAA8B,GAC/B,MAAM,cAAc,CAAC;AACtB,OAAO,EAEL,sBAAsB,GAEvB,MAAM,qBAAqB,CAAC;AAiB7B,MAAM,CAAC,KAAK,UAAU,6BAA6B,CACjD,MAAgC,EAChC,UAAiD,EACjD,YAAwB,sBAAsB;IAE9C,IAAI,MAAM,CAAC,QAAQ;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IAC/D,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC;IACjC,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC5D,MAAM,IAAI,GAAG,mBAAmB,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;IAC7D,IAAI,CAAC,oBAAoB,CAAC,IAAI,EAAE,UAAU,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAC9F,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IACvD,OAAO;QACL,EAAE,EAAE,IAAI;QACR,SAAS,EAAE;YACT,IAAI;YACJ,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,IAAI;YACJ,WAAW,EAAE,KAAK,CAAC,QAAQ;YAC3B,SAAS,EAAE,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU;SACzC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,KAA2B;IAE3B,IAAI,CAAC;QACH,MAAM,cAAc,GAAG,MAAM,MAAM,CAAC,sBAAsB,CAAC,CAAC;QAC5D,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,gBAAgB,CAAC;YACnD,MAAM,EAAE,KAAK;YACb,oBAAoB,EAAE,IAAI;YAC1B,QAAQ,EAAE,KAAK;YACf,IAAI,EAAE,8BAA8B,CAAC,KAAK,CAAC,UAAU,CAAC;SACvD,CAAC,CAAC;QACH,OAAO,MAAM,6BAA6B,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAChD,CAAC;AACH,CAAC","sourcesContent":["import type { MediaAssetKind } from '@ankhorage/contracts';\n\nimport {\n inferMediaAssetKind,\n isRequestedMediaKind,\n resolveDocumentPickerMimeTypes,\n} from './mediaKinds';\nimport {\n type ArrayBufferReadable,\n readSelectedMediaBytes,\n type SelectedMediaByteSource,\n} from './readSelectedBytes';\nimport type { ExpoMediaPickerInput, ExpoMediaPickerResult } from './types';\n\ninterface DocumentPickerAssetLike extends SelectedMediaByteSource {\n readonly name: string;\n readonly mimeType?: string;\n readonly size?: number;\n readonly file?: ArrayBufferReadable;\n}\n\ninterface DocumentPickerResultLike {\n readonly canceled: boolean;\n readonly assets: readonly DocumentPickerAssetLike[] | null;\n}\n\ntype ByteReader = (source: SelectedMediaByteSource) => Promise<Uint8Array | null>;\n\nexport async function normalizeDocumentPickerResult(\n result: DocumentPickerResultLike,\n mediaKinds: readonly MediaAssetKind[] | undefined,\n readBytes: ByteReader = readSelectedMediaBytes,\n): Promise<ExpoMediaPickerResult> {\n if (result.canceled) return { ok: false, reason: 'cancelled' };\n const asset = result.assets?.[0];\n if (!asset) return { ok: false, reason: 'empty-selection' };\n const kind = inferMediaAssetKind(asset.mimeType, asset.name);\n if (!isRequestedMediaKind(kind, mediaKinds)) return { ok: false, reason: 'unsupported-kind' };\n const body = await readBytes(asset);\n if (!body) return { ok: false, reason: 'read-failed' };\n return {\n ok: true,\n selection: {\n kind,\n name: asset.name,\n body,\n contentType: asset.mimeType,\n sizeBytes: asset.size ?? body.byteLength,\n },\n };\n}\n\nexport async function pickDocumentMedia(\n input: ExpoMediaPickerInput,\n): Promise<ExpoMediaPickerResult> {\n try {\n const DocumentPicker = await import('expo-document-picker');\n const result = await DocumentPicker.getDocumentAsync({\n base64: false,\n copyToCacheDirectory: true,\n multiple: false,\n type: resolveDocumentPickerMimeTypes(input.mediaKinds),\n });\n return await normalizeDocumentPickerResult(result, input.mediaKinds);\n } catch {\n return { ok: false, reason: 'picker-failed' };\n }\n}\n"]}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createExpoMediaPickerAdapter } from './createExpoMediaPickerAdapter';
|
|
2
|
+
export { EXPO_MEDIA_PICKER_SOURCES, type ExpoMediaPickerAdapter, type ExpoMediaPickerFailureReason, type ExpoMediaPickerInput, type ExpoMediaPickerResult, type ExpoMediaPickerSelection, type ExpoMediaPickerSource, } from './types';
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/mediaPicker/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAC9E,OAAO,EACL,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,4BAA4B,EACjC,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,wBAAwB,EAC7B,KAAK,qBAAqB,GAC3B,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/mediaPicker/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAC9E,OAAO,EACL,yBAAyB,GAO1B,MAAM,SAAS,CAAC","sourcesContent":["export { createExpoMediaPickerAdapter } from './createExpoMediaPickerAdapter';\nexport {\n EXPO_MEDIA_PICKER_SOURCES,\n type ExpoMediaPickerAdapter,\n type ExpoMediaPickerFailureReason,\n type ExpoMediaPickerInput,\n type ExpoMediaPickerResult,\n type ExpoMediaPickerSelection,\n type ExpoMediaPickerSource,\n} from './types';\n"]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
export declare function inferMediaAssetKind(contentType: string | undefined, name: string): MediaAssetKind;
|
|
3
|
+
export declare function isRequestedMediaKind(kind: MediaAssetKind, mediaKinds?: readonly MediaAssetKind[]): boolean;
|
|
4
|
+
export declare function resolveDocumentPickerMimeTypes(mediaKinds?: readonly MediaAssetKind[]): string | string[];
|
|
5
|
+
//# sourceMappingURL=mediaKinds.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mediaKinds.d.ts","sourceRoot":"","sources":["../../src/mediaPicker/mediaKinds.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAmB3D,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,cAAc,CASjG;AAED,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,cAAc,EACpB,UAAU,CAAC,EAAE,SAAS,cAAc,EAAE,GACrC,OAAO,CAET;AAED,wBAAgB,8BAA8B,CAC5C,UAAU,CAAC,EAAE,SAAS,cAAc,EAAE,GACrC,MAAM,GAAG,MAAM,EAAE,CAKnB"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const FONT_EXTENSIONS = /\.(eot|otf|ttf|woff2?)$/i;
|
|
2
|
+
const FONT_CONTENT_TYPES = new Set([
|
|
3
|
+
'application/font-sfnt',
|
|
4
|
+
'application/font-woff',
|
|
5
|
+
'application/font-woff2',
|
|
6
|
+
'application/vnd.ms-fontobject',
|
|
7
|
+
'application/x-font-opentype',
|
|
8
|
+
'application/x-font-ttf',
|
|
9
|
+
]);
|
|
10
|
+
const DOCUMENT_MIME_TYPES = {
|
|
11
|
+
image: ['image/*'],
|
|
12
|
+
audio: ['audio/*'],
|
|
13
|
+
video: ['video/*'],
|
|
14
|
+
font: ['font/*', ...FONT_CONTENT_TYPES],
|
|
15
|
+
};
|
|
16
|
+
export function inferMediaAssetKind(contentType, name) {
|
|
17
|
+
const normalizedType = contentType?.toLowerCase();
|
|
18
|
+
if (normalizedType?.startsWith('image/'))
|
|
19
|
+
return 'image';
|
|
20
|
+
if (normalizedType?.startsWith('audio/'))
|
|
21
|
+
return 'audio';
|
|
22
|
+
if (normalizedType?.startsWith('video/'))
|
|
23
|
+
return 'video';
|
|
24
|
+
if (normalizedType?.startsWith('font/') || FONT_CONTENT_TYPES.has(normalizedType ?? '')) {
|
|
25
|
+
return 'font';
|
|
26
|
+
}
|
|
27
|
+
return FONT_EXTENSIONS.test(name) ? 'font' : 'file';
|
|
28
|
+
}
|
|
29
|
+
export function isRequestedMediaKind(kind, mediaKinds) {
|
|
30
|
+
return !mediaKinds?.length || mediaKinds.includes(kind);
|
|
31
|
+
}
|
|
32
|
+
export function resolveDocumentPickerMimeTypes(mediaKinds) {
|
|
33
|
+
if (!mediaKinds?.length || mediaKinds.includes('file'))
|
|
34
|
+
return '*/*';
|
|
35
|
+
return Array.from(new Set(mediaKinds.flatMap((kind) => (kind === 'file' ? [] : DOCUMENT_MIME_TYPES[kind]))));
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=mediaKinds.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mediaKinds.js","sourceRoot":"","sources":["../../src/mediaPicker/mediaKinds.ts"],"names":[],"mappings":"AAEA,MAAM,eAAe,GAAG,0BAA0B,CAAC;AACnD,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC;IACjC,uBAAuB;IACvB,uBAAuB;IACvB,wBAAwB;IACxB,+BAA+B;IAC/B,6BAA6B;IAC7B,wBAAwB;CACzB,CAAC,CAAC;AAEH,MAAM,mBAAmB,GAAyE;IAChG,KAAK,EAAE,CAAC,SAAS,CAAC;IAClB,KAAK,EAAE,CAAC,SAAS,CAAC;IAClB,KAAK,EAAE,CAAC,SAAS,CAAC;IAClB,IAAI,EAAE,CAAC,QAAQ,EAAE,GAAG,kBAAkB,CAAC;CACxC,CAAC;AAEF,MAAM,UAAU,mBAAmB,CAAC,WAA+B,EAAE,IAAY;IAC/E,MAAM,cAAc,GAAG,WAAW,EAAE,WAAW,EAAE,CAAC;IAClD,IAAI,cAAc,EAAE,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,OAAO,CAAC;IACzD,IAAI,cAAc,EAAE,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,OAAO,CAAC;IACzD,IAAI,cAAc,EAAE,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,OAAO,CAAC;IACzD,IAAI,cAAc,EAAE,UAAU,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,GAAG,CAAC,cAAc,IAAI,EAAE,CAAC,EAAE,CAAC;QACxF,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;AACtD,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,IAAoB,EACpB,UAAsC;IAEtC,OAAO,CAAC,UAAU,EAAE,MAAM,IAAI,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,8BAA8B,CAC5C,UAAsC;IAEtC,IAAI,CAAC,UAAU,EAAE,MAAM,IAAI,UAAU,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAO,KAAK,CAAC;IACrE,OAAO,KAAK,CAAC,IAAI,CACf,IAAI,GAAG,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAC1F,CAAC;AACJ,CAAC","sourcesContent":["import type { MediaAssetKind } from '@ankhorage/contracts';\n\nconst FONT_EXTENSIONS = /\\.(eot|otf|ttf|woff2?)$/i;\nconst FONT_CONTENT_TYPES = new Set([\n 'application/font-sfnt',\n 'application/font-woff',\n 'application/font-woff2',\n 'application/vnd.ms-fontobject',\n 'application/x-font-opentype',\n 'application/x-font-ttf',\n]);\n\nconst DOCUMENT_MIME_TYPES: Readonly<Record<Exclude<MediaAssetKind, 'file'>, readonly string[]>> = {\n image: ['image/*'],\n audio: ['audio/*'],\n video: ['video/*'],\n font: ['font/*', ...FONT_CONTENT_TYPES],\n};\n\nexport function inferMediaAssetKind(contentType: string | undefined, name: string): MediaAssetKind {\n const normalizedType = contentType?.toLowerCase();\n if (normalizedType?.startsWith('image/')) return 'image';\n if (normalizedType?.startsWith('audio/')) return 'audio';\n if (normalizedType?.startsWith('video/')) return 'video';\n if (normalizedType?.startsWith('font/') || FONT_CONTENT_TYPES.has(normalizedType ?? '')) {\n return 'font';\n }\n return FONT_EXTENSIONS.test(name) ? 'font' : 'file';\n}\n\nexport function isRequestedMediaKind(\n kind: MediaAssetKind,\n mediaKinds?: readonly MediaAssetKind[],\n): boolean {\n return !mediaKinds?.length || mediaKinds.includes(kind);\n}\n\nexport function resolveDocumentPickerMimeTypes(\n mediaKinds?: readonly MediaAssetKind[],\n): string | string[] {\n if (!mediaKinds?.length || mediaKinds.includes('file')) return '*/*';\n return Array.from(\n new Set(mediaKinds.flatMap((kind) => (kind === 'file' ? [] : DOCUMENT_MIME_TYPES[kind]))),\n );\n}\n"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
import { type ArrayBufferReadable, type SelectedMediaByteSource } from './readSelectedBytes';
|
|
3
|
+
import type { ExpoMediaPickerInput, ExpoMediaPickerResult } from './types';
|
|
4
|
+
export type ExpoPhotoLibraryMediaType = 'images' | 'videos';
|
|
5
|
+
interface PhotoLibraryAssetLike extends SelectedMediaByteSource {
|
|
6
|
+
readonly fileName?: string | null;
|
|
7
|
+
readonly fileSize?: number;
|
|
8
|
+
readonly mimeType?: string | null;
|
|
9
|
+
readonly width: number;
|
|
10
|
+
readonly height: number;
|
|
11
|
+
readonly duration?: number | null;
|
|
12
|
+
readonly type?: 'image' | 'video' | 'livePhoto' | 'pairedVideo' | null;
|
|
13
|
+
readonly file?: ArrayBufferReadable;
|
|
14
|
+
}
|
|
15
|
+
interface PhotoLibraryResultLike {
|
|
16
|
+
readonly canceled: boolean;
|
|
17
|
+
readonly assets: readonly PhotoLibraryAssetLike[] | null;
|
|
18
|
+
}
|
|
19
|
+
type ByteReader = (source: SelectedMediaByteSource) => Promise<Uint8Array | null>;
|
|
20
|
+
export declare function resolvePhotoLibraryMediaTypes(mediaKinds?: readonly MediaAssetKind[]): ExpoPhotoLibraryMediaType[] | null;
|
|
21
|
+
export declare function normalizePhotoLibraryResult(result: PhotoLibraryResultLike, mediaKinds: readonly MediaAssetKind[] | undefined, readBytes?: ByteReader): Promise<ExpoMediaPickerResult>;
|
|
22
|
+
export declare function pickPhotoLibraryMedia(input: ExpoMediaPickerInput): Promise<ExpoMediaPickerResult>;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=photoLibraryPicker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"photoLibraryPicker.d.ts","sourceRoot":"","sources":["../../src/mediaPicker/photoLibraryPicker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG3D,OAAO,EACL,KAAK,mBAAmB,EAExB,KAAK,uBAAuB,EAC7B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAE3E,MAAM,MAAM,yBAAyB,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,UAAU,qBAAsB,SAAQ,uBAAuB;IAC7D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,aAAa,GAAG,IAAI,CAAC;IACvE,QAAQ,CAAC,IAAI,CAAC,EAAE,mBAAmB,CAAC;CACrC;AAED,UAAU,sBAAsB;IAC9B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,qBAAqB,EAAE,GAAG,IAAI,CAAC;CAC1D;AAED,KAAK,UAAU,GAAG,CAAC,MAAM,EAAE,uBAAuB,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;AAElF,wBAAgB,6BAA6B,CAC3C,UAAU,CAAC,EAAE,SAAS,cAAc,EAAE,GACrC,yBAAyB,EAAE,GAAG,IAAI,CAMpC;AAED,wBAAsB,2BAA2B,CAC/C,MAAM,EAAE,sBAAsB,EAC9B,UAAU,EAAE,SAAS,cAAc,EAAE,GAAG,SAAS,EACjD,SAAS,GAAE,UAAmC,GAC7C,OAAO,CAAC,qBAAqB,CAAC,CAqBhC;AAED,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,qBAAqB,CAAC,CAiBhC"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRequestedMediaKind } from './mediaKinds';
|
|
2
|
+
import { readSelectedMediaBytes, } from './readSelectedBytes';
|
|
3
|
+
export function resolvePhotoLibraryMediaTypes(mediaKinds) {
|
|
4
|
+
if (!mediaKinds?.length)
|
|
5
|
+
return ['images', 'videos'];
|
|
6
|
+
const mediaTypes = [];
|
|
7
|
+
if (mediaKinds.includes('image'))
|
|
8
|
+
mediaTypes.push('images');
|
|
9
|
+
if (mediaKinds.includes('video'))
|
|
10
|
+
mediaTypes.push('videos');
|
|
11
|
+
return mediaTypes.length > 0 ? mediaTypes : null;
|
|
12
|
+
}
|
|
13
|
+
export async function normalizePhotoLibraryResult(result, mediaKinds, readBytes = readSelectedMediaBytes) {
|
|
14
|
+
if (result.canceled)
|
|
15
|
+
return { ok: false, reason: 'cancelled' };
|
|
16
|
+
const asset = result.assets?.[0];
|
|
17
|
+
if (!asset)
|
|
18
|
+
return { ok: false, reason: 'empty-selection' };
|
|
19
|
+
const kind = resolvePhotoLibraryAssetKind(asset);
|
|
20
|
+
if (!isRequestedMediaKind(kind, mediaKinds))
|
|
21
|
+
return { ok: false, reason: 'unsupported-kind' };
|
|
22
|
+
const body = await readBytes(asset);
|
|
23
|
+
if (!body)
|
|
24
|
+
return { ok: false, reason: 'read-failed' };
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
selection: {
|
|
28
|
+
kind,
|
|
29
|
+
name: asset.fileName?.trim() ?? `selected-${kind}`,
|
|
30
|
+
body,
|
|
31
|
+
contentType: asset.mimeType ?? undefined,
|
|
32
|
+
sizeBytes: asset.fileSize ?? body.byteLength,
|
|
33
|
+
width: asset.width,
|
|
34
|
+
height: asset.height,
|
|
35
|
+
durationMs: asset.duration ?? undefined,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function pickPhotoLibraryMedia(input) {
|
|
40
|
+
const mediaTypes = resolvePhotoLibraryMediaTypes(input.mediaKinds);
|
|
41
|
+
if (!mediaTypes)
|
|
42
|
+
return { ok: false, reason: 'unsupported-kind' };
|
|
43
|
+
try {
|
|
44
|
+
const ImagePicker = await import('expo-image-picker');
|
|
45
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
46
|
+
allowsEditing: false,
|
|
47
|
+
allowsMultipleSelection: false,
|
|
48
|
+
base64: false,
|
|
49
|
+
exif: false,
|
|
50
|
+
mediaTypes,
|
|
51
|
+
quality: 1,
|
|
52
|
+
});
|
|
53
|
+
return await normalizePhotoLibraryResult(result, input.mediaKinds);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { ok: false, reason: 'picker-failed' };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function resolvePhotoLibraryAssetKind(asset) {
|
|
60
|
+
if (asset.type === 'video' || asset.type === 'pairedVideo')
|
|
61
|
+
return 'video';
|
|
62
|
+
if (asset.type === 'image' || asset.type === 'livePhoto')
|
|
63
|
+
return 'image';
|
|
64
|
+
if (asset.mimeType?.startsWith('video/'))
|
|
65
|
+
return 'video';
|
|
66
|
+
return asset.duration !== null && asset.duration !== undefined ? 'video' : 'image';
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=photoLibraryPicker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"photoLibraryPicker.js","sourceRoot":"","sources":["../../src/mediaPicker/photoLibraryPicker.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAEL,sBAAsB,GAEvB,MAAM,qBAAqB,CAAC;AAuB7B,MAAM,UAAU,6BAA6B,CAC3C,UAAsC;IAEtC,IAAI,CAAC,UAAU,EAAE,MAAM;QAAE,OAAO,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IACrD,MAAM,UAAU,GAAgC,EAAE,CAAC;IACnD,IAAI,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC5D,IAAI,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC5D,OAAO,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC;AACnD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,2BAA2B,CAC/C,MAA8B,EAC9B,UAAiD,EACjD,YAAwB,sBAAsB;IAE9C,IAAI,MAAM,CAAC,QAAQ;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IAC/D,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC;IACjC,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC5D,MAAM,IAAI,GAAG,4BAA4B,CAAC,KAAK,CAAC,CAAC;IACjD,IAAI,CAAC,oBAAoB,CAAC,IAAI,EAAE,UAAU,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAC9F,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IACvD,OAAO;QACL,EAAE,EAAE,IAAI;QACR,SAAS,EAAE;YACT,IAAI;YACJ,IAAI,EAAE,KAAK,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,YAAY,IAAI,EAAE;YAClD,IAAI;YACJ,WAAW,EAAE,KAAK,CAAC,QAAQ,IAAI,SAAS;YACxC,SAAS,EAAE,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC,UAAU;YAC5C,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,MAAM,EAAE,KAAK,CAAC,MAAM;YACpB,UAAU,EAAE,KAAK,CAAC,QAAQ,IAAI,SAAS;SACxC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,KAA2B;IAE3B,MAAM,UAAU,GAAG,6BAA6B,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACnE,IAAI,CAAC,UAAU;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAClE,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,CAAC;QACtD,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,uBAAuB,CAAC;YACvD,aAAa,EAAE,KAAK;YACpB,uBAAuB,EAAE,KAAK;YAC9B,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,KAAK;YACX,UAAU;YACV,OAAO,EAAE,CAAC;SACX,CAAC,CAAC;QACH,OAAO,MAAM,2BAA2B,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;IACrE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAChD,CAAC;AACH,CAAC;AAED,SAAS,4BAA4B,CAAC,KAA4B;IAChE,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,IAAI,KAAK,CAAC,IAAI,KAAK,aAAa;QAAE,OAAO,OAAO,CAAC;IAC3E,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,IAAI,KAAK,CAAC,IAAI,KAAK,WAAW;QAAE,OAAO,OAAO,CAAC;IACzE,IAAI,KAAK,CAAC,QAAQ,EAAE,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,OAAO,CAAC;IACzD,OAAO,KAAK,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;AACrF,CAAC","sourcesContent":["import type { MediaAssetKind } from '@ankhorage/contracts';\n\nimport { isRequestedMediaKind } from './mediaKinds';\nimport {\n type ArrayBufferReadable,\n readSelectedMediaBytes,\n type SelectedMediaByteSource,\n} from './readSelectedBytes';\nimport type { ExpoMediaPickerInput, ExpoMediaPickerResult } from './types';\n\nexport type ExpoPhotoLibraryMediaType = 'images' | 'videos';\n\ninterface PhotoLibraryAssetLike extends SelectedMediaByteSource {\n readonly fileName?: string | null;\n readonly fileSize?: number;\n readonly mimeType?: string | null;\n readonly width: number;\n readonly height: number;\n readonly duration?: number | null;\n readonly type?: 'image' | 'video' | 'livePhoto' | 'pairedVideo' | null;\n readonly file?: ArrayBufferReadable;\n}\n\ninterface PhotoLibraryResultLike {\n readonly canceled: boolean;\n readonly assets: readonly PhotoLibraryAssetLike[] | null;\n}\n\ntype ByteReader = (source: SelectedMediaByteSource) => Promise<Uint8Array | null>;\n\nexport function resolvePhotoLibraryMediaTypes(\n mediaKinds?: readonly MediaAssetKind[],\n): ExpoPhotoLibraryMediaType[] | null {\n if (!mediaKinds?.length) return ['images', 'videos'];\n const mediaTypes: ExpoPhotoLibraryMediaType[] = [];\n if (mediaKinds.includes('image')) mediaTypes.push('images');\n if (mediaKinds.includes('video')) mediaTypes.push('videos');\n return mediaTypes.length > 0 ? mediaTypes : null;\n}\n\nexport async function normalizePhotoLibraryResult(\n result: PhotoLibraryResultLike,\n mediaKinds: readonly MediaAssetKind[] | undefined,\n readBytes: ByteReader = readSelectedMediaBytes,\n): Promise<ExpoMediaPickerResult> {\n if (result.canceled) return { ok: false, reason: 'cancelled' };\n const asset = result.assets?.[0];\n if (!asset) return { ok: false, reason: 'empty-selection' };\n const kind = resolvePhotoLibraryAssetKind(asset);\n if (!isRequestedMediaKind(kind, mediaKinds)) return { ok: false, reason: 'unsupported-kind' };\n const body = await readBytes(asset);\n if (!body) return { ok: false, reason: 'read-failed' };\n return {\n ok: true,\n selection: {\n kind,\n name: asset.fileName?.trim() ?? `selected-${kind}`,\n body,\n contentType: asset.mimeType ?? undefined,\n sizeBytes: asset.fileSize ?? body.byteLength,\n width: asset.width,\n height: asset.height,\n durationMs: asset.duration ?? undefined,\n },\n };\n}\n\nexport async function pickPhotoLibraryMedia(\n input: ExpoMediaPickerInput,\n): Promise<ExpoMediaPickerResult> {\n const mediaTypes = resolvePhotoLibraryMediaTypes(input.mediaKinds);\n if (!mediaTypes) return { ok: false, reason: 'unsupported-kind' };\n try {\n const ImagePicker = await import('expo-image-picker');\n const result = await ImagePicker.launchImageLibraryAsync({\n allowsEditing: false,\n allowsMultipleSelection: false,\n base64: false,\n exif: false,\n mediaTypes,\n quality: 1,\n });\n return await normalizePhotoLibraryResult(result, input.mediaKinds);\n } catch {\n return { ok: false, reason: 'picker-failed' };\n }\n}\n\nfunction resolvePhotoLibraryAssetKind(asset: PhotoLibraryAssetLike): 'image' | 'video' {\n if (asset.type === 'video' || asset.type === 'pairedVideo') return 'video';\n if (asset.type === 'image' || asset.type === 'livePhoto') return 'image';\n if (asset.mimeType?.startsWith('video/')) return 'video';\n return asset.duration !== null && asset.duration !== undefined ? 'video' : 'image';\n}\n"]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ArrayBufferReadable {
|
|
2
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
3
|
+
}
|
|
4
|
+
export interface SelectedMediaByteSource {
|
|
5
|
+
readonly uri: string;
|
|
6
|
+
readonly file?: ArrayBufferReadable;
|
|
7
|
+
}
|
|
8
|
+
export declare function readSelectedMediaBytes(source: SelectedMediaByteSource): Promise<Uint8Array | null>;
|
|
9
|
+
//# sourceMappingURL=readSelectedBytes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readSelectedBytes.d.ts","sourceRoot":"","sources":["../../src/mediaPicker/readSelectedBytes.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,mBAAmB,CAAC;CACrC;AAED,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAU5B"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export async function readSelectedMediaBytes(source) {
|
|
2
|
+
try {
|
|
3
|
+
if (source.file) {
|
|
4
|
+
return new Uint8Array(await source.file.arrayBuffer());
|
|
5
|
+
}
|
|
6
|
+
const { File } = await import('expo-file-system');
|
|
7
|
+
return await new File(source.uri).bytes();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=readSelectedBytes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readSelectedBytes.js","sourceRoot":"","sources":["../../src/mediaPicker/readSelectedBytes.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAC1C,MAA+B;IAE/B,IAAI,CAAC;QACH,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;YAChB,OAAO,IAAI,UAAU,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QACzD,CAAC;QACD,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAClD,OAAO,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC;IAC5C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC","sourcesContent":["export interface ArrayBufferReadable {\n arrayBuffer(): Promise<ArrayBuffer>;\n}\n\nexport interface SelectedMediaByteSource {\n readonly uri: string;\n readonly file?: ArrayBufferReadable;\n}\n\nexport async function readSelectedMediaBytes(\n source: SelectedMediaByteSource,\n): Promise<Uint8Array | null> {\n try {\n if (source.file) {\n return new Uint8Array(await source.file.arrayBuffer());\n }\n const { File } = await import('expo-file-system');\n return await new File(source.uri).bytes();\n } catch {\n return null;\n }\n}\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
export declare const EXPO_MEDIA_PICKER_SOURCES: readonly ["file", "photo-library"];
|
|
3
|
+
export type ExpoMediaPickerSource = (typeof EXPO_MEDIA_PICKER_SOURCES)[number];
|
|
4
|
+
export interface ExpoMediaPickerInput {
|
|
5
|
+
readonly source: ExpoMediaPickerSource;
|
|
6
|
+
readonly mediaKinds?: readonly MediaAssetKind[];
|
|
7
|
+
}
|
|
8
|
+
export interface ExpoMediaPickerSelection {
|
|
9
|
+
readonly kind: MediaAssetKind;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly body: Uint8Array;
|
|
12
|
+
readonly contentType?: string;
|
|
13
|
+
readonly sizeBytes?: number;
|
|
14
|
+
readonly width?: number;
|
|
15
|
+
readonly height?: number;
|
|
16
|
+
readonly durationMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export type ExpoMediaPickerFailureReason = 'cancelled' | 'empty-selection' | 'picker-failed' | 'read-failed' | 'unsupported-kind';
|
|
19
|
+
export type ExpoMediaPickerResult = {
|
|
20
|
+
readonly ok: true;
|
|
21
|
+
readonly selection: ExpoMediaPickerSelection;
|
|
22
|
+
} | {
|
|
23
|
+
readonly ok: false;
|
|
24
|
+
readonly reason: ExpoMediaPickerFailureReason;
|
|
25
|
+
};
|
|
26
|
+
export interface ExpoMediaPickerAdapter {
|
|
27
|
+
pick(input: ExpoMediaPickerInput): Promise<ExpoMediaPickerResult>;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/mediaPicker/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,eAAO,MAAM,yBAAyB,oCAAqC,CAAC;AAE5E,MAAM,MAAM,qBAAqB,GAAG,CAAC,OAAO,yBAAyB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE/E,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IACvC,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;CACjD;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,4BAA4B,GACpC,WAAW,GACX,iBAAiB,GACjB,eAAe,GACf,aAAa,GACb,kBAAkB,CAAC;AAEvB,MAAM,MAAM,qBAAqB,GAC7B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,wBAAwB,CAAA;CAAE,GACnE;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,4BAA4B,CAAA;CAAE,CAAC;AAE1E,MAAM,WAAW,sBAAsB;IACrC,IAAI,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;CACnE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/mediaPicker/types.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,MAAM,EAAE,eAAe,CAAU,CAAC","sourcesContent":["import type { MediaAssetKind } from '@ankhorage/contracts';\n\nexport const EXPO_MEDIA_PICKER_SOURCES = ['file', 'photo-library'] as const;\n\nexport type ExpoMediaPickerSource = (typeof EXPO_MEDIA_PICKER_SOURCES)[number];\n\nexport interface ExpoMediaPickerInput {\n readonly source: ExpoMediaPickerSource;\n readonly mediaKinds?: readonly MediaAssetKind[];\n}\n\nexport interface ExpoMediaPickerSelection {\n readonly kind: MediaAssetKind;\n readonly name: string;\n readonly body: Uint8Array;\n readonly contentType?: string;\n readonly sizeBytes?: number;\n readonly width?: number;\n readonly height?: number;\n readonly durationMs?: number;\n}\n\nexport type ExpoMediaPickerFailureReason =\n | 'cancelled'\n | 'empty-selection'\n | 'picker-failed'\n | 'read-failed'\n | 'unsupported-kind';\n\nexport type ExpoMediaPickerResult =\n | { readonly ok: true; readonly selection: ExpoMediaPickerSelection }\n | { readonly ok: false; readonly reason: ExpoMediaPickerFailureReason };\n\nexport interface ExpoMediaPickerAdapter {\n pick(input: ExpoMediaPickerInput): Promise<ExpoMediaPickerResult>;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/expo-runtime",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"description": "Declarative runtime integration for Expo apps: maps app capabilities to permissions, packages, config plugins, providers, and adapters",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/expo-runtime#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"runtime"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@ankhorage/contracts": "^
|
|
25
|
+
"@ankhorage/contracts": "^7.6.0",
|
|
26
26
|
"@ankhorage/permissions": "^0.2.1"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
@@ -47,6 +47,13 @@
|
|
|
47
47
|
"types": "./src/planning.ts",
|
|
48
48
|
"import": "./dist/planning.js",
|
|
49
49
|
"default": "./dist/planning.js"
|
|
50
|
+
},
|
|
51
|
+
"./media-picker": {
|
|
52
|
+
"react-native": "./src/mediaPicker/index.ts",
|
|
53
|
+
"browser": "./src/mediaPicker/index.ts",
|
|
54
|
+
"types": "./src/mediaPicker/index.ts",
|
|
55
|
+
"import": "./dist/mediaPicker/index.js",
|
|
56
|
+
"default": "./dist/mediaPicker/index.js"
|
|
50
57
|
}
|
|
51
58
|
},
|
|
52
59
|
"publishConfig": {
|
|
@@ -71,9 +78,23 @@
|
|
|
71
78
|
"@ankhorage/zora": "^2.8.7",
|
|
72
79
|
"expo": "~54.0.34",
|
|
73
80
|
"expo-camera": "~17.0.10",
|
|
81
|
+
"expo-document-picker": "~14.0.8",
|
|
82
|
+
"expo-file-system": "~19.0.23",
|
|
83
|
+
"expo-image-picker": "~17.0.11",
|
|
74
84
|
"react": "19.1.0",
|
|
75
85
|
"react-native": "0.81.5"
|
|
76
86
|
},
|
|
87
|
+
"peerDependenciesMeta": {
|
|
88
|
+
"expo-document-picker": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"expo-file-system": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"expo-image-picker": {
|
|
95
|
+
"optional": true
|
|
96
|
+
}
|
|
97
|
+
},
|
|
77
98
|
"devDependencies": {
|
|
78
99
|
"@ankhorage/devtools": "^1.0.6",
|
|
79
100
|
"@ankhorage/paradox": "^0.1.10",
|
|
@@ -84,6 +105,9 @@
|
|
|
84
105
|
"@types/react": "^19.2.14",
|
|
85
106
|
"expo": "~54.0.34",
|
|
86
107
|
"expo-camera": "~17.0.10",
|
|
108
|
+
"expo-document-picker": "~14.0.8",
|
|
109
|
+
"expo-file-system": "~19.0.23",
|
|
110
|
+
"expo-image-picker": "~17.0.11",
|
|
87
111
|
"react": "19.1.0",
|
|
88
112
|
"react-native": "0.81.5",
|
|
89
113
|
"typescript": "^5.9.3"
|
|
@@ -63,6 +63,13 @@ const CAMERA_PLAN: ExpoRuntimePlan = {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
describe('generatedAppOutput', () => {
|
|
66
|
+
test('keeps Expo config plugins as a distinct external ecosystem concept', () => {
|
|
67
|
+
expect(resolveExpoRuntimeConfigPluginOutput(CAMERA_PLAN)).toEqual([
|
|
68
|
+
['expo-camera', { cameraPermission: 'Allow camera access to scan barcodes.' }],
|
|
69
|
+
'expo-something',
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
66
73
|
test('returns empty output for missing or empty plans', () => {
|
|
67
74
|
expect(resolveExpoRuntimeDependencyMap(undefined)).toEqual({});
|
|
68
75
|
expect(resolveExpoRuntimeConfigPluginOutput(undefined)).toEqual([]);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { pickDocumentMedia } from './documentPicker';
|
|
2
|
+
import { pickPhotoLibraryMedia } from './photoLibraryPicker';
|
|
3
|
+
import type { ExpoMediaPickerAdapter } from './types';
|
|
4
|
+
|
|
5
|
+
export function createExpoMediaPickerAdapter(): ExpoMediaPickerAdapter {
|
|
6
|
+
return {
|
|
7
|
+
pick: (input) =>
|
|
8
|
+
input.source === 'photo-library' ? pickPhotoLibraryMedia(input) : pickDocumentMedia(input),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
inferMediaAssetKind,
|
|
5
|
+
isRequestedMediaKind,
|
|
6
|
+
resolveDocumentPickerMimeTypes,
|
|
7
|
+
} from './mediaKinds';
|
|
8
|
+
import {
|
|
9
|
+
type ArrayBufferReadable,
|
|
10
|
+
readSelectedMediaBytes,
|
|
11
|
+
type SelectedMediaByteSource,
|
|
12
|
+
} from './readSelectedBytes';
|
|
13
|
+
import type { ExpoMediaPickerInput, ExpoMediaPickerResult } from './types';
|
|
14
|
+
|
|
15
|
+
interface DocumentPickerAssetLike extends SelectedMediaByteSource {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly mimeType?: string;
|
|
18
|
+
readonly size?: number;
|
|
19
|
+
readonly file?: ArrayBufferReadable;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DocumentPickerResultLike {
|
|
23
|
+
readonly canceled: boolean;
|
|
24
|
+
readonly assets: readonly DocumentPickerAssetLike[] | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ByteReader = (source: SelectedMediaByteSource) => Promise<Uint8Array | null>;
|
|
28
|
+
|
|
29
|
+
export async function normalizeDocumentPickerResult(
|
|
30
|
+
result: DocumentPickerResultLike,
|
|
31
|
+
mediaKinds: readonly MediaAssetKind[] | undefined,
|
|
32
|
+
readBytes: ByteReader = readSelectedMediaBytes,
|
|
33
|
+
): Promise<ExpoMediaPickerResult> {
|
|
34
|
+
if (result.canceled) return { ok: false, reason: 'cancelled' };
|
|
35
|
+
const asset = result.assets?.[0];
|
|
36
|
+
if (!asset) return { ok: false, reason: 'empty-selection' };
|
|
37
|
+
const kind = inferMediaAssetKind(asset.mimeType, asset.name);
|
|
38
|
+
if (!isRequestedMediaKind(kind, mediaKinds)) return { ok: false, reason: 'unsupported-kind' };
|
|
39
|
+
const body = await readBytes(asset);
|
|
40
|
+
if (!body) return { ok: false, reason: 'read-failed' };
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
selection: {
|
|
44
|
+
kind,
|
|
45
|
+
name: asset.name,
|
|
46
|
+
body,
|
|
47
|
+
contentType: asset.mimeType,
|
|
48
|
+
sizeBytes: asset.size ?? body.byteLength,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function pickDocumentMedia(
|
|
54
|
+
input: ExpoMediaPickerInput,
|
|
55
|
+
): Promise<ExpoMediaPickerResult> {
|
|
56
|
+
try {
|
|
57
|
+
const DocumentPicker = await import('expo-document-picker');
|
|
58
|
+
const result = await DocumentPicker.getDocumentAsync({
|
|
59
|
+
base64: false,
|
|
60
|
+
copyToCacheDirectory: true,
|
|
61
|
+
multiple: false,
|
|
62
|
+
type: resolveDocumentPickerMimeTypes(input.mediaKinds),
|
|
63
|
+
});
|
|
64
|
+
return await normalizeDocumentPickerResult(result, input.mediaKinds);
|
|
65
|
+
} catch {
|
|
66
|
+
return { ok: false, reason: 'picker-failed' };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createExpoMediaPickerAdapter } from './createExpoMediaPickerAdapter';
|
|
2
|
+
export {
|
|
3
|
+
EXPO_MEDIA_PICKER_SOURCES,
|
|
4
|
+
type ExpoMediaPickerAdapter,
|
|
5
|
+
type ExpoMediaPickerFailureReason,
|
|
6
|
+
type ExpoMediaPickerInput,
|
|
7
|
+
type ExpoMediaPickerResult,
|
|
8
|
+
type ExpoMediaPickerSelection,
|
|
9
|
+
type ExpoMediaPickerSource,
|
|
10
|
+
} from './types';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
|
|
3
|
+
const FONT_EXTENSIONS = /\.(eot|otf|ttf|woff2?)$/i;
|
|
4
|
+
const FONT_CONTENT_TYPES = new Set([
|
|
5
|
+
'application/font-sfnt',
|
|
6
|
+
'application/font-woff',
|
|
7
|
+
'application/font-woff2',
|
|
8
|
+
'application/vnd.ms-fontobject',
|
|
9
|
+
'application/x-font-opentype',
|
|
10
|
+
'application/x-font-ttf',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const DOCUMENT_MIME_TYPES: Readonly<Record<Exclude<MediaAssetKind, 'file'>, readonly string[]>> = {
|
|
14
|
+
image: ['image/*'],
|
|
15
|
+
audio: ['audio/*'],
|
|
16
|
+
video: ['video/*'],
|
|
17
|
+
font: ['font/*', ...FONT_CONTENT_TYPES],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function inferMediaAssetKind(contentType: string | undefined, name: string): MediaAssetKind {
|
|
21
|
+
const normalizedType = contentType?.toLowerCase();
|
|
22
|
+
if (normalizedType?.startsWith('image/')) return 'image';
|
|
23
|
+
if (normalizedType?.startsWith('audio/')) return 'audio';
|
|
24
|
+
if (normalizedType?.startsWith('video/')) return 'video';
|
|
25
|
+
if (normalizedType?.startsWith('font/') || FONT_CONTENT_TYPES.has(normalizedType ?? '')) {
|
|
26
|
+
return 'font';
|
|
27
|
+
}
|
|
28
|
+
return FONT_EXTENSIONS.test(name) ? 'font' : 'file';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isRequestedMediaKind(
|
|
32
|
+
kind: MediaAssetKind,
|
|
33
|
+
mediaKinds?: readonly MediaAssetKind[],
|
|
34
|
+
): boolean {
|
|
35
|
+
return !mediaKinds?.length || mediaKinds.includes(kind);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveDocumentPickerMimeTypes(
|
|
39
|
+
mediaKinds?: readonly MediaAssetKind[],
|
|
40
|
+
): string | string[] {
|
|
41
|
+
if (!mediaKinds?.length || mediaKinds.includes('file')) return '*/*';
|
|
42
|
+
return Array.from(
|
|
43
|
+
new Set(mediaKinds.flatMap((kind) => (kind === 'file' ? [] : DOCUMENT_MIME_TYPES[kind]))),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { normalizeDocumentPickerResult } from './documentPicker';
|
|
4
|
+
import { inferMediaAssetKind, resolveDocumentPickerMimeTypes } from './mediaKinds';
|
|
5
|
+
import { normalizePhotoLibraryResult, resolvePhotoLibraryMediaTypes } from './photoLibraryPicker';
|
|
6
|
+
import { readSelectedMediaBytes } from './readSelectedBytes';
|
|
7
|
+
|
|
8
|
+
const BYTES = new Uint8Array([1, 2, 3]);
|
|
9
|
+
const readBytes = () => Promise.resolve(BYTES);
|
|
10
|
+
|
|
11
|
+
describe('Expo media picker boundary', () => {
|
|
12
|
+
test('maps document MIME types and font extensions to canonical media kinds', () => {
|
|
13
|
+
expect(inferMediaAssetKind('image/svg+xml', 'logo.svg')).toBe('image');
|
|
14
|
+
expect(inferMediaAssetKind(undefined, 'Brand.woff2')).toBe('font');
|
|
15
|
+
expect(resolveDocumentPickerMimeTypes(['image', 'audio'])).toEqual(['image/*', 'audio/*']);
|
|
16
|
+
expect(resolveDocumentPickerMimeTypes(['file'])).toBe('*/*');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('normalizes document selection to bytes without leaking the transient URI', async () => {
|
|
20
|
+
const result = await normalizeDocumentPickerResult(
|
|
21
|
+
{
|
|
22
|
+
canceled: false,
|
|
23
|
+
assets: [
|
|
24
|
+
{
|
|
25
|
+
uri: 'content://temporary/logo',
|
|
26
|
+
name: 'logo.svg',
|
|
27
|
+
mimeType: 'image/svg+xml',
|
|
28
|
+
size: 3,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
['image'],
|
|
33
|
+
readBytes,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(result.ok).toBe(true);
|
|
37
|
+
if (!result.ok) return;
|
|
38
|
+
expect(result.selection).toEqual({
|
|
39
|
+
kind: 'image',
|
|
40
|
+
name: 'logo.svg',
|
|
41
|
+
body: BYTES,
|
|
42
|
+
contentType: 'image/svg+xml',
|
|
43
|
+
sizeBytes: 3,
|
|
44
|
+
});
|
|
45
|
+
expect('uri' in result.selection).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('rejects document selections outside the requested media kinds', async () => {
|
|
49
|
+
const result = await normalizeDocumentPickerResult(
|
|
50
|
+
{
|
|
51
|
+
canceled: false,
|
|
52
|
+
assets: [{ uri: 'file:///tmp/song.mp3', name: 'song.mp3', mimeType: 'audio/mpeg' }],
|
|
53
|
+
},
|
|
54
|
+
['image'],
|
|
55
|
+
readBytes,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual({ ok: false, reason: 'unsupported-kind' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('limits photo library picker to image and video kinds', () => {
|
|
62
|
+
expect(resolvePhotoLibraryMediaTypes()).toEqual(['images', 'videos']);
|
|
63
|
+
expect(resolvePhotoLibraryMediaTypes(['image'])).toEqual(['images']);
|
|
64
|
+
expect(resolvePhotoLibraryMediaTypes(['audio', 'font'])).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('normalizes photo library metadata without exposing its local URI', async () => {
|
|
68
|
+
const result = await normalizePhotoLibraryResult(
|
|
69
|
+
{
|
|
70
|
+
canceled: false,
|
|
71
|
+
assets: [
|
|
72
|
+
{
|
|
73
|
+
uri: 'file:///tmp/clip.mov',
|
|
74
|
+
fileName: 'clip.mov',
|
|
75
|
+
fileSize: 3,
|
|
76
|
+
mimeType: 'video/quicktime',
|
|
77
|
+
width: 1920,
|
|
78
|
+
height: 1080,
|
|
79
|
+
duration: 2400,
|
|
80
|
+
type: 'video',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
['video'],
|
|
85
|
+
readBytes,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(result.ok).toBe(true);
|
|
89
|
+
if (!result.ok) return;
|
|
90
|
+
expect(result.selection).toMatchObject({
|
|
91
|
+
kind: 'video',
|
|
92
|
+
name: 'clip.mov',
|
|
93
|
+
contentType: 'video/quicktime',
|
|
94
|
+
sizeBytes: 3,
|
|
95
|
+
width: 1920,
|
|
96
|
+
height: 1080,
|
|
97
|
+
durationMs: 2400,
|
|
98
|
+
});
|
|
99
|
+
expect('uri' in result.selection).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('reads web picker files without persisting their blob URI', async () => {
|
|
103
|
+
const { buffer } = new Uint8Array([4, 5, 6]);
|
|
104
|
+
const bytes = await readSelectedMediaBytes({
|
|
105
|
+
uri: 'blob:https://studio.local/transient',
|
|
106
|
+
file: { arrayBuffer: () => Promise.resolve(buffer) },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(bytes).toEqual(new Uint8Array([4, 5, 6]));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('normalizes cancellation', async () => {
|
|
113
|
+
expect(
|
|
114
|
+
await normalizeDocumentPickerResult({ canceled: true, assets: null }, undefined),
|
|
115
|
+
).toEqual({
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: 'cancelled',
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
|
|
3
|
+
import { isRequestedMediaKind } from './mediaKinds';
|
|
4
|
+
import {
|
|
5
|
+
type ArrayBufferReadable,
|
|
6
|
+
readSelectedMediaBytes,
|
|
7
|
+
type SelectedMediaByteSource,
|
|
8
|
+
} from './readSelectedBytes';
|
|
9
|
+
import type { ExpoMediaPickerInput, ExpoMediaPickerResult } from './types';
|
|
10
|
+
|
|
11
|
+
export type ExpoPhotoLibraryMediaType = 'images' | 'videos';
|
|
12
|
+
|
|
13
|
+
interface PhotoLibraryAssetLike extends SelectedMediaByteSource {
|
|
14
|
+
readonly fileName?: string | null;
|
|
15
|
+
readonly fileSize?: number;
|
|
16
|
+
readonly mimeType?: string | null;
|
|
17
|
+
readonly width: number;
|
|
18
|
+
readonly height: number;
|
|
19
|
+
readonly duration?: number | null;
|
|
20
|
+
readonly type?: 'image' | 'video' | 'livePhoto' | 'pairedVideo' | null;
|
|
21
|
+
readonly file?: ArrayBufferReadable;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PhotoLibraryResultLike {
|
|
25
|
+
readonly canceled: boolean;
|
|
26
|
+
readonly assets: readonly PhotoLibraryAssetLike[] | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ByteReader = (source: SelectedMediaByteSource) => Promise<Uint8Array | null>;
|
|
30
|
+
|
|
31
|
+
export function resolvePhotoLibraryMediaTypes(
|
|
32
|
+
mediaKinds?: readonly MediaAssetKind[],
|
|
33
|
+
): ExpoPhotoLibraryMediaType[] | null {
|
|
34
|
+
if (!mediaKinds?.length) return ['images', 'videos'];
|
|
35
|
+
const mediaTypes: ExpoPhotoLibraryMediaType[] = [];
|
|
36
|
+
if (mediaKinds.includes('image')) mediaTypes.push('images');
|
|
37
|
+
if (mediaKinds.includes('video')) mediaTypes.push('videos');
|
|
38
|
+
return mediaTypes.length > 0 ? mediaTypes : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function normalizePhotoLibraryResult(
|
|
42
|
+
result: PhotoLibraryResultLike,
|
|
43
|
+
mediaKinds: readonly MediaAssetKind[] | undefined,
|
|
44
|
+
readBytes: ByteReader = readSelectedMediaBytes,
|
|
45
|
+
): Promise<ExpoMediaPickerResult> {
|
|
46
|
+
if (result.canceled) return { ok: false, reason: 'cancelled' };
|
|
47
|
+
const asset = result.assets?.[0];
|
|
48
|
+
if (!asset) return { ok: false, reason: 'empty-selection' };
|
|
49
|
+
const kind = resolvePhotoLibraryAssetKind(asset);
|
|
50
|
+
if (!isRequestedMediaKind(kind, mediaKinds)) return { ok: false, reason: 'unsupported-kind' };
|
|
51
|
+
const body = await readBytes(asset);
|
|
52
|
+
if (!body) return { ok: false, reason: 'read-failed' };
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
selection: {
|
|
56
|
+
kind,
|
|
57
|
+
name: asset.fileName?.trim() ?? `selected-${kind}`,
|
|
58
|
+
body,
|
|
59
|
+
contentType: asset.mimeType ?? undefined,
|
|
60
|
+
sizeBytes: asset.fileSize ?? body.byteLength,
|
|
61
|
+
width: asset.width,
|
|
62
|
+
height: asset.height,
|
|
63
|
+
durationMs: asset.duration ?? undefined,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function pickPhotoLibraryMedia(
|
|
69
|
+
input: ExpoMediaPickerInput,
|
|
70
|
+
): Promise<ExpoMediaPickerResult> {
|
|
71
|
+
const mediaTypes = resolvePhotoLibraryMediaTypes(input.mediaKinds);
|
|
72
|
+
if (!mediaTypes) return { ok: false, reason: 'unsupported-kind' };
|
|
73
|
+
try {
|
|
74
|
+
const ImagePicker = await import('expo-image-picker');
|
|
75
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
76
|
+
allowsEditing: false,
|
|
77
|
+
allowsMultipleSelection: false,
|
|
78
|
+
base64: false,
|
|
79
|
+
exif: false,
|
|
80
|
+
mediaTypes,
|
|
81
|
+
quality: 1,
|
|
82
|
+
});
|
|
83
|
+
return await normalizePhotoLibraryResult(result, input.mediaKinds);
|
|
84
|
+
} catch {
|
|
85
|
+
return { ok: false, reason: 'picker-failed' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolvePhotoLibraryAssetKind(asset: PhotoLibraryAssetLike): 'image' | 'video' {
|
|
90
|
+
if (asset.type === 'video' || asset.type === 'pairedVideo') return 'video';
|
|
91
|
+
if (asset.type === 'image' || asset.type === 'livePhoto') return 'image';
|
|
92
|
+
if (asset.mimeType?.startsWith('video/')) return 'video';
|
|
93
|
+
return asset.duration !== null && asset.duration !== undefined ? 'video' : 'image';
|
|
94
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ArrayBufferReadable {
|
|
2
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface SelectedMediaByteSource {
|
|
6
|
+
readonly uri: string;
|
|
7
|
+
readonly file?: ArrayBufferReadable;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function readSelectedMediaBytes(
|
|
11
|
+
source: SelectedMediaByteSource,
|
|
12
|
+
): Promise<Uint8Array | null> {
|
|
13
|
+
try {
|
|
14
|
+
if (source.file) {
|
|
15
|
+
return new Uint8Array(await source.file.arrayBuffer());
|
|
16
|
+
}
|
|
17
|
+
const { File } = await import('expo-file-system');
|
|
18
|
+
return await new File(source.uri).bytes();
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MediaAssetKind } from '@ankhorage/contracts';
|
|
2
|
+
|
|
3
|
+
export const EXPO_MEDIA_PICKER_SOURCES = ['file', 'photo-library'] as const;
|
|
4
|
+
|
|
5
|
+
export type ExpoMediaPickerSource = (typeof EXPO_MEDIA_PICKER_SOURCES)[number];
|
|
6
|
+
|
|
7
|
+
export interface ExpoMediaPickerInput {
|
|
8
|
+
readonly source: ExpoMediaPickerSource;
|
|
9
|
+
readonly mediaKinds?: readonly MediaAssetKind[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ExpoMediaPickerSelection {
|
|
13
|
+
readonly kind: MediaAssetKind;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly body: Uint8Array;
|
|
16
|
+
readonly contentType?: string;
|
|
17
|
+
readonly sizeBytes?: number;
|
|
18
|
+
readonly width?: number;
|
|
19
|
+
readonly height?: number;
|
|
20
|
+
readonly durationMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ExpoMediaPickerFailureReason =
|
|
24
|
+
| 'cancelled'
|
|
25
|
+
| 'empty-selection'
|
|
26
|
+
| 'picker-failed'
|
|
27
|
+
| 'read-failed'
|
|
28
|
+
| 'unsupported-kind';
|
|
29
|
+
|
|
30
|
+
export type ExpoMediaPickerResult =
|
|
31
|
+
| { readonly ok: true; readonly selection: ExpoMediaPickerSelection }
|
|
32
|
+
| { readonly ok: false; readonly reason: ExpoMediaPickerFailureReason };
|
|
33
|
+
|
|
34
|
+
export interface ExpoMediaPickerAdapter {
|
|
35
|
+
pick(input: ExpoMediaPickerInput): Promise<ExpoMediaPickerResult>;
|
|
36
|
+
}
|