sms: device heartbeat + last-forward tracking (Phase 2 Slice B)

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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-11 18:41:57 +02:00
parent 6d389ea6af
commit 52809f6ed4
4 changed files with 289 additions and 0 deletions

View file

@ -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: <token>
* {
* "device_id": "<token>",
* "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<DeviceHeartbeatWorker>(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)
}
}
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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"
}
}