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