Android: lock ongoing-recording notification to custom card + regression test

The working tree had reverted RecordingNotificationHelper to plain
system MediaStyle, which renders the small 'SHONAR - SHONAR' text row
with truncated filename and no big timer. Restore the user-verified
custom RemoteViews card (mic + Recording + filename + big timer, X /
pause / check pills, same view collapsed and expanded) and drop the
non-compiling MediaSession dead code from RecordingService
(unresolved activeNotifId / mediaSession / alertOnce references).

Add RecordingNotificationLayoutTest to fail the build if MediaStyle
is reintroduced or the timer/pills leave the layout.
This commit is contained in:
avi 2026-09-14 11:14:53 -05:00
commit a5201c1f2f
3 changed files with 74 additions and 52 deletions

View file

@ -19,6 +19,12 @@ import com.shonar.R
* the default view already has full controls.
* Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — 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.
*/
object RecordingNotificationHelper {
const val CHANNEL_ID = "shonar_recording_ongoing"

View file

@ -338,64 +338,14 @@ class RecordingService : Service() {
val nm = getSystemService(android.app.NotificationManager::class.java)
runCatching {
nm.notify(
activeNotifId,
RecordingNotificationHelper.NOTIFICATION_ID,
RecordingNotificationHelper.build(
this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard,
alertOnce = true, sessionToken = mediaSession?.sessionToken,
),
)
}
}
private fun startMediaSession(playing: Boolean, positionMs: Long) {
releaseMediaSession()
val session = android.support.v4.media.session.MediaSessionCompat(this, "ShonarRec").apply {
setFlags(android.support.v4.media.session.MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
setMetadata(
android.support.v4.media.MediaMetadataCompat.Builder()
.putString(
android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE,
"Recording",
)
.putString(
android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ARTIST,
displayFileName(),
)
.build(),
)
setActive(true)
}
mediaSession = session
updateMediaSession(playing, positionMs)
}
private fun updateMediaSession(playing: Boolean, positionMs: Long) {
val session = mediaSession ?: return
val state = if (playing) {
android.support.v4.media.session.PlaybackStateCompat.STATE_PLAYING
} else {
android.support.v4.media.session.PlaybackStateCompat.STATE_PAUSED
}
session.setPlaybackState(
android.support.v4.media.session.PlaybackStateCompat.Builder()
.setActions(
android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY or
android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE or
android.support.v4.media.session.PlaybackStateCompat.ACTION_STOP,
)
.setState(state, positionMs, if (playing) 1f else 0f, SystemClock.elapsedRealtime())
.build(),
)
}
private fun releaseMediaSession() {
runCatching {
mediaSession?.setActive(false)
mediaSession?.release()
}
mediaSession = null
}
private fun displayFileName(): String {
val base = RecordingFiles.defaultDisplayName(createdAtMs.takeIf { it > 0 } ?: System.currentTimeMillis())
return "$base.m4a"
@ -554,7 +504,6 @@ class RecordingService : Service() {
// a 0-byte temp here; anything with audio is NEVER silently dropped.
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
val tmp = currentTemp
releaseMediaSession()
shutdownRecorder(releaseOnly = true, keepTemp = true)
if (tmp != null && tmp.exists() && tmp.length() == 0L) {
runCatching { tmp.delete() }

View file

@ -0,0 +1,67 @@
package com.shonar.recording
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Locks the user-approved ongoing-recording card (verified on-device
* 2026-09-14): a single custom RemoteViews card with big timer + X/pause/
* check pills, same view collapsed and expanded.
*
* A previous revert to system MediaStyle rendered the small
* "SHONAR • SHONAR" text row with truncated filename and no big timer.
* This plain-JVM test guards the source + layout so that regression fails
* the build instead of shipping silently. (NotificationCompat needs the
* Android framework, hence source-level assertions — no Robolectric.)
*/
class RecordingNotificationLayoutTest {
private fun moduleDir(): File {
var dir = File(System.getProperty("user.dir"))
while (dir.resolve("src/main/java/com/shonar/recording/RecordingNotificationHelper.kt").exists().not()) {
dir = dir.parentFile ?: error("cannot locate android/app module dir from ${System.getProperty("user.dir")}")
}
return dir
}
private fun helperSource(): String {
val f = moduleDir().resolve("src/main/java/com/shonar/recording/RecordingNotificationHelper.kt")
assertTrue("helper source missing: $f", f.exists())
return f.readText()
}
private fun layout(name: String): String {
val f = moduleDir().resolve("src/main/res/layout/$name.xml")
assertTrue("layout missing: $f", f.exists())
return f.readText()
}
@Test fun ongoingCard_usesCustomRemoteViewsWithBigTimer() {
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 set same card for collapsed view", src.contains("setCustomContentView"))
assertTrue("must set same card for expanded view", src.contains("setCustomBigContentView"))
}
@Test fun ongoingCard_doesNotUseMediaStyle() {
val src = helperSource()
assertTrue(
"import of MediaStyle renders the old small text row — do not reintroduce",
"import androidx.media.app.NotificationCompat.MediaStyle" !in src,
)
assertTrue(
"MediaStyle() renders the old small text row — do not reintroduce",
"MediaStyle(" !in src,
)
}
@Test fun ongoingCard_layoutHasTimerAndThreePills() {
val xml = layout("notification_recording")
for (id in listOf("notif_title", "notif_subtitle", "notif_timer", "notif_btn_discard", "notif_btn_pause", "notif_btn_save")) {
assertTrue("layout missing @$id", xml.contains("@+id/$id"))
}
}
}