Android WIP: recording notification layout + service updates (in-flight from prior session)

This commit is contained in:
avi 2026-09-14 10:37:09 -05:00
commit 67b1eb65c7
4 changed files with 214 additions and 70 deletions

View file

@ -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 / <file> / 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()
}

View file

@ -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() }

View file

@ -1,12 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shonar media-card: mic | titles | timer, then X / pause / check pills.
RemoteViews-safe views only. System controls the card background;
Shonar flavor comes from the navy mic disc + navy pause pill + teal check. -->
<!-- Top-card clone: mic | titles | big timer, then 3 wide ovals.
RemoteViews-safe views only. No system header (fully custom). -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
android:padding="14dp">
<LinearLayout
android:layout_width="match_parent"
@ -16,9 +15,10 @@
<ImageView
android:id="@+id/notif_icon"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/widget_record"
android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
@ -32,16 +32,18 @@
android:id="@+id/notif_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notif_recording"
android:textSize="15sp"
android:singleLine="true"
android:text="Recording"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_subtitle"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="Shonar"
android:textSize="13sp" />
android:textSize="14sp" />
</LinearLayout>
<TextView
@ -51,21 +53,21 @@
android:layout_marginStart="8dp"
android:singleLine="true"
android:text="00:00"
android:textSize="22sp"
android:textSize="28sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginTop="12dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/notif_btn_discard"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_height="52dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"
@ -76,7 +78,7 @@
<ImageButton
android:id="@+id/notif_btn_pause"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_height="52dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
@ -88,7 +90,7 @@
<ImageButton
android:id="@+id/notif_btn_save"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_height="52dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"

View file

@ -1,51 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Collapsed row: mic | titles | timer. No buttons (expand for pills). -->
<!-- Collapsed: same pills, compact (40dp buttons, tighter padding) so the
system doesn't truncate it back to the stock row. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:orientation="vertical"
android:padding="10dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/notif_title_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="Recording"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_subtitle_small"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="Shonar"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/notif_title_small"
android:id="@+id/notif_timer_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:singleLine="true"
android:text="@string/notif_recording"
android:textSize="14sp"
android:text="00:00"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_subtitle_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="Shonar"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/notif_timer_small"
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:singleLine="true"
android:text="00:00"
android:textSize="20sp"
android:textStyle="bold" />
android:layout_marginTop="8dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/notif_btn_discard_small"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"
android:contentDescription="Discard"
android:scaleType="center"
android:src="@drawable/ic_notif_close" />
<ImageButton
android:id="@+id/notif_btn_pause_small"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill_accent"
android:contentDescription="Pause"
android:scaleType="center"
android:src="@drawable/ic_notif_pause" />
<ImageButton
android:id="@+id/notif_btn_save_small"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"
android:contentDescription="Save"
android:scaleType="center"
android:src="@drawable/ic_notif_check" />
</LinearLayout>
</LinearLayout>