DEV Community

Cover image for android_sms: build a real default SMS app on Android with Flutter
Abdeldjalil Chougui
Abdeldjalil Chougui

Posted on Originally published at abdeldjalilchougui.biz

android_sms: build a real default SMS app on Android with Flutter

Most Flutter SMS plugins let you send a message and listen for incoming ones. That is enough for an OTP screen, but not for a messaging app. On Android, only one app at a time is the default SMS app, and that app has jobs nobody else can do: it is the only one that receives the SMS_DELIVER broadcast, and if it does not write an incoming message to the system Inbox, the message is simply not stored anywhere.

android_sms is a Flutter plugin built around that rule. It covers the whole path through the official default-SMS-app role: asking the user for the role, sending with status reports, storing incoming messages, reading conversations, and handling messages that arrive while your app is closed.

What it does

The role. isDefaultSmsApp() tells you whether your app holds the role, and requestDefaultSmsApp() opens Android's own dialog. It uses RoleManager on Android 10 and later, and the older ACTION_CHANGE_DEFAULT intent below that. The defaultSmsAppChanged stream fires whenever the role is gained or lost, including when the user changes it in Settings.

Sending. send(address, body) returns a message id. Long texts are split with SmsManager.divideMessage, and you can pass a subscriptionId to send from a specific SIM. The sendStatus stream gives one aggregated event per message: sent once every part is out, failed as soon as one part fails (with the SmsManager error code), and delivered or deliveryFailed when the network sends a status report. As the default app, the plugin also writes the message to the Sent box and moves it to Failed if sending fails.

Receiving. As the default app, every incoming message is parsed, multipart messages are joined, and the result is stored in the system Inbox. If your Dart code is listening, it arrives on incomingMessages. If not, the plugin runs your background handler in a headless engine, so you can show a notification even when the app is closed.

The Inbox. threads() returns conversations with their snippet, date, message count, unread count and recipients. messages(threadId) pages backwards through a conversation. markRead, delete and deleteThread work only for the default app, and throw NotDefaultSmsAppException otherwise, because Android silently ignores those writes from other apps.

Phone numbers that belong together. PhoneNormalizer.threadingKey maps 0661 23 45 67, 00213661234567 and +213661234567 to the same key, with a configurable default region (Algeria is built in), short codes, alphanumeric senders and Arabic-Indic digits. It is a key for grouping, not a validator: it does not check number lengths or ranges.

How to use it

Add it from GitHub:

dependencies:
  android_sms:
    git: https://github.com/AbdeldjalilChougui/android_sms
Enter fullscreen mode Exit fullscreen mode

It needs minSdk 21, and it is Android only. iOS does not let third-party apps send, receive or read SMS at all.

Android lists an app in the "Default SMS app" picker only if it declares four components: a compose activity (SENDTO for sms:/smsto:), an SMS_DELIVER receiver, a WAP_PUSH_DELIVER receiver for MMS, and a "respond via message" service. The plugin ships the classes; your app declares them in its manifest, so apps that only read SMS don't get receivers they don't want. The README has the exact manifest, and the example app uses it as is.

Then, in Dart:

import 'package:android_sms/android_sms.dart';

@pragma('vm:entry-point')
Future<void> onBackgroundSms(IncomingSms sms) async {
  // Runs in a headless engine. The message is already in the Inbox.
}

Future<void> setUpSms() async {
  if (!await AndroidSms.isDefaultSmsApp()) {
    final granted = await AndroidSms.requestDefaultSmsApp();
    if (!granted) return;
  }
  await AndroidSms.setBackgroundMessageHandler(onBackgroundSms);

  AndroidSms.incomingMessages.listen((sms) {
    print('${sms.address}: ${sms.body}');
  });

  AndroidSms.sendStatus.listen((status) {
    if (status.isFailure) print('Failed ${status.messageId}: ${status.errorCode}');
  });

  final id = await AndroidSms.send('+213661234567', 'Hello!', requestDelivery: true);
  print('queued $id');

  for (final t in await AndroidSms.threads(limit: 50)) {
    if (t.hasUnread) await AndroidSms.markRead(t.id);
  }
}
Enter fullscreen mode Exit fullscreen mode

When the user grants the role, Android grants the SMS permissions with it. The plugin never asks for permissions on its own; permissionStatus() tells you what you have.

Errors are typed, so you can react to each case:

try {
  await AndroidSms.delete(messageId);
} on NotDefaultSmsAppException {
  // Only the default SMS app may change the Inbox.
} on SmsPermissionException catch (e) {
  print('Missing ${e.permission}');
}
Enter fullscreen mode Exit fullscreen mode

Things to know before you ship

  • Google Play policy. Play restricts the SMS permissions to the app the user chose as default SMS handler, and to a short list of approved exceptions. That is why the plugin adds none of these permissions itself: you declare only what your use case is allowed to have.
  • MMS content is out of scope. The plugin reads and stores only the MMS notification header (sender, subject, size). It does not download pictures or group messages.
  • Send status lives in memory. If the process dies in the middle of a multipart message, one status can be lost. The provider row is still updated for each final outcome.
  • What was tested. The plugin's Kotlin logic has unit tests, and the example app has an emulator integration test that takes the role, injects SMS (including multipart Arabic), and checks the streams, the Inbox rows, threads, read marks, deletes and sending. It was tested on the Android emulator (Pixel 10 image, API 37), not on a physical phone, and real network delivery reports were not tested.

Why I built it

The existing Flutter options (telephony and its forks) send and listen, but they have no role flow, and they don't store incoming messages. That is fine until your app becomes the default SMS app: from then on, every message it doesn't write to the Inbox is lost for good. Building a real SMS app needs the role, the four manifest components and the Inbox writes together, so I put them in one plugin, with typed errors and a migration table from telephony in the README.

Get the code

The source, the README with the full manifest, the example app and the tests are on GitHub: https://github.com/AbdeldjalilChougui/android_sms. It's MIT licensed. Issues and pull requests are welcome.


Originally published at abdeldjalilchougui.biz.

Top comments (0)