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 com.shonar.recording.PlaybackController
import com.shonar.recording.PlaybackOwner
import com.shonar.recording.RecordingNotificationHelper
import com.shonar.recording.RecordingRepository
import com.shonar.recording.ShonarDatabase
import com.shonar.settings.DataStoreSettingsStore
@ -69,6 +70,10 @@ class ShonarApplication : Application(), PlaybackOwner {
.detectCustomSlowCalls().penaltyLog().build()
)
}
appScope.launch {
RecordingNotificationHelper.ensureChannel(this@ShonarApplication)
database
}
appScope.launch {
runCatching { recordingRepository.refreshRoot() }
runCatching { recordingRepository.migrateRestoreTimes() }

View file

@ -39,31 +39,33 @@ object RecordingNotificationHelper {
private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing"
const val NOTIFICATION_ID = 1001
@Volatile
private var channelReady = false
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)
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) }
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH ||
existing.sound != null
) {
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
if (existing == null) {
mgr.createNotificationChannel(
NotificationChannel(
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(
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)
},
)
channelReady = true
}
private fun svcIntent(ctx: Context, action: String, req: Int): PendingIntent {
@ -96,6 +98,7 @@ object RecordingNotificationHelper {
elapsedMs: Long,
fileName: String = "Shonar",
confirmingDiscard: Boolean = false,
alertOnce: Boolean = true,
): Notification {
val openApp = PendingIntent.getActivity(
ctx, 10,
@ -150,7 +153,7 @@ object RecordingNotificationHelper {
// name in the system header.
.setContentText("$elapsed • $fileName")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setOnlyAlertOnce(alertOnce)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
// PRIVATE + publicVersion below: the secure lockscreen shows the
// 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.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Foreground microphone owner. Single-flight: START while active is a
@ -69,6 +70,7 @@ class RecordingService : Service() {
private var accumulatedMs = 0L
private var resumedAtElapsed = 0L
private var ticker: Job? = null
private var starting = false
private lateinit var stateStore: RecordingStateStore
@ -80,6 +82,14 @@ class RecordingService : Service() {
}
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() }
when (intent?.action) {
ACTION_START -> startRecording()
@ -97,7 +107,7 @@ class RecordingService : Service() {
// ---------- public API used by widget / ViewModel ----------
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) !=
PackageManager.PERMISSION_GRANTED
) {
@ -106,74 +116,64 @@ class RecordingService : Service() {
stopSelf()
return
}
starting = true
val app = application as ShonarApplication
val id = app.recordingRepository.newRecordingId()
val temp = app.recordingRepository.tempFile(id)
runCatching { temp.parentFile?.mkdirs() }
try {
createdAtMs = System.currentTimeMillis()
val notif = RecordingNotificationHelper.build(
this, RecordingSnapshot.Phase.RECORDING, 0L,
displayFileName(),
)
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)
createdAtMs = System.currentTimeMillis()
// prepare() touches the filesystem (and the codec HAL) — off the
// main thread. The FGS banner from ensureForeground() already told
// the user recording started; a start() failure reverts the UI.
scope.launch(Dispatchers.IO) {
val temp = app.recordingRepository.tempFile(id)
val ready = try {
recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this@RecordingService) else MediaRecorder()).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
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) {
// API 31+ background-start race: bail cleanly, widget stays idle.
android.util.Log.w("ShonarRec", "FGS start denied", e)
broadcast()
stopSelf()
return
}
withContext(Dispatchers.Main) {
starting = false
if (!ready) {
stopForeground(STOP_FOREGROUND_REMOVE)
broadcast()
stopSelf()
return@withContext
}
try {
recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioEncodingBitRate(128_000)
setAudioSamplingRate(44_100)
setOutputFile(temp.absolutePath)
prepare()
start()
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()
}
} 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() {
@ -292,18 +292,52 @@ class RecordingService : Service() {
shutdownRecorder(releaseOnly = false, keepTemp = false)
ticker?.cancel()
ticker = null
starting = false
runCatching { temp?.delete() }
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 {
stateStore.clear()
ShonarWidgetUpdater.refresh(applicationContext)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
// ---------- 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) {
RecordingSnapshot.Phase.RECORDING ->
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreVert
@ -129,56 +129,71 @@ fun HomeScreen(
)
},
) { padding ->
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 24.dp),
) {
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
Text(
"Oral Notes and Audio Recorder — everything stays on this phone",
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,
item { Text("SHONAR", style = MaterialTheme.typography.headlineMedium) }
item {
Text(
"Oral Notes and Audio Recorder — everything stays on this phone",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (recordings.isNotEmpty()) {
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.Start))
notice?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.Start))
item { StorageChip() }
if (storageOk == false) {
item {
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)
)
}
})
}
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(
recording = recording,
playing = playingId == recording.id,
@ -189,11 +204,13 @@ fun HomeScreen(
)
}
} else {
Text(
"Your recordings will appear here and stay on this device.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
item {
Text(
"Your recordings will appear here and stay on this device.",
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) {
PendingIntent.getBroadcast(
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,
)
} else {

View file

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

View file

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

View file

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