Instant record banner from widget tap; fix ghost notification + main-thread recorder prep

- ensureForeground() posts the FGS banner at the top of onStartCommand on a
  cold-start START only (was: every action -> raced stopForeground(REMOVE)
  and re-flipped the discard-confirm card back to the plain card)
- MediaRecorder prepare()/start() moved to Dispatchers.IO (StrictMode:
  disk I/O was on the main thread); start failure reverts UI
- discardRecording() tears the banner down synchronously before stopSelf()
  so a scope cancel in onDestroy can't leave a ghost notification
- ensureChannel() memoized + legacy channel cleanup kept (one IPC pass per
  process instead of per call)
- restore migrateRestoreTimes() + runCatching around refreshRoot() in
  Application.onCreate (dropped accidentally during timing instrumentation)
- Home screen: Column+verticalScroll -> LazyColumn with keyed items
- Notification layouts: explicit textColor for lockscreen/shade legibility
- Widget tap PendingIntent: FLAG_RECEIVER_FOREGROUND for prompt delivery
- All ShonarTiming instrumentation probes removed

On-device (Pixel 8 Pro): widget tap -> banner ~195ms; recording file grows
in .in-progress/; Discard->No survives, Discard->Yes removes service, temp,
and notification with nothing left live; no StrictMode recorder violation.
This commit is contained in:
avi 2026-09-17 13:23:38 -05:00
commit 645e466b2f
8 changed files with 197 additions and 129 deletions

View file

