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:
parent
d68aab5336
commit
8cd8861695
5 changed files with 210 additions and 120 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue