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.
|
* the default view already has full controls.
|
||||||
* Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — never
|
* Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — never
|
||||||
* the dark media-player template.
|
* 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 {
|
object RecordingNotificationHelper {
|
||||||
const val CHANNEL_ID = "shonar_recording_ongoing"
|
const val CHANNEL_ID = "shonar_recording_ongoing"
|
||||||
|
|
|
||||||
|
|
@ -338,64 +338,14 @@ class RecordingService : Service() {
|
||||||
val nm = getSystemService(android.app.NotificationManager::class.java)
|
val nm = getSystemService(android.app.NotificationManager::class.java)
|
||||||
runCatching {
|
runCatching {
|
||||||
nm.notify(
|
nm.notify(
|
||||||
activeNotifId,
|
RecordingNotificationHelper.NOTIFICATION_ID,
|
||||||
RecordingNotificationHelper.build(
|
RecordingNotificationHelper.build(
|
||||||
this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard,
|
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 {
|
private fun displayFileName(): String {
|
||||||
val base = RecordingFiles.defaultDisplayName(createdAtMs.takeIf { it > 0 } ?: System.currentTimeMillis())
|
val base = RecordingFiles.defaultDisplayName(createdAtMs.takeIf { it > 0 } ?: System.currentTimeMillis())
|
||||||
return "$base.m4a"
|
return "$base.m4a"
|
||||||
|
|
@ -554,7 +504,6 @@ class RecordingService : Service() {
|
||||||
// a 0-byte temp here; anything with audio is NEVER silently dropped.
|
// a 0-byte temp here; anything with audio is NEVER silently dropped.
|
||||||
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
|
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
|
||||||
val tmp = currentTemp
|
val tmp = currentTemp
|
||||||
releaseMediaSession()
|
|
||||||
shutdownRecorder(releaseOnly = true, keepTemp = true)
|
shutdownRecorder(releaseOnly = true, keepTemp = true)
|
||||||
if (tmp != null && tmp.exists() && tmp.length() == 0L) {
|
if (tmp != null && tmp.exists() && tmp.length() == 0L) {
|
||||||
runCatching { tmp.delete() }
|
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