diff --git a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt index 60cb0f9..31b40a2 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt @@ -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" diff --git a/android/app/src/main/java/com/shonar/recording/RecordingService.kt b/android/app/src/main/java/com/shonar/recording/RecordingService.kt index 28a4d73..23b01b0 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingService.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt @@ -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() } diff --git a/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt new file mode 100644 index 0000000..dd1b6e2 --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt @@ -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")) + } + } +}