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