From 73ce9afa8e9c79ae519f51b4b777e38736674c16 Mon Sep 17 00:00:00 2001 From: jay Date: Wed, 21 Oct 2026 11:56:37 -0400 Subject: [PATCH 01/13] feat(meeting): honest mic signal, live level meter, Tailscale auto-connect - MicLevelMonitor: fail-closed Mic signal. Green only with a non-zero frame in the last 2 s; red after 5 s of all-zero audio or 5 s with no frames, and at once when Android reports the capture silenced (AudioRecordingConfiguration, API 29+). Re-judged on a 200 ms clock so a stalled capture loop cannot stay green. - Live mic meter in the app and a level bar in the notification; the notification leads with the worst signal. One alert per incident: the mic alert clears only after 3 s of continuous sound. - TailscaleLink: when no VPN is up, ask Tailscale to connect through its exported CONNECT_VPN receiver; after 20 s without a VPN, alert with a tap-to-open action. The network note shows only while delivery is failing. - A sticky restart after an OS kill no longer tries to reopen the mic (Android 14+ refuses background microphone FGS starts); it posts a notification that opens the app, which restarts capture through the normal Start path. - Setup checklist (Always-on VPN, never-sleeping apps, mic access tile) and a banner when notifications are disabled. - Version 0.4.0 (4) with the build time shown. --- clients/android-mic/app/build.gradle.kts | 11 +- .../app/src/main/AndroidManifest.xml | 6 + .../com/genesis/meetingmic/MainActivity.kt | 61 +++++- .../com/genesis/meetingmic/MicLevelMonitor.kt | 111 ++++++++++ .../genesis/meetingmic/MicStreamService.kt | 189 ++++++++++++++++- .../com/genesis/meetingmic/ProblemAlerts.kt | 85 ++++++++ .../com/genesis/meetingmic/TailscaleLink.kt | 39 ++++ .../app/src/main/res/layout/activity_main.xml | 65 +++++- .../app/src/main/res/values/strings.xml | 5 + .../genesis/meetingmic/MicLevelMonitorTest.kt | 200 ++++++++++++++++++ 10 files changed, 758 insertions(+), 14 deletions(-) create mode 100644 clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicLevelMonitor.kt create mode 100644 clients/android-mic/app/src/main/java/com/genesis/meetingmic/ProblemAlerts.kt create mode 100644 clients/android-mic/app/src/main/java/com/genesis/meetingmic/TailscaleLink.kt create mode 100644 clients/android-mic/app/src/test/java/com/genesis/meetingmic/MicLevelMonitorTest.kt diff --git a/clients/android-mic/app/build.gradle.kts b/clients/android-mic/app/build.gradle.kts index 5556fa0..d810c8c 100644 --- a/clients/android-mic/app/build.gradle.kts +++ b/clients/android-mic/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.util.Properties plugins { @@ -25,6 +28,8 @@ fun cfg(key: String, env: String, default: String): String = // build time or entered in-app. Kept as a compile-time string so a fresh clone builds. val meetingWsUrl = cfg("meetingWsUrl", "MEETING_WS_URL", "wss://EDITME.example.ts.net/meeting/") val meetingToken = cfg("meetingToken", "MEETING_TOKEN", "") +val buildTime: String = ZonedDateTime.now(ZoneOffset.UTC) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'")) android { namespace = "com.genesis.meetingmic" @@ -34,12 +39,14 @@ android { applicationId = "com.genesis.meetingmic" minSdk = 26 targetSdk = 34 - versionCode = 3 - versionName = "0.3.0" + versionCode = 4 + versionName = "0.4.0" // Surfaced to Kotlin as BuildConfig.MEETING_WS_URL / MEETING_TOKEN. buildConfigField("String", "MEETING_WS_URL", "\"$meetingWsUrl\"") buildConfigField("String", "MEETING_TOKEN", "\"$meetingToken\"") + // Shown beside the version so two sideloaded builds are never mistaken for each other. + buildConfigField("String", "BUILD_TIME", "\"$buildTime\"") } buildFeatures { diff --git a/clients/android-mic/app/src/main/AndroidManifest.xml b/clients/android-mic/app/src/main/AndroidManifest.xml index dff0360..f70a884 100644 --- a/clients/android-mic/app/src/main/AndroidManifest.xml +++ b/clients/android-mic/app/src/main/AndroidManifest.xml @@ -19,6 +19,12 @@ which otherwise "sleep" the service mid-meeting despite the foreground notification. --> + + + + + = Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + android.content.pm.PackageManager.PERMISSION_GRANTED + b.notifBanner.visibility = if (notifOff) android.view.View.VISIBLE else android.view.View.GONE } private fun requestThenStart() { @@ -134,6 +162,10 @@ class MainActivity : AppCompatActivity() { MicStreamService.Companion.Phase.UNCONFIRMED -> Color.parseColor("#33FF9800") else -> Color.TRANSPARENT }) + renderMic(s) + b.netStatus.text = "✕ ${s.netNote}" + b.netStatus.visibility = + if (s.netNote.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE val live = s.phase == MicStreamService.Companion.Phase.LIVE || s.phase == MicStreamService.Companion.Phase.RECONNECTING || s.phase == MicStreamService.Companion.Phase.UNCONFIRMED || @@ -145,6 +177,33 @@ class MainActivity : AppCompatActivity() { } } + private fun renderMic(s: MicStreamService.Companion.CaptureState) { + val capturing = s.phase != MicStreamService.Companion.Phase.IDLE && + s.phase != MicStreamService.Companion.Phase.STOPPED && + s.phase != MicStreamService.Companion.Phase.ERROR + if (!capturing) { + b.micStatus.text = getString(R.string.mic_off) + b.micStatus.setBackgroundColor(Color.TRANSPARENT) + b.micMeter.progress = 0 + return + } + b.micMeter.progress = (s.mic.level * 100).toInt() + when (s.mic.signal) { + MicSignal.GREEN -> { + b.micStatus.text = "● Mic hearing sound" + b.micStatus.setBackgroundColor(Color.TRANSPARENT) + } + MicSignal.GREY -> { + b.micStatus.text = "◌ Mic: ${s.mic.reason}" + b.micStatus.setBackgroundColor(Color.parseColor("#22888888")) + } + MicSignal.RED -> { + b.micStatus.text = "✕ Mic: ${s.mic.reason} — nothing is being recorded" + b.micStatus.setBackgroundColor(Color.parseColor("#33D32F2F")) + } + } + } + // ── battery optimization (Samsung) ───────────────────────────────────────── private fun isBatteryExempt(): Boolean { val pm = getSystemService(Context.POWER_SERVICE) as PowerManager diff --git a/clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicLevelMonitor.kt b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicLevelMonitor.kt new file mode 100644 index 0000000..ca7de63 --- /dev/null +++ b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicLevelMonitor.kt @@ -0,0 +1,111 @@ +package com.genesis.meetingmic + +import kotlin.math.abs +import kotlin.math.log10 + +enum class MicSignal { GREY, GREEN, RED } + +data class MicReading( + val signal: MicSignal, + val reason: String, + /** 0..1 meter position (log scale, -60 dBFS → 0). */ + val level: Float, +) + +/** + * Pure, fail-closed judgement of whether the microphone is actually hearing anything. + * + * GREEN must be EARNED by fresh evidence: a non-zero frame within [greenWindowMs]. Everything else is + * GREY (not yet known) or RED (known bad). A silenced capture delivers exact digital zeros, which a real + * mic never produces even in a quiet room (measured on a live install: quiet room peak ≈ 270–700, + * a muted capture peak = 0), so "all zeros" is a clean signal rather than a loudness heuristic. + * + * Time comes in as a monotonic clock from the caller. [read] is called on a timer as well as per frame, + * so a capture loop that stops delivering frames cannot freeze the signal green. + */ +class MicLevelMonitor( + private val greenWindowMs: Long = 2_000, + private val redAfterMs: Long = 5_000, +) { + private var startedAtMs = -1L + private var lastFrameMs = -1L + private var lastNonZeroMs = -1L + private var zeroRunStartMs = -1L + private var silenced = false + private var level = 0f + + @Synchronized + fun start(nowMs: Long) { + startedAtMs = nowMs + lastFrameMs = -1L + lastNonZeroMs = -1L + zeroRunStartMs = -1L + silenced = false + level = 0f + } + + @Synchronized + fun stop() { + startedAtMs = -1L + level = 0f + } + + @Synchronized + fun onFrame(peak: Int, nowMs: Long) { + if (startedAtMs < 0) return + lastFrameMs = nowMs + if (peak > 0) { + lastNonZeroMs = nowMs + zeroRunStartMs = -1L + } else if (zeroRunStartMs < 0) { + zeroRunStartMs = nowMs + } + // Fast attack, slow decay, so the meter is readable at a glance. + val target = levelFor(peak) + level = if (target >= level) target else level * 0.8f + target * 0.2f + } + + /** Fed from the platform's recording-configuration callback (API 29+). */ + @Synchronized + fun setSilenced(value: Boolean) { + silenced = value + } + + @Synchronized + fun read(nowMs: Long): MicReading { + if (startedAtMs < 0) return MicReading(MicSignal.GREY, "mic off", 0f) + if (silenced) return MicReading(MicSignal.RED, "Android is muting this app's mic", 0f) + if (lastNonZeroMs >= 0 && nowMs - lastNonZeroMs <= greenWindowMs) { + return MicReading(MicSignal.GREEN, "hearing sound", level) + } + val sinceFrame = nowMs - (if (lastFrameMs >= 0) lastFrameMs else startedAtMs) + if (sinceFrame >= redAfterMs) { + return MicReading(MicSignal.RED, "no audio frames from the mic", 0f) + } + if (zeroRunStartMs >= 0 && nowMs - zeroRunStartMs >= redAfterMs) { + return MicReading(MicSignal.RED, "mic is getting no sound (all-zero audio)", 0f) + } + return MicReading(MicSignal.GREY, "waiting for mic audio", level) + } + + companion object { + /** Peak absolute amplitude of little-endian PCM16 in `buf[0, n)`. */ + fun peakOf(buf: ByteArray, n: Int): Int { + var peak = 0 + var i = 0 + while (i + 1 < n) { + val sample = (buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8) + val a = abs(sample.toShort().toInt()) + if (a > peak) peak = a + i += 2 + } + return peak + } + + fun levelFor(peak: Int): Float { + if (peak <= 0) return 0f + val dbfs = 20.0 * log10(peak / 32768.0) + return ((dbfs + 60.0) / 60.0).toFloat().coerceIn(0f, 1f) + } + } +} diff --git a/clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicStreamService.kt b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicStreamService.kt index 28323e6..1486fff 100644 --- a/clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicStreamService.kt +++ b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicStreamService.kt @@ -11,7 +11,9 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.media.AudioFormat +import android.media.AudioManager import android.media.AudioRecord +import android.media.AudioRecordingConfiguration import android.media.MediaRecorder import android.os.Build import android.os.Handler @@ -72,6 +74,14 @@ class MicStreamService : LifecycleService() { private const val NOTIF_ID = 1 private const val FAILURE_NOTIF_ID = 2 + // Re-judges the mic on a clock, not only per frame, so a stalled capture loop cannot hold + // the Mic signal green. + private const val TICK_MS = 200L + private const val NOTIF_REFRESH_MS = 1_000L + // How long Tailscale gets to come up after our CONNECT_VPN request before we ask the user. + private const val VPN_ESCALATE_MS = 20_000L + private const val MIC_ALERT_CLEAR_MS = 3_000L + // Persisted so a START_STICKY restart (OS kills the process mid-meeting, redelivers a null // intent) can resume with the same endpoint/model instead of erroring out. App-private storage. private const val PREFS = "capture_creds" @@ -109,6 +119,9 @@ class MicStreamService : LifecycleService() { val detail: String = "", val bytesConfirmed: Long = 0, val startedAtMs: Long = 0, + val mic: MicReading = MicReading(MicSignal.GREY, "mic off", 0f), + /** Network-side problem we can name (e.g. Tailscale off); empty when none. */ + val netNote: String = "", ) private val _state = MutableStateFlow(CaptureState()) @@ -125,6 +138,26 @@ class MicStreamService : LifecycleService() { private var wakeLock: PowerManager.WakeLock? = null private val mainHandler = Handler(Looper.getMainLooper()) + private val mic = MicLevelMonitor() + private val tailscale by lazy { TailscaleLink(this) } + private val alerts by lazy { ProblemAlerts(this, FAILURE_CHANNEL_ID) { createChannel() } } + private var micReading = MicReading(MicSignal.GREY, "mic off", 0f) + private var netNote = "" + private var vpnRequestedAtMs = -1L + private var vpnEscalated = false + private var honestyActive = false + private var micAlertShown = false + private var micGreenSinceMs = -1L + private var deliveryNotifText = "Starting…" + private var lastNotifText = "" + private var lastNotifAtMs = 0L + private val tick = object : Runnable { + override fun run() { + onTick() + mainHandler.postDelayed(this, TICK_MS) + } + } + private val http: OkHttpClient by lazy { // No read timeout: a WebSocket is long-lived. The bridge sends heartbeat pings and OkHttp // auto-answers them at the protocol level, so the server detects a dead peer; our own @@ -137,23 +170,31 @@ class MicStreamService : LifecycleService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) - when (intent?.action) { + if (intent == null) { + // A START_STICKY restart after the OS killed us mid-capture. Android 14+ refuses to open + // a microphone foreground service from the background, so don't try: tell the user. The + // notification opens the app, which restarts capture from the foreground through its + // normal validated Start path. Creds are only present if the user never pressed Stop. + val p = getSharedPreferences(PREFS, MODE_PRIVATE) + if (!p.getString(KEY_TOKEN, "").isNullOrEmpty()) alerts.showResume() + stopSelf() + return START_NOT_STICKY + } + when (intent.action) { ACTION_STOP -> { stopCapture("stopped"); return START_NOT_STICKY } ACTION_MARK -> { sendMarker(); return START_STICKY } - ACTION_START, null -> { - var base = intent?.getStringExtra(EXTRA_WS_URL).orEmpty() - var token = intent?.getStringExtra(EXTRA_TOKEN).orEmpty() - var model = intent?.getStringExtra(EXTRA_MODEL).orEmpty() + ACTION_START -> { + var base = intent.getStringExtra(EXTRA_WS_URL).orEmpty() + var token = intent.getStringExtra(EXTRA_TOKEN).orEmpty() + var model = intent.getStringExtra(EXTRA_MODEL).orEmpty() if (base.isEmpty() || token.isEmpty()) { - // Null/empty intent == a START_STICKY restart after an OS kill. Restore the last - // creds so capture actually resumes instead of erroring out. (Whether Android - // permits re-opening a mic FGS from this background restart is device-dependent; - // if it's blocked, startCapture surfaces an error rather than failing silently.) + // Defensive: a start with no extras reuses the last creds. val p = getSharedPreferences(PREFS, MODE_PRIVATE) base = p.getString(KEY_URL, "").orEmpty() token = p.getString(KEY_TOKEN, "").orEmpty() model = p.getString(KEY_MODEL, "").orEmpty() } + alerts.clear(ProblemAlerts.RESUME) startCapture(base, token, model) } } @@ -192,6 +233,13 @@ class MicStreamService : LifecycleService() { acquireWakeLock() applyDeliveryEffects(started, runId) startedAtMs = System.currentTimeMillis() + deliveryNotifText = "Starting…" + mic.start(SystemClock.elapsedRealtime()) + micGreenSinceMs = -1L + ensureVpn() + honestyActive = true + mainHandler.removeCallbacks(tick) + mainHandler.post(tick) publish(Phase.CONNECTING, "connecting") captureJob = lifecycleScope.launch(Dispatchers.IO) { @@ -225,7 +273,9 @@ class MicStreamService : LifecycleService() { return } + var silenceWatch: AudioManager.AudioRecordingCallback? = null try { + silenceWatch = try { watchSilencing(recorder.audioSessionId) } catch (_: Exception) { null } recorder.startRecording() val buf = ByteArray(FRAME_BYTES) withContext(Dispatchers.Main.immediate) { openSocket(url, runId) } @@ -238,6 +288,7 @@ class MicStreamService : LifecycleService() { throw IllegalStateException("microphone capture failed ($n)") } if (n <= 0) { delay(20); continue } + mic.onFrame(MicLevelMonitor.peakOf(buf, n), SystemClock.elapsedRealtime()) // Safety auto-stop after the max continuous duration (routed to the main thread so the // FGS teardown runs there; break out of this loop immediately). if (startedAtMs > 0 && System.currentTimeMillis() - startedAtMs >= MAX_SESSION_MS) { @@ -264,6 +315,7 @@ class MicStreamService : LifecycleService() { } catch (e: Exception) { reportCaptureFailure(runId, e.message ?: "capture error") } finally { + silenceWatch?.let { getSystemService(AudioManager::class.java)?.unregisterAudioRecordingCallback(it) } try { recorder.stop() } catch (_: Exception) {} recorder.release() } @@ -425,10 +477,99 @@ class MicStreamService : LifecycleService() { return@postDelayed } } + ensureVpn() openSocket(url, runId) }, delayMs) } + // ── mic + network honesty ──────────────────────────────────────────────── + /** + * Android silences a capture (delivers zeros) for the mic privacy toggle and for concurrent-capture + * policy; API 29+ reports it per recording. Match our own entry by audio session id. Below API 29 + * the all-zero check in [MicLevelMonitor] still catches it, just a few seconds later. + */ + private fun watchSilencing(sessionId: Int): AudioManager.AudioRecordingCallback? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val am = getSystemService(AudioManager::class.java) ?: return null + val cb = object : AudioManager.AudioRecordingCallback() { + override fun onRecordingConfigChanged(configs: List) { + val mine = configs.firstOrNull { it.clientAudioSessionId == sessionId } + mic.setSilenced(mine?.isClientSilenced == true) + } + } + am.registerAudioRecordingCallback(cb, mainHandler) + return cb + } + + /** If no VPN is up, ask Tailscale to connect (once per outage); [onTick] escalates if it doesn't. */ + private fun ensureVpn() { + if (tailscale.isVpnUp()) { + clearVpnNote() + return + } + if (vpnRequestedAtMs >= 0) return + if (!tailscale.isInstalled()) { + netNote = "No VPN is up and Tailscale isn't installed" + return + } + tailscale.requestConnect() + vpnRequestedAtMs = SystemClock.elapsedRealtime() + vpnEscalated = false + netNote = "Tailscale is off — starting it…" + } + + private fun clearVpnNote() { + vpnRequestedAtMs = -1L + vpnEscalated = false + netNote = "" + alerts.clear(ProblemAlerts.TAILSCALE) + } + + private fun onTick() { + val now = SystemClock.elapsedRealtime() + val previous = micReading.signal + micReading = mic.read(now) + if (micReading.signal == MicSignal.RED && previous != MicSignal.RED) { + micAlertShown = true + alerts.showMic(micReading.reason) + } + // Clear the loud alert only after the mic has been green for a while, so a flapping mic + // buzzes once per incident instead of on every flap. + micGreenSinceMs = if (micReading.signal == MicSignal.GREEN) { + if (micGreenSinceMs < 0) now else micGreenSinceMs + } else -1L + if (micAlertShown && micGreenSinceMs >= 0 && now - micGreenSinceMs >= MIC_ALERT_CLEAR_MS) { + micAlertShown = false + alerts.clear(ProblemAlerts.MIC) + } + + // A network note is only shown while delivery is actually failing: confirmed delivery is the + // evidence that outranks any guess about the VPN. + if (_state.value.phase == Phase.LIVE) { + if (netNote.isNotEmpty()) clearVpnNote() + } else if (netNote.isNotEmpty() && tailscale.isVpnUp()) { + clearVpnNote() + } else if (vpnRequestedAtMs >= 0) { + if (!vpnEscalated && now - vpnRequestedAtMs >= VPN_ESCALATE_MS) { + vpnEscalated = true + netNote = "Tailscale didn't start — tap to open it" + tailscale.launchIntent()?.let { alerts.showTailscale(it) } + } + } + publish(_state.value.phase) + renderNotif(force = false) + } + + private fun stopHonesty() { + honestyActive = false + mainHandler.removeCallbacks(tick) + mic.stop() + micReading = mic.read(SystemClock.elapsedRealtime()) + clearVpnNote() + micAlertShown = false + alerts.clear(ProblemAlerts.MIC) + } + private fun supportsAudioAck(message: JSONObject): Boolean { val capabilities = message.optJSONArray("capabilities") ?: return false return (0 until capabilities.length()).any { @@ -452,6 +593,9 @@ class MicStreamService : LifecycleService() { val socket = synchronized(ws) { val update = delivery.captureFailed(runId) ?: return@post activeUrl = null + // Retire the mic/network signals BEFORE rendering, so the notification can never + // pair a dead recorder with a stale green meter. + stopHonesty() ws.getAndSet(null).also { publish(Phase.ERROR, "audio capture failed; not reaching bridge ($reason)") updateNotif("Audio capture failed") @@ -477,6 +621,8 @@ class MicStreamService : LifecycleService() { } } socket?.close(1000, "client stop") + stopHonesty() + alerts.clear(ProblemAlerts.RESUME) releaseWakeLock() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) @@ -496,6 +642,7 @@ class MicStreamService : LifecycleService() { ws.getAndSet(null) } socket?.cancel() + stopHonesty() releaseWakeLock() super.onDestroy() } @@ -516,7 +663,7 @@ class MicStreamService : LifecycleService() { private fun publish(phase: Phase, detail: String = _state.value.detail) { val confirmed = delivery.snapshot().confirmedBytes - _state.value = CaptureState(phase, detail, confirmed, startedAtMs) + _state.value = CaptureState(phase, detail, confirmed, startedAtMs, micReading, netNote) } private fun acquireWakeLock() { @@ -544,11 +691,33 @@ class MicStreamService : LifecycleService() { } } + /** Records the delivery line; the notification itself always leads with the worst signal. */ private fun updateNotif(text: String) { + deliveryNotifText = text + renderNotif(force = true) + } + + private fun renderNotif(force: Boolean) { + val text = when { + !honestyActive -> deliveryNotifText + micReading.signal == MicSignal.RED -> "⚠ Mic: ${micReading.reason} · $deliveryNotifText" + netNote.isNotEmpty() -> "⚠ $netNote · $deliveryNotifText" + micReading.signal == MicSignal.GREEN -> "Mic ${meterBar(micReading.level)} · $deliveryNotifText" + else -> "Mic: ${micReading.reason} · $deliveryNotifText" + } + val now = SystemClock.elapsedRealtime() + if (!force && (text == lastNotifText || now - lastNotifAtMs < NOTIF_REFRESH_MS)) return + lastNotifText = text + lastNotifAtMs = now val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIF_ID, buildNotif(text)) } + private fun meterBar(level: Float): String { + val filled = (level * 5).toInt().coerceIn(0, 5) + return "▮".repeat(filled) + "▯".repeat(5 - filled) + } + private fun liveNotifText(snapshot: CaptureDeliverySnapshot): String { if (snapshot.hadDeliveryGap) return "Audio restored — earlier gap detected" val secs = if (startedAtMs > 0) (System.currentTimeMillis() - startedAtMs) / 1000 else 0L diff --git a/clients/android-mic/app/src/main/java/com/genesis/meetingmic/ProblemAlerts.kt b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/ProblemAlerts.kt new file mode 100644 index 0000000..140f877 --- /dev/null +++ b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/ProblemAlerts.kt @@ -0,0 +1,85 @@ +package com.genesis.meetingmic + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat + +/** + * The loud, one-buzz-per-incident notifications for problems the user must act on: the mic hearing + * nothing, Tailscale not starting, and the recorder having been closed by Android. They use the + * high-importance delivery-failure channel and are separate notifications from the delivery alert, so + * clearing one never hides another. + */ +class ProblemAlerts( + private val context: Context, + private val channelId: String, + private val ensureChannel: () -> Unit, +) { + private val nm get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + fun showMic(reason: String) = post( + MIC, + "Meeting mic is not hearing anything", + reason.replaceFirstChar { it.uppercase() } + ". Nothing is being recorded.", + PendingIntent.getActivity( + context, 15, Intent(context, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ) + + fun showTailscale(launch: Intent) = post( + TAILSCALE, + "Tailscale is off — meeting audio can't reach Genesis", + "Tap to open Tailscale and connect.", + PendingIntent.getActivity( + context, 16, launch, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ) + + /** + * Opens the app, which restarts capture from the foreground through its normal validated Start + * path — never a background microphone start, which Android 14+ refuses. + */ + fun showResume() = post( + RESUME, + "Meeting recording stopped unexpectedly", + "Android closed the recorder. Tap to resume recording.", + PendingIntent.getActivity( + context, 17, + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.EXTRA_RESUME, true) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ongoing = false, + ) + + fun clear(id: Int) = nm.cancel(id) + + private fun post(id: Int, title: String, text: String, pi: PendingIntent, ongoing: Boolean = true) { + ensureChannel() + nm.notify( + id, + NotificationCompat.Builder(context, channelId) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setSmallIcon(R.drawable.ic_mic) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pi) + .setAutoCancel(!ongoing) + .setVibrate(longArrayOf(0, 300)) + .setOnlyAlertOnce(true) + .setOngoing(ongoing) + .build(), + ) + } + + companion object { + const val MIC = 3 + const val TAILSCALE = 4 + const val RESUME = 5 + } +} diff --git a/clients/android-mic/app/src/main/java/com/genesis/meetingmic/TailscaleLink.kt b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/TailscaleLink.kt new file mode 100644 index 0000000..c1e38cb --- /dev/null +++ b/clients/android-mic/app/src/main/java/com/genesis/meetingmic/TailscaleLink.kt @@ -0,0 +1,39 @@ +package com.genesis.meetingmic + +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities + +/** + * The bridge is reachable only over the tailnet, so a stopped Tailscale is the most common reason audio + * never arrives. Tailscale's Android app exports a receiver for exactly this: + * `com.tailscale.ipn/.IPNReceiver` handles `com.tailscale.ipn.CONNECT_VPN` with no permission or extras, + * and starts the VPN when it is logged in and already has VPN consent (otherwise Tailscale posts its own + * "open Tailscale" notification). Package visibility is declared in the manifest ``. + */ +class TailscaleLink(private val context: Context) { + + fun isVpnUp(): Boolean { + val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false + val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false + return caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + } + + fun isInstalled(): Boolean = launchIntent() != null + + fun requestConnect() { + if (!isInstalled()) return + context.sendBroadcast( + Intent(ACTION_CONNECT).setClassName(PACKAGE, RECEIVER), + ) + } + + fun launchIntent(): Intent? = context.packageManager.getLaunchIntentForPackage(PACKAGE) + + companion object { + const val PACKAGE = "com.tailscale.ipn" + private const val RECEIVER = "com.tailscale.ipn.IPNReceiver" + private const val ACTION_CONNECT = "com.tailscale.ipn.CONNECT_VPN" + } +} diff --git a/clients/android-mic/app/src/main/res/layout/activity_main.xml b/clients/android-mic/app/src/main/res/layout/activity_main.xml index b48f5fb..1d6cf70 100644 --- a/clients/android-mic/app/src/main/res/layout/activity_main.xml +++ b/clients/android-mic/app/src/main/res/layout/activity_main.xml @@ -82,14 +82,46 @@ android:text="@string/btn_stop" /> + + + + + + + + + + + + + + + +