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…