diff --git a/android/app/src/main/java/com/shonar/ShonarApplication.kt b/android/app/src/main/java/com/shonar/ShonarApplication.kt index 525a67f..987ee74 100644 --- a/android/app/src/main/java/com/shonar/ShonarApplication.kt +++ b/android/app/src/main/java/com/shonar/ShonarApplication.kt @@ -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() } diff --git a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt index 74a6719..9e0c010 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt @@ -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. diff --git a/android/app/src/main/java/com/shonar/recording/RecordingService.kt b/android/app/src/main/java/com/shonar/recording/RecordingService.kt index 564f975..24b4353 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingService.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt @@ -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) diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt index 2bf72cd..877619f 100644 --- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -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, + ) + } } } } diff --git a/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt b/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt index 52f8c64..e2f6579 100644 --- a/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt +++ b/android/app/src/main/java/com/shonar/widget/ShonarWidget.kt @@ -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 { diff --git a/android/app/src/main/res/layout/notification_confirm_delete.xml b/android/app/src/main/res/layout/notification_confirm_delete.xml index f747668..d1b4025 100644 --- a/android/app/src/main/res/layout/notification_confirm_delete.xml +++ b/android/app/src/main/res/layout/notification_confirm_delete.xml @@ -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" /> @@ -43,6 +44,7 @@ android:layout_height="wrap_content" android:singleLine="true" android:text="Shonar" + android:textColor="@color/shonar_widget_text" android:textSize="14sp" /> @@ -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" /> diff --git a/android/app/src/main/res/layout/notification_recording_small.xml b/android/app/src/main/res/layout/notification_recording_small.xml index 0731efb..afb6de6 100644 --- a/android/app/src/main/res/layout/notification_recording_small.xml +++ b/android/app/src/main/res/layout/notification_recording_small.xml @@ -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" /> @@ -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" />