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 8f9fa97..60cb0f9 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt @@ -9,16 +9,16 @@ import android.content.Intent import android.os.Build import android.widget.RemoteViews import androidx.core.app.NotificationCompat -import androidx.media.app.NotificationCompat.MediaStyle import com.shonar.MainActivity import com.shonar.R /** - * System MediaStyle card: "Recording / / 00:06" + 3 icon pills - * X (discard-confirm) / pause-resume (Shonar navy) / check (save). - * System renders the pills (like Easy Voice Recorder) — custom RemoteViews - * get flattened on Android 12+/Graphene, so we don't fight the OS. - * Shonar flavor: mic small icon, navy pause icon, teal check icon. + * Single custom card — Image 1 is the ONLY view. + * mic | "Recording" + filename | big timer, then X / pause / check pills. + * Same RemoteViews for collapsed AND expanded, so there is no duality: + * the default view already has full controls. + * Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — never + * the dark media-player template. */ object RecordingNotificationHelper { const val CHANNEL_ID = "shonar_recording_ongoing" @@ -30,7 +30,9 @@ object RecordingNotificationHelper { val mgr = ctx.getSystemService(NotificationManager::class.java) runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) } val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() - if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH) { + if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH || + existing.sound != null + ) { runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) } } mgr.createNotificationChannel( @@ -38,7 +40,11 @@ object RecordingNotificationHelper { CHANNEL_ID, ctx.getString(R.string.notif_channel_recording), NotificationManager.IMPORTANCE_HIGH, - ).apply { description = ctx.getString(R.string.notif_channel_recording_desc) }, + ).apply { + description = ctx.getString(R.string.notif_channel_recording_desc) + setSound(null, null) + enableVibration(false) + }, ) } @@ -69,6 +75,31 @@ object RecordingNotificationHelper { val paused = phase == RecordingSnapshot.Phase.PAUSED val elapsed = formatElapsed(elapsedMs) + val card = RemoteViews(ctx.packageName, R.layout.notification_recording) + card.setTextViewText(R.id.notif_title, "Recording") + card.setTextViewText(R.id.notif_subtitle, fileName) + card.setTextViewText(R.id.notif_timer, elapsed) + card.setImageViewResource( + R.id.notif_btn_pause, + if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause, + ) + card.setOnClickPendingIntent( + R.id.notif_btn_discard, + svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21), + ) + card.setOnClickPendingIntent( + R.id.notif_btn_pause, + svcIntent( + ctx, + if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22, + ), + ) + card.setOnClickPendingIntent( + R.id.notif_btn_save, + svcIntent(ctx, RecordingService.ACTION_SAVE, 24), + ) + card.setOnClickPendingIntent(R.id.notif_icon, openApp) + return NotificationCompat.Builder(ctx, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_mic) .setContentTitle("Recording") @@ -76,13 +107,16 @@ object RecordingNotificationHelper { .setSubText("SHONAR") .setOngoing(true) .setOnlyAlertOnce(true) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(openApp) .setShowWhen(false) .setPriority(NotificationCompat.PRIORITY_HIGH) - .addAction(R.drawable.ic_notif_close, "Discard", - svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21)) + // Fallback actions (Android Auto, watches, if custom is ever stripped). + .addAction( + R.drawable.ic_notif_close, "Discard", + svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21), + ) .addAction( if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause, if (paused) "Resume" else "Pause", @@ -91,13 +125,18 @@ object RecordingNotificationHelper { if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22, ), ) - .addAction(R.drawable.ic_notif_check, "Save", - svcIntent(ctx, RecordingService.ACTION_SAVE, 24)) - .setStyle(MediaStyle().setShowActionsInCompactView(0, 1, 2)) + .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) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) .build() } - /** "Delete current recording? No / Yes" — recording keeps running underneath. */ + /** "Delete current recording? No / Yes" — same single-card pattern. */ private fun buildConfirm(ctx: Context, openApp: PendingIntent): Notification { val card = RemoteViews(ctx.packageName, R.layout.notification_confirm_delete) card.setOnClickPendingIntent( @@ -110,10 +149,10 @@ object RecordingNotificationHelper { return NotificationCompat.Builder(ctx, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_mic) .setContentTitle("Delete current recording?") - .setContentText("No • Yes") + .setContentText("Shonar") .setOngoing(true) .setOnlyAlertOnce(true) - .setCategory(NotificationCompat.CATEGORY_STATUS) + .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(openApp) .setShowWhen(false) @@ -121,10 +160,14 @@ object RecordingNotificationHelper { .setCustomBigContentView(card) .setStyle(NotificationCompat.DecoratedCustomViewStyle()) .setPriority(NotificationCompat.PRIORITY_HIGH) - .addAction(R.drawable.ic_notif_close, "No", - svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 43)) - .addAction(R.drawable.ic_notif_check, "Yes", - svcIntent(ctx, RecordingService.ACTION_CANCEL, 44)) + .addAction( + R.drawable.ic_notif_close, "No", + svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 43), + ) + .addAction( + R.drawable.ic_notif_check, "Yes", + svcIntent(ctx, RecordingService.ACTION_CANCEL, 44), + ) .build() } 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 5a50383..28a4d73 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingService.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt @@ -112,6 +112,7 @@ class RecordingService : Service() { runCatching { temp.parentFile?.mkdirs() } try { + createdAtMs = System.currentTimeMillis() val notif = RecordingNotificationHelper.build( this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(), @@ -158,7 +159,6 @@ class RecordingService : Service() { currentId = id currentTemp = temp - createdAtMs = System.currentTimeMillis() accumulatedMs = 0L resumedAtElapsed = SystemClock.elapsedRealtime() acquireWake() @@ -338,14 +338,64 @@ class RecordingService : Service() { val nm = getSystemService(android.app.NotificationManager::class.java) runCatching { nm.notify( - RecordingNotificationHelper.NOTIFICATION_ID, + activeNotifId, 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" @@ -504,6 +554,7 @@ 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/main/res/layout/notification_recording.xml b/android/app/src/main/res/layout/notification_recording.xml index 96d3e8d..f171d4f 100644 --- a/android/app/src/main/res/layout/notification_recording.xml +++ b/android/app/src/main/res/layout/notification_recording.xml @@ -1,12 +1,11 @@ - + + android:padding="14dp"> + android:textSize="14sp" /> - + - - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + + - - - + android:layout_marginTop="8dp" + android:gravity="center" + android:orientation="horizontal"> + + + + + + +