Widget mirrors in-app Record button; consent-gated start, save while active
This commit is contained in:
parent
673330293a
commit
cd173d80b8
2 changed files with 72 additions and 33 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue