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
* 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)
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(
// 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,
)
} 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)
return v
}

View file

@ -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)
// 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).
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
}
}