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:
parent
6d389ea6af
commit
52809f6ed4
4 changed files with 289 additions and 0 deletions
243
app/src/main/java/io/heckel/ntfy/sms/DeviceHeartbeatWorker.kt
Normal file
243
app/src/main/java/io/heckel/ntfy/sms/DeviceHeartbeatWorker.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
22
app/src/main/java/io/heckel/ntfy/sms/SmsRelayInit.kt
Normal file
22
app/src/main/java/io/heckel/ntfy/sms/SmsRelayInit.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue