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.
This commit is contained in:
avi 2026-09-15 05:56:34 -05:00
commit 8cd8861695
5 changed files with 210 additions and 120 deletions

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController 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.rename.RenameAfterSaveDialog
import com.shonar.ui.settings.SettingsScreen import com.shonar.ui.settings.SettingsScreen
import com.shonar.ui.theme.ShonarTheme import com.shonar.ui.theme.ShonarTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -129,6 +132,26 @@ class MainActivity : ComponentActivity() {
} }
intent.action = Intent.ACTION_MAIN 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 } val renameId = intent?.takeIf { it.action == RecordingService.ACTION_RENAME }
?.getStringExtra(RecordingService.EXTRA_RECORDING_ID) ?.getStringExtra(RecordingService.EXTRA_RECORDING_ID)
?: RenamePendingHolder.pendingRenameId ?: RenamePendingHolder.pendingRenameId

View file

@ -13,18 +13,22 @@ import com.shonar.MainActivity
import com.shonar.R import com.shonar.R
/** /**
* Single custom card — Image 1 is the ONLY view. * Custom card pair in a decorated notification — the user-verified look, no
* mic | "Recording" + filename | big timer, then X / pause / check pills. * system media template.
* Same RemoteViews for collapsed AND expanded, so there is no duality: * Expanded: mic | "Recording" + filename | big timer, then X / pause /
* the default view already has full controls. * check pills. Collapsed (shade row, lock screen): slim header-only card
* Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — never * (icon | titles | small timer, tap opens app) — the lock shade clips
* the dark media-player template. * 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 * LOCKED 2026-09-14 (user-verified on-device): do NOT revert build() to
* system MediaStyle — that renders the small "SHONAR • SHONAR" text row * system MediaStyle — that renders the small "SHONAR • SHONAR" text row
* with truncated filename and hides the big timer. Any layout change here * with truncated filename and hides the big timer — and do NOT drop the
* must keep the custom RemoteViews card + big timer, and must keep * DecoratedCustomViewStyle: without it the system ignores both custom
* RecordingNotificationLayoutTest green. * 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 { object RecordingNotificationHelper {
const val CHANNEL_ID = "shonar_recording_ongoing" const val CHANNEL_ID = "shonar_recording_ongoing"
@ -36,7 +40,7 @@ object RecordingNotificationHelper {
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) }
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.importance != NotificationManager.IMPORTANCE_DEFAULT ||
existing.sound != null existing.sound != null
) { ) {
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) } runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
@ -45,7 +49,12 @@ object RecordingNotificationHelper {
NotificationChannel( NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
ctx.getString(R.string.notif_channel_recording), 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 { ).apply {
description = ctx.getString(R.string.notif_channel_recording_desc) description = ctx.getString(R.string.notif_channel_recording_desc)
setSound(null, null) 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 @JvmOverloads
fun build( fun build(
ctx: Context, ctx: Context,
@ -102,42 +126,48 @@ object RecordingNotificationHelper {
) )
card.setOnClickPendingIntent( card.setOnClickPendingIntent(
R.id.notif_btn_save, R.id.notif_btn_save,
svcIntent(ctx, RecordingService.ACTION_SAVE, 24), activitySaveIntent(ctx),
) )
card.setOnClickPendingIntent(R.id.notif_icon, openApp) 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) return NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic) .setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle("Recording") .setContentTitle("Recording")
.setContentText("$fileName • $elapsed") // Fallback template (shade row until RemoteViews inflate; watches,
.setSubText("SHONAR") // 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) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp) .setContentIntent(openApp)
.setShowWhen(false) .setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
// Fallback actions (Android Auto, watches, if custom is ever stripped). // No addAction() here: system action rows duplicate the pills
.addAction( // below and crowd the expanded card. (Surfaces that can't show
R.drawable.ic_notif_close, "Discard", // custom views — watches, Auto — get title/text + tap-to-open;
svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21), // the confirm-delete card below keeps its No/Yes actions.)
) // Slim header collapsed; full pills expanded. DecoratedCustomViewStyle
.addAction( // is REQUIRED: without it the system drops both custom views and
if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause, // the shade is stuck on the plain template (title/text/actions,
if (paused) "Resume" else "Pause", // expansion broken) — seen on targetSdk 35 / recent platform
svcIntent( // versions. The header adds a small app icon next to the card's
ctx, // own mic; that duplication is the price of the card rendering.
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22, .setCustomContentView(small)
),
)
.addAction(
R.drawable.ic_notif_check, "Save",
svcIntent(ctx, RecordingService.ACTION_SAVE, 24),
)
// Same card for collapsed + big: no expanded duality.
.setCustomContentView(card)
.setCustomBigContentView(card) .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()) .setStyle(NotificationCompat.DecoratedCustomViewStyle())
.build() .build()
} }

View file

@ -164,7 +164,12 @@ class RecordingService : Service() {
acquireWake() acquireWake()
requestFocus() requestFocus()
watchCalls() 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 { scope.launch {
stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs) stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs)
} }
@ -306,9 +311,9 @@ class RecordingService : Service() {
RecordingSnapshot.Phase.IDLE -> 0L 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) _snapshot.value = RecordingSnapshot(p, currentId, elapsedMs(), confirmingDiscard = false)
pushNotification() if (push) pushNotification()
ShonarWidgetUpdater.refresh(this) ShonarWidgetUpdater.refresh(this)
} }

View file

@ -1,28 +1,29 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Collapsed: same pills, compact (40dp buttons, tighter padding) so the <!-- Collapsed / lock-screen: slim single row (icon | titles | timer).
system doesn't truncate it back to the stock row. --> No pills here — the lock shade constrains compact height and clips
anything taller. Full X / pause / check pills live in the expanded
big card. Tapping the row opens the app. RemoteViews-safe views only. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:id="@+id/notif_small_root"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:paddingStart="4dp"
android:paddingEnd="4dp">
<ImageView <ImageView
android:layout_width="40dp" android:layout_width="32dp"
android:layout_height="40dp" android:layout_height="32dp"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" /> android:src="@drawable/ic_widget_mic" />
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="10dp" android:layout_marginStart="8dp"
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical"> android:orientation="vertical">
@ -39,6 +40,7 @@
android:id="@+id/notif_subtitle_small" android:id="@+id/notif_subtitle_small"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true" android:singleLine="true"
android:text="Shonar" android:text="Shonar"
android:textSize="12sp" /> android:textSize="12sp" />
@ -51,49 +53,6 @@
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:singleLine="true" android:singleLine="true"
android:text="00:00" android:text="00:00"
android:textSize="20sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/notif_btn_discard_small"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"
android:contentDescription="Discard"
android:scaleType="center"
android:src="@drawable/ic_notif_close" />
<ImageButton
android:id="@+id/notif_btn_pause_small"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill_accent"
android:contentDescription="Pause"
android:scaleType="center"
android:src="@drawable/ic_notif_pause" />
<ImageButton
android:id="@+id/notif_btn_save_small"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"
android:contentDescription="Save"
android:scaleType="center"
android:src="@drawable/ic_notif_check" />
</LinearLayout>
</LinearLayout>

View file

@ -41,9 +41,17 @@ class RecordingNotificationLayoutTest {
val src = helperSource() val src = helperSource()
assertTrue("must inflate R.layout.notification_recording", src.contains("R.layout.notification_recording")) 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 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 collapsed view", src.contains("setCustomContentView"))
assertTrue("must set same card for expanded view", src.contains("setCustomBigContentView")) 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() { @Test fun ongoingCard_doesNotUseMediaStyle() {
@ -64,4 +72,69 @@ class RecordingNotificationLayoutTest {
assertTrue("layout missing @$id", xml.contains("@+id/$id")) 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,
)
}
} }