From 6cb5797990949f15d3d80e6634caa296e9db1761 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 6 Oct 2026 14:31:08 -0500 Subject: [PATCH] Widget instant start: sync consent cache, plain banner with actions Cold widget taps waited on TOP-resume (never fires for the translucent trampoline) plus a DataStore consent read, costing seconds and spurious app-opens when the read timed out. Fire from onResume, cache consent in SharedPreferences, keep the first 15s of banner on the stock template (now with Pause/Save/Discard actions) until RemoteViews inflation is warm. --- .../recording/RecordingNotificationHelper.kt | 29 ++++- .../com/shonar/recording/RecordingService.kt | 14 +++ .../java/com/shonar/ui/home/HomeScreen.kt | 17 +++ .../shonar/widget/StartRecordingActivity.kt | 111 +++++++++--------- android/app/src/main/res/values/strings.xml | 4 + 5 files changed, 117 insertions(+), 58 deletions(-) diff --git a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt index 4a0c6b2..4cfb11a 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt @@ -215,7 +215,34 @@ object RecordingNotificationHelper { // to the card's own mic; that duplication is the price of the // card rendering. .apply { - if (!rich) return@apply // plain first post: stock template only + if (!rich) { + // Plain first post: stock actions so the instant banner + // keeps transport (pause / save / discard) until the + // rich card takes over after warmup. Without these the + // stock template is informational only. + addAction( + if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause, + ctx.getString( + if (paused) R.string.notif_resume else R.string.notif_pause, + ), + svcIntent( + ctx, + if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, + 42, + ), + ) + addAction( + R.drawable.ic_notif_check, + ctx.getString(R.string.notif_save), + activitySaveIntent(ctx), + ) + addAction( + R.drawable.ic_notif_close, + ctx.getString(R.string.notif_discard), + svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 43), + ) + return@apply + } setCustomContentView(small) setCustomBigContentView(card) // Heads-up uses this on the first post; without it the system diff --git a/android/app/src/main/java/com/shonar/recording/RecordingService.kt b/android/app/src/main/java/com/shonar/recording/RecordingService.kt index bdf5961..bd1f1bd 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingService.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt @@ -71,6 +71,12 @@ class RecordingService : Service() { private var resumedAtElapsed = 0L private var ticker: Job? = null private var starting = false + /** Plain-template window: custom RemoteViews first-bind stalls ~10s on + * cold SystemUI (traced: FGS post :26.0, heads-up :36.5). Keep the banner + * on the stock template until inflation is warm so the shade row + + * heads-up pill show instantly like other recorder apps; the 1s ticker + * flips to the rich card after warmup. */ + private var fgsStartedAtElapsed = 0L private lateinit var stateStore: RecordingStateStore @@ -320,11 +326,13 @@ class RecordingService : Service() { * builds); the 1s ticker swaps in the rich card. */ private fun ensureForeground() { + fgsStartedAtElapsed = android.os.SystemClock.elapsedRealtime() try { val notif = RecordingNotificationHelper.build( this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(), alertOnce = false, rich = false, ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( RecordingNotificationHelper.NOTIFICATION_ID, notif, @@ -333,6 +341,7 @@ class RecordingService : Service() { } else { startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif) } + } catch (e: Exception) { android.util.Log.w("ShonarRec", "FGS start denied", e) broadcast() @@ -376,12 +385,15 @@ class RecordingService : Service() { private fun pushNotification() { val s = _snapshot.value if (s.phase == RecordingSnapshot.Phase.IDLE) return + val warm = fgsStartedAtElapsed == 0L || + android.os.SystemClock.elapsedRealtime() - fgsStartedAtElapsed >= RICH_WARMUP_MS val nm = getSystemService(android.app.NotificationManager::class.java) runCatching { nm.notify( RecordingNotificationHelper.NOTIFICATION_ID, RecordingNotificationHelper.build( this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard, + rich = warm, ), ) } @@ -559,6 +571,8 @@ class RecordingService : Service() { override fun onBind(intent: Intent?): IBinder? = null companion object { + /** Stock-template window before the rich custom card is allowed. */ + private const val RICH_WARMUP_MS = 15_000L const val ACTION_START = "com.shonar.recording.START" const val ACTION_PAUSE = "com.shonar.recording.PAUSE" const val ACTION_RESUME = "com.shonar.recording.RESUME" diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt index 40c0050..e5872cf 100644 --- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -130,6 +130,15 @@ fun HomeScreen( LaunchedEffect(Unit) { app.settingsManager.ensureLoaded() consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) + // Backfill the widget's sync cache for users who consented before + // the cache existed (else their first cold tap still takes the slow + // DataStore path). + if (consentSeen == true) { + runCatching { + context.getSharedPreferences("shonar_widget", android.content.Context.MODE_PRIVATE) + .edit().putBoolean("consent", true).apply() + } + } // Adopt files added outside the app (own file sync, USB…) into the library. runCatching { app.recordingRepository.refreshRoot() } storageOk = runCatching { app.recordingRepository.libraryAccessible() } @@ -298,6 +307,14 @@ fun HomeScreen( scope.launch { app.settingsManager.setValue(BuiltInSettings.CONSENT, "true") } + // Sync mirror for the widget trampoline: DataStore reads can + // exceed the cold-start bound, sending consented users to the + // app instead of recording. SharedPreferences reads are + // synchronous, so cold widget taps stay instant. + runCatching { + context.getSharedPreferences("shonar_widget", android.content.Context.MODE_PRIVATE) + .edit().putBoolean("consent", true).apply() + } consentSeen = true }) } diff --git a/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt b/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt index 6af2ea9..22486aa 100644 --- a/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt +++ b/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt @@ -14,42 +14,24 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull /** - * Invisible trampoline for the widget tap. A widget broadcast into a cold - * process is subject to battery-optimization throttling (the tap could sit - * ~10s before the service woke up). An activity start is exempt: the - * process gets full priority and startForegroundService from an activity - * context always satisfies Android 12+ FGS-start rules. - * - * Mirrors the in-app Record button exactly: - * - Mic permission missing -> MainActivity (existing permission flow). - * - Consent notice not yet acknowledged -> MainActivity, which shows the - * consent dialog (same gate as the in-app button). - * - Idle -> START. - * - Recording/paused -> SAVE (stop + rename sheet on next app open), - * exactly like the in-app Stop button. - * - * Finishes before the first frame draws (Translucent.NoTitleBar, no content - * view), so there is no visible flash. - * - * The trampoline stays alive (still invisible) until the service reports a - * non-IDLE snapshot or a short bound fires. Finishing instantly drops our - * uid from TOP before startForeground() lands, making the start - * background-originated — the system then defers the notification's - * buzz/heads-up by ~10s (traced on-device 2026-09-18: FGS allowed - * 17:03:17.4, first buzz 17:03:27.5). Waiting for the snapshot keeps the - * whole start foreground-originated; the wait ends the moment recording - * state flips, so it adds no artificial delay. + * Invisible trampoline for the widget tap. Fires from onResume (foreground) + * so the START is foreground-originated and the banner posts instantly. + * Gates on consent BEFORE starting (no phantom red-dot then cancel): + * unconsented -> MainActivity consent dialog, exactly like the in-app + * Record button. Consent loads in parallel with resume; onResume lands in + * ~100-200ms (the old onTopResumedActivityChanged never fires for the + * translucent theme, costing a 2s timeout every cold tap). */ class StartRecordingActivity : Activity() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - /** Set when the trampoline is TOP-resumed (uid counts as foreground). */ - private val topResumed = kotlinx.coroutines.flow.MutableStateFlow(false) + private val resumed = MutableStateFlow(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -62,33 +44,44 @@ class StartRecordingActivity : Activity() { finish() return } + // Active session: SAVE needs no consent gate; fire at once. + if (RecordingService.snapshot.value.phase != RecordingSnapshot.Phase.IDLE) { + RecordingService.command(this, RecordingService.ACTION_SAVE) + ShonarWidgetUpdater.refresh(this) + finish() + return + } + // Sync fast path: cached consent needs no DataStore wait. + if (cachedConsent() == true) { + scope.launch { + withTimeoutOrNull(RESUME_WAIT_MS) { resumed.first { it } } + RecordingService.command( + this@StartRecordingActivity, RecordingService.ACTION_START, + ) + withTimeoutOrNull(TRAMPOLINE_WAIT_MS) { + RecordingService.snapshot.first { + it.phase != RecordingSnapshot.Phase.IDLE + } + } + ShonarWidgetUpdater.refresh(this@StartRecordingActivity) + finish() + } + return + } scope.launch { val app = application as ShonarApplication - // Same consent gate as the in-app Record button. Bound the read - // so a wedged store degrades to opening the app, never a hang. val consentedDeferred = async { withTimeoutOrNull(CONSENT_WAIT_MS) { runCatching { app.settingsManager.ensureLoaded() app.settingsManager.bool(BuiltInSettings.CONSENT) - }.getOrDefault(false) + }.getOrNull() } == true } - // Wait until TOP-resumed so the START/SAVE call below is - // foreground-originated. Sent from onCreate, the system stamps - // the intent FROM_BACKGROUND (activity not yet resumed) and - // defers the notification's interruption ~10s — traced - // on-device: FGS allowed +0.1s, first buzz +10.1s, every session. - // Bound: a missed resume signal must never strand the activity. - withTimeoutOrNull(RESUME_WAIT_MS) { topResumed.first { it } } + withTimeoutOrNull(RESUME_WAIT_MS) { resumed.first { it } } val consented = withTimeoutOrNull(CONSENT_WAIT_MS) { consentedDeferred.await() } == true - // Active session: mirror the in-app Stop button (save; the rename - // sheet opens on next app launch via RenamePendingHolder). - if (RecordingService.snapshot.value.phase != RecordingSnapshot.Phase.IDLE) { - RecordingService.command( - this@StartRecordingActivity, RecordingService.ACTION_SAVE, - ) - } else if (!consented) { + if (consented) cacheConsent() + if (!consented) { startActivity( Intent(this@StartRecordingActivity, MainActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), @@ -97,9 +90,6 @@ class StartRecordingActivity : Activity() { RecordingService.command( this@StartRecordingActivity, RecordingService.ACTION_START, ) - // Bound, not a delay: returns the instant recording starts; - // the timeout only covers mic-failure paths (the service - // reverts the UI itself there). withTimeoutOrNull(TRAMPOLINE_WAIT_MS) { RecordingService.snapshot.first { it.phase != RecordingSnapshot.Phase.IDLE @@ -113,20 +103,11 @@ class StartRecordingActivity : Activity() { override fun onResume() { super.onResume() - // Pre-29 has no top-resumed signal (and no background-FGS - // restriction); onResume is sufficient there. - if (android.os.Build.VERSION.SDK_INT < 29) topResumed.value = true - } - - override fun onTopResumedActivityChanged(isTopResumed: Boolean) { - super.onTopResumedActivityChanged(isTopResumed) - if (isTopResumed) topResumed.value = true + resumed.value = true } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - // singleInstance re-tap while a waiter is pending: the first waiter - // already covers the start — never strand a second instance. finish() } @@ -135,6 +116,22 @@ class StartRecordingActivity : Activity() { super.onDestroy() } + private fun cachedConsent(): Boolean? { + return try { + val sp = getSharedPreferences("shonar_widget", MODE_PRIVATE) + if (sp.contains("consent")) sp.getBoolean("consent", false) else null + } catch (_: Exception) { + null + } + } + + private fun cacheConsent() { + runCatching { + getSharedPreferences("shonar_widget", MODE_PRIVATE) + .edit().putBoolean("consent", true).apply() + } + } + companion object { private const val TRAMPOLINE_WAIT_MS = 2_500L private const val CONSENT_WAIT_MS = 2_000L diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7c10317..cbba412 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,6 +5,10 @@ Shonar recording controls. Kept silent so takes stay clean. Shonar is recording Shonar recording paused + Pause + Resume + Save + Discard Record Recording…