From cd173d80b8e35d54138497b14620a6b2027ec927 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 18:17:37 -0500 Subject: [PATCH] Widget mirrors in-app Record button; consent-gated start, save while active --- .../java/com/shonar/widget/ShonarWidget.kt | 47 ++++++++------- .../shonar/widget/StartRecordingActivity.kt | 58 +++++++++++++++---- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt b/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt index 22e7786..93739f1 100644 --- a/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt +++ b/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt @@ -20,7 +20,8 @@ import com.shonar.recording.RecordingService * gone = idle, red = recording, amber = paused. All transport controls live * 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() { @@ -32,11 +33,21 @@ class ShonarRecordWidget : AppWidgetProvider() { override fun onReceive(ctx: Context, intent: Intent) { super.onReceive(ctx, intent) 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)) { val open = Intent(ctx, MainActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .setAction(RecordingService.ACTION_START) - ctx.startActivity(open) + runCatching { + ctx.startActivity(open) + } + } else if (RecordingService.snapshot.value.phase != + com.shonar.recording.RecordingSnapshot.Phase.IDLE + ) { + RecordingService.command(ctx, RecordingService.ACTION_SAVE) } else { RecordingService.command(ctx, RecordingService.ACTION_START) } @@ -95,26 +106,18 @@ class ShonarRecordWidget : AppWidgetProvider() { } } - val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) { - // Invisible activity trampoline, NOT a broadcast: a cold - // process woken by a broadcast is battery-throttled (the - // banner could take ~10s); an activity start runs at full - // priority and gives startForegroundService its FGS - // exemption on Android 12+. - PendingIntent.getActivity( - ctx, 50, - Intent(ctx, StartRecordingActivity::class.java), - 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, - ) - } + // Every tap goes through the invisible activity trampoline, NOT a + // broadcast: a cold process woken by a broadcast is + // battery-throttled (the banner could take ~10s); an activity + // start runs at full priority and gives startForegroundService + // its FGS exemption on Android 12+. The trampoline mirrors the + // in-app Record button (consent-gated START when idle, SAVE while + // active), so one tap target serves all phases. + val tap: PendingIntent = PendingIntent.getActivity( + ctx, 50, + Intent(ctx, StartRecordingActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) v.setOnClickPendingIntent(R.id.widget_button, tap) return v } 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 42cc270..76d27e9 100644 --- a/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt +++ b/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt @@ -4,9 +4,11 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import com.shonar.MainActivity +import com.shonar.ShonarApplication import com.shonar.recording.RecordingPermissionHelper import com.shonar.recording.RecordingService import com.shonar.recording.RecordingSnapshot +import com.shonar.settings.BuiltInSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -22,9 +24,16 @@ import kotlinx.coroutines.withTimeoutOrNull * process gets full priority and startForegroundService from an activity * context always satisfies Android 12+ FGS-start rules. * - * 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. + * 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 @@ -50,17 +59,43 @@ class StartRecordingActivity : Activity() { finish() return } - RecordingService.command(this, RecordingService.ACTION_START) - ShonarWidgetUpdater.refresh(this) + // 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) + finish() + return + } 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 + 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 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) { + RecordingService.snapshot.first { + it.phase != RecordingSnapshot.Phase.IDLE + } } } + ShonarWidgetUpdater.refresh(this@StartRecordingActivity) finish() } } @@ -79,5 +114,6 @@ class StartRecordingActivity : Activity() { companion object { private const val TRAMPOLINE_WAIT_MS = 2_500L + private const val CONSENT_WAIT_MS = 2_000L } }