@symbiote-native/sms 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/build/angular/index.d.ts +1 -0
  4. package/build/angular/index.js +4 -0
  5. package/build/core/index.d.ts +2 -0
  6. package/build/core/index.js +1 -0
  7. package/build/core/native-module.d.ts +14 -0
  8. package/build/core/native-module.js +3 -0
  9. package/build/core/sms.d.ts +19 -0
  10. package/build/core/sms.js +47 -0
  11. package/build/core/types.d.ts +31 -0
  12. package/build/core/types.js +1 -0
  13. package/build/react/index.d.ts +1 -0
  14. package/build/react/index.js +6 -0
  15. package/build/vue/index.d.ts +1 -0
  16. package/build/vue/index.js +4 -0
  17. package/build-ngc/angular/index.d.ts +1 -0
  18. package/build-ngc/angular/index.js +5 -0
  19. package/build-ngc/angular/index.js.map +1 -0
  20. package/build-ngc/core/index.d.ts +2 -0
  21. package/build-ngc/core/index.js +2 -0
  22. package/build-ngc/core/index.js.map +1 -0
  23. package/build-ngc/core/native-module.d.ts +14 -0
  24. package/build-ngc/core/native-module.js +4 -0
  25. package/build-ngc/core/native-module.js.map +1 -0
  26. package/build-ngc/core/sms.d.ts +19 -0
  27. package/build-ngc/core/sms.js +48 -0
  28. package/build-ngc/core/sms.js.map +1 -0
  29. package/build-ngc/core/types.d.ts +31 -0
  30. package/build-ngc/core/types.js +2 -0
  31. package/build-ngc/core/types.js.map +1 -0
  32. package/native-link.json +12 -0
  33. package/package.json +107 -0
  34. package/src/angular/index.ts +4 -0
  35. package/src/core/index.ts +2 -0
  36. package/src/core/native-module.ts +27 -0
  37. package/src/core/sms.test.ts +141 -0
  38. package/src/core/sms.ts +63 -0
  39. package/src/core/types.ts +34 -0
  40. package/src/react/index.ts +6 -0
  41. package/src/vue/index.ts +4 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 A. Prokopenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @symbiote-native/sms
2
+
3
+ A wrapper package for [SymbioteNative](../../README.md) that makes
4
+ [`expo-sms`](https://github.com/expo/expo/tree/main/packages/expo-sms) — opening the system SMS
5
+ composer prefilled with recipients, a message, and optionally an attachment — usable from
6
+ **every** adapter, React, Vue, and Angular, not just React. Built the same way as
7
+ [`@symbiote-native/secure-store`](../secure-store): an `expo-modules-core`-based wrapper (see the
8
+ `symbiote-expo-native-module` project skill for the full mechanism — why `expo-modules-core` is
9
+ depended on directly and never the `expo` meta-package, why the upstream JS is hand-ported into
10
+ `core/` rather than imported, and how autolinking picks up the native module).
11
+
12
+ Nothing is ever sent on the user's behalf. Both platforms open their own composer with the draft
13
+ filled in; the user presses send, edits, or discards it.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @symbiote-native/sms
19
+ ```
20
+
21
+ `expo-sms` and `expo-modules-core` come along as regular, pinned dependencies — never install
22
+ either yourself, and never add the `expo` meta-package to this project (it bundles its own
23
+ Metro/Babel pipeline that conflicts with this project's own).
24
+
25
+ ### Required one-time step: native autolinking wiring
26
+
27
+ Unlike a plain RN native module, `expo-sms`'s native code is discovered by
28
+ `expo-modules-autolinking` — this needs wiring into the native host app **once**, covering this
29
+ package and every other `expo-modules-core` package with zero further changes:
30
+
31
+ | Platform | Touches |
32
+ |---|---|
33
+ | iOS | `ios/Podfile` — add `use_expo_modules!` |
34
+ | iOS | `AppDelegate.swift` — Expo's runtime-bootstrap hook |
35
+ | Android | `settings.gradle` / `app/build.gradle` — resolve and include the Expo Gradle projects |
36
+ | Android | `MainApplication.kt` — Expo's bootstrap hook, plus a native-module name map |
37
+
38
+ Full mechanics live in the `symbiote-expo-native-module` skill. The per-package half of that
39
+ table — the Gradle dependency and the module map entry — is generated by
40
+ [`@symbiote-native/expo-modules-link`](../expo-modules-link) from this package's
41
+ `native-link.json` on every install.
42
+
43
+ ### No permissions, no manifest edits, no config plugin
44
+
45
+ This is the least invasive of the Expo wrappers here:
46
+
47
+ - **No runtime permission on either platform.** Opening the composer is an intent
48
+ (`ACTION_SENDTO`/`ACTION_SEND`) on Android and an `MFMessageComposeViewController` on iOS —
49
+ the user's own app does the sending, so neither platform asks for one. `expo-sms` requests no
50
+ permission anywhere in its native code, and reading the *outcome* of a sent message on Android
51
+ would need `READ_SMS`, which is exactly why it doesn't try (see the `unknown` result below).
52
+ - **No `Info.plist` usage-description key.**
53
+ - **No manifest edit.** `expo-sms` ships its own `<queries>` block declaring the `SEND`/`SENDTO`
54
+ intents it resolves, needed on Android 11+ package-visibility rules; it merges into your app
55
+ automatically once the Gradle project is included.
56
+ - **No config plugin.** Upstream ships none at all, so there is nothing else to apply.
57
+
58
+ ## Shape
59
+
60
+ ```
61
+ src/core/ the whole API: sendSMSAsync + isAvailableAsync. native-module.ts
62
+ resolves ExpoSMS through expo-modules-core's requireNativeModule.
63
+ src/react/ @symbiote-native/sms/react
64
+ src/vue/ @symbiote-native/sms/vue
65
+ src/angular/ @symbiote-native/sms/angular
66
+ ```
67
+
68
+ All three adapter entries are plain re-exports of `core/`. Both exports are stateless free
69
+ functions — `sendSMSAsync` resolves once the composer closes and holds nothing afterwards, and
70
+ there is no event stream — so there is nothing for a hook, composable, or service to wrap, the
71
+ same reason [`@symbiote-native/secure-store`](../secure-store) re-exports rather than wraps.
72
+ Import from `@symbiote-native/sms` directly if you don't care which adapter you're on; the
73
+ per-adapter subpaths exist so every wrapper package has the same import surface.
74
+
75
+ ## Use it
76
+
77
+ ```ts
78
+ import { isAvailableAsync, sendSMSAsync } from '@symbiote-native/sms';
79
+
80
+ if (await isAvailableAsync()) {
81
+ const { result } = await sendSMSAsync(['0123456789', '9876543210'], 'Running late, sorry!');
82
+ // 'sent' | 'cancelled' on iOS; always 'unknown' on Android
83
+ }
84
+ ```
85
+
86
+ A single recipient may be passed as a bare string — it is normalised into an array before the
87
+ native call:
88
+
89
+ ```ts
90
+ await sendSMSAsync('0123456789', 'Running late, sorry!');
91
+ ```
92
+
93
+ With an attachment:
94
+
95
+ ```ts
96
+ await sendSMSAsync('0123456789', 'Here is the receipt', {
97
+ attachments: {
98
+ uri: 'content://media/external/images/media/1',
99
+ mimeType: 'image/png',
100
+ filename: 'receipt.png',
101
+ },
102
+ });
103
+ ```
104
+
105
+ The `uri` has to be a **content** URI — the composer runs in another app's process, and a plain
106
+ file path is not readable from there.
107
+
108
+ ## API
109
+
110
+ | Export | Signature | Notes |
111
+ |---|---|---|
112
+ | `isAvailableAsync` | `() => Promise<boolean>` | `false` on the iOS simulator, which has no Messages app, and on Android devices without telephony hardware. |
113
+ | `sendSMSAsync` | `(addresses, message, options?) => Promise<ISmsResponse>` | Opens the composer. Resolves when it closes. Throws if the device has no messaging app. |
114
+
115
+ `ISmsOptions`: `attachments` — one `ISmsAttachment` or a list of them.
116
+ `ISmsAttachment`: `uri` (content URI), `mimeType`, `filename`.
117
+ `ISmsResponse`: `{ result: 'sent' | 'cancelled' | 'unknown' }`.
118
+
119
+ ## Notes
120
+
121
+ - **Android always resolves `unknown`.** The only way to learn whether a message actually left
122
+ the device is to query the SMS database, which needs the `READ_SMS` permission Google restricts
123
+ to default-SMS-app publishers. Treat `unknown` as "the composer closed", not as a failure.
124
+ - **Android carries one attachment.** Its composer intent has a single `EXTRA_STREAM` slot, so
125
+ anything past the first is dropped before the native call rather than silently ignored deeper
126
+ down. iOS attaches all of them.
127
+ - **`sendSMSAsync` can reject.** Android throws when no messaging application is installed; iOS
128
+ throws when the device cannot send text at all, or when a composer is already open.
129
+
130
+ ## Test it
131
+
132
+ ```bash
133
+ pnpm vitest run packages/sms
134
+ ```
135
+
136
+ The core tests fake the native module in place of `requireNativeModule`'s runtime resolution —
137
+ `ExpoSMS` only exists on a device, so a headless run would otherwise throw at import. The
138
+ composer itself, and the `sent`/`cancelled` distinction, can only be verified on a device.
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/sms/angular: the Angular entry over the framework-agnostic core. Same
2
+ // reasoning as the React/Vue entries — no per-instance state and no event stream to wrap in an
3
+ // injectable service, so this is a plain re-export.
4
+ export * from '../core';
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, sendSMSAsync } from './sms';
2
+ export type { ISmsAttachment, ISmsOptions, ISmsResponse, ISmsResultStatus } from './types';
@@ -0,0 +1 @@
1
+ export { isAvailableAsync, sendSMSAsync } from './sms';
@@ -0,0 +1,14 @@
1
+ import type { ISmsAttachment, ISmsResponse } from './types';
2
+ /**
3
+ * The options record the native side decodes — narrower than the public `ISmsOptions`, which
4
+ * also accepts a lone attachment object. Both `SMSOptions.kt` and `SMSOptions.swift` declare
5
+ * `attachments` as a list, so the single-object form is normalised away before it gets here.
6
+ */
7
+ export type INativeSmsOptions = {
8
+ attachments?: ISmsAttachment[];
9
+ };
10
+ export type INativeSmsModule = {
11
+ isAvailableAsync?(): Promise<boolean>;
12
+ sendSMSAsync?(addresses: string[], message: string, options: INativeSmsOptions): Promise<ISmsResponse>;
13
+ };
14
+ export declare const expoSms: INativeSmsModule;
@@ -0,0 +1,3 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ const EXPO_SMS_MODULE_NAME = 'ExpoSMS';
3
+ export const expoSms = requireNativeModule(EXPO_SMS_MODULE_NAME);
@@ -0,0 +1,19 @@
1
+ import type { ISmsOptions, ISmsResponse } from './types';
2
+ /**
3
+ * Whether this device can send an SMS at all. `false` on the iOS simulator, which has no
4
+ * Messages app, and on any Android device without telephony hardware.
5
+ */
6
+ export declare function isAvailableAsync(): Promise<boolean>;
7
+ /**
8
+ * Opens the system SMS composer with the recipients and message text filled in. Nothing is sent
9
+ * on the user's behalf — they still press send themselves, and may edit or discard the draft.
10
+ *
11
+ * Resolves once the composer closes:
12
+ * - `{ result: 'sent' }` — the user sent or scheduled the message.
13
+ * - `{ result: 'cancelled' }` — the user dismissed the composer.
14
+ * - `{ result: 'unknown' }` — always, on Android, which cannot report the outcome.
15
+ *
16
+ * The only thing observed is whether a message left the composer; neither its final text nor
17
+ * its final recipients are read back.
18
+ */
19
+ export declare function sendSMSAsync(addresses: string | string[], message: string, options?: ISmsOptions): Promise<ISmsResponse>;
@@ -0,0 +1,47 @@
1
+ import { Platform, UnavailabilityError } from 'expo-modules-core';
2
+ import { expoSms } from './native-module';
3
+ const NATIVE_MODULE_NAME = 'expo-sms';
4
+ // Android's SMSModule puts a single `Intent.EXTRA_STREAM` on the composer intent and reads
5
+ // `attachments[0]` to fill it, so anything past the first would silently never arrive.
6
+ const ANDROID_ATTACHMENT_LIMIT = 1;
7
+ /**
8
+ * Whether this device can send an SMS at all. `false` on the iOS simulator, which has no
9
+ * Messages app, and on any Android device without telephony hardware.
10
+ */
11
+ export async function isAvailableAsync() {
12
+ return expoSms.isAvailableAsync?.() ?? false;
13
+ }
14
+ /**
15
+ * Opens the system SMS composer with the recipients and message text filled in. Nothing is sent
16
+ * on the user's behalf — they still press send themselves, and may edit or discard the draft.
17
+ *
18
+ * Resolves once the composer closes:
19
+ * - `{ result: 'sent' }` — the user sent or scheduled the message.
20
+ * - `{ result: 'cancelled' }` — the user dismissed the composer.
21
+ * - `{ result: 'unknown' }` — always, on Android, which cannot report the outcome.
22
+ *
23
+ * The only thing observed is whether a message left the composer; neither its final text nor
24
+ * its final recipients are read back.
25
+ */
26
+ export async function sendSMSAsync(addresses, message, options) {
27
+ if (!expoSms.sendSMSAsync) {
28
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'sendSMSAsync');
29
+ }
30
+ const recipients = Array.isArray(addresses) ? addresses : [addresses];
31
+ for (const recipient of recipients) {
32
+ // Guards a caller without type checking: the native side decodes a list of strings, and a
33
+ // number or a null in there fails as an opaque conversion error far from its cause.
34
+ if (typeof recipient !== 'string') {
35
+ throw new TypeError('Invalid address passed to sendSMSAsync. Every recipient must be a string.');
36
+ }
37
+ }
38
+ const nativeOptions = {};
39
+ if (options?.attachments) {
40
+ nativeOptions.attachments = normalizeAttachments(options.attachments);
41
+ }
42
+ return expoSms.sendSMSAsync(recipients, message, nativeOptions);
43
+ }
44
+ function normalizeAttachments(attachments) {
45
+ const list = Array.isArray(attachments) ? attachments : [attachments];
46
+ return Platform.OS === 'android' ? list.slice(0, ANDROID_ATTACHMENT_LIMIT) : list;
47
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * How the composer was dismissed.
3
+ *
4
+ * Android never reports it: reading the outcome would mean querying the device's SMS database,
5
+ * which needs the `READ_SMS` permission Google restricts to default-SMS-app publishers, so the
6
+ * native module resolves `unknown` unconditionally there.
7
+ */
8
+ export type ISmsResultStatus = 'unknown' | 'sent' | 'cancelled';
9
+ export type ISmsResponse = {
10
+ /** Status of the SMS action the user invoked. */
11
+ result: ISmsResultStatus;
12
+ };
13
+ /** A file to attach to the drafted message. */
14
+ export type ISmsAttachment = {
15
+ /**
16
+ * Content URI of the file. It has to be a content URI so applications outside your own can
17
+ * read it — a plain file path is not reachable from the system composer.
18
+ */
19
+ uri: string;
20
+ /** MIME type of the attachment, such as `image/png`. */
21
+ mimeType: string;
22
+ /** File name shown for the attachment in the composer. */
23
+ filename: string;
24
+ };
25
+ export type ISmsOptions = {
26
+ /**
27
+ * One attachment or a list of them. Android carries only the first — its composer intent has
28
+ * a single `EXTRA_STREAM` slot — so anything past it is dropped before the native call.
29
+ */
30
+ attachments?: ISmsAttachment | ISmsAttachment[];
31
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,6 @@
1
+ // @symbiote-native/sms/react: the React entry over the framework-agnostic core.
2
+ // Both exports are stateless free functions — `sendSMSAsync` resolves once the system composer
3
+ // closes and holds nothing afterwards, and there is no event stream to subscribe to — so there
4
+ // is nothing for a hook to own or clean up. Plain re-export, the same shape
5
+ // packages/secure-store's React entry has for the same reason.
6
+ export * from '../core';
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/sms/vue: the Vue entry over the framework-agnostic core. Same reasoning as
2
+ // the React entry — no per-instance state and no event stream to wire onto Vue's reactivity, so
3
+ // this is a plain re-export.
4
+ export * from '../core';
@@ -0,0 +1 @@
1
+ export * from '../core';
@@ -0,0 +1,5 @@
1
+ // @symbiote-native/sms/angular: the Angular entry over the framework-agnostic core. Same
2
+ // reasoning as the React/Vue entries — no per-instance state and no event stream to wrap in an
3
+ // injectable service, so this is a plain re-export.
4
+ export * from '../core';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/angular/index.ts"],"names":[],"mappings":"AAAA,yFAAyF;AACzF,+FAA+F;AAC/F,oDAAoD;AACpD,cAAc,SAAS,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, sendSMSAsync } from './sms';
2
+ export type { ISmsAttachment, ISmsOptions, ISmsResponse, ISmsResultStatus } from './types';
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, sendSMSAsync } from './sms';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { ISmsAttachment, ISmsResponse } from './types';
2
+ /**
3
+ * The options record the native side decodes — narrower than the public `ISmsOptions`, which
4
+ * also accepts a lone attachment object. Both `SMSOptions.kt` and `SMSOptions.swift` declare
5
+ * `attachments` as a list, so the single-object form is normalised away before it gets here.
6
+ */
7
+ export type INativeSmsOptions = {
8
+ attachments?: ISmsAttachment[];
9
+ };
10
+ export type INativeSmsModule = {
11
+ isAvailableAsync?(): Promise<boolean>;
12
+ sendSMSAsync?(addresses: string[], message: string, options: INativeSmsOptions): Promise<ISmsResponse>;
13
+ };
14
+ export declare const expoSms: INativeSmsModule;
@@ -0,0 +1,4 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ const EXPO_SMS_MODULE_NAME = 'ExpoSMS';
3
+ export const expoSms = requireNativeModule(EXPO_SMS_MODULE_NAME);
4
+ //# sourceMappingURL=native-module.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-module.js","sourceRoot":"","sources":["../../src/core/native-module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAGxD,MAAM,oBAAoB,GAAG,SAAS,CAAC;AAuBvC,MAAM,CAAC,MAAM,OAAO,GAAG,mBAAmB,CAAmB,oBAAoB,CAAC,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { ISmsOptions, ISmsResponse } from './types';
2
+ /**
3
+ * Whether this device can send an SMS at all. `false` on the iOS simulator, which has no
4
+ * Messages app, and on any Android device without telephony hardware.
5
+ */
6
+ export declare function isAvailableAsync(): Promise<boolean>;
7
+ /**
8
+ * Opens the system SMS composer with the recipients and message text filled in. Nothing is sent
9
+ * on the user's behalf — they still press send themselves, and may edit or discard the draft.
10
+ *
11
+ * Resolves once the composer closes:
12
+ * - `{ result: 'sent' }` — the user sent or scheduled the message.
13
+ * - `{ result: 'cancelled' }` — the user dismissed the composer.
14
+ * - `{ result: 'unknown' }` — always, on Android, which cannot report the outcome.
15
+ *
16
+ * The only thing observed is whether a message left the composer; neither its final text nor
17
+ * its final recipients are read back.
18
+ */
19
+ export declare function sendSMSAsync(addresses: string | string[], message: string, options?: ISmsOptions): Promise<ISmsResponse>;
@@ -0,0 +1,48 @@
1
+ import { Platform, UnavailabilityError } from 'expo-modules-core';
2
+ import { expoSms } from './native-module';
3
+ const NATIVE_MODULE_NAME = 'expo-sms';
4
+ // Android's SMSModule puts a single `Intent.EXTRA_STREAM` on the composer intent and reads
5
+ // `attachments[0]` to fill it, so anything past the first would silently never arrive.
6
+ const ANDROID_ATTACHMENT_LIMIT = 1;
7
+ /**
8
+ * Whether this device can send an SMS at all. `false` on the iOS simulator, which has no
9
+ * Messages app, and on any Android device without telephony hardware.
10
+ */
11
+ export async function isAvailableAsync() {
12
+ return expoSms.isAvailableAsync?.() ?? false;
13
+ }
14
+ /**
15
+ * Opens the system SMS composer with the recipients and message text filled in. Nothing is sent
16
+ * on the user's behalf — they still press send themselves, and may edit or discard the draft.
17
+ *
18
+ * Resolves once the composer closes:
19
+ * - `{ result: 'sent' }` — the user sent or scheduled the message.
20
+ * - `{ result: 'cancelled' }` — the user dismissed the composer.
21
+ * - `{ result: 'unknown' }` — always, on Android, which cannot report the outcome.
22
+ *
23
+ * The only thing observed is whether a message left the composer; neither its final text nor
24
+ * its final recipients are read back.
25
+ */
26
+ export async function sendSMSAsync(addresses, message, options) {
27
+ if (!expoSms.sendSMSAsync) {
28
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'sendSMSAsync');
29
+ }
30
+ const recipients = Array.isArray(addresses) ? addresses : [addresses];
31
+ for (const recipient of recipients) {
32
+ // Guards a caller without type checking: the native side decodes a list of strings, and a
33
+ // number or a null in there fails as an opaque conversion error far from its cause.
34
+ if (typeof recipient !== 'string') {
35
+ throw new TypeError('Invalid address passed to sendSMSAsync. Every recipient must be a string.');
36
+ }
37
+ }
38
+ const nativeOptions = {};
39
+ if (options?.attachments) {
40
+ nativeOptions.attachments = normalizeAttachments(options.attachments);
41
+ }
42
+ return expoSms.sendSMSAsync(recipients, message, nativeOptions);
43
+ }
44
+ function normalizeAttachments(attachments) {
45
+ const list = Array.isArray(attachments) ? attachments : [attachments];
46
+ return Platform.OS === 'android' ? list.slice(0, ANDROID_ATTACHMENT_LIMIT) : list;
47
+ }
48
+ //# sourceMappingURL=sms.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sms.js","sourceRoot":"","sources":["../../src/core/sms.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAElE,OAAO,EAAE,OAAO,EAA0B,MAAM,iBAAiB,CAAC;AAGlE,MAAM,kBAAkB,GAAG,UAAU,CAAC;AAEtC,2FAA2F;AAC3F,uFAAuF;AACvF,MAAM,wBAAwB,GAAG,CAAC,CAAC;AAEnC;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB;IACpC,OAAO,OAAO,CAAC,gBAAgB,EAAE,EAAE,IAAI,KAAK,CAAC;AAC/C,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,SAA4B,EAC5B,OAAe,EACf,OAAqB;IAErB,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,CAAC;QAC1B,MAAM,IAAI,mBAAmB,CAAC,kBAAkB,EAAE,cAAc,CAAC,CAAC;IACpE,CAAC;IAED,MAAM,UAAU,GAAG,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACtE,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,0FAA0F;QAC1F,oFAAoF;QACpF,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;YAClC,MAAM,IAAI,SAAS,CACjB,2EAA2E,CAC5E,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,aAAa,GAAsB,EAAE,CAAC;IAC5C,IAAI,OAAO,EAAE,WAAW,EAAE,CAAC;QACzB,aAAa,CAAC,WAAW,GAAG,oBAAoB,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACxE,CAAC;IAED,OAAO,OAAO,CAAC,YAAY,CAAC,UAAU,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC;AAClE,CAAC;AAED,SAAS,oBAAoB,CAAC,WAA8C;IAC1E,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;IACtE,OAAO,QAAQ,CAAC,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,wBAAwB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACpF,CAAC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * How the composer was dismissed.
3
+ *
4
+ * Android never reports it: reading the outcome would mean querying the device's SMS database,
5
+ * which needs the `READ_SMS` permission Google restricts to default-SMS-app publishers, so the
6
+ * native module resolves `unknown` unconditionally there.
7
+ */
8
+ export type ISmsResultStatus = 'unknown' | 'sent' | 'cancelled';
9
+ export type ISmsResponse = {
10
+ /** Status of the SMS action the user invoked. */
11
+ result: ISmsResultStatus;
12
+ };
13
+ /** A file to attach to the drafted message. */
14
+ export type ISmsAttachment = {
15
+ /**
16
+ * Content URI of the file. It has to be a content URI so applications outside your own can
17
+ * read it — a plain file path is not reachable from the system composer.
18
+ */
19
+ uri: string;
20
+ /** MIME type of the attachment, such as `image/png`. */
21
+ mimeType: string;
22
+ /** File name shown for the attachment in the composer. */
23
+ filename: string;
24
+ };
25
+ export type ISmsOptions = {
26
+ /**
27
+ * One attachment or a list of them. Android carries only the first — its composer intent has
28
+ * a single `EXTRA_STREAM` slot — so anything past it is dropped before the native call.
29
+ */
30
+ attachments?: ISmsAttachment | ISmsAttachment[];
31
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,12 @@
1
+ {
2
+ "android": {
3
+ "gradleProjectName": "expo-sms",
4
+ "modules": [
5
+ {
6
+ "importPath": "expo.modules.sms.SMSModule",
7
+ "className": "SMSModule",
8
+ "nativeName": "ExpoSMS"
9
+ }
10
+ ]
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@symbiote-native/sms",
3
+ "version": "0.0.1",
4
+ "description": "expo-sms wrapped for SymbioteNative — one framework-agnostic core, built once and reachable from the React, Vue, and Angular adapters. Opens the system SMS composer prefilled with recipients and a message.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/OneEyed1366/symbiote-native.git",
9
+ "directory": "packages/sms"
10
+ },
11
+ "homepage": "https://github.com/OneEyed1366/symbiote-native/tree/master/packages/sms#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/OneEyed1366/symbiote-native/issues"
14
+ },
15
+ "author": "Andrey Prokopenko <psevdoproger@gmail.com>",
16
+ "type": "module",
17
+ "main": "./build/core/index.js",
18
+ "module": "./build/core/index.js",
19
+ "types": "./build/core/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./build/core/index.d.ts",
23
+ "default": "./build/core/index.js"
24
+ },
25
+ "./vue": {
26
+ "types": "./build/vue/index.d.ts",
27
+ "default": "./build/vue/index.js"
28
+ },
29
+ "./react": {
30
+ "types": "./build/react/index.d.ts",
31
+ "default": "./build/react/index.js"
32
+ },
33
+ "./angular": {
34
+ "types": "./build-ngc/angular/index.d.ts",
35
+ "react-native": "./build-ngc/angular/index.js",
36
+ "default": "./src/angular/index.ts"
37
+ }
38
+ },
39
+ "files": [
40
+ "src",
41
+ "build",
42
+ "build-ngc",
43
+ "native-link.json"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {
49
+ "expo-sms": "57.0.1",
50
+ "expo-modules-core": "57.0.5"
51
+ },
52
+ "peerDependencies": {
53
+ "@symbiote-native/engine": ">=0.1.7",
54
+ "@angular/core": ">=20",
55
+ "@vue/runtime-core": "^3.5.13",
56
+ "react": ">=19.0.0",
57
+ "react-native": ">=0.86",
58
+ "vue": ">=3.5.0",
59
+ "@symbiote-native/angular": "0.6.1",
60
+ "@symbiote-native/react": "0.2.8",
61
+ "@symbiote-native/vue": "0.3.8"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@symbiote-native/angular": {
65
+ "optional": true
66
+ },
67
+ "@symbiote-native/react": {
68
+ "optional": true
69
+ },
70
+ "@symbiote-native/vue": {
71
+ "optional": true
72
+ },
73
+ "@angular/core": {
74
+ "optional": true
75
+ },
76
+ "@vue/runtime-core": {
77
+ "optional": true
78
+ },
79
+ "react": {
80
+ "optional": true
81
+ },
82
+ "vue": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "devDependencies": {
87
+ "@angular/compiler": "^22",
88
+ "@angular/compiler-cli": "^22",
89
+ "@angular/core": "^22",
90
+ "@types/node": "^26.0.0",
91
+ "@types/react": "^19.2.0",
92
+ "@vue/runtime-core": "^3.5.13",
93
+ "react": "19.2.3",
94
+ "typescript": "~6.0.0",
95
+ "@symbiote-native/test-utils": "0.1.6",
96
+ "@symbiote-native/engine": "0.1.7",
97
+ "@symbiote-native/react": "0.2.8",
98
+ "@symbiote-native/angular": "0.6.1",
99
+ "@symbiote-native/vue": "0.3.8"
100
+ },
101
+ "scripts": {
102
+ "typecheck": "tsc --build",
103
+ "clean": "rm -rf build-ngc",
104
+ "ng:build": "pnpm run clean && ngc -p tsconfig.angular.json",
105
+ "format": "prettier --write \"src/**/*.{ts,tsx}\""
106
+ }
107
+ }
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/sms/angular: the Angular entry over the framework-agnostic core. Same
2
+ // reasoning as the React/Vue entries — no per-instance state and no event stream to wrap in an
3
+ // injectable service, so this is a plain re-export.
4
+ export * from '../core';
@@ -0,0 +1,2 @@
1
+ export { isAvailableAsync, sendSMSAsync } from './sms';
2
+ export type { ISmsAttachment, ISmsOptions, ISmsResponse, ISmsResultStatus } from './types';
@@ -0,0 +1,27 @@
1
+ import { requireNativeModule } from 'expo-modules-core';
2
+ import type { ISmsAttachment, ISmsResponse } from './types';
3
+
4
+ const EXPO_SMS_MODULE_NAME = 'ExpoSMS';
5
+
6
+ /**
7
+ * The options record the native side decodes — narrower than the public `ISmsOptions`, which
8
+ * also accepts a lone attachment object. Both `SMSOptions.kt` and `SMSOptions.swift` declare
9
+ * `attachments` as a list, so the single-object form is normalised away before it gets here.
10
+ */
11
+ export type INativeSmsOptions = {
12
+ attachments?: ISmsAttachment[];
13
+ };
14
+
15
+ // Both members are optional: each call site checks for its own before calling through, the same
16
+ // per-platform capability check upstream makes, rather than assuming the native module
17
+ // implements the whole surface.
18
+ export type INativeSmsModule = {
19
+ isAvailableAsync?(): Promise<boolean>;
20
+ sendSMSAsync?(
21
+ addresses: string[],
22
+ message: string,
23
+ options: INativeSmsOptions,
24
+ ): Promise<ISmsResponse>;
25
+ };
26
+
27
+ export const expoSms = requireNativeModule<INativeSmsModule>(EXPO_SMS_MODULE_NAME);
@@ -0,0 +1,141 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const FAKE_NATIVE_SMS = {
4
+ isAvailableAsync: vi.fn(async () => true),
5
+ sendSMSAsync: vi.fn(async () => ({ result: 'sent' })),
6
+ };
7
+
8
+ // Mutated per test — `Platform.OS` is read at call time, so flipping this between cases is how
9
+ // the Android attachment limit gets exercised without a second import of the module under test.
10
+ const FAKE_PLATFORM = { OS: 'ios' };
11
+
12
+ // The real ExpoSMS native module only exists on device — resolving it via requireNativeModule()
13
+ // at import time would throw in this headless run, so the module-lookup file is faked in place
14
+ // of expo-modules-core's runtime resolution, the same pattern
15
+ // packages/secure-store/src/core/secure-store.test.ts uses.
16
+ vi.mock('./native-module', () => ({
17
+ expoSms: FAKE_NATIVE_SMS,
18
+ }));
19
+
20
+ // expo-modules-core's real entry transitively imports 'react-native', whose Flow-typed source
21
+ // Vitest's Oxc transform can't parse — so only the members used as values are faked.
22
+ vi.mock('expo-modules-core', () => ({
23
+ Platform: FAKE_PLATFORM,
24
+ UnavailabilityError: class UnavailabilityError extends Error {
25
+ constructor(moduleName: string, propertyName: string) {
26
+ super(`${propertyName} is not available on ${moduleName}`);
27
+ }
28
+ },
29
+ }));
30
+
31
+ const { isAvailableAsync, sendSMSAsync } = await import('./sms');
32
+
33
+ const IMAGE_ATTACHMENT = {
34
+ uri: 'content://media/external/images/media/1',
35
+ mimeType: 'image/png',
36
+ filename: 'myfile.png',
37
+ };
38
+ const AUDIO_ATTACHMENT = {
39
+ uri: 'content://media/external/audio/media/2',
40
+ mimeType: 'audio/mpeg',
41
+ filename: 'myfile.mp3',
42
+ };
43
+
44
+ beforeEach(() => {
45
+ FAKE_PLATFORM.OS = 'ios';
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ describe('isAvailableAsync', () => {
53
+ it('delegates to the native module', async () => {
54
+ await expect(isAvailableAsync()).resolves.toBe(true);
55
+ expect(FAKE_NATIVE_SMS.isAvailableAsync).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it('reports unavailable rather than throwing when the native method is absent', async () => {
59
+ const { isAvailableAsync: native } = FAKE_NATIVE_SMS;
60
+ // @ts-expect-error -- simulating a platform where the native module has no such method
61
+ FAKE_NATIVE_SMS.isAvailableAsync = undefined;
62
+
63
+ await expect(isAvailableAsync()).resolves.toBe(false);
64
+
65
+ FAKE_NATIVE_SMS.isAvailableAsync = native;
66
+ });
67
+ });
68
+
69
+ describe('sendSMSAsync recipients', () => {
70
+ it('normalizes a single address into an array', async () => {
71
+ await sendSMSAsync('0123456789', 'test');
72
+ expect(FAKE_NATIVE_SMS.sendSMSAsync).toHaveBeenLastCalledWith(['0123456789'], 'test', {});
73
+ });
74
+
75
+ it('passes an array of addresses through unchanged', async () => {
76
+ await sendSMSAsync(['0123456789', '9876543210'], 'test');
77
+ expect(FAKE_NATIVE_SMS.sendSMSAsync).toHaveBeenLastCalledWith(
78
+ ['0123456789', '9876543210'],
79
+ 'test',
80
+ {},
81
+ );
82
+ });
83
+
84
+ it('resolves with the native result', async () => {
85
+ await expect(sendSMSAsync('0123456789', 'test')).resolves.toEqual({ result: 'sent' });
86
+ });
87
+
88
+ it('rejects a non-string recipient before reaching the native module', async () => {
89
+ // @ts-expect-error -- the guard exists precisely for callers without type checking
90
+ await expect(sendSMSAsync(['0123456789', null], 'test')).rejects.toThrow(TypeError);
91
+ expect(FAKE_NATIVE_SMS.sendSMSAsync).not.toHaveBeenCalled();
92
+ });
93
+ });
94
+
95
+ describe('sendSMSAsync attachments', () => {
96
+ it('omits the attachments key entirely when none are given', async () => {
97
+ await sendSMSAsync('0123456789', 'test', {});
98
+ expect(FAKE_NATIVE_SMS.sendSMSAsync).toHaveBeenLastCalledWith(['0123456789'], 'test', {});
99
+ });
100
+
101
+ it('normalizes a single attachment into an array', async () => {
102
+ await sendSMSAsync('0123456789', 'test', { attachments: IMAGE_ATTACHMENT });
103
+ expect(FAKE_NATIVE_SMS.sendSMSAsync).toHaveBeenLastCalledWith(['0123456789'], 'test', {
104
+ attachments: [IMAGE_ATTACHMENT],
105
+ });
106
+ });
107
+
108
+ it('keeps every attachment on iOS', async () => {
109
+ await sendSMSAsync('0123456789', 'test', {
110
+ attachments: [IMAGE_ATTACHMENT, AUDIO_ATTACHMENT],
111
+ });
112
+ expect(FAKE_NATIVE_SMS.sendSMSAsync).toHaveBeenLastCalledWith(['0123456789'], 'test', {
113
+ attachments: [IMAGE_ATTACHMENT, AUDIO_ATTACHMENT],
114
+ });
115
+ });
116
+
117
+ it('keeps only the first attachment on Android', async () => {
118
+ FAKE_PLATFORM.OS = 'android';
119
+
120
+ await sendSMSAsync('0123456789', 'test', {
121
+ attachments: [IMAGE_ATTACHMENT, AUDIO_ATTACHMENT],
122
+ });
123
+ expect(FAKE_NATIVE_SMS.sendSMSAsync).toHaveBeenLastCalledWith(['0123456789'], 'test', {
124
+ attachments: [IMAGE_ATTACHMENT],
125
+ });
126
+ });
127
+ });
128
+
129
+ describe('sendSMSAsync without the native method', () => {
130
+ it('throws an UnavailabilityError-shaped error', async () => {
131
+ const { sendSMSAsync: native } = FAKE_NATIVE_SMS;
132
+ // @ts-expect-error -- simulating a platform where the native module has no such method
133
+ FAKE_NATIVE_SMS.sendSMSAsync = undefined;
134
+
135
+ await expect(sendSMSAsync('0123456789', 'test')).rejects.toThrow(
136
+ 'sendSMSAsync is not available on expo-sms',
137
+ );
138
+
139
+ FAKE_NATIVE_SMS.sendSMSAsync = native;
140
+ });
141
+ });
@@ -0,0 +1,63 @@
1
+ import { Platform, UnavailabilityError } from 'expo-modules-core';
2
+
3
+ import { expoSms, type INativeSmsOptions } from './native-module';
4
+ import type { ISmsAttachment, ISmsOptions, ISmsResponse } from './types';
5
+
6
+ const NATIVE_MODULE_NAME = 'expo-sms';
7
+
8
+ // Android's SMSModule puts a single `Intent.EXTRA_STREAM` on the composer intent and reads
9
+ // `attachments[0]` to fill it, so anything past the first would silently never arrive.
10
+ const ANDROID_ATTACHMENT_LIMIT = 1;
11
+
12
+ /**
13
+ * Whether this device can send an SMS at all. `false` on the iOS simulator, which has no
14
+ * Messages app, and on any Android device without telephony hardware.
15
+ */
16
+ export async function isAvailableAsync(): Promise<boolean> {
17
+ return expoSms.isAvailableAsync?.() ?? false;
18
+ }
19
+
20
+ /**
21
+ * Opens the system SMS composer with the recipients and message text filled in. Nothing is sent
22
+ * on the user's behalf — they still press send themselves, and may edit or discard the draft.
23
+ *
24
+ * Resolves once the composer closes:
25
+ * - `{ result: 'sent' }` — the user sent or scheduled the message.
26
+ * - `{ result: 'cancelled' }` — the user dismissed the composer.
27
+ * - `{ result: 'unknown' }` — always, on Android, which cannot report the outcome.
28
+ *
29
+ * The only thing observed is whether a message left the composer; neither its final text nor
30
+ * its final recipients are read back.
31
+ */
32
+ export async function sendSMSAsync(
33
+ addresses: string | string[],
34
+ message: string,
35
+ options?: ISmsOptions,
36
+ ): Promise<ISmsResponse> {
37
+ if (!expoSms.sendSMSAsync) {
38
+ throw new UnavailabilityError(NATIVE_MODULE_NAME, 'sendSMSAsync');
39
+ }
40
+
41
+ const recipients = Array.isArray(addresses) ? addresses : [addresses];
42
+ for (const recipient of recipients) {
43
+ // Guards a caller without type checking: the native side decodes a list of strings, and a
44
+ // number or a null in there fails as an opaque conversion error far from its cause.
45
+ if (typeof recipient !== 'string') {
46
+ throw new TypeError(
47
+ 'Invalid address passed to sendSMSAsync. Every recipient must be a string.',
48
+ );
49
+ }
50
+ }
51
+
52
+ const nativeOptions: INativeSmsOptions = {};
53
+ if (options?.attachments) {
54
+ nativeOptions.attachments = normalizeAttachments(options.attachments);
55
+ }
56
+
57
+ return expoSms.sendSMSAsync(recipients, message, nativeOptions);
58
+ }
59
+
60
+ function normalizeAttachments(attachments: ISmsAttachment | ISmsAttachment[]): ISmsAttachment[] {
61
+ const list = Array.isArray(attachments) ? attachments : [attachments];
62
+ return Platform.OS === 'android' ? list.slice(0, ANDROID_ATTACHMENT_LIMIT) : list;
63
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How the composer was dismissed.
3
+ *
4
+ * Android never reports it: reading the outcome would mean querying the device's SMS database,
5
+ * which needs the `READ_SMS` permission Google restricts to default-SMS-app publishers, so the
6
+ * native module resolves `unknown` unconditionally there.
7
+ */
8
+ export type ISmsResultStatus = 'unknown' | 'sent' | 'cancelled';
9
+
10
+ export type ISmsResponse = {
11
+ /** Status of the SMS action the user invoked. */
12
+ result: ISmsResultStatus;
13
+ };
14
+
15
+ /** A file to attach to the drafted message. */
16
+ export type ISmsAttachment = {
17
+ /**
18
+ * Content URI of the file. It has to be a content URI so applications outside your own can
19
+ * read it — a plain file path is not reachable from the system composer.
20
+ */
21
+ uri: string;
22
+ /** MIME type of the attachment, such as `image/png`. */
23
+ mimeType: string;
24
+ /** File name shown for the attachment in the composer. */
25
+ filename: string;
26
+ };
27
+
28
+ export type ISmsOptions = {
29
+ /**
30
+ * One attachment or a list of them. Android carries only the first — its composer intent has
31
+ * a single `EXTRA_STREAM` slot — so anything past it is dropped before the native call.
32
+ */
33
+ attachments?: ISmsAttachment | ISmsAttachment[];
34
+ };
@@ -0,0 +1,6 @@
1
+ // @symbiote-native/sms/react: the React entry over the framework-agnostic core.
2
+ // Both exports are stateless free functions — `sendSMSAsync` resolves once the system composer
3
+ // closes and holds nothing afterwards, and there is no event stream to subscribe to — so there
4
+ // is nothing for a hook to own or clean up. Plain re-export, the same shape
5
+ // packages/secure-store's React entry has for the same reason.
6
+ export * from '../core';
@@ -0,0 +1,4 @@
1
+ // @symbiote-native/sms/vue: the Vue entry over the framework-agnostic core. Same reasoning as
2
+ // the React entry — no per-instance state and no event stream to wire onto Vue's reactivity, so
3
+ // this is a plain re-export.
4
+ export * from '../core';