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:
parent
a9c5cda4bd
commit
6d389ea6af
5 changed files with 329 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
114
app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt
Normal file
114
app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt
Normal file
65
app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/src/main/java/io/heckel/ntfy/sms/SmsRelayReceiver.kt
Normal file
83
app/src/main/java/io/heckel/ntfy/sms/SmsRelayReceiver.kt
Normal 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
66
flake.nix
Normal 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"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue