From 8cd8861695a1b81422837a4125cb6ae4f7d3714d Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 15 Sep 2026 05:56:34 -0500 Subject: [PATCH] Recording notification: correct card, fast, no pop-banner - Post the foreground notification once on start (skip the duplicate nm.notify that restarted SystemUI RemoteViews inflation). - Collapsed shade row is a slim header-only card (lock shade clips taller cards); expanded keeps the pills card. Restore DecoratedCustomViewStyle (without it the system drops both custom views and expansion breaks) plus a custom heads-up view. - Fallback template leads with the timer; drop the SHONAR subtext ('SHONAR x SHONAR' header) and the system action row duplicating the pills. - Recording channel is DEFAULT: no heads-up banner pinning the shade item expanded on every record start. - Regression tests lock each behavior in RecordingNotificationLayoutTest. --- .../src/main/java/com/shonar/MainActivity.kt | 23 ++++ .../recording/RecordingNotificationHelper.kt | 98 +++++++++----- .../com/shonar/recording/RecordingService.kt | 11 +- .../layout/notification_recording_small.xml | 123 ++++++------------ .../RecordingNotificationLayoutTest.kt | 75 ++++++++++- 5 files changed, 210 insertions(+), 120 deletions(-) diff --git a/android/app/src/main/java/com/shonar/MainActivity.kt b/android/app/src/main/java/com/shonar/MainActivity.kt index 0bf705b..92b6891 100644 --- a/android/app/src/main/java/com/shonar/MainActivity.kt +++ b/android/app/src/main/java/com/shonar/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -26,6 +27,8 @@ import com.shonar.ui.home.HomeScreen import com.shonar.ui.rename.RenameAfterSaveDialog import com.shonar.ui.settings.SettingsScreen import com.shonar.ui.theme.ShonarTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -129,6 +132,26 @@ class MainActivity : ComponentActivity() { } intent.action = Intent.ACTION_MAIN } + // Notification Save pill opens the app with ACTION_SAVE (service + // intents from the shade can't launch UI on API 29+). Trigger the + // service save now that we're foreground, then show rename when + // the service hands back the saved id. + if (intent?.action == RecordingService.ACTION_SAVE) { + intent.action = Intent.ACTION_MAIN + RecordingService.command(this, RecordingService.ACTION_SAVE) + lifecycleScope.launch { + var waited = 0 + while (RenamePendingHolder.pendingRenameId == null && waited < 5_000) { + delay(100) + waited += 100 + } + val id = RenamePendingHolder.pendingRenameId + if (id != null) { + RenamePendingHolder.pendingRenameId = null + pendingRename.value = id + } + } + } val renameId = intent?.takeIf { it.action == RecordingService.ACTION_RENAME } ?.getStringExtra(RecordingService.EXTRA_RECORDING_ID) ?: RenamePendingHolder.pendingRenameId 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 31b40a2..6b2a409 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt @@ -13,18 +13,22 @@ import com.shonar.MainActivity import com.shonar.R /** - * Single custom card — Image 1 is the ONLY view. - * mic | "Recording" + filename | big timer, then X / pause / check pills. - * Same RemoteViews for collapsed AND expanded, so there is no duality: - * the default view already has full controls. - * Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — never - * the dark media-player template. + * Custom card pair in a decorated notification — the user-verified look, no + * system media template. + * Expanded: mic | "Recording" + filename | big timer, then X / pause / + * check pills. Collapsed (shade row, lock screen): slim header-only card + * (icon | titles | small timer, tap opens app) — the lock shade clips + * anything taller, so pills live only in the expanded card. + * DecoratedCustomViewStyle + CATEGORY_SERVICE: light system chrome around + * the cards — never the dark media-player template. * * LOCKED 2026-09-14 (user-verified on-device): do NOT revert build() to * system MediaStyle — that renders the small "SHONAR • SHONAR" text row - * with truncated filename and hides the big timer. Any layout change here - * must keep the custom RemoteViews card + big timer, and must keep - * RecordingNotificationLayoutTest green. + * with truncated filename and hides the big timer — and do NOT drop the + * DecoratedCustomViewStyle: without it the system ignores both custom + * views entirely (plain template stuck, cannot expand). Any layout change + * here must keep the decorated custom RemoteViews cards + big timer, and + * must keep RecordingNotificationLayoutTest green. */ object RecordingNotificationHelper { const val CHANNEL_ID = "shonar_recording_ongoing" @@ -36,7 +40,7 @@ object RecordingNotificationHelper { val mgr = ctx.getSystemService(NotificationManager::class.java) runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) } val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() - if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH || + if (existing == null || existing.importance != NotificationManager.IMPORTANCE_DEFAULT || existing.sound != null ) { runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) } @@ -45,7 +49,12 @@ object RecordingNotificationHelper { NotificationChannel( CHANNEL_ID, ctx.getString(R.string.notif_channel_recording), - NotificationManager.IMPORTANCE_HIGH, + // DEFAULT, not HIGH: a heads-up banner on every record start + // pins the shade item expanded for ~5s, which reads as the + // "wrong" notification. The user just tapped record + // deliberately; the widget badge + ongoing row are + // confirmation enough. Ongoing FGS rows stay top-ranked. + NotificationManager.IMPORTANCE_DEFAULT, ).apply { description = ctx.getString(R.string.notif_channel_recording_desc) setSound(null, null) @@ -62,6 +71,21 @@ object RecordingNotificationHelper { ) } + private fun activitySaveIntent(ctx: Context): PendingIntent { + // Save must bring the app forward: a service start from the shade + // can't launch the rename sheet on API 29+ (background activity + // launch block), forcing the user to open the app manually. + // Tapping Save opens MainActivity with ACTION_SAVE; MainActivity + // triggers the service save then shows rename when it lands. + val i = Intent(ctx, MainActivity::class.java) + .setAction(RecordingService.ACTION_SAVE) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + return PendingIntent.getActivity( + ctx, 124, i, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + @JvmOverloads fun build( ctx: Context, @@ -102,42 +126,48 @@ object RecordingNotificationHelper { ) card.setOnClickPendingIntent( R.id.notif_btn_save, - svcIntent(ctx, RecordingService.ACTION_SAVE, 24), + activitySaveIntent(ctx), ) card.setOnClickPendingIntent(R.id.notif_icon, openApp) + // Collapsed (shade row, lock screen): slim header-only card — the + // lock shade clips anything taller. Expanded: full card with pills. + val small = RemoteViews(ctx.packageName, R.layout.notification_recording_small) + small.setTextViewText(R.id.notif_title_small, "Recording") + small.setTextViewText(R.id.notif_subtitle_small, fileName) + small.setTextViewText(R.id.notif_timer_small, elapsed) + small.setOnClickPendingIntent(R.id.notif_small_root, openApp) + return NotificationCompat.Builder(ctx, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_mic) .setContentTitle("Recording") - .setContentText("$fileName • $elapsed") - .setSubText("SHONAR") + // Fallback template (shade row until RemoteViews inflate; watches, + // Auto): timer first so a long filename can't truncate it out. + // No setSubText: it renders as "SHONAR • SHONAR" next to the app + // name in the system header. + .setContentText("$elapsed • $fileName") .setOngoing(true) .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(openApp) .setShowWhen(false) - .setPriority(NotificationCompat.PRIORITY_HIGH) - // Fallback actions (Android Auto, watches, if custom is ever stripped). - .addAction( - R.drawable.ic_notif_close, "Discard", - svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21), - ) - .addAction( - if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause, - if (paused) "Resume" else "Pause", - svcIntent( - ctx, - if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22, - ), - ) - .addAction( - R.drawable.ic_notif_check, "Save", - svcIntent(ctx, RecordingService.ACTION_SAVE, 24), - ) - // Same card for collapsed + big: no expanded duality. - .setCustomContentView(card) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + // No addAction() here: system action rows duplicate the pills + // below and crowd the expanded card. (Surfaces that can't show + // custom views — watches, Auto — get title/text + tap-to-open; + // the confirm-delete card below keeps its No/Yes actions.) + // Slim header collapsed; full pills expanded. DecoratedCustomViewStyle + // is REQUIRED: without it the system drops both custom views and + // the shade is stuck on the plain template (title/text/actions, + // expansion broken) — seen on targetSdk 35 / recent platform + // versions. The header adds a small app icon next to the card's + // own mic; that duplication is the price of the card rendering. + .setCustomContentView(small) .setCustomBigContentView(card) + // Heads-up uses this on the first post; without it the system + // pops the plain template for ~5s until the shade row takes over. + .setCustomHeadsUpContentView(small) .setStyle(NotificationCompat.DecoratedCustomViewStyle()) .build() } 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 23b01b0..564f975 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingService.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt @@ -164,7 +164,12 @@ class RecordingService : Service() { acquireWake() requestFocus() watchCalls() - setPhase(RecordingSnapshot.Phase.RECORDING) + // 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) } @@ -306,9 +311,9 @@ class RecordingService : Service() { RecordingSnapshot.Phase.IDLE -> 0L } - private fun setPhase(p: RecordingSnapshot.Phase) { + private fun setPhase(p: RecordingSnapshot.Phase, push: Boolean = true) { _snapshot.value = RecordingSnapshot(p, currentId, elapsedMs(), confirmingDiscard = false) - pushNotification() + if (push) pushNotification() ShonarWidgetUpdater.refresh(this) } 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 9b49ff5..0731efb 100644 --- a/android/app/src/main/res/layout/notification_recording_small.xml +++ b/android/app/src/main/res/layout/notification_recording_small.xml @@ -1,99 +1,58 @@ - + + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingTop="6dp" + android:paddingBottom="6dp" + android:paddingStart="4dp" + android:paddingEnd="4dp"> + + - - - - - - - - - + android:layout_marginStart="8dp" + android:layout_weight="1" + android:orientation="vertical"> + + - - - - - - - - + android:layout_marginStart="8dp" + android:singleLine="true" + android:text="00:00" + android:textSize="16sp" + android:textStyle="bold" /> diff --git a/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt index dd1b6e2..ee1ed64 100644 --- a/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt +++ b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt @@ -41,9 +41,17 @@ class RecordingNotificationLayoutTest { val src = helperSource() assertTrue("must inflate R.layout.notification_recording", src.contains("R.layout.notification_recording")) assertTrue("must bind big timer view", src.contains("R.id.notif_timer")) - assertTrue("must use DecoratedCustomViewStyle", src.contains("DecoratedCustomViewStyle")) + assertTrue( + "must APPLY DecoratedCustomViewStyle (without it the system drops " + + "both custom views: shade stuck on plain template, cannot expand)", + src.contains(".setStyle(NotificationCompat.DecoratedCustomViewStyle())"), + ) assertTrue("must set same card for collapsed view", src.contains("setCustomContentView")) assertTrue("must set same card for expanded view", src.contains("setCustomBigContentView")) + assertTrue( + "must set heads-up view (else first post pops the plain template for ~5s)", + src.contains("setCustomHeadsUpContentView"), + ) } @Test fun ongoingCard_doesNotUseMediaStyle() { @@ -64,4 +72,69 @@ class RecordingNotificationLayoutTest { assertTrue("layout missing @$id", xml.contains("@+id/$id")) } } + + @Test fun collapsedCard_isSlimHeaderWithTimer() { + val src = helperSource() + assertTrue("must inflate R.layout.notification_recording_small", src.contains("R.layout.notification_recording_small")) + assertTrue("must bind small timer view", src.contains("R.id.notif_timer_small")) + val xml = layout("notification_recording_small") + for (id in listOf("notif_title_small", "notif_subtitle_small", "notif_timer_small")) { + assertTrue("collapsed layout missing @$id", xml.contains("@+id/$id")) + } + assertTrue( + "collapsed card must stay pill-free (lock shade clips taller cards)", + "notif_btn_discard_small" !in xml && "notif_btn_pause_small" !in xml, + ) + } + + @Test fun startPath_postsForegroundNotificationOnlyOnce() { + // startRecording() posts via startForeground(); an immediate second + // notify() hands SystemUI fresh RemoteViews mid-inflation, so an + // instant shade pull shows the plain system row until inflation + // restarts and completes. The start path must skip the duplicate push + // (the 1s ticker covers updates). + val f = moduleDir().resolve("src/main/java/com/shonar/recording/RecordingService.kt") + assertTrue("service source missing: $f", f.exists()) + val src = f.readText() + assertTrue( + "start must reuse the startForeground card without a duplicate push", + src.contains("setPhase(RecordingSnapshot.Phase.RECORDING, push = false)"), + ) + } + + @Test fun fallbackText_leadsWithTimer() { + // Until RemoteViews inflate, the shade shows the fallback template; + // a long filename must not truncate the timer out of that frame. + val src = helperSource() + assertTrue( + "fallback content text must lead with the timer", + src.contains("setContentText(\"\$elapsed") || src.contains("\$elapsed • \$fileName"), + ) + assertTrue( + "no setSubText() call: it renders as 'SHONAR • SHONAR' next to the app name", + "setSubText(" !in src, + ) + } + + @Test fun ongoingCard_hasNoSystemActionRow() { + // The expanded card's X/pause/check pills are the controls; system + // addAction() rows would duplicate them under the card. Only the + // confirm-delete card (No/Yes) may add actions. + val src = helperSource() + assertTrue( + "ongoing card must not add system actions (only confirm-delete's No/Yes)", + src.split(".addAction(").size - 1 == 2, + ) + } + + @Test fun recordingChannel_doesNotPopHeadsUp() { + // HIGH importance pops a heads-up on every record start, pinning the + // shade item expanded for ~5s (reads as the "wrong" notification). + // DEFAULT keeps the ongoing row top-ranked with no banner. + val src = helperSource() + assertTrue( + "channel must be IMPORTANCE_DEFAULT, not HIGH", + src.contains("IMPORTANCE_DEFAULT") && "IMPORTANCE_HIGH" !in src, + ) + } }