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
|
* 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue