From 52809f6ed44d6d10e8a92aad5c7ba8549177629f Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Mon, 11 May 2026 18:41:57 +0200 Subject: [PATCH] sms: device heartbeat + last-forward tracking (Phase 2 Slice B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So centralcloud-ops can SEE that this phone is alive and the SMS relay is working. Server side (POST /api/devices/heartbeat + /devices LiveView) landed in centralcloud_ops commit 6a7f22e. DeviceHeartbeatWorker Periodic CoroutineWorker (15min, the WorkManager floor) that POSTs sms_relay state + device_state to /api/devices/heartbeat. sms_relay summary: enabled, whitelist_size, last_forward_at, failure_count_24h. device_state: battery_level, is_charging, network_type, low_power_mode, dnd_active, ringer_mode. DND read via NotificationManager.currentInterruptionFilter — works on API 23+ without notification policy access permission. Skips (Result.success) when relay isn't configured yet — no point spamming the server, but the schedule stays alive so it picks up automatically as soon as config lands. SmsRelayInit One-liner bootstrap. Call from Application.onCreate when your managed-config / branding WIP merges: SmsRelayInit.start(this) Idempotent (WorkManager KEEP policy) so safe across cold starts. SmsRelayPreferences (extended) Tracks lastForwardAtMs (epoch-ms, set by SmsForwardWorker on 200) and failureCount24h (rolling, reset by heartbeat on success). SmsForwardWorker (extended) On 200 → record lastForwardAtMs. On 4xx/5xx/exception → bump failureCount24h. Heartbeat then reports both up to the server, which renders them on the /devices LiveView. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../heckel/ntfy/sms/DeviceHeartbeatWorker.kt | 243 ++++++++++++++++++ .../io/heckel/ntfy/sms/SmsForwardWorker.kt | 4 + .../java/io/heckel/ntfy/sms/SmsRelayInit.kt | 22 ++ .../io/heckel/ntfy/sms/SmsRelayPreferences.kt | 20 ++ 4 files changed, 289 insertions(+) create mode 100644 app/src/main/java/io/heckel/ntfy/sms/DeviceHeartbeatWorker.kt create mode 100644 app/src/main/java/io/heckel/ntfy/sms/SmsRelayInit.kt diff --git a/app/src/main/java/io/heckel/ntfy/sms/DeviceHeartbeatWorker.kt b/app/src/main/java/io/heckel/ntfy/sms/DeviceHeartbeatWorker.kt new file mode 100644 index 00000000..42adab87 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/sms/DeviceHeartbeatWorker.kt @@ -0,0 +1,243 @@ +package io.heckel.ntfy.sms + +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import android.os.PowerManager +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +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 +import java.util.concurrent.TimeUnit + +/** + * Periodic heartbeat to centralcloud-ops so the team can SEE this device is + * alive and the SMS relay is in the expected state. Runs once every 15 + * minutes (Android WorkManager's minimum periodic interval) when the device + * has network connectivity. + * + * POST {baseUrl}/api/devices/heartbeat + * X-Device-Id: + * { + * "device_id": "", + * "sms_relay": { + * "enabled": bool, + * "whitelist_size": int, + * "last_forward_at": iso8601 | null, + * "failure_count_24h": int + * }, + * "device_state": { + * "battery_level": 0.0..1.0, + * "is_charging": bool, + * "network_type": "wifi" | "cellular" | "ethernet" | "none", + * "low_power_mode": bool, + * "dnd_active": bool, // omitted on older Android + * "ringer_mode": "silent" | "vibrate" | "normal" + * } + * } + * + * Scheduled via [scheduleIfConfigured]. Skips heartbeat (returns success) + * if relay isn't configured yet — we don't want to spam an unconfigured + * server. + */ +class DeviceHeartbeatWorker(appContext: Context, params: WorkerParameters) : + CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val prefs = SmsRelayPreferences(applicationContext) + + if (prefs.baseUrl.isBlank() || prefs.deviceId.isBlank()) { + Log.d(TAG, "skip: base_url or device_id unset") + return Result.success() + } + + return try { + val payload = buildPayload(prefs).toString() + val url = "${prefs.baseUrl}/api/devices/heartbeat" + 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 -> { + // 24h reset on each successful heartbeat is a rough + // approximation. Real "last 24h" is computed server-side + // when we add proper rolling windows. + prefs.resetFailureCount() + Log.d(TAG, "heartbeat ok code=${response.code}") + Result.success() + } + + response.code in 400..499 -> { + // Bad device_id / bad payload — won't recover by retrying. + Log.w(TAG, "heartbeat permanent reject code=${response.code}") + Result.failure() + } + + else -> { + Log.w(TAG, "heartbeat transient fail code=${response.code}") + Result.retry() + } + } + } + } catch (t: Throwable) { + Log.w(TAG, "heartbeat exception", t) + Result.retry() + } + } + + private fun buildPayload(prefs: SmsRelayPreferences): JSONObject { + val smsRelay = JSONObject() + .put("enabled", prefs.enabled) + .put("whitelist_size", prefs.whitelist.size) + .put("failure_count_24h", prefs.failureCount24h) + .apply { + val ts = prefs.lastForwardAtMs + put("last_forward_at", if (ts > 0L) iso8601(ts) else JSONObject.NULL) + } + + val deviceState = collectDeviceState() + + return JSONObject() + .put("device_id", prefs.deviceId) + .put("sms_relay", smsRelay) + .put("device_state", deviceState) + } + + private fun collectDeviceState(): JSONObject { + val ctx = applicationContext + val obj = JSONObject() + + // Battery + try { + val intent: Intent? = + ctx.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + if (intent != null) { + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + if (level >= 0 && scale > 0) { + obj.put("battery_level", level.toFloat() / scale.toFloat()) + } + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + obj.put( + "is_charging", + status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + ) + } + } catch (_: Throwable) {} + + // Network + try { + val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val active = cm.activeNetwork + val caps = active?.let { cm.getNetworkCapabilities(it) } + val netType = when { + caps == null -> "none" + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + else -> "other" + } + obj.put("network_type", netType) + } catch (_: Throwable) {} + + // Low-power mode + try { + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + obj.put("low_power_mode", pm.isPowerSaveMode) + } catch (_: Throwable) {} + + // DND — readable without notification policy access on API 23+. + try { + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val filter = nm.currentInterruptionFilter + obj.put( + "dnd_active", + filter == NotificationManager.INTERRUPTION_FILTER_PRIORITY || + filter == NotificationManager.INTERRUPTION_FILTER_NONE || + filter == NotificationManager.INTERRUPTION_FILTER_ALARMS + ) + } catch (_: Throwable) {} + + // Ringer mode + try { + val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val mode = when (am.ringerMode) { + AudioManager.RINGER_MODE_SILENT -> "silent" + AudioManager.RINGER_MODE_VIBRATE -> "vibrate" + AudioManager.RINGER_MODE_NORMAL -> "normal" + else -> "unknown" + } + obj.put("ringer_mode", mode) + } catch (_: Throwable) {} + + return obj + } + + 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(ms)) + } + + companion object { + private const val TAG = "DeviceHeartbeatWorker" + const val UNIQUE_NAME = "centralcloud-ops-heartbeat" + + private val JSON = "application/json; charset=utf-8".toMediaType() + + /** + * Schedule the periodic heartbeat. Idempotent — safe to call from + * Application.onCreate or anywhere else; uses KEEP policy so existing + * scheduled work is preserved across restarts. + * + * 15-minute interval is WorkManager's minimum periodic interval. + */ + fun scheduleIfConfigured(context: Context) { + val req = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .addTag(UNIQUE_NAME) + .build() + + WorkManager + .getInstance(context.applicationContext) + .enqueueUniquePeriodicWork( + UNIQUE_NAME, + ExistingPeriodicWorkPolicy.KEEP, + req + ) + } + + /** Stop the periodic heartbeat (e.g. when user disables the relay). */ + fun cancel(context: Context) { + WorkManager + .getInstance(context.applicationContext) + .cancelUniqueWork(UNIQUE_NAME) + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt b/app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt index 4e182fdb..dfeb79eb 100644 --- a/app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/sms/SmsForwardWorker.kt @@ -67,23 +67,27 @@ class SmsForwardWorker(appContext: Context, params: WorkerParameters) : client.newCall(request).execute().use { response -> when { response.isSuccessful -> { + prefs.lastForwardAtMs = System.currentTimeMillis() 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. + prefs.bumpFailureCount() Log.w(TAG, "permanent reject cid=$clientMessageId code=${response.code}") Result.failure() } else -> { + prefs.bumpFailureCount() Log.w(TAG, "transient fail cid=$clientMessageId code=${response.code}") Result.retry() } } } } catch (t: Throwable) { + prefs.bumpFailureCount() Log.w(TAG, "forward exception cid=$clientMessageId", t) Result.retry() } diff --git a/app/src/main/java/io/heckel/ntfy/sms/SmsRelayInit.kt b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayInit.kt new file mode 100644 index 00000000..ef4b1120 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayInit.kt @@ -0,0 +1,22 @@ +package io.heckel.ntfy.sms + +import android.content.Context + +/** + * Tiny init helper to bootstrap the SMS-relay subsystem from outside the + * `sms/` package — call once from `Application.onCreate`: + * + * SmsRelayInit.start(this) + * + * Currently this only schedules the periodic device heartbeat. The + * SmsRelayReceiver is registered in AndroidManifest.xml and fires by itself + * on SMS_RECEIVED, so it doesn't need anything bootstrapped here. + * + * The schedule call is idempotent (WorkManager KEEP policy) so calling it + * every cold start is fine. + */ +object SmsRelayInit { + fun start(context: Context) { + DeviceHeartbeatWorker.scheduleIfConfigured(context) + } +} diff --git a/app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt index f01e3112..edf96e1f 100644 --- a/app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt +++ b/app/src/main/java/io/heckel/ntfy/sms/SmsRelayPreferences.kt @@ -44,6 +44,24 @@ class SmsRelayPreferences(context: Context) { get() = prefs.getStringSet(KEY_WHITELIST, emptySet())!!.toSet() set(value) = prefs.edit().putStringSet(KEY_WHITELIST, value).apply() + /** Epoch-ms of the last SUCCESSFUL forward (200 from the server), or 0 if never. */ + var lastForwardAtMs: Long + get() = prefs.getLong(KEY_LAST_FORWARD_AT_MS, 0L) + set(value) = prefs.edit().putLong(KEY_LAST_FORWARD_AT_MS, value).apply() + + /** Rolling 24h forward-failure counter (best-effort; reset by DeviceHeartbeatWorker). */ + var failureCount24h: Int + get() = prefs.getInt(KEY_FAILURE_COUNT_24H, 0) + set(value) = prefs.edit().putInt(KEY_FAILURE_COUNT_24H, value).apply() + + fun bumpFailureCount() { + failureCount24h = failureCount24h + 1 + } + + fun resetFailureCount() { + failureCount24h = 0 + } + /** * Returns true iff this SMS should be forwarded right now (relay is * enabled, fully configured, and the sender is in the whitelist). @@ -61,5 +79,7 @@ class SmsRelayPreferences(context: Context) { private const val KEY_BASE_URL = "base_url" private const val KEY_DEVICE_ID = "device_id" private const val KEY_WHITELIST = "whitelist" + private const val KEY_LAST_FORWARD_AT_MS = "last_forward_at_ms" + private const val KEY_FAILURE_COUNT_24H = "failure_count_24h" } }