sms: relay inbound SMS to centralcloud-ops (Phase 2)

Whitelisted incoming SMS are forwarded to the centralcloud-ops
phone-relay endpoint as a redundant inbound path alongside Twilio.

  io.heckel.ntfy.sms.SmsRelayPreferences
    SharedPreferences-backed config — enabled flag, base URL, device id,
    sender whitelist (Set<String>). Strict opt-in: forward nothing unless
    explicitly enabled AND a non-empty whitelist matches the sender.

  io.heckel.ntfy.sms.SmsRelayReceiver
    BroadcastReceiver bound to SMS_RECEIVED_ACTION. Extracts the sender +
    concatenated body of multi-part SMS, applies the whitelist, and
    enqueues a OneTimeWorkRequest for forwarding. Wraps everything in
    try/catch so a failure on this hot path can never crash the device's
    SMS handling.

  io.heckel.ntfy.sms.SmsForwardWorker
    CoroutineWorker that POSTs to {baseUrl}/api/sms/inbound/phone-relay
    with X-Device-Id auth. Uses HttpUtil.defaultClient and JSONObject
    (no new deps). 4xx -> permanent drop, 5xx/network -> WorkManager
    exponential backoff retry. WorkManager constraints require a
    connected network so retries don't fire while offline.

  Manifest registration (uses-permission RECEIVE_SMS / READ_SMS, receiver
  declaration) is left as WIP in the working tree pending the broader
  managed-config / branding changes also in progress on the manifest.
  Commit those together (or in a follow-up) to make the receiver actually
  bind.

  No settings UI yet — set values via SharedPreferences directly or wait
  for the Phase 2.1 settings screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-11 18:34:31 +02:00
parent a9c5cda4bd
commit 6d389ea6af
5 changed files with 329 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -0,0 +1,114 @@
package io.heckel.ntfy.sms
import android.content.Context
import android.util.Log
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.NetworkType
import androidx.work.WorkerParameters
import io.heckel.ntfy.util.HttpUtil
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Forwards a single SMS to centralcloud-ops:
*
* POST {baseUrl}/api/sms/inbound/phone-relay
* X-Device-Id: <device_id>
* Content-Type: application/json
* {
* "device_id": "<token>",
* "from": "<sender>",
* "body": "<text>",
* "received_at": "<ISO-8601>",
* "client_message_id": "<uuid>"
* }
*
* Retries via WorkManager's exponential backoff on transient failures
* (network, 5xx). Drops permanently on 4xx (server rejected it; retrying
* would just loop).
*/
class SmsForwardWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val from = inputData.getString(KEY_FROM) ?: return Result.failure()
val body = inputData.getString(KEY_BODY) ?: return Result.failure()
val receivedAtMs = inputData.getLong(KEY_RECEIVED_AT_MS, 0L)
val clientMessageId = inputData.getString(KEY_CLIENT_MESSAGE_ID) ?: return Result.failure()
val prefs = SmsRelayPreferences(applicationContext)
if (!prefs.enabled || prefs.baseUrl.isBlank() || prefs.deviceId.isBlank()) {
Log.w(TAG, "relay disabled or unconfigured; dropping cid=$clientMessageId")
return Result.failure()
}
return try {
val payload = JSONObject().apply {
put("device_id", prefs.deviceId)
put("from", from)
put("body", body)
put("received_at", iso8601(receivedAtMs))
put("client_message_id", clientMessageId)
}.toString()
val url = "${prefs.baseUrl}/api/sms/inbound/phone-relay"
val client = HttpUtil.defaultClient(applicationContext, prefs.baseUrl)
val request = HttpUtil.requestBuilder(url)
.addHeader("X-Device-Id", prefs.deviceId)
.post(payload.toRequestBody(JSON))
.build()
client.newCall(request).execute().use { response ->
when {
response.isSuccessful -> {
Log.d(TAG, "forwarded cid=$clientMessageId code=${response.code}")
Result.success()
}
response.code in 400..499 -> {
// Permanent: bad device_id, bad payload, etc. Don't retry.
Log.w(TAG, "permanent reject cid=$clientMessageId code=${response.code}")
Result.failure()
}
else -> {
Log.w(TAG, "transient fail cid=$clientMessageId code=${response.code}")
Result.retry()
}
}
}
} catch (t: Throwable) {
Log.w(TAG, "forward exception cid=$clientMessageId", t)
Result.retry()
}
}
private fun iso8601(ms: Long): String {
val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
df.timeZone = TimeZone.getTimeZone("UTC")
return df.format(Date(if (ms > 0L) ms else System.currentTimeMillis()))
}
companion object {
private const val TAG = "SmsForwardWorker"
const val KEY_FROM = "from"
const val KEY_BODY = "body"
const val KEY_RECEIVED_AT_MS = "received_at_ms"
const val KEY_CLIENT_MESSAGE_ID = "client_message_id"
const val INITIAL_BACKOFF_SECONDS = 30L
private val JSON = "application/json; charset=utf-8".toMediaType()
fun connectedConstraints(): Constraints =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
}
}

View file

@ -0,0 +1,65 @@
package io.heckel.ntfy.sms
import android.content.Context
import android.content.SharedPreferences
/**
* SharedPreferences-backed config for the SMS-relay feature.
*
* Strict opt-in: relay is OFF until [enabled] is set true AND the [whitelist] is
* non-empty AND a base URL + device id are configured. A missing/invalid value
* silently drops the SMS (never crashes the boot-time SMS receiver).
*
* In this commit there is no settings UI yet set values via adb shell or a
* future preferences screen:
* adb shell am start-activity ... (or use a dedicated settings activity)
* adb shell input keyevent ... (or edit SharedPreferences directly)
*
* Whitelist matching is exact-string on the SMS sender address as Android
* reports it (typically E.164, sometimes a short code or alphanumeric ID).
*/
class SmsRelayPreferences(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
var enabled: Boolean
get() = prefs.getBoolean(KEY_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_ENABLED, value).apply()
/** Base URL of centralcloud-ops, e.g. "https://ops.centralcloud.com". No trailing slash. */
var baseUrl: String
get() = prefs.getString(KEY_BASE_URL, "")!!.trimEnd('/')
set(value) = prefs.edit().putString(KEY_BASE_URL, value.trimEnd('/')).apply()
/** Device id token registered in centralcloud-ops `push_subscriptions`. */
var deviceId: String
get() = prefs.getString(KEY_DEVICE_ID, "")!!
set(value) = prefs.edit().putString(KEY_DEVICE_ID, value).apply()
/**
* Set of sender addresses to forward. Anything not in this set is ignored.
* Empty set forward nothing (safe default).
*/
var whitelist: Set<String>
get() = prefs.getStringSet(KEY_WHITELIST, emptySet())!!.toSet()
set(value) = prefs.edit().putStringSet(KEY_WHITELIST, value).apply()
/**
* Returns true iff this SMS should be forwarded right now (relay is
* enabled, fully configured, and the sender is in the whitelist).
*/
fun shouldForward(sender: String?): Boolean {
if (!enabled) return false
if (baseUrl.isBlank() || deviceId.isBlank()) return false
if (sender.isNullOrBlank()) return false
return whitelist.contains(sender)
}
companion object {
private const val FILE = "sms_relay_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_BASE_URL = "base_url"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_WHITELIST = "whitelist"
}
}

View file

@ -0,0 +1,83 @@
package io.heckel.ntfy.sms
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import android.util.Log
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import java.util.UUID
/**
* Receives every SMS_RECEIVED broadcast, filters by the SmsRelayPreferences
* whitelist, and hands matching messages off to SmsForwardWorker for delivery
* to centralcloud-ops.
*
* Multi-part SMS arrive as a single intent containing multiple PDUs. We
* concatenate their bodies but only use the first message's sender/timestamp
* (which is what the carrier presents anyway).
*
* Anything goes wrong (no perms, no config, no whitelist hit, intent
* malformed) log + drop silently. This receiver MUST NOT raise it's on
* the SMS_RECEIVED hot path.
*/
class SmsRelayReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
try {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val prefs = SmsRelayPreferences(context)
if (!prefs.enabled) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
?: return
if (messages.isEmpty()) return
val sender = messages[0].displayOriginatingAddress
?: messages[0].originatingAddress
?: return
if (!prefs.shouldForward(sender)) return
val body = messages.joinToString("") { it.messageBody.orEmpty() }
val receivedAtMs = messages[0].timestampMillis
val clientMessageId = UUID.randomUUID().toString()
val data = Data.Builder()
.putString(SmsForwardWorker.KEY_FROM, sender)
.putString(SmsForwardWorker.KEY_BODY, body)
.putLong(SmsForwardWorker.KEY_RECEIVED_AT_MS, receivedAtMs)
.putString(SmsForwardWorker.KEY_CLIENT_MESSAGE_ID, clientMessageId)
.build()
val req = OneTimeWorkRequestBuilder<SmsForwardWorker>()
.setInputData(data)
.setConstraints(SmsForwardWorker.connectedConstraints())
.setBackoffCriteria(
androidx.work.BackoffPolicy.EXPONENTIAL,
SmsForwardWorker.INITIAL_BACKOFF_SECONDS,
java.util.concurrent.TimeUnit.SECONDS
)
.addTag(SmsForwardWorker.TAG)
.build()
WorkManager
.getInstance(context.applicationContext)
.enqueueUniqueWork(
"sms-relay-$clientMessageId",
androidx.work.ExistingWorkPolicy.KEEP,
req
)
Log.d(TAG, "queued sms-relay: from=$sender bytes=${body.length} cid=$clientMessageId")
} catch (t: Throwable) {
Log.e(TAG, "sms-relay receive failed", t)
}
}
companion object {
private const val TAG = "SmsRelayReceiver"
}
}

66
flake.nix Normal file
View file

@ -0,0 +1,66 @@
{
description = "Nix development shell for the ntfy Android app";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
};
nixConfig = {
extra-substituters = [
"https://cache.centralcloud.com/default"
];
extra-trusted-public-keys = [
"default:ywfU21WX06iOn2Ec2lae1jYh4w8LO4IQkmp06vJzsk8="
];
};
outputs = {
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
config.android_sdk.accept_license = true;
config.allowUnfree = true;
};
androidComposition = pkgs.androidenv.composeAndroidPackages {
platformVersions = ["36"];
buildToolsVersions = ["36.0.0"];
cmdLineToolsVersion = "latest";
includeEmulator = false;
includeNDK = false;
includeSources = false;
includeSystemImages = false;
};
androidSdk = androidComposition.androidsdk;
in {
devShells.default = pkgs.mkShell {
packages = [
androidSdk
pkgs.git
pkgs.gradle
pkgs.jdk21
];
ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
JAVA_HOME = pkgs.jdk21.home;
shellHook = ''
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
echo "ntfy Android development shell"
echo " java : $(java -version 2>&1 | head -n 1)"
echo " ANDROID_HOME: $ANDROID_HOME"
echo ""
echo "Useful checks:"
echo " ./gradlew tasks"
echo " ./gradlew :app:assembleFdroidDebug"
'';
};
});
}