Widget instant start: sync consent cache, plain banner with actions
Some checks failed
CI / android (push) Has been cancelled
Some checks failed
CI / android (push) Has been cancelled
Cold widget taps waited on TOP-resume (never fires for the translucent trampoline) plus a DataStore consent read, costing seconds and spurious app-opens when the read timed out. Fire from onResume, cache consent in SharedPreferences, keep the first 15s of banner on the stock template (now with Pause/Save/Discard actions) until RemoteViews inflation is warm.
This commit is contained in:
parent
c35e6935b5
commit
6cb5797990
5 changed files with 117 additions and 58 deletions
|
|
@ -215,7 +215,34 @@ object RecordingNotificationHelper {
|
||||||
// to the card's own mic; that duplication is the price of the
|
// to the card's own mic; that duplication is the price of the
|
||||||
// card rendering.
|
// card rendering.
|
||||||
.apply {
|
.apply {
|
||||||
if (!rich) return@apply // plain first post: stock template only
|
if (!rich) {
|
||||||
|
// Plain first post: stock actions so the instant banner
|
||||||
|
// keeps transport (pause / save / discard) until the
|
||||||
|
// rich card takes over after warmup. Without these the
|
||||||
|
// stock template is informational only.
|
||||||
|
addAction(
|
||||||
|
if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause,
|
||||||
|
ctx.getString(
|
||||||
|
if (paused) R.string.notif_resume else R.string.notif_pause,
|
||||||
|
),
|
||||||
|
svcIntent(
|
||||||
|
ctx,
|
||||||
|
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE,
|
||||||
|
42,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
addAction(
|
||||||
|
R.drawable.ic_notif_check,
|
||||||
|
ctx.getString(R.string.notif_save),
|
||||||
|
activitySaveIntent(ctx),
|
||||||
|
)
|
||||||
|
addAction(
|
||||||
|
R.drawable.ic_notif_close,
|
||||||
|
ctx.getString(R.string.notif_discard),
|
||||||
|
svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 43),
|
||||||
|
)
|
||||||
|
return@apply
|
||||||
|
}
|
||||||
setCustomContentView(small)
|
setCustomContentView(small)
|
||||||
setCustomBigContentView(card)
|
setCustomBigContentView(card)
|
||||||
// Heads-up uses this on the first post; without it the system
|
// Heads-up uses this on the first post; without it the system
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@ class RecordingService : Service() {
|
||||||
private var resumedAtElapsed = 0L
|
private var resumedAtElapsed = 0L
|
||||||
private var ticker: Job? = null
|
private var ticker: Job? = null
|
||||||
private var starting = false
|
private var starting = false
|
||||||
|
/** Plain-template window: custom RemoteViews first-bind stalls ~10s on
|
||||||
|
* cold SystemUI (traced: FGS post :26.0, heads-up :36.5). Keep the banner
|
||||||
|
* on the stock template until inflation is warm so the shade row +
|
||||||
|
* heads-up pill show instantly like other recorder apps; the 1s ticker
|
||||||
|
* flips to the rich card after warmup. */
|
||||||
|
private var fgsStartedAtElapsed = 0L
|
||||||
|
|
||||||
private lateinit var stateStore: RecordingStateStore
|
private lateinit var stateStore: RecordingStateStore
|
||||||
|
|
||||||
|
|
@ -320,11 +326,13 @@ class RecordingService : Service() {
|
||||||
* builds); the 1s ticker swaps in the rich card.
|
* builds); the 1s ticker swaps in the rich card.
|
||||||
*/
|
*/
|
||||||
private fun ensureForeground() {
|
private fun ensureForeground() {
|
||||||
|
fgsStartedAtElapsed = android.os.SystemClock.elapsedRealtime()
|
||||||
try {
|
try {
|
||||||
val notif = RecordingNotificationHelper.build(
|
val notif = RecordingNotificationHelper.build(
|
||||||
this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(),
|
this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(),
|
||||||
alertOnce = false, rich = false,
|
alertOnce = false, rich = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
startForeground(
|
startForeground(
|
||||||
RecordingNotificationHelper.NOTIFICATION_ID, notif,
|
RecordingNotificationHelper.NOTIFICATION_ID, notif,
|
||||||
|
|
@ -333,6 +341,7 @@ class RecordingService : Service() {
|
||||||
} else {
|
} else {
|
||||||
startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif)
|
startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w("ShonarRec", "FGS start denied", e)
|
android.util.Log.w("ShonarRec", "FGS start denied", e)
|
||||||
broadcast()
|
broadcast()
|
||||||
|
|
@ -376,12 +385,15 @@ class RecordingService : Service() {
|
||||||
private fun pushNotification() {
|
private fun pushNotification() {
|
||||||
val s = _snapshot.value
|
val s = _snapshot.value
|
||||||
if (s.phase == RecordingSnapshot.Phase.IDLE) return
|
if (s.phase == RecordingSnapshot.Phase.IDLE) return
|
||||||
|
val warm = fgsStartedAtElapsed == 0L ||
|
||||||
|
android.os.SystemClock.elapsedRealtime() - fgsStartedAtElapsed >= RICH_WARMUP_MS
|
||||||
val nm = getSystemService(android.app.NotificationManager::class.java)
|
val nm = getSystemService(android.app.NotificationManager::class.java)
|
||||||
runCatching {
|
runCatching {
|
||||||
nm.notify(
|
nm.notify(
|
||||||
RecordingNotificationHelper.NOTIFICATION_ID,
|
RecordingNotificationHelper.NOTIFICATION_ID,
|
||||||
RecordingNotificationHelper.build(
|
RecordingNotificationHelper.build(
|
||||||
this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard,
|
this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard,
|
||||||
|
rich = warm,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -559,6 +571,8 @@ class RecordingService : Service() {
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/** Stock-template window before the rich custom card is allowed. */
|
||||||
|
private const val RICH_WARMUP_MS = 15_000L
|
||||||
const val ACTION_START = "com.shonar.recording.START"
|
const val ACTION_START = "com.shonar.recording.START"
|
||||||
const val ACTION_PAUSE = "com.shonar.recording.PAUSE"
|
const val ACTION_PAUSE = "com.shonar.recording.PAUSE"
|
||||||
const val ACTION_RESUME = "com.shonar.recording.RESUME"
|
const val ACTION_RESUME = "com.shonar.recording.RESUME"
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,15 @@ fun HomeScreen(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
app.settingsManager.ensureLoaded()
|
app.settingsManager.ensureLoaded()
|
||||||
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
|
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
|
||||||
|
// Backfill the widget's sync cache for users who consented before
|
||||||
|
// the cache existed (else their first cold tap still takes the slow
|
||||||
|
// DataStore path).
|
||||||
|
if (consentSeen == true) {
|
||||||
|
runCatching {
|
||||||
|
context.getSharedPreferences("shonar_widget", android.content.Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean("consent", true).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
// Adopt files added outside the app (own file sync, USB…) into the library.
|
// Adopt files added outside the app (own file sync, USB…) into the library.
|
||||||
runCatching { app.recordingRepository.refreshRoot() }
|
runCatching { app.recordingRepository.refreshRoot() }
|
||||||
storageOk = runCatching { app.recordingRepository.libraryAccessible() }
|
storageOk = runCatching { app.recordingRepository.libraryAccessible() }
|
||||||
|
|
@ -298,6 +307,14 @@ fun HomeScreen(
|
||||||
scope.launch {
|
scope.launch {
|
||||||
app.settingsManager.setValue(BuiltInSettings.CONSENT, "true")
|
app.settingsManager.setValue(BuiltInSettings.CONSENT, "true")
|
||||||
}
|
}
|
||||||
|
// Sync mirror for the widget trampoline: DataStore reads can
|
||||||
|
// exceed the cold-start bound, sending consented users to the
|
||||||
|
// app instead of recording. SharedPreferences reads are
|
||||||
|
// synchronous, so cold widget taps stay instant.
|
||||||
|
runCatching {
|
||||||
|
context.getSharedPreferences("shonar_widget", android.content.Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean("consent", true).apply()
|
||||||
|
}
|
||||||
consentSeen = true
|
consentSeen = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,42 +14,24 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invisible trampoline for the widget tap. A widget broadcast into a cold
|
* Invisible trampoline for the widget tap. Fires from onResume (foreground)
|
||||||
* process is subject to battery-optimization throttling (the tap could sit
|
* so the START is foreground-originated and the banner posts instantly.
|
||||||
* ~10s before the service woke up). An activity start is exempt: the
|
* Gates on consent BEFORE starting (no phantom red-dot then cancel):
|
||||||
* process gets full priority and startForegroundService from an activity
|
* unconsented -> MainActivity consent dialog, exactly like the in-app
|
||||||
* context always satisfies Android 12+ FGS-start rules.
|
* Record button. Consent loads in parallel with resume; onResume lands in
|
||||||
*
|
* ~100-200ms (the old onTopResumedActivityChanged never fires for the
|
||||||
* Mirrors the in-app Record button exactly:
|
* translucent theme, costing a 2s timeout every cold tap).
|
||||||
* - 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
|
|
||||||
* uid from TOP before startForeground() lands, making the start
|
|
||||||
* background-originated — the system then defers the notification's
|
|
||||||
* buzz/heads-up by ~10s (traced on-device 2026-09-18: FGS allowed
|
|
||||||
* 17:03:17.4, first buzz 17:03:27.5). Waiting for the snapshot keeps the
|
|
||||||
* whole start foreground-originated; the wait ends the moment recording
|
|
||||||
* state flips, so it adds no artificial delay.
|
|
||||||
*/
|
*/
|
||||||
class StartRecordingActivity : Activity() {
|
class StartRecordingActivity : Activity() {
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
/** Set when the trampoline is TOP-resumed (uid counts as foreground). */
|
private val resumed = MutableStateFlow(false)
|
||||||
private val topResumed = kotlinx.coroutines.flow.MutableStateFlow(false)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -62,33 +44,44 @@ class StartRecordingActivity : Activity() {
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Active session: SAVE needs no consent gate; fire at once.
|
||||||
|
if (RecordingService.snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
|
||||||
|
RecordingService.command(this, RecordingService.ACTION_SAVE)
|
||||||
|
ShonarWidgetUpdater.refresh(this)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Sync fast path: cached consent needs no DataStore wait.
|
||||||
|
if (cachedConsent() == true) {
|
||||||
|
scope.launch {
|
||||||
|
withTimeoutOrNull(RESUME_WAIT_MS) { resumed.first { it } }
|
||||||
|
RecordingService.command(
|
||||||
|
this@StartRecordingActivity, RecordingService.ACTION_START,
|
||||||
|
)
|
||||||
|
withTimeoutOrNull(TRAMPOLINE_WAIT_MS) {
|
||||||
|
RecordingService.snapshot.first {
|
||||||
|
it.phase != RecordingSnapshot.Phase.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShonarWidgetUpdater.refresh(this@StartRecordingActivity)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val app = application as ShonarApplication
|
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 consentedDeferred = async {
|
val consentedDeferred = async {
|
||||||
withTimeoutOrNull(CONSENT_WAIT_MS) {
|
withTimeoutOrNull(CONSENT_WAIT_MS) {
|
||||||
runCatching {
|
runCatching {
|
||||||
app.settingsManager.ensureLoaded()
|
app.settingsManager.ensureLoaded()
|
||||||
app.settingsManager.bool(BuiltInSettings.CONSENT)
|
app.settingsManager.bool(BuiltInSettings.CONSENT)
|
||||||
}.getOrDefault(false)
|
}.getOrNull()
|
||||||
} == true
|
} == true
|
||||||
}
|
}
|
||||||
// Wait until TOP-resumed so the START/SAVE call below is
|
withTimeoutOrNull(RESUME_WAIT_MS) { resumed.first { it } }
|
||||||
// foreground-originated. Sent from onCreate, the system stamps
|
|
||||||
// the intent FROM_BACKGROUND (activity not yet resumed) and
|
|
||||||
// defers the notification's interruption ~10s — traced
|
|
||||||
// on-device: FGS allowed +0.1s, first buzz +10.1s, every session.
|
|
||||||
// Bound: a missed resume signal must never strand the activity.
|
|
||||||
withTimeoutOrNull(RESUME_WAIT_MS) { topResumed.first { it } }
|
|
||||||
val consented = withTimeoutOrNull(CONSENT_WAIT_MS) { consentedDeferred.await() } == true
|
val consented = withTimeoutOrNull(CONSENT_WAIT_MS) { consentedDeferred.await() } == true
|
||||||
// Active session: mirror the in-app Stop button (save; the rename
|
if (consented) cacheConsent()
|
||||||
// sheet opens on next app launch via RenamePendingHolder).
|
if (!consented) {
|
||||||
if (RecordingService.snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
|
|
||||||
RecordingService.command(
|
|
||||||
this@StartRecordingActivity, RecordingService.ACTION_SAVE,
|
|
||||||
)
|
|
||||||
} else if (!consented) {
|
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent(this@StartRecordingActivity, MainActivity::class.java)
|
Intent(this@StartRecordingActivity, MainActivity::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
|
@ -97,9 +90,6 @@ class StartRecordingActivity : Activity() {
|
||||||
RecordingService.command(
|
RecordingService.command(
|
||||||
this@StartRecordingActivity, RecordingService.ACTION_START,
|
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
|
||||||
|
|
@ -113,20 +103,11 @@ class StartRecordingActivity : Activity() {
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Pre-29 has no top-resumed signal (and no background-FGS
|
resumed.value = true
|
||||||
// restriction); onResume is sufficient there.
|
|
||||||
if (android.os.Build.VERSION.SDK_INT < 29) topResumed.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTopResumedActivityChanged(isTopResumed: Boolean) {
|
|
||||||
super.onTopResumedActivityChanged(isTopResumed)
|
|
||||||
if (isTopResumed) topResumed.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// singleInstance re-tap while a waiter is pending: the first waiter
|
|
||||||
// already covers the start — never strand a second instance.
|
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,6 +116,22 @@ class StartRecordingActivity : Activity() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cachedConsent(): Boolean? {
|
||||||
|
return try {
|
||||||
|
val sp = getSharedPreferences("shonar_widget", MODE_PRIVATE)
|
||||||
|
if (sp.contains("consent")) sp.getBoolean("consent", false) else null
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheConsent() {
|
||||||
|
runCatching {
|
||||||
|
getSharedPreferences("shonar_widget", MODE_PRIVATE)
|
||||||
|
.edit().putBoolean("consent", true).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
private const val CONSENT_WAIT_MS = 2_000L
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
<string name="notif_channel_recording_desc">Shonar recording controls. Kept silent so takes stay clean.</string>
|
<string name="notif_channel_recording_desc">Shonar recording controls. Kept silent so takes stay clean.</string>
|
||||||
<string name="notif_recording">Shonar is recording</string>
|
<string name="notif_recording">Shonar is recording</string>
|
||||||
<string name="notif_paused">Shonar recording paused</string>
|
<string name="notif_paused">Shonar recording paused</string>
|
||||||
|
<string name="notif_pause">Pause</string>
|
||||||
|
<string name="notif_resume">Resume</string>
|
||||||
|
<string name="notif_save">Save</string>
|
||||||
|
<string name="notif_discard">Discard</string>
|
||||||
<!-- Widget -->
|
<!-- Widget -->
|
||||||
<string name="widget_record">Record</string>
|
<string name="widget_record">Record</string>
|
||||||
<string name="widget_recording">Recording…</string>
|
<string name="widget_recording">Recording…</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue