diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt b/app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt new file mode 100644 index 00000000..4e182fdb --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt @@ -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: + * Content-Type: application/json + * { + * "device_id": "", + * "from": "", + * "body": "", + * "received_at": "", + * "client_message_id": "" + * } + * + * 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() + } +} diff --git a/app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt new file mode 100644 index 00000000..f01e3112 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt @@ -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 + 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" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/sms/SmsRelayReceiver.kt b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayReceiver.kt new file mode 100644 index 00000000..c189b329 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayReceiver.kt @@ -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() + .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" + } +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..95111f16 --- /dev/null +++ b/flake.nix @@ -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" + ''; + }; + }); +}