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:
parent
31e03262fd
commit
a5201c1f2f
3 changed files with 74 additions and 52 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue