This commit is contained in:
parent
cd173d80b8
commit
c35e6935b5
23 changed files with 188 additions and 85 deletions
|
|
@ -16,9 +16,11 @@ import com.shonar.R
|
|||
* Custom card pair in a decorated notification — the user-verified look, no
|
||||
* system media template.
|
||||
* Expanded: mic | "Recording" + filename | big timer, then X / pause /
|
||||
* check pills. Collapsed (shade row, lock screen): slim header-only card
|
||||
* (icon | titles | small timer, tap opens app) — the lock shade clips
|
||||
* anything taller, so pills live only in the expanded card.
|
||||
* check pills. Collapsed (shade row, lock screen, heads-up): the same card
|
||||
* shrunk to one row — icon | titles | timer | compact X / pause / check —
|
||||
* so transport is always one tap away with no expanding (user-requested
|
||||
* 2026-09-18). The collapsed slot clips anything taller, so the row stays
|
||||
* single-line.
|
||||
* DecoratedCustomViewStyle + CATEGORY_SERVICE: light system chrome around
|
||||
* the cards — never the dark media-player template.
|
||||
*
|
||||
|
|
@ -109,6 +111,14 @@ object RecordingNotificationHelper {
|
|||
fileName: String = "Shonar",
|
||||
confirmingDiscard: Boolean = false,
|
||||
alertOnce: Boolean = true,
|
||||
// First post (startForeground banner) goes out plain: on several
|
||||
// SystemUI builds a custom RemoteViews first-bind stalls ~10s
|
||||
// before anything displays (traced 2026-09-18: post 17:03:17.4,
|
||||
// first evaluation 17:03:27.5; plain-template recorder apps on the
|
||||
// same phone display instantly). The 1s ticker swaps in the rich
|
||||
// card, so the plain row lives ~1s. Stock template + timer-first
|
||||
// text keeps title and timer visible in that window.
|
||||
rich: Boolean = true,
|
||||
): Notification {
|
||||
val openApp = PendingIntent.getActivity(
|
||||
ctx, 10,
|
||||
|
|
@ -146,12 +156,33 @@ object RecordingNotificationHelper {
|
|||
)
|
||||
card.setOnClickPendingIntent(R.id.notif_icon, openApp)
|
||||
|
||||
// Collapsed (shade row, lock screen): slim header-only card — the
|
||||
// lock shade clips anything taller. Expanded: full card with pills.
|
||||
// Collapsed (shade row, lock screen, heads-up): slim single row —
|
||||
// the collapsed slot clips anything taller. Compact X / pause /
|
||||
// check pills ride in the row so transport never needs expanding.
|
||||
// Expanded: full card with big pills.
|
||||
val small = RemoteViews(ctx.packageName, R.layout.notification_recording_small)
|
||||
small.setTextViewText(R.id.notif_title_small, "Recording")
|
||||
small.setTextViewText(R.id.notif_subtitle_small, fileName)
|
||||
small.setTextViewText(R.id.notif_timer_small, elapsed)
|
||||
small.setImageViewResource(
|
||||
R.id.notif_btn_pause_small,
|
||||
if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause,
|
||||
)
|
||||
small.setOnClickPendingIntent(
|
||||
R.id.notif_btn_discard_small,
|
||||
svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 31),
|
||||
)
|
||||
small.setOnClickPendingIntent(
|
||||
R.id.notif_btn_pause_small,
|
||||
svcIntent(
|
||||
ctx,
|
||||
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 32,
|
||||
),
|
||||
)
|
||||
small.setOnClickPendingIntent(
|
||||
R.id.notif_btn_save_small,
|
||||
activitySaveIntent(ctx),
|
||||
)
|
||||
small.setOnClickPendingIntent(R.id.notif_small_root, openApp)
|
||||
|
||||
return NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
|
|
@ -177,17 +208,21 @@ object RecordingNotificationHelper {
|
|||
// custom views — watches, Auto — get title/text + tap-to-open;
|
||||
// the confirm-delete card below keeps its No/Yes actions.)
|
||||
// Slim header collapsed; full pills expanded. DecoratedCustomViewStyle
|
||||
// is REQUIRED: without it the system drops both custom views and
|
||||
// the shade is stuck on the plain template (title/text/actions,
|
||||
// expansion broken) — seen on targetSdk 35 / recent platform
|
||||
// versions. The header adds a small app icon next to the card's
|
||||
// own mic; that duplication is the price of the card rendering.
|
||||
.setCustomContentView(small)
|
||||
.setCustomBigContentView(card)
|
||||
// Heads-up uses this on the first post; without it the system
|
||||
// pops the plain template for ~5s until the shade row takes over.
|
||||
.setCustomHeadsUpContentView(small)
|
||||
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
||||
// is REQUIRED for the rich card: without it the system drops both
|
||||
// custom views and the shade is stuck on the plain template
|
||||
// (title/text/actions, expansion broken) — seen on targetSdk 35 /
|
||||
// recent platform versions. The header adds a small app icon next
|
||||
// to the card's own mic; that duplication is the price of the
|
||||
// card rendering.
|
||||
.apply {
|
||||
if (!rich) return@apply // plain first post: stock template only
|
||||
setCustomContentView(small)
|
||||
setCustomBigContentView(card)
|
||||
// Heads-up uses this on the first post; without it the system
|
||||
// pops the plain template for ~5s until the shade row takes over.
|
||||
setCustomHeadsUpContentView(small)
|
||||
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -315,13 +315,15 @@ class RecordingService : Service() {
|
|||
/**
|
||||
* Post a foreground notification immediately. Called at the top of
|
||||
* onStartCommand so the banner appears instantly — no waiting for
|
||||
* MediaRecorder setup, file I/O, or DB access.
|
||||
* MediaRecorder setup, file I/O, or DB access. Posts the PLAIN template
|
||||
* first (custom RemoteViews first-bind stalls ~10s on some SystemUI
|
||||
* builds); the 1s ticker swaps in the rich card.
|
||||
*/
|
||||
private fun ensureForeground() {
|
||||
try {
|
||||
val notif = RecordingNotificationHelper.build(
|
||||
this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(),
|
||||
alertOnce = false,
|
||||
alertOnce = false, rich = false,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.shonar.settings.BuiltInSettings
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -47,6 +48,8 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||
class StartRecordingActivity : Activity() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
/** Set when the trampoline is TOP-resumed (uid counts as foreground). */
|
||||
private val topResumed = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -59,25 +62,33 @@ class StartRecordingActivity : Activity() {
|
|||
finish()
|
||||
return
|
||||
}
|
||||
// Active session: mirror the in-app Stop button (save; the rename
|
||||
// sheet opens on next app launch via RenamePendingHolder).
|
||||
if (RecordingService.snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
|
||||
RecordingService.command(this, RecordingService.ACTION_SAVE)
|
||||
ShonarWidgetUpdater.refresh(this)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
val app = application as ShonarApplication
|
||||
// Same consent gate as the in-app Record button. Bound the read
|
||||
// so a wedged store degrades to opening the app, never a hang.
|
||||
val consented = withTimeoutOrNull(CONSENT_WAIT_MS) {
|
||||
runCatching {
|
||||
app.settingsManager.ensureLoaded()
|
||||
app.settingsManager.bool(BuiltInSettings.CONSENT)
|
||||
}.getOrDefault(false)
|
||||
} == true
|
||||
if (!consented) {
|
||||
val consentedDeferred = async {
|
||||
withTimeoutOrNull(CONSENT_WAIT_MS) {
|
||||
runCatching {
|
||||
app.settingsManager.ensureLoaded()
|
||||
app.settingsManager.bool(BuiltInSettings.CONSENT)
|
||||
}.getOrDefault(false)
|
||||
} == true
|
||||
}
|
||||
// Wait until TOP-resumed so the START/SAVE call below is
|
||||
// foreground-originated. Sent from onCreate, the system stamps
|
||||
// the intent FROM_BACKGROUND (activity not yet resumed) and
|
||||
// defers the notification's interruption ~10s — traced
|
||||
// on-device: FGS allowed +0.1s, first buzz +10.1s, every session.
|
||||
// Bound: a missed resume signal must never strand the activity.
|
||||
withTimeoutOrNull(RESUME_WAIT_MS) { topResumed.first { it } }
|
||||
val consented = withTimeoutOrNull(CONSENT_WAIT_MS) { consentedDeferred.await() } == true
|
||||
// Active session: mirror the in-app Stop button (save; the rename
|
||||
// sheet opens on next app launch via RenamePendingHolder).
|
||||
if (RecordingService.snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
|
||||
RecordingService.command(
|
||||
this@StartRecordingActivity, RecordingService.ACTION_SAVE,
|
||||
)
|
||||
} else if (!consented) {
|
||||
startActivity(
|
||||
Intent(this@StartRecordingActivity, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
|
|
@ -100,6 +111,18 @@ class StartRecordingActivity : Activity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Pre-29 has no top-resumed signal (and no background-FGS
|
||||
// restriction); onResume is sufficient there.
|
||||
if (android.os.Build.VERSION.SDK_INT < 29) topResumed.value = true
|
||||
}
|
||||
|
||||
override fun onTopResumedActivityChanged(isTopResumed: Boolean) {
|
||||
super.onTopResumedActivityChanged(isTopResumed)
|
||||
if (isTopResumed) topResumed.value = true
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// singleInstance re-tap while a waiter is pending: the first waiter
|
||||
|
|
@ -115,5 +138,6 @@ class StartRecordingActivity : Activity() {
|
|||
companion object {
|
||||
private const val TRAMPOLINE_WAIT_MS = 2_500L
|
||||
private const val CONSENT_WAIT_MS = 2_000L
|
||||
private const val RESUME_WAIT_MS = 2_000L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Collapsed / lock-screen: slim single row (icon | titles | timer).
|
||||
No pills here — the lock shade constrains compact height and clips
|
||||
anything taller. Full X / pause / check pills live in the expanded
|
||||
big card. Tapping the row opens the app. RemoteViews-safe views only. -->
|
||||
<!-- Collapsed / lock-screen / heads-up: slim single row (icon | titles |
|
||||
timer | compact X / pause / check). One row only — the collapsed slot
|
||||
clips anything taller — so the transport controls are always one tap
|
||||
away with no expanding needed. Full-size pills live in the expanded
|
||||
big card. Tapping empty row space opens the app. RemoteViews-safe
|
||||
views only. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/notif_small_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp">
|
||||
|
||||
|
|
@ -34,7 +36,7 @@
|
|||
android:singleLine="true"
|
||||
android:text="Recording"
|
||||
android:textColor="@color/notif_text"
|
||||
android:textSize="14sp"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
|
|
@ -45,7 +47,7 @@
|
|||
android:singleLine="true"
|
||||
android:text="Shonar"
|
||||
android:textColor="@color/notif_text"
|
||||
android:textSize="12sp" />
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
|
|
@ -56,6 +58,36 @@
|
|||
android:singleLine="true"
|
||||
android:text="00:00"
|
||||
android:textColor="@color/notif_text"
|
||||
android:textSize="16sp"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/notif_btn_discard_small"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="8dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="6dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:background="@drawable/notif_pill"
|
||||
android:contentDescription="Save"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_notif_check" />
|
||||
</LinearLayout>
|
||||
|
|
|
|||
|
|
@ -81,10 +81,20 @@ class RecordingNotificationLayoutTest {
|
|||
for (id in listOf("notif_title_small", "notif_subtitle_small", "notif_timer_small")) {
|
||||
assertTrue("collapsed layout missing @$id", xml.contains("@+id/$id"))
|
||||
}
|
||||
assertTrue(
|
||||
"collapsed card must stay pill-free (lock shade clips taller cards)",
|
||||
"notif_btn_discard_small" !in xml && "notif_btn_pause_small" !in xml,
|
||||
)
|
||||
// User-requested 2026-09-18: collapsed row carries compact transport
|
||||
// pills (single row — the collapsed slot clips anything taller) so
|
||||
// recording controls never need expanding. Big-card ids must stay
|
||||
// out of the small layout (exact-match: no substring confusion with
|
||||
// the _small ids).
|
||||
for (id in listOf("notif_btn_discard_small", "notif_btn_pause_small", "notif_btn_save_small")) {
|
||||
assertTrue("collapsed layout missing @$id", xml.contains("@+id/$id"))
|
||||
}
|
||||
for (id in listOf("notif_btn_discard\"", "notif_btn_pause\"", "notif_btn_save\"")) {
|
||||
assertTrue("small layout must not contain big-card @$id", !xml.contains("@+id/$id"))
|
||||
}
|
||||
assertTrue("must wire small pause button", src.contains("R.id.notif_btn_pause_small"))
|
||||
assertTrue("must wire small save button", src.contains("R.id.notif_btn_save_small"))
|
||||
assertTrue("must wire small discard button", src.contains("R.id.notif_btn_discard_small"))
|
||||
}
|
||||
|
||||
@Test fun startPath_postsForegroundNotificationOnlyOnce() {
|
||||
|
|
@ -143,6 +153,28 @@ class RecordingNotificationLayoutTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test fun firstPost_isPlainTemplateForInstantDisplay() {
|
||||
// Custom RemoteViews first-bind stalls ~10s before anything displays
|
||||
// on some SystemUI builds (traced on-device 2026-09-18), while plain
|
||||
// stock-template posts display instantly. The startForeground banner
|
||||
// must go out plain; the 1s ticker swaps in the rich card.
|
||||
val src = helperSource()
|
||||
assertTrue(
|
||||
"build() must offer the plain-first-post flag",
|
||||
src.contains("rich: Boolean"),
|
||||
)
|
||||
assertTrue(
|
||||
"plain post must skip all three custom views",
|
||||
src.contains("if (!rich)"),
|
||||
)
|
||||
val f = moduleDir().resolve("src/main/java/com/shonar/recording/RecordingService.kt")
|
||||
assertTrue("service source missing: $f", f.exists())
|
||||
assertTrue(
|
||||
"startForeground banner must request the plain post",
|
||||
f.readText().contains("rich = false"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun lockscreen_showsSimplifiedPublicVersion() {
|
||||
// Secure lockscreen shows the single-icon template row instead of
|
||||
// the full card (no doubled icons, no pills behind the lock).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue