Send an SMS on Android without asking for READ_PHONE_STATE.
A permission-minimal Flutter SMS sender for Android. It sends a long message as one concatenated SMS using only the SEND_SMS permission — never the phone-identity permission that the platform's own multipart helper quietly started to require.
Extracted from Oksigenia SOS, an offline-first personal-safety app, where a silently-failing SOS is not an option.
On Android 12+ (and strictly on GrapheneOS / Android 17), the usual path for a long text —
SmsManager.divideMessage(text) // ← calls getGroupIdLevel1() internally
SmsManager.sendMultipartTextMessage(...)— throws a SecurityException unless the app holds READ_PHONE_STATE. divideMessage() calls getGroupIdLevel1(), which reads phone identity. A privacy-respecting app that deliberately never requests that permission gets its emergency SMS dropped, silently.
oksigenia_sms splits the message itself (GSM-7 / UCS-2 aware, never breaking a surrogate pair) and hands the parts straight to sendMultipartTextMessage together with sent-status PendingIntents. That path needs only SEND_SMS. The recipient still receives a single, seamless concatenated message — and you get a real per-part delivery result back from the radio.
- 📵 No
READ_PHONE_STATE. OnlySEND_SMS. The package declares zero permissions of its own. - ✉️ One concatenated message, not a burst of fragments — even for long text with a maps link.
- 🔤 Encoding-aware split: 153-char GSM-7 vs 67-char UCS-2 segments, chosen automatically; never splits an emoji in half.
- ✅ Real send confirmation per segment (
sent/failed/unknown), with the failing error code surfaced. - 🌍 De-Googled friendly: pure
SmsManager, no Google Play Services, works on GrapheneOS. - 🪶 Tiny, single method channel, no transitive bloat.
dependencies:
oksigenia_sms: ^0.1.0Status: currently developed inside the Oksigenia SOS monorepo. Standalone repository + pub.dev publication are on the way; until then, depend on it via git or a local path.
Declare the permission in your app's AndroidManifest.xml and request it at runtime (e.g. with permission_handler):
<uses-permission android:name="android.permission.SEND_SMS" />import 'package:oksigenia_sms/oksigenia_sms.dart';
final result = await OksigeniaSms.send(
to: '+341234567890',
message: 'SOS — I need help.\ngeo:40.4168,-3.7038\n'
'https://maps.google.com/?q=40.4168,-3.7038',
);
if (result.ok) {
print('Delivered in ${result.parts} segment(s).');
} else if (result.status == OksigeniaSmsStatus.failed) {
print('Radio rejected it: ${result.error} (${result.okParts}/${result.parts} ok)');
} else {
// OksigeniaSmsStatus.unknown — handed to the radio, no confirmation in time.
print('Submitted, no delivery result within the timeout.');
}send() waits up to timeout (default 20 s) for the per-part results before returning.
status |
Meaning |
|---|---|
sent |
Every segment reported RESULT_OK. result.ok is true. |
failed |
At least one segment returned an error; see result.error (e.g. no_service, radio_off). |
unknown |
No result arrived within the timeout. The message was still submitted to the radio and usually goes out — you just can't confirm it. |
result.parts / result.okParts tell you how many segments were sent and how many were confirmed.
- Android only. Other platforms are no-ops by design.
- Sends must be sequential, not concurrent (the sent-status receiver is shared). Await each
send()before the next. unknowndoes not mean failure — treat it as "submitted". Re-sending onunknownrisks duplicate messages.- This package does not read the inbox, track delivery reports, or persist anything. It sends. That's it.
Licensed under the Apache License 2.0. See NOTICE.
Copyright 2026 Oksigenia SL.
oksigenia_sms is free software, born out of a FOSS safety app. If it saved you a permission (or a headache), you can support the work: