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.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

View file

@ -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()
}

View file

@ -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)
}

View file

@ -1,99 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Collapsed: same pills, compact (40dp buttons, tighter padding) so the
system doesn't truncate it back to the stock row. -->
<!-- Collapsed / lock-screen: slim single row (icon | titles | timer).
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"
android:id="@+id/notif_small_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="10dp">
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:paddingStart="4dp"
android:paddingEnd="4dp">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/notif_title_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="Recording"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_subtitle_small"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="Shonar"
android:textSize="12sp" />
</LinearLayout>
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/notif_timer_small"
android:id="@+id/notif_title_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:singleLine="true"
android:text="00:00"
android:textSize="20sp"
android:text="Recording"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_subtitle_small"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:text="Shonar"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
<TextView
android:id="@+id/notif_timer_small"
android:layout_width="wrap_content"
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>
android:layout_marginStart="8dp"
android:singleLine="true"
android:text="00:00"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>

View file

@ -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,
)
}
}