@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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/build/angular/index.d.ts +1 -0
- package/build/angular/index.js +4 -0
- package/build/core/index.d.ts +2 -0
- package/build/core/index.js +1 -0
- package/build/core/native-module.d.ts +14 -0
- package/build/core/native-module.js +3 -0
- package/build/core/sms.d.ts +19 -0
- package/build/core/sms.js +47 -0
- package/build/core/types.d.ts +31 -0
- package/build/core/types.js +1 -0
- package/build/react/index.d.ts +1 -0
- package/build/react/index.js +6 -0
- package/build/vue/index.d.ts +1 -0
- package/build/vue/index.js +4 -0
- package/build-ngc/angular/index.d.ts +1 -0
- package/build-ngc/angular/index.js +5 -0
- package/build-ngc/angular/index.js.map +1 -0
- package/build-ngc/core/index.d.ts +2 -0
- package/build-ngc/core/index.js +2 -0
- package/build-ngc/core/index.js.map +1 -0
- package/build-ngc/core/native-module.d.ts +14 -0
- package/build-ngc/core/native-module.js +4 -0
- package/build-ngc/core/native-module.js.map +1 -0
- package/build-ngc/core/sms.d.ts +19 -0
- package/build-ngc/core/sms.js +48 -0
- package/build-ngc/core/sms.js.map +1 -0
- package/build-ngc/core/types.d.ts +31 -0
- package/build-ngc/core/types.js +2 -0
- package/build-ngc/core/types.js.map +1 -0
- package/native-link.json +12 -0
- package/package.json +107 -0
- package/src/angular/index.ts +4 -0
- package/src/core/index.ts +2 -0
- package/src/core/native-module.ts +27 -0
- package/src/core/sms.test.ts +141 -0
- package/src/core/sms.ts +63 -0
- package/src/core/types.ts +34 -0
- package/src/react/index.ts +6 -0
- 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 @@
|
|
|
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,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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":""}
|
package/native-link.json
ADDED
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,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
|
+
});
|
package/src/core/sms.ts
ADDED
|
@@ -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';
|
package/src/vue/index.ts
ADDED