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.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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,58 @@
|
||||||
<?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:id="@+id/notif_small_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:gravity="center_vertical"
|
||||||
android:padding="10dp">
|
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
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
android:layout_marginStart="8dp"
|
||||||
android:orientation="horizontal">
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
<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>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/notif_timer_small"
|
android:id="@+id/notif_title_small"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="00:00"
|
android:text="Recording"
|
||||||
android:textSize="20sp"
|
android:textSize="14sp"
|
||||||
android:textStyle="bold" />
|
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>
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:id="@+id/notif_timer_small"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:gravity="center"
|
android:singleLine="true"
|
||||||
android:orientation="horizontal">
|
android:text="00:00"
|
||||||
|
android:textSize="16sp"
|
||||||
<ImageButton
|
android:textStyle="bold" />
|
||||||
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>
|
</LinearLayout>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue