Widget trampoline waits for foreground recording; fixes 10s notification delay

This commit is contained in:
avi 2026-09-18 17:24:03 -05:00
commit 673330293a

View file

@ -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
}
}