diff --git a/.gitignore b/.gitignore
index 508abf7..dc84843 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,42 +1,19 @@
-# --- Python ---
-__pycache__/
-*.py[cod]
-.venv/
-*.egg-info/
-.pytest_cache/
-.mypy_cache/
-.ruff_cache/
+node_modules/
+.next/
dist/
build/
-
-# --- Secrets & data ---
-.env
-.env.*
-!.env.example
-data/
-*.sqlite3
-
-# --- 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
+venv/
+.venv/
+__pycache__/
+*.pyc
+.gradle/
+target/
+*.log
.DS_Store
-
-# --- Release signing (local-only, never commit) ---
-android/keystores/*.jks
+.env
+*.key
+*.pem
+*.jks
+tsconfig.tsbuildinfo
+instance/
+captures/
diff --git a/.icon.png b/.icon.png
new file mode 100644
index 0000000..79dfc77
Binary files /dev/null and b/.icon.png differ
diff --git a/DesignReferenceShonar.png b/DesignReferenceShonar.png
new file mode 100644
index 0000000..19bba55
Binary files /dev/null and b/DesignReferenceShonar.png differ
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 4c00d65..4a0c6b2 100644
--- a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt
+++ b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt
@@ -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()
}
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 24b4353..bdf5961 100644
--- a/android/app/src/main/java/com/shonar/recording/RecordingService.kt
+++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt
@@ -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(
diff --git a/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt b/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt
index 76d27e9..6af2ea9 100644
--- a/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt
+++ b/android/app/src/main/java/com/shonar/widget/StartRecordingActivity.kt
@@ -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
}
}
diff --git a/android/app/src/main/res/layout/notification_recording_small.xml b/android/app/src/main/res/layout/notification_recording_small.xml
index af08b84..5147577 100644
--- a/android/app/src/main/res/layout/notification_recording_small.xml
+++ b/android/app/src/main/res/layout/notification_recording_small.xml
@@ -1,16 +1,18 @@
-
+
@@ -34,7 +36,7 @@
android:singleLine="true"
android:text="Recording"
android:textColor="@color/notif_text"
- android:textSize="14sp"
+ android:textSize="13sp"
android:textStyle="bold" />
+ android:textSize="11sp" />
+
+
+
+
+
+
diff --git a/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt
index 7a169e8..0581917 100644
--- a/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt
+++ b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt
@@ -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).
diff --git a/android/local.properties b/android/local.properties
new file mode 100644
index 0000000..13f38ad
--- /dev/null
+++ b/android/local.properties
@@ -0,0 +1 @@
+sdk.dir=/home/avi/.local/android-sdk
diff --git a/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/0b90341f-9895-4a80-a3cd-119603e6bc83.m4a b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/0b90341f-9895-4a80-a3cd-119603e6bc83.m4a
new file mode 100644
index 0000000..49f126d
Binary files /dev/null and b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/0b90341f-9895-4a80-a3cd-119603e6bc83.m4a differ
diff --git a/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/16e175a5-03e0-4138-af93-895e93679deb.m4a b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/16e175a5-03e0-4138-af93-895e93679deb.m4a
new file mode 100644
index 0000000..34e4e6f
Binary files /dev/null and b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/16e175a5-03e0-4138-af93-895e93679deb.m4a differ
diff --git a/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/3b65a243-ad1e-43ee-9ab7-1dc6871d90a5.m4a b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/3b65a243-ad1e-43ee-9ab7-1dc6871d90a5.m4a
new file mode 100644
index 0000000..741fff7
Binary files /dev/null and b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/3b65a243-ad1e-43ee-9ab7-1dc6871d90a5.m4a differ
diff --git a/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/5ce3e6c0-8224-4bcc-af93-065d78949be7.m4a b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/5ce3e6c0-8224-4bcc-af93-065d78949be7.m4a
new file mode 100644
index 0000000..e7a441d
Binary files /dev/null and b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/5ce3e6c0-8224-4bcc-af93-065d78949be7.m4a differ
diff --git a/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/8ecfd8c1-21b0-4632-ad60-047ea7967375.m4a b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/8ecfd8c1-21b0-4632-ad60-047ea7967375.m4a
new file mode 100644
index 0000000..d115405
Binary files /dev/null and b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/8ecfd8c1-21b0-4632-ad60-047ea7967375.m4a differ
diff --git a/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/bec55d1e-9611-4019-bfe6-4987513cf088.m4a b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/bec55d1e-9611-4019-bfe6-4987513cf088.m4a
new file mode 100644
index 0000000..a6511d7
Binary files /dev/null and b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/bec55d1e-9611-4019-bfe6-4987513cf088.m4a differ
diff --git a/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/e995109b-09eb-46ab-83b4-92eb2e836ad2.m4a b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/e995109b-09eb-46ab-83b4-92eb2e836ad2.m4a
new file mode 100644
index 0000000..9e7f76a
Binary files /dev/null and b/data/storage/recordings/b9d1a208-be6b-413c-8501-2461f8810fba/e995109b-09eb-46ab-83b4-92eb2e836ad2.m4a differ
diff --git a/data/storage/uploads/75d1a1d5-f6d1-489c-a46e-e7e9a95af885/000000.part b/data/storage/uploads/75d1a1d5-f6d1-489c-a46e-e7e9a95af885/000000.part
new file mode 100644
index 0000000..2bbb7b8
Binary files /dev/null and b/data/storage/uploads/75d1a1d5-f6d1-489c-a46e-e7e9a95af885/000000.part differ
diff --git a/data/storage/uploads/75d1a1d5-f6d1-489c-a46e-e7e9a95af885/000001.part b/data/storage/uploads/75d1a1d5-f6d1-489c-a46e-e7e9a95af885/000001.part
new file mode 100644
index 0000000..8376d4a
Binary files /dev/null and b/data/storage/uploads/75d1a1d5-f6d1-489c-a46e-e7e9a95af885/000001.part differ
diff --git a/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000000.part b/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000000.part
new file mode 100644
index 0000000..e7694f2
Binary files /dev/null and b/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000000.part differ
diff --git a/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000001.part b/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000001.part
new file mode 100644
index 0000000..4c5df71
Binary files /dev/null and b/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000001.part differ
diff --git a/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000002.part b/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000002.part
new file mode 100644
index 0000000..f31e1f0
Binary files /dev/null and b/data/storage/uploads/d78f1545-c761-4399-b98f-21b12791111b/000002.part differ
diff --git a/data/storage/uploads/daf74e31-eead-4304-86f5-0ecd1ce105c9/000000.part b/data/storage/uploads/daf74e31-eead-4304-86f5-0ecd1ce105c9/000000.part
new file mode 100644
index 0000000..0ff8fa8
Binary files /dev/null and b/data/storage/uploads/daf74e31-eead-4304-86f5-0ecd1ce105c9/000000.part differ
diff --git a/data/storage/uploads/daf74e31-eead-4304-86f5-0ecd1ce105c9/000001.part b/data/storage/uploads/daf74e31-eead-4304-86f5-0ecd1ce105c9/000001.part
new file mode 100644
index 0000000..1bf876d
Binary files /dev/null and b/data/storage/uploads/daf74e31-eead-4304-86f5-0ecd1ce105c9/000001.part differ