Checkpoint: current Shonar state
Some checks failed
CI / android (push) Has been cancelled

This commit is contained in:
avi 2026-09-23 18:58:28 -05:00
commit c35e6935b5
23 changed files with 188 additions and 85 deletions

55
.gitignore vendored
View file

@ -1,42 +1,19 @@
# --- Python --- node_modules/
__pycache__/ .next/
*.py[cod]
.venv/
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
dist/ dist/
build/ build/
venv/
# --- Secrets & data --- .venv/
.env __pycache__/
.env.* *.pyc
!.env.example .gradle/
data/ target/
*.sqlite3 *.log
# --- Android / Gradle ---
android/.gradle/
android/.kotlin/
android/build/
android/app/build/
*.apk
*.aab
*.keystore
local.properties
# --- Desktop (Compose for Desktop) ---
desktop/.gradle/
desktop/.kotlin/
desktop/build/
desktop/app/build/
# --- IDE / OS ---
.idea/
.vscode/
*.swp
.DS_Store .DS_Store
.env
# --- Release signing (local-only, never commit) --- *.key
android/keystores/*.jks *.pem
*.jks
tsconfig.tsbuildinfo
instance/
captures/

BIN
.icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
DesignReferenceShonar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -16,9 +16,11 @@ import com.shonar.R
* Custom card pair in a decorated notification — the user-verified look, no * Custom card pair in a decorated notification — the user-verified look, no
* system media template. * system media template.
* Expanded: mic | "Recording" + filename | big timer, then X / pause / * Expanded: mic | "Recording" + filename | big timer, then X / pause /
* check pills. Collapsed (shade row, lock screen): slim header-only card * check pills. Collapsed (shade row, lock screen, heads-up): the same card
* (icon | titles | small timer, tap opens app) — the lock shade clips * shrunk to one row — icon | titles | timer | compact X / pause / check —
* anything taller, so pills live only in the expanded card. * 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 * DecoratedCustomViewStyle + CATEGORY_SERVICE: light system chrome around
* the cards — never the dark media-player template. * the cards — never the dark media-player template.
* *
@ -109,6 +111,14 @@ object RecordingNotificationHelper {
fileName: String = "Shonar", fileName: String = "Shonar",
confirmingDiscard: Boolean = false, confirmingDiscard: Boolean = false,
alertOnce: Boolean = true, 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 { ): Notification {
val openApp = PendingIntent.getActivity( val openApp = PendingIntent.getActivity(
ctx, 10, ctx, 10,
@ -146,12 +156,33 @@ object RecordingNotificationHelper {
) )
card.setOnClickPendingIntent(R.id.notif_icon, openApp) card.setOnClickPendingIntent(R.id.notif_icon, openApp)
// Collapsed (shade row, lock screen): slim header-only card — the // Collapsed (shade row, lock screen, heads-up): slim single row —
// lock shade clips anything taller. Expanded: full card with pills. // 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) val small = RemoteViews(ctx.packageName, R.layout.notification_recording_small)
small.setTextViewText(R.id.notif_title_small, "Recording") small.setTextViewText(R.id.notif_title_small, "Recording")
small.setTextViewText(R.id.notif_subtitle_small, fileName) small.setTextViewText(R.id.notif_subtitle_small, fileName)
small.setTextViewText(R.id.notif_timer_small, elapsed) 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) small.setOnClickPendingIntent(R.id.notif_small_root, openApp)
return NotificationCompat.Builder(ctx, CHANNEL_ID) return NotificationCompat.Builder(ctx, CHANNEL_ID)
@ -177,17 +208,21 @@ object RecordingNotificationHelper {
// custom views — watches, Auto — get title/text + tap-to-open; // custom views — watches, Auto — get title/text + tap-to-open;
// the confirm-delete card below keeps its No/Yes actions.) // the confirm-delete card below keeps its No/Yes actions.)
// Slim header collapsed; full pills expanded. DecoratedCustomViewStyle // Slim header collapsed; full pills expanded. DecoratedCustomViewStyle
// is REQUIRED: without it the system drops both custom views and // is REQUIRED for the rich card: without it the system drops both
// the shade is stuck on the plain template (title/text/actions, // custom views and the shade is stuck on the plain template
// expansion broken) — seen on targetSdk 35 / recent platform // (title/text/actions, expansion broken) — seen on targetSdk 35 /
// versions. The header adds a small app icon next to the card's // recent platform versions. The header adds a small app icon next
// own mic; that duplication is the price of the card rendering. // to the card's own mic; that duplication is the price of the
.setCustomContentView(small) // card rendering.
.setCustomBigContentView(card) .apply {
// Heads-up uses this on the first post; without it the system if (!rich) return@apply // plain first post: stock template only
// pops the plain template for ~5s until the shade row takes over. setCustomContentView(small)
.setCustomHeadsUpContentView(small) setCustomBigContentView(card)
.setStyle(NotificationCompat.DecoratedCustomViewStyle()) // 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() .build()
} }

View file

@ -315,13 +315,15 @@ class RecordingService : Service() {
/** /**
* Post a foreground notification immediately. Called at the top of * Post a foreground notification immediately. Called at the top of
* onStartCommand so the banner appears instantly — no waiting for * 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() { private fun ensureForeground() {
try { try {
val notif = RecordingNotificationHelper.build( val notif = RecordingNotificationHelper.build(
this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(), this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(),
alertOnce = false, alertOnce = false, rich = false,
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground( startForeground(

View file

@ -12,6 +12,7 @@ import com.shonar.settings.BuiltInSettings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -47,6 +48,8 @@ import kotlinx.coroutines.withTimeoutOrNull
class StartRecordingActivity : Activity() { class StartRecordingActivity : Activity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -59,25 +62,33 @@ class StartRecordingActivity : Activity() {
finish() finish()
return 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 { scope.launch {
val app = application as ShonarApplication val app = application as ShonarApplication
// Same consent gate as the in-app Record button. Bound the read // Same consent gate as the in-app Record button. Bound the read
// so a wedged store degrades to opening the app, never a hang. // so a wedged store degrades to opening the app, never a hang.
val consented = withTimeoutOrNull(CONSENT_WAIT_MS) { val consentedDeferred = async {
runCatching { withTimeoutOrNull(CONSENT_WAIT_MS) {
app.settingsManager.ensureLoaded() runCatching {
app.settingsManager.bool(BuiltInSettings.CONSENT) app.settingsManager.ensureLoaded()
}.getOrDefault(false) app.settingsManager.bool(BuiltInSettings.CONSENT)
} == true }.getOrDefault(false)
if (!consented) { } == 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( startActivity(
Intent(this@StartRecordingActivity, MainActivity::class.java) Intent(this@StartRecordingActivity, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), .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) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
// singleInstance re-tap while a waiter is pending: the first waiter // singleInstance re-tap while a waiter is pending: the first waiter
@ -115,5 +138,6 @@ class StartRecordingActivity : Activity() {
companion object { companion object {
private const val TRAMPOLINE_WAIT_MS = 2_500L private const val TRAMPOLINE_WAIT_MS = 2_500L
private const val CONSENT_WAIT_MS = 2_000L private const val CONSENT_WAIT_MS = 2_000L
private const val RESUME_WAIT_MS = 2_000L
} }
} }

View file

@ -1,16 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Collapsed / lock-screen: slim single row (icon | titles | timer). <!-- Collapsed / lock-screen / heads-up: slim single row (icon | titles |
No pills here — the lock shade constrains compact height and clips timer | compact X / pause / check). One row only — the collapsed slot
anything taller. Full X / pause / check pills live in the expanded clips anything taller — so the transport controls are always one tap
big card. Tapping the row opens the app. RemoteViews-safe views only. --> 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" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/notif_small_root" android:id="@+id/notif_small_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingTop="6dp" android:paddingTop="4dp"
android:paddingBottom="6dp" android:paddingBottom="4dp"
android:paddingStart="4dp" android:paddingStart="4dp"
android:paddingEnd="4dp"> android:paddingEnd="4dp">
@ -34,7 +36,7 @@
android:singleLine="true" android:singleLine="true"
android:text="Recording" android:text="Recording"
android:textColor="@color/notif_text" android:textColor="@color/notif_text"
android:textSize="14sp" android:textSize="13sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
@ -45,7 +47,7 @@
android:singleLine="true" android:singleLine="true"
android:text="Shonar" android:text="Shonar"
android:textColor="@color/notif_text" android:textColor="@color/notif_text"
android:textSize="12sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView
@ -56,6 +58,36 @@
android:singleLine="true" android:singleLine="true"
android:text="00:00" android:text="00:00"
android:textColor="@color/notif_text" android:textColor="@color/notif_text"
android:textSize="16sp" android:textSize="14sp"
android:textStyle="bold" /> 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> </LinearLayout>

View file

@ -81,10 +81,20 @@ class RecordingNotificationLayoutTest {
for (id in listOf("notif_title_small", "notif_subtitle_small", "notif_timer_small")) { for (id in listOf("notif_title_small", "notif_subtitle_small", "notif_timer_small")) {
assertTrue("collapsed layout missing @$id", xml.contains("@+id/$id")) assertTrue("collapsed layout missing @$id", xml.contains("@+id/$id"))
} }
assertTrue( // User-requested 2026-09-18: collapsed row carries compact transport
"collapsed card must stay pill-free (lock shade clips taller cards)", // pills (single row — the collapsed slot clips anything taller) so
"notif_btn_discard_small" !in xml && "notif_btn_pause_small" !in xml, // 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() { @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() { @Test fun lockscreen_showsSimplifiedPublicVersion() {
// Secure lockscreen shows the single-icon template row instead of // Secure lockscreen shows the single-icon template row instead of
// the full card (no doubled icons, no pills behind the lock). // the full card (no doubled icons, no pills behind the lock).

1
android/local.properties Normal file
View file

@ -0,0 +1 @@
sdk.dir=/home/avi/.local/android-sdk