From 673330293a354e7e1d09e5ff5c136d666c0d0a73 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 17:24:03 -0500 Subject: [PATCH] Widget trampoline waits for foreground recording; fixes 10s notification delay --- .../shonar/widget/StartRecordingActivity.kt | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) 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 a019ca5..42cc270 100644 --- a/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt +++ b/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt @@ -6,6 +6,14 @@ import android.os.Bundle import com.shonar.MainActivity import com.shonar.recording.RecordingPermissionHelper import com.shonar.recording.RecordingService +import com.shonar.recording.RecordingSnapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +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 @@ -17,9 +25,20 @@ import com.shonar.recording.RecordingService * Forwards to RecordingService.START (or MainActivity when the mic * permission is missing) and finishes before the first frame draws, 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. */ class StartRecordingActivity : Activity() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!RecordingPermissionHelper.hasRecordAudio(this)) { @@ -28,10 +47,37 @@ class StartRecordingActivity : Activity() { .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .setAction(RecordingService.ACTION_START), ) - } else { - RecordingService.command(this, RecordingService.ACTION_START) + finish() + return } + RecordingService.command(this, RecordingService.ACTION_START) ShonarWidgetUpdater.refresh(this) + scope.launch { + // 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 + } + } + finish() + } + } + + 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() } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + companion object { + private const val TRAMPOLINE_WAIT_MS = 2_500L + } }