@ -4,6 +4,7 @@ import android.app.Application
import androidx.room.Room import androidx.room.Room
import com.shonar.recording.PlaybackController import com.shonar.recording.PlaybackController
import com.shonar.recording.PlaybackOwner import com.shonar.recording.PlaybackOwner
import com.shonar.recording.RecordingNotificationHelper
import com.shonar.recording.RecordingRepository import com.shonar.recording.RecordingRepository
import com.shonar.recording.ShonarDatabase import com.shonar.recording.ShonarDatabase
import com.shonar.settings.DataStoreSettingsStore import com.shonar.settings.DataStoreSettingsStore
@ -69,6 +70,10 @@ class ShonarApplication : Application(), PlaybackOwner {
.detectCustomSlowCalls().penaltyLog().build() .detectCustomSlowCalls().penaltyLog().build()
) )
} }
appScope.launch {
RecordingNotificationHelper.ensureChannel(this@ShonarApplication)
database
}
appScope.launch { appScope.launch {
runCatching { recordingRepository.refreshRoot() } runCatching { recordingRepository.refreshRoot() }
runCatching { recordingRepository.migrateRestoreTimes() } runCatching { recordingRepository.migrateRestoreTimes() }

View file

@ -39,31 +39,33 @@ object RecordingNotificationHelper {
private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing" private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing"
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
@Volatile
private var channelReady = false
fun ensureChannel(ctx: Context) { fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (channelReady) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
channelReady = true
return
}
val mgr = ctx.getSystemService(NotificationManager::class.java) val mgr = ctx.getSystemService(NotificationManager::class.java)
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) } runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) } runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) }
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH || if (existing == null) {
existing.sound != null mgr.createNotificationChannel(
) { NotificationChannel(
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) } CHANNEL_ID,
ctx.getString(R.string.notif_channel_recording),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = ctx.getString(R.string.notif_channel_recording_desc)
setSound(null, null)
enableVibration(false)
},
)
} }
mgr.createNotificationChannel( channelReady = true
NotificationChannel(
CHANNEL_ID,
ctx.getString(R.string.notif_channel_recording),
// HIGH: a heads-up banner on record start is the visible
// confirmation that recording began. Ongoing FGS rows stay
// top-ranked.
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = ctx.getString(R.string.notif_channel_recording_desc)
setSound(null, null)
enableVibration(false)
},
)
} }
private fun svcIntent(ctx: Context, action: String, req: Int): PendingIntent { private fun svcIntent(ctx: Context, action: String, req: Int): PendingIntent {
@ -96,6 +98,7 @@ object RecordingNotificationHelper {
elapsedMs: Long, elapsedMs: Long,
fileName: String = "Shonar", fileName: String = "Shonar",
confirmingDiscard: Boolean = false, confirmingDiscard: Boolean = false,
alertOnce: Boolean = true,
): Notification { ): Notification {
val openApp = PendingIntent.getActivity( val openApp = PendingIntent.getActivity(
ctx, 10, ctx, 10,
@ -150,7 +153,7 @@ object RecordingNotificationHelper {
// name in the system header. // name in the system header.
.setContentText("$elapsed • $fileName") .setContentText("$elapsed • $fileName")
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(alertOnce)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
// PRIVATE + publicVersion below: the secure lockscreen shows the // PRIVATE + publicVersion below: the secure lockscreen shows the
// simplified single-icon row instead of the full card. // simplified single-icon row instead of the full card.

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* Foreground microphone owner. Single-flight: START while active is a * Foreground microphone owner. Single-flight: START while active is a
@ -69,6 +70,7 @@ class RecordingService : Service() {
private var accumulatedMs = 0L private var accumulatedMs = 0L
private var resumedAtElapsed = 0L private var resumedAtElapsed = 0L
private var ticker: Job? = null private var ticker: Job? = null
private var starting = false
private lateinit var stateStore: RecordingStateStore private lateinit var stateStore: RecordingStateStore
@ -80,6 +82,14 @@ class RecordingService : Service() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
// Only a cold-start START posts the banner. Re-posting on every
// action raced the stopForeground(REMOVE) in discard/save and left
// a ghost "Recording" notification after the service was gone; it
// also flickered the discard-confirm card back to the plain card.
if (action == ACTION_START && _snapshot.value.phase == RecordingSnapshot.Phase.IDLE && !starting) {
ensureForeground()
}
scope.launch { recoverIfNeeded() } scope.launch { recoverIfNeeded() }
when (intent?.action) { when (intent?.action) {
ACTION_START -> startRecording() ACTION_START -> startRecording()
@ -97,7 +107,7 @@ class RecordingService : Service() {
// ---------- public API used by widget / ViewModel ---------- // ---------- public API used by widget / ViewModel ----------
private fun startRecording() { private fun startRecording() {
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return // no duplicates if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE || starting) return // no duplicates
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
) { ) {
@ -106,74 +116,64 @@ class RecordingService : Service() {
stopSelf() stopSelf()
return return
} }
starting = true
val app = application as ShonarApplication val app = application as ShonarApplication
val id = app.recordingRepository.newRecordingId() val id = app.recordingRepository.newRecordingId()
val temp = app.recordingRepository.tempFile(id)
runCatching { temp.parentFile?.mkdirs() }
try { createdAtMs = System.currentTimeMillis()
createdAtMs = System.currentTimeMillis()
val notif = RecordingNotificationHelper.build( // prepare() touches the filesystem (and the codec HAL) — off the
this, RecordingSnapshot.Phase.RECORDING, 0L, // main thread. The FGS banner from ensureForeground() already told
displayFileName(), // the user recording started; a start() failure reverts the UI.
) scope.launch(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val temp = app.recordingRepository.tempFile(id)
startForeground( val ready = try {
RecordingNotificationHelper.NOTIFICATION_ID, notif, recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this@RecordingService) else MediaRecorder()).apply {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, setAudioSource(MediaRecorder.AudioSource.MIC)
) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
} else { setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif) setAudioEncodingBitRate(128_000)
setAudioSamplingRate(44_100)
setOutputFile(temp.absolutePath)
prepare()
start()
}
true
} catch (e: Exception) {
android.util.Log.w("ShonarRec", "mic unavailable", e)
recorder?.release()
recorder = null
runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
false
} }
} catch (e: Exception) { withContext(Dispatchers.Main) {
// API 31+ background-start race: bail cleanly, widget stays idle. starting = false
android.util.Log.w("ShonarRec", "FGS start denied", e) if (!ready) {
broadcast() stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() broadcast()
return stopSelf()
} return@withContext
}
try { currentId = id
recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()).apply { currentTemp = temp
setAudioSource(MediaRecorder.AudioSource.MIC) accumulatedMs = 0L
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) resumedAtElapsed = SystemClock.elapsedRealtime()
setAudioEncoder(MediaRecorder.AudioEncoder.AAC) acquireWake()
setAudioEncodingBitRate(128_000) requestFocus()
setAudioSamplingRate(44_100) watchCalls()
setOutputFile(temp.absolutePath) // startForeground() above already posted this exact card: pushing a
prepare() // second notify() here hands SystemUI fresh RemoteViews while it is
start() // still inflating the first set, so an instant shade pull shows the
// plain system row until inflation restarts and completes. The 1s
// ticker covers subsequent updates.
setPhase(RecordingSnapshot.Phase.RECORDING, push = false)
scope.launch {
stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs)
}
startTicker()
} }
} catch (e: Exception) {
// Mic busy (another app) or HW error: keep temp (0 bytes -> sweep
// later), release, go IDLE. Never crash the widget tap.
android.util.Log.w("ShonarRec", "mic unavailable", e)
recorder?.release()
recorder = null
runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
stopForeground(STOP_FOREGROUND_REMOVE)
broadcast()
stopSelf()
return
} }
currentId = id
currentTemp = temp
accumulatedMs = 0L
resumedAtElapsed = SystemClock.elapsedRealtime()
acquireWake()
requestFocus()
watchCalls()
// startForeground() above already posted this exact card: pushing a
// second notify() here hands SystemUI fresh RemoteViews while it is
// still inflating the first set, so an instant shade pull shows the
// plain system row until inflation restarts and completes. The 1s
// ticker covers subsequent updates.
setPhase(RecordingSnapshot.Phase.RECORDING, push = false)
scope.launch {
stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs)
}
startTicker()
} }
private fun pauseRecording() { private fun pauseRecording() {
@ -292,18 +292,52 @@ class RecordingService : Service() {
shutdownRecorder(releaseOnly = false, keepTemp = false) shutdownRecorder(releaseOnly = false, keepTemp = false)
ticker?.cancel() ticker?.cancel()
ticker = null ticker = null
starting = false
runCatching { temp?.delete() } runCatching { temp?.delete() }
setPhase(RecordingSnapshot.Phase.IDLE) setPhase(RecordingSnapshot.Phase.IDLE)
// Tear the banner down NOW, synchronously: a later scope.cancel()
// in onDestroy would otherwise kill the cleanup coroutine before
// stopForeground() runs and leave a ghost notification behind.
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
runCatching {
getSystemService(android.app.NotificationManager::class.java)
.cancel(RecordingNotificationHelper.NOTIFICATION_ID)
}
scope.launch { scope.launch {
stateStore.clear() stateStore.clear()
ShonarWidgetUpdater.refresh(applicationContext) ShonarWidgetUpdater.refresh(applicationContext)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
} }
// ---------- internals ---------- // ---------- internals ----------
/**
* Post a foreground notification immediately. Called at the top of
* onStartCommand so the banner appears instantly — no waiting for
* MediaRecorder setup, file I/O, or DB access.
*/
private fun ensureForeground() {
try {
val notif = RecordingNotificationHelper.build(
this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(),
alertOnce = false,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
RecordingNotificationHelper.NOTIFICATION_ID, notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif)
}
} catch (e: Exception) {
android.util.Log.w("ShonarRec", "FGS start denied", e)
broadcast()
stopSelf()
}
}
private fun elapsedMs(): Long = when (_snapshot.value.phase) { private fun elapsedMs(): Long = when (_snapshot.value.phase) {
RecordingSnapshot.Phase.RECORDING -> RecordingSnapshot.Phase.RECORDING ->
accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed) accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed)

View file

@ -13,8 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@ -129,56 +129,71 @@ fun HomeScreen(
) )
}, },
) { padding -> ) { padding ->
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(24.dp) .padding(horizontal = 24.dp),
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 24.dp),
) { ) {
Text("SHONAR", style = MaterialTheme.typography.headlineMedium) item { Text("SHONAR", style = MaterialTheme.typography.headlineMedium) }
Text( item {
"Oral Notes and Audio Recorder — everything stays on this phone", Text(
style = MaterialTheme.typography.bodyMedium, "Oral Notes and Audio Recorder — everything stays on this phone",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
StorageChip()
if (storageOk == false) {
val ctx = context
StorageAccessCard(onGrant = {
runCatching {
ctx.startActivity(
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
android.net.Uri.parse("package:${ctx.packageName}"),
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
})
}
Spacer(Modifier.height(24.dp))
QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings)
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
RecordingStatusCard(
state = recorderState,
onPause = vm::pause,
onResume = vm::resume,
onStop = vm::stop,
onDiscard = vm::discard,
) )
} }
if (recordings.isNotEmpty()) { item { StorageChip() }
Text("Your recordings", style = MaterialTheme.typography.titleLarge, if (storageOk == false) {
modifier = Modifier.align(Alignment.Start)) item {
notice?.let { val ctx = context
Text(it, color = MaterialTheme.colorScheme.error, StorageAccessCard(onGrant = {
style = MaterialTheme.typography.bodySmall, runCatching {
modifier = Modifier.align(Alignment.Start)) ctx.startActivity(
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
android.net.Uri.parse("package:${ctx.packageName}"),
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
})
} }
recordings.forEach { recording -> }
item { Spacer(Modifier.height(8.dp)) }
item {
QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings)
}
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
item {
RecordingStatusCard(
state = recorderState,
onPause = vm::pause,
onResume = vm::resume,
onStop = vm::stop,
onDiscard = vm::discard,
)
}
}
if (recordings.isNotEmpty()) {
item {
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth())
}
notice?.let { msg ->
item {
Text(msg, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth())
}
}
items(
items = recordings,
key = { it.id },
) { recording ->
RecordingRow( RecordingRow(
recording = recording, recording = recording,
playing = playingId == recording.id, playing = playingId == recording.id,
@ -189,11 +204,13 @@ fun HomeScreen(
) )
} }
} else { } else {
Text( item {
"Your recordings will appear here and stay on this device.", Text(
style = MaterialTheme.typography.bodySmall, "Your recordings will appear here and stay on this device.",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
} }
} }

View file

@ -79,7 +79,8 @@ class ShonarRecordWidget : AppWidgetProvider() {
val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) { val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
ctx, 50, ctx, 50,
Intent(ctx, ShonarRecordWidget::class.java).setAction(ACTION_WIDGET_RECORD), Intent(ctx, ShonarRecordWidget::class.java).setAction(ACTION_WIDGET_RECORD)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
} else { } else {

View file

@ -26,6 +26,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Shonar" android:text="Shonar"
android:textColor="@color/shonar_widget_text"
android:textSize="15sp" android:textSize="15sp"
android:textStyle="bold" /> android:textStyle="bold" />
@ -33,6 +34,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Delete current recording?" android:text="Delete current recording?"
android:textColor="@color/shonar_widget_text"
android:textSize="14sp" /> android:textSize="14sp" />
<LinearLayout <LinearLayout

View file

@ -34,6 +34,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:singleLine="true" android:singleLine="true"
android:text="Recording" android:text="Recording"
android:textColor="@color/shonar_widget_text"
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
@ -43,6 +44,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:singleLine="true" android:singleLine="true"
android:text="Shonar" android:text="Shonar"
android:textColor="@color/shonar_widget_text"
android:textSize="14sp" /> android:textSize="14sp" />
</LinearLayout> </LinearLayout>
@ -53,6 +55,7 @@
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:singleLine="true" android:singleLine="true"
android:text="00:00" android:text="00:00"
android:textColor="@color/shonar_widget_text"
android:textSize="28sp" android:textSize="28sp"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>

View file

@ -33,6 +33,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:singleLine="true" android:singleLine="true"
android:text="Recording" android:text="Recording"
android:textColor="@color/shonar_widget_text"
android:textSize="14sp" android:textSize="14sp"
android:textStyle="bold" /> android:textStyle="bold" />
@ -43,6 +44,7 @@
android:ellipsize="end" android:ellipsize="end"
android:singleLine="true" android:singleLine="true"
android:text="Shonar" android:text="Shonar"
android:textColor="@color/shonar_widget_text"
android:textSize="12sp" /> android:textSize="12sp" />
</LinearLayout> </LinearLayout>
@ -53,6 +55,7 @@
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:singleLine="true" android:singleLine="true"
android:text="00:00" android:text="00:00"
android:textColor="@color/shonar_widget_text"
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>