Widget mirrors in-app Record button; consent-gated start, save while active

This commit is contained in:
avi 2026-09-18 18:17:37 -05:00
commit cd173d80b8
2 changed files with 72 additions and 33 deletions

View file

@ -20,7 +20,8 @@ import com.shonar.recording.RecordingService
* gone = idle, red = recording, amber = paused. All transport controls live * gone = idle, red = recording, amber = paused. All transport controls live
* in the notification (pause / stop / disregard / save-checkmark) per spec. * in the notification (pause / stop / disregard / save-checkmark) per spec.
* *
* Tap: idle -> START. Active/paused -> open app (never one-tap-discard). * Tap mirrors the in-app Record button: idle -> START (consent-gated),
* active/paused -> SAVE (rename sheet on next app open).
*/ */
class ShonarRecordWidget : AppWidgetProvider() { class ShonarRecordWidget : AppWidgetProvider() {
@ -32,11 +33,21 @@ class ShonarRecordWidget : AppWidgetProvider() {
override fun onReceive(ctx: Context, intent: Intent) { override fun onReceive(ctx: Context, intent: Intent) {
super.onReceive(ctx, intent) super.onReceive(ctx, intent)
if (intent.action == ACTION_WIDGET_RECORD) { if (intent.action == ACTION_WIDGET_RECORD) {
// Legacy fallback for widgets baked before the trampoline: same
// toggle as the in-app button (SAVE while active, else START).
// Consent can't be read synchronously here, so this path keeps
// the permission gate only; current widgets use the trampoline.
if (!RecordingPermissionHelper.hasRecordAudio(ctx)) { if (!RecordingPermissionHelper.hasRecordAudio(ctx)) {
val open = Intent(ctx, MainActivity::class.java) val open = Intent(ctx, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setAction(RecordingService.ACTION_START) .setAction(RecordingService.ACTION_START)
runCatching {
ctx.startActivity(open) ctx.startActivity(open)
}
} else if (RecordingService.snapshot.value.phase !=
com.shonar.recording.RecordingSnapshot.Phase.IDLE
) {
RecordingService.command(ctx, RecordingService.ACTION_SAVE)
} else { } else {
RecordingService.command(ctx, RecordingService.ACTION_START) RecordingService.command(ctx, RecordingService.ACTION_START)
} }
@ -95,26 +106,18 @@ class ShonarRecordWidget : AppWidgetProvider() {
} }
} }
val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) { // Every tap goes through the invisible activity trampoline, NOT a
// Invisible activity trampoline, NOT a broadcast: a cold // broadcast: a cold process woken by a broadcast is
// process woken by a broadcast is battery-throttled (the // battery-throttled (the banner could take ~10s); an activity
// banner could take ~10s); an activity start runs at full // start runs at full priority and gives startForegroundService
// priority and gives startForegroundService its FGS // its FGS exemption on Android 12+. The trampoline mirrors the
// exemption on Android 12+. // in-app Record button (consent-gated START when idle, SAVE while
PendingIntent.getActivity( // active), so one tap target serves all phases.
val tap: PendingIntent = PendingIntent.getActivity(
ctx, 50, ctx, 50,
Intent(ctx, StartRecordingActivity::class.java), Intent(ctx, StartRecordingActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
} else {
PendingIntent.getActivity(
ctx, 51,
Intent(ctx, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
v.setOnClickPendingIntent(R.id.widget_button, tap) v.setOnClickPendingIntent(R.id.widget_button, tap)
return v return v
} }

View file

@ -4,9 +4,11 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import com.shonar.MainActivity import com.shonar.MainActivity
import com.shonar.ShonarApplication
import com.shonar.recording.RecordingPermissionHelper import com.shonar.recording.RecordingPermissionHelper
import com.shonar.recording.RecordingService import com.shonar.recording.RecordingService
import com.shonar.recording.RecordingSnapshot import com.shonar.recording.RecordingSnapshot
import com.shonar.settings.BuiltInSettings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -22,9 +24,16 @@ import kotlinx.coroutines.withTimeoutOrNull
* process gets full priority and startForegroundService from an activity * process gets full priority and startForegroundService from an activity
* context always satisfies Android 12+ FGS-start rules. * context always satisfies Android 12+ FGS-start rules.
* *
* Forwards to RecordingService.START (or MainActivity when the mic * Mirrors the in-app Record button exactly:
* permission is missing) and finishes before the first frame draws, so * - Mic permission missing -> MainActivity (existing permission flow).
* there is no visible flash. * - 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 * The trampoline stays alive (still invisible) until the service reports a
* non-IDLE snapshot or a short bound fires. Finishing instantly drops our * non-IDLE snapshot or a short bound fires. Finishing instantly drops our
@ -50,17 +59,43 @@ class StartRecordingActivity : Activity() {
finish() finish()
return return
} }
RecordingService.command(this, RecordingService.ACTION_START) // 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, RecordingService.ACTION_SAVE)
ShonarWidgetUpdater.refresh(this) ShonarWidgetUpdater.refresh(this)
finish()
return
}
scope.launch { scope.launch {
// Bound, not a delay: returns the instant recording starts; the val app = application as ShonarApplication
// timeout only covers mic-failure paths (the service reverts the // Same consent gate as the in-app Record button. Bound the read
// UI itself there). // so a wedged store degrades to opening the app, never a hang.
val consented = withTimeoutOrNull(CONSENT_WAIT_MS) {
runCatching {
app.settingsManager.ensureLoaded()
app.settingsManager.bool(BuiltInSettings.CONSENT)
}.getOrDefault(false)
} == true
if (!consented) {
startActivity(
Intent(this@StartRecordingActivity, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
} else {
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) { withTimeoutOrNull(TRAMPOLINE_WAIT_MS) {
RecordingService.snapshot.first { RecordingService.snapshot.first {
it.phase != RecordingSnapshot.Phase.IDLE it.phase != RecordingSnapshot.Phase.IDLE
} }
} }
}
ShonarWidgetUpdater.refresh(this@StartRecordingActivity)
finish() finish()
} }
} }
@ -79,5 +114,6 @@ class StartRecordingActivity : Activity() {
companion object { companion object {
private const val TRAMPOLINE_WAIT_MS = 2_500L private const val TRAMPOLINE_WAIT_MS = 2_500L
private const val CONSENT_WAIT_MS = 2_000L
} }
} }