package com.shire.mobile import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.app.job.JobInfo import android.app.job.JobParameters import android.app.job.JobScheduler import android.app.job.JobService import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.util.Log import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL import java.util.LinkedHashSet import java.util.concurrent.atomic.AtomicBoolean internal data class SentinelMobileAlert( val id: String, val title: String, val severity: String, val category: String, val timestamp: String, val selfTest: Boolean, ) internal object SentinelMobileAlerts { private const val TAG = "SHiRESentinel" private const val ALERT_CHANNEL = "shire_sentinel_mobile_alerts" private const val LINK_CHANNEL = "shire_sentinel_mobile_link" private const val PREFS = "shire_sentinel_mobile_alert_state" private const val KEY_INITIALISED = "initialised" private const val KEY_SEEN = "seen_alert_ids" private const val LINK_NOTIFICATION_ID = 73010 private const val FALLBACK_JOB_ID = 73011 private const val MAX_SEEN_IDS = 500 private const val POLL_DELAY_MS = 30_000L private val importantKeywords = listOf( "adult", "porn", "explicit", "social media", "tiktok", "snapchat", "instagram", "facebook", "discord", "vpn", "proxy", "tor", "malware", "trojan", "ransomware", "phishing", "credential", "botnet", "command and control", "exploit", "unknown device", "after bedtime", "bypass", ) fun initialise(context: Context) { ensureChannels(context) scheduleFallback(context) startLink(context) } fun ensureChannels(context: Context) { val manager = context.getSystemService( NotificationManager::class.java, ) val alertChannel = NotificationChannel( ALERT_CHANNEL, "SENTiNEL security alerts", NotificationManager.IMPORTANCE_HIGH, ).apply { description = "Private security and family-safety alerts from SHiRE Mini" enableVibration(true) lockscreenVisibility = Notification.VISIBILITY_PRIVATE } val linkChannel = NotificationChannel( LINK_CHANNEL, "SENTiNEL private link", NotificationManager.IMPORTANCE_LOW, ).apply { description = "Keeps the private Tailscale SENTiNEL alert link running" setShowBadge(false) lockscreenVisibility = Notification.VISIBILITY_PRIVATE } manager.createNotificationChannel(alertChannel) manager.createNotificationChannel(linkChannel) } fun startLink(context: Context) { val intent = Intent( context, SentinelAlertLinkService::class.java, ) try { context.startForegroundService(intent) } catch (error: Exception) { Log.w( TAG, "LINK_START_FAILED " + "${error.javaClass.simpleName}: ${error.message}", ) } } fun scheduleFallback(context: Context) { val scheduler = context.getSystemService( JobScheduler::class.java, ) val component = ComponentName( context, SentinelAlertPollJobService::class.java, ) val job = JobInfo.Builder( FALLBACK_JOB_ID, component, ) .setRequiredNetworkType( JobInfo.NETWORK_TYPE_ANY, ) .setPersisted(true) .setPeriodic(15L * 60L * 1000L) .build() val result = scheduler.schedule(job) Log.i( TAG, "FALLBACK_SCHEDULED result=$result", ) } internal fun linkNotification( context: Context, ): Notification { val pendingIntent = PendingIntent.getActivity( context, LINK_NOTIFICATION_ID, Intent( context, SentinelActivity::class.java, ), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return Notification.Builder( context, LINK_CHANNEL, ) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle( "SENTiNEL private link running", ) .setContentText( "Watching SHiRE Mini through Tailscale", ) .setContentIntent(pendingIntent) .setCategory(Notification.CATEGORY_SERVICE) .setOngoing(true) .setOnlyAlertOnce(true) .setVisibility(Notification.VISIBILITY_PRIVATE) .build() } internal fun poll(context: Context): Int { val alerts = fetchAlerts() val preferences = context.getSharedPreferences( PREFS, Context.MODE_PRIVATE, ) val stored = preferences.getStringSet( KEY_SEEN, emptySet(), ) ?: emptySet() val seen = LinkedHashSet(stored) val initialised = preferences.getBoolean( KEY_INITIALISED, false, ) if (!initialised) { alerts.forEach { alert -> seen.add(alert.id) } trimSeen(seen) preferences.edit() .putStringSet(KEY_SEEN, HashSet(seen)) .putBoolean(KEY_INITIALISED, true) .apply() Log.i( TAG, "POLL_SEEDED alerts=${alerts.size}", ) return 0 } var delivered = 0 alerts.asReversed().forEach { alert -> if (!seen.add(alert.id)) { return@forEach } if ( isImportant(alert) && notifyAlert(context, alert) ) { delivered += 1 } } trimSeen(seen) preferences.edit() .putStringSet(KEY_SEEN, HashSet(seen)) .apply() Log.i( TAG, "POLL_READY alerts=${alerts.size} delivered=$delivered", ) return delivered } private fun fetchAlerts(): List { val base = BuildConfig.MOBILE_COMMAND_API_BASE_URL .trimEnd('/') val token = BuildConfig.MOBILE_COMMAND_API_TOKEN .trim() if (base.isBlank() || token.isBlank()) { throw IllegalStateException( "SENTiNEL gateway configuration is incomplete", ) } val url = URL("$base/v1/sentinel/status") if (!approvedTailnetHost(url.host.lowercase())) { throw SecurityException( "SENTiNEL refused a non-Tailscale endpoint", ) } val connection = url.openConnection() as HttpURLConnection try { connection.requestMethod = "GET" connection.connectTimeout = 10_000 connection.readTimeout = 15_000 connection.useCaches = false connection.setRequestProperty( "Authorization", "Bearer $token", ) connection.setRequestProperty( "Accept", "application/json", ) val status = connection.responseCode if (status != 200) { throw IllegalStateException( "SENTiNEL gateway returned HTTP $status", ) } val body = connection.inputStream .bufferedReader() .use { reader -> reader.readText() } val root = JSONObject(body) val rows = root.optJSONArray( "recent_alerts", ) ?: return emptyList() val alerts = ArrayList( rows.length(), ) for (index in 0 until rows.length()) { val row = rows.optJSONObject(index) ?: continue val id = row.optString("id").trim() if (id.isBlank()) { continue } alerts.add( SentinelMobileAlert( id = id, title = row.optString( "title", "SENTiNEL alert", ), severity = row.optString( "severity", "UNKNOWN", ), category = row.optString( "category", "security", ), timestamp = row.optString( "timestamp", "", ), selfTest = row.optBoolean( "self_test", false, ), ), ) } return alerts } finally { connection.disconnect() } } private fun approvedTailnetHost( host: String, ): Boolean { if (host.endsWith(".ts.net")) { return true } val parts = host.split(".") if (parts.size != 4) { return false } val octets = parts.map { part -> part.toIntOrNull() ?: return false } if (octets.any { value -> value !in 0..255 }) { return false } return octets[0] == 100 && octets[1] in 64..127 } private fun isImportant( alert: SentinelMobileAlert, ): Boolean { if (alert.selfTest) { return true } val severity = alert.severity.trim().lowercase() val numeric = severity.toIntOrNull() if (numeric != null && numeric <= 2) { return true } if ( severity.contains("critical") || severity.contains("high") ) { return true } val searchable = "${alert.title} ${alert.category}" .lowercase() return importantKeywords.any { keyword -> searchable.contains(keyword) } } private fun notifyAlert( context: Context, alert: SentinelMobileAlert, ): Boolean { if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && context.checkSelfPermission( Manifest.permission.POST_NOTIFICATIONS, ) != PackageManager.PERMISSION_GRANTED ) { Log.w( TAG, "NOTIFICATION_PERMISSION_MISSING", ) return false } val manager = context.getSystemService( NotificationManager::class.java, ) val pendingIntent = PendingIntent.getActivity( context, alert.id.hashCode(), Intent( context, SentinelActivity::class.java, ).apply { putExtra( "sentinel_alert_id", alert.id, ) }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val severityLabel = when { alert.selfTest -> "SELF-TEST" alert.severity.toIntOrNull() == 1 -> "CRITICAL" alert.severity.toIntOrNull() == 2 -> "HIGH" alert.severity.contains( "critical", ignoreCase = true, ) -> "CRITICAL" alert.severity.contains( "high", ignoreCase = true, ) -> "HIGH" else -> alert.severity.uppercase() } val detail = buildString { append(alert.title) if (alert.category.isNotBlank()) { append("\nCategory: ") append(alert.category) } if (alert.timestamp.isNotBlank()) { append("\nDetected: ") append(alert.timestamp) } append( "\nNo private messages, passwords, " + "screens or packet contents were included.", ) } val title = if (alert.selfTest) { "SENTiNEL mobile self-test" } else { "SENTiNEL $severityLabel alert" } val notification = Notification.Builder( context, ALERT_CHANNEL, ) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title) .setContentText(alert.title) .setStyle( Notification.BigTextStyle() .bigText(detail), ) .setContentIntent(pendingIntent) .setCategory(Notification.CATEGORY_ERROR) .setAutoCancel(true) .setOnlyAlertOnce(true) .setVisibility(Notification.VISIBILITY_PRIVATE) .setGroup("shire_sentinel_alerts") .build() manager.notify( alert.id.hashCode() and 0x7fffffff, notification, ) if (alert.selfTest) { Log.i( TAG, "MOBILE_SELF_TEST id=${alert.id}", ) } else { Log.i( TAG, "MOBILE_ALERT id=${alert.id} " + "severity=${alert.severity}", ) } return true } private fun trimSeen( seen: LinkedHashSet, ) { while (seen.size > MAX_SEEN_IDS) { val first = seen.firstOrNull() ?: break seen.remove(first) } } internal fun pollDelay(): Long = POLL_DELAY_MS internal fun linkNotificationId(): Int = LINK_NOTIFICATION_ID } class SentinelAlertLinkService : Service() { private val running = AtomicBoolean(false) private var worker: Thread? = null override fun onCreate() { super.onCreate() SentinelMobileAlerts.ensureChannels(this) val notification = SentinelMobileAlerts.linkNotification(this) if (Build.VERSION.SDK_INT >= 34) { startForeground( SentinelMobileAlerts.linkNotificationId(), notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, ) } else { startForeground( SentinelMobileAlerts.linkNotificationId(), notification, ) } running.set(true) worker = Thread( { while (running.get()) { try { SentinelMobileAlerts.poll(this) } catch (error: Exception) { Log.w( "SHiRESentinel", "POLL_FAILED " + "${error.javaClass.simpleName}: " + "${error.message}", ) } try { Thread.sleep( SentinelMobileAlerts.pollDelay(), ) } catch (_: InterruptedException) { break } } }, "shire-sentinel-mobile-link", ).apply { isDaemon = true start() } } override fun onStartCommand( intent: Intent?, flags: Int, startId: Int, ): Int = START_STICKY override fun onDestroy() { running.set(false) worker?.interrupt() worker = null super.onDestroy() } override fun onBind( intent: Intent?, ): IBinder? = null } class SentinelAlertPollJobService : JobService() { override fun onStartJob( parameters: JobParameters, ): Boolean { Thread( { try { SentinelMobileAlerts.poll(this) } catch (error: Exception) { Log.w( "SHiRESentinel", "FALLBACK_POLL_FAILED " + "${error.javaClass.simpleName}: " + "${error.message}", ) } finally { jobFinished( parameters, false, ) } }, "shire-sentinel-fallback-poll", ).start() return true } override fun onStopJob( parameters: JobParameters, ): Boolean = true } class SentinelAlertBootReceiver : BroadcastReceiver() { override fun onReceive( context: Context, intent: Intent, ) { SentinelMobileAlerts.ensureChannels(context) SentinelMobileAlerts.scheduleFallback(context) Log.i( "SHiRESentinel", "BOOT_FALLBACK_READY action=${intent.action}", ) } }