diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 229cc171..34e03b75 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -136,33 +136,16 @@ dependencies { // Chrome/composer icons (mic, attach, pair, settings, menu, close). The // full extended set is large, but release builds R8-minify (above) and // drop every unused icon; debug builds simply carry the dead weight. - implementation("androidx.compose.material:material-icons-extended") + implementation(libs.androidx.material.icons.extended) implementation(libs.kotlinx.coroutines.android) - // JNA: the FFI bridge uniffi-bindgen's generated Kotlin uses to call into - // crates/client-ffi's cdylib. Version pinned loosely on purpose — this - // is exactly the dependency the generated bindings file itself declares - // as a prerequisite; keep it in step with whatever `uniffi` crate version - // crates/client-ffi/Cargo.toml pins (currently 0.32). - implementation("net.java.dev.jna:jna:5.19.0@aar") - - // Tink directly, not its deprecated androidx.security:security-crypto - // wrapper (frozen at 1.1.0-alpha07): platform/SecureIdentityStore.kt - // uses AndroidKeysetManager to keep the persisted bridge identity - // secret encrypted at rest under a Keystore-held master key. - implementation("com.google.crypto.tink:tink-android:1.19.0") - - // Pairing-QR camera scan (ui/screens/PairingScanView.kt): CameraX for the - // preview/analysis pipeline, ZXing's core decoder for the QR code — a - // plain Java library with no dependencies of its own. Not ML Kit: it - // brings Play Services, Firebase components and a usage-reporting - // transport (a startup provider, a background upload job) into every - // launch, all to read one QR code. Neither library is covered by the - // Compose BOM, so they are pinned explicitly. - val camerax = "1.6.2" - implementation("androidx.camera:camera-camera2:$camerax") - implementation("androidx.camera:camera-lifecycle:$camerax") - implementation("androidx.camera:camera-view:$camerax") - implementation("com.google.zxing:core:3.5.4") + // The aar: JNA's Android build, with its native dispatch libraries. + // Why each of these is here: gradle/libs.versions.toml. + implementation(variantOf(libs.jna) { artifactType("aar") }) + implementation(libs.tink.android) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.zxing.core) // Compose-native Markdown for assistant/plan transcript rows. // GFM (tables, task lists, strikethrough, autolinks) is the renderer's diff --git a/apps/android/app/lint.xml b/apps/android/app/lint.xml new file mode 100644 index 00000000..62742505 --- /dev/null +++ b/apps/android/app/lint.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 2021a00e..ec2ad8db 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -28,9 +28,11 @@ - + edit.putString(KIND, "device") - is Login.SignerApp -> edit.putString(KIND, "signer") - .putString(PACKAGE, login.packageName) - .putString(PUBKEY, login.pubkeyHex) + // Committed synchronously: the login must be on disk before the core + // starts, and the service reads it right after. + prefs.edit(commit = true) { + clear() + when (login) { + Login.OnDevice -> putString(KIND, "device") + is Login.SignerApp -> putString(KIND, "signer") + .putString(PACKAGE, login.packageName) + .putString(PUBKEY, login.pubkeyHex) + } } - // Committed synchronously: the service reads it right after. - edit.commit() } fun clear() { - prefs.edit().clear().commit() + prefs.edit(commit = true) { clear() } } } diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt index b81ed81d..a6e10c9b 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt @@ -4,8 +4,8 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor -import android.net.Uri import android.util.Log +import androidx.core.net.toUri import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.put @@ -39,7 +39,7 @@ data class SignerAppInfo(val packageName: String, val label: String) /** Every installed NIP-55 signer app, by label. */ fun installedSignerApps(context: Context): List { val pm = context.packageManager - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")) + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()) return pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) .map { SignerAppInfo(it.activityInfo.packageName, it.loadLabel(pm).toString()) } .distinctBy { it.packageName } @@ -50,7 +50,7 @@ fun installedSignerApps(context: Context): List { * everything the core will need so later requests can be answered without * the user. */ fun getPublicKeyIntent(packageName: String): Intent = - Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply { + Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply { `package` = packageName putExtra("type", "get_public_key") putExtra("permissions", signerPermissionsJson()) @@ -157,7 +157,7 @@ class Nip55Signer( } private fun resolve(method: String, payload: String, peer: String): ProviderAnswer { - val uri = Uri.parse("content://$packageName.$method") + val uri = "content://$packageName.$method".toUri() val cursor: Cursor = try { // NIP-55 passes the arguments in the projection slot. app.contentResolver.query(uri, arrayOf(payload, peer, currentUser), null, null, null) @@ -170,7 +170,7 @@ class Nip55Signer( private fun viaActivity(type: String, payload: String, peer: String?): SignerAnswer { val id = UUID.randomUUID().toString() - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$payload")).apply { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply { `package` = packageName putExtra("type", type) putExtra("id", id) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/Notifier.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/Notifier.kt index 5b42879a..2e85e5dc 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/Notifier.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/Notifier.kt @@ -91,7 +91,11 @@ class Notifier(private val context: Context) : UniffiNotifier { } val notification = builder.build() - NotificationManagerCompat.from(context).notify(effectiveTag, idFor(effectiveTag), notification) + try { + NotificationManagerCompat.from(context).notify(effectiveTag, idFor(effectiveTag), notification) + } catch (e: SecurityException) { + // Permission revoked since the check above: drop the post. + } } override fun cancel(tag: String) { @@ -119,7 +123,11 @@ class Notifier(private val context: Context) : UniffiNotifier { ), ) .build() - NotificationManagerCompat.from(context).notify(SIGNER_TAG, idFor(SIGNER_TAG), notification) + try { + NotificationManagerCompat.from(context).notify(SIGNER_TAG, idFor(SIGNER_TAG), notification) + } catch (e: SecurityException) { + // Permission revoked since the check above: drop the post. + } } fun cancelSignerApproval() = cancel(SIGNER_TAG) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/SecureIdentityStore.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/SecureIdentityStore.kt index 852b35c5..80435177 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/SecureIdentityStore.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/SecureIdentityStore.kt @@ -1,6 +1,7 @@ package com.codedeck.plus.platform import android.content.Context +import androidx.core.content.edit import com.google.crypto.tink.Aead import com.google.crypto.tink.KeyTemplates import com.google.crypto.tink.RegistryConfiguration @@ -143,7 +144,7 @@ class KeyVault(context: Context) { } } app.getSharedPreferences("codedeck_identity_keyset_prefs", Context.MODE_PRIVATE) - .edit().clear().apply() + .edit { clear() } identityFile.delete() sessionKeysFile.delete() buildKeysetHandle() diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt index 4f75f3a9..87ac1a27 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt @@ -14,8 +14,8 @@ import android.os.Binder import android.os.Build import android.os.IBinder import android.os.PowerManager -import android.util.Log import android.os.SystemClock +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat @@ -419,14 +419,14 @@ class StayConnectedService : Service() { * and its bridges is exactly that type, and unlike `dataSync` it has no * daily time budget — Android 15 stops a `dataSync` service after 6 h per * 24 h, which silently ended "stay connected". Older versions only know - * `dataSync` (no budget there). + * `dataSync` (no budget there), and before Android 10 a foreground + * service has no type at all. */ - private fun foregroundType(): Int = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } + private fun foregroundType(): Int = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + else -> 0 + } /** Re-posting under the foreground notification's own id replaces it in * place; the platform drops the post silently when notifications are diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/components/Kit.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/components/Kit.kt index 3f02d766..f9fa95e2 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/components/Kit.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/components/Kit.kt @@ -155,9 +155,9 @@ fun IconAction(icon: ImageVector, label: String, onClick: () -> Unit, tint: Colo */ @Composable fun Group( + modifier: Modifier = Modifier, title: String? = null, footer: String? = null, - modifier: Modifier = Modifier, rows: @Composable GroupScope.() -> Unit, ) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Tokens.Space2)) { diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt index f38b6287..1d68f46f 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt @@ -5,6 +5,7 @@ import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.os.Build import android.os.PersistableBundle import android.provider.Settings import androidx.activity.compose.BackHandler @@ -29,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -376,7 +378,7 @@ internal fun AppearancePage(view: UniffiSettingsView, dispatch: (UniffiIntent) - // Local position while dragging; re-keyed on the stored value // so a change made elsewhere snaps the thumb to it. The intent // fires on release. - var position by remember(view.uiScale) { mutableStateOf(view.uiScale.toFloat()) } + var position by remember(view.uiScale) { mutableFloatStateOf(view.uiScale.toFloat()) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { Text("${Math.round(position * 100)}%", color = Tokens.Text, fontSize = Tokens.TextXl, modifier = Modifier.weight(1f)) QuietButton("Reset", enabled = position != UI_SCALE_DEFAULT, onClick = { @@ -592,7 +594,11 @@ private fun SecretKeyGroup(revealKey: suspend () -> String?) { private fun copySensitive(context: Context, text: String) { val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return val clip = ClipData.newPlainText("Secret key", text) - clip.description.extras = PersistableBundle().apply { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) } + // API 33 only: before that there is no clipboard preview to hide the + // secret from. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clip.description.extras = PersistableBundle().apply { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) } + } clipboard.setPrimaryClip(clip) } diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Attachment.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Attachment.kt index 922dd6e2..8dd6a750 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Attachment.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Attachment.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.provider.OpenableColumns import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.scale import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream @@ -163,7 +164,7 @@ private fun downscaledIfHuge(resolver: ContentResolver, picked: PickedFile, file BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sampleSize }) } ?: return null val scaled = if (decoded.width != targetWidth || decoded.height != targetHeight) { - Bitmap.createScaledBitmap(decoded, targetWidth, targetHeight, true) + decoded.scale(targetWidth, targetHeight, true) } else { decoded } diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Composer.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Composer.kt index ea888152..84fcb077 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Composer.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/Composer.kt @@ -168,7 +168,7 @@ internal fun Composer( .size(44.dp) .clip(CircleShape) .background(if (canSend || stop) Tokens.Accent else Tokens.SurfaceHover) - .clickable(enabled = canSend || stop, onClick = if (stop) onStop!! else onSend) + .clickable(enabled = canSend || stop, onClick = if (stop) onStop else onSend) .semantics { contentDescription = if (stop) "Stop" else "Send" }, contentAlignment = Alignment.Center, ) { diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt index 995fa6d0..801f7c07 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt @@ -4,8 +4,8 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.SystemClock -import android.util.Log import android.speech.RecognizerIntent +import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -182,8 +182,8 @@ fun SessionScreen( core: CoreHost, machine: String, sessionId: String, - onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, ) { val machinesView by core.machines.collectAsState() val uiView by core.ui.collectAsState() diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/MarkdownBlocks.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/MarkdownBlocks.kt index e9aa58a7..ef5f4132 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/MarkdownBlocks.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/MarkdownBlocks.kt @@ -61,7 +61,7 @@ fun markdownBlocks(text: String, target: Int = BLOCK_TARGET): List { } append(line) } else { - val open = fence!! + val open = fence if (trimmed.startsWith(open) && trimmed.trimEnd().all { it == open[0] }) { fence = null append(line) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/ToolGroupRow.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/ToolGroupRow.kt index e54ad551..e7ac8786 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/ToolGroupRow.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/ToolGroupRow.kt @@ -9,13 +9,13 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.DriveFileMove import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Build import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Checklist import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.DriveFileMove import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Psychology import androidx.compose.material.icons.outlined.Search @@ -49,7 +49,7 @@ internal fun toolKindIcon(kind: String): ImageVector = when (kind) { "read" -> Icons.Outlined.Visibility "edit" -> Icons.Outlined.Description "delete" -> Icons.Outlined.Delete - "move" -> Icons.Outlined.DriveFileMove + "move" -> Icons.AutoMirrored.Outlined.DriveFileMove "search" -> Icons.Outlined.Search "execute" -> Icons.Outlined.Terminal "think" -> Icons.Outlined.Checklist diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 100% rename from apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml similarity index 100% rename from apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/MarkdownParityTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/MarkdownParityTest.kt index cf0b099e..543ed602 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/MarkdownParityTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/MarkdownParityTest.kt @@ -32,7 +32,7 @@ class MarkdownParityTest { ) @Composable - private fun dark(content: @Composable () -> Unit) { + private fun Dark(content: @Composable () -> Unit) { CodeDeckTheme { Surface(color = Tokens.Bg, contentColor = Tokens.Text) { Column(Modifier.background(Tokens.Bg).fillMaxWidth().padding(16.dp)) { content() } @@ -42,6 +42,6 @@ class MarkdownParityTest { @Test fun assistant_markdown_gfm_and_code() { - paparazzi.snapshot { dark { TranscriptMarkdown(MarkdownCorpus.ASSISTANT_MARKDOWN) } } + paparazzi.snapshot { Dark { TranscriptMarkdown(MarkdownCorpus.ASSISTANT_MARKDOWN) } } } } diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/TranscriptRowsParityTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/TranscriptRowsParityTest.kt index 20ea7532..addec0b8 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/TranscriptRowsParityTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/TranscriptRowsParityTest.kt @@ -48,7 +48,7 @@ class TranscriptRowsParityTest { ) @Composable - private fun dark(content: @Composable () -> Unit) { + private fun Dark(content: @Composable () -> Unit) { CodeDeckTheme { Surface(color = Tokens.Bg, contentColor = Tokens.Text) { Column(Modifier.background(Tokens.Bg).fillMaxWidth().padding(16.dp)) { content() } @@ -75,7 +75,7 @@ class TranscriptRowsParityTest { added = 0, removed = 0, failed = 0, ) paparazzi.snapshot { - dark { + Dark { Column { groups.forEach { ToolGroupRow(it, live = false, onOpen = {}) } ToolGroupRow(lone, live = true, onOpen = {}) @@ -91,7 +91,7 @@ class TranscriptRowsParityTest { } @Composable - private fun sheet(content: @Composable () -> Unit) { + private fun Sheet(content: @Composable () -> Unit) { CodeDeckTheme { Surface(color = Tokens.SurfaceRaised, contentColor = Tokens.Text) { Column(Modifier.background(Tokens.SurfaceRaised).fillMaxWidth().padding(top = 16.dp)) { content() } @@ -102,19 +102,19 @@ class TranscriptRowsParityTest { @Test fun tool_sheet_timeline() { val work = corpus().filterIsInstance()[1] - paparazzi.snapshot { sheet { ToolSheetContent(work, live = false, openPath = emptyList(), onOpenPath = {}, onClose = {}) } } + paparazzi.snapshot { Sheet { ToolSheetContent(work, live = false, openPath = emptyList(), onOpenPath = {}, onClose = {}) } } } @Test fun tool_sheet_edit_page() { val work = editGroup() - paparazzi.snapshot { sheet { ToolSheetContent(work, live = false, openPath = listOf(work.steps[0].seq), onOpenPath = {}, onClose = {}) } } + paparazzi.snapshot { Sheet { ToolSheetContent(work, live = false, openPath = listOf(work.steps[0].seq), onOpenPath = {}, onClose = {}) } } } @Test fun tool_sheet_failed_command_page() { val work = editGroup() - paparazzi.snapshot { sheet { ToolSheetContent(work, live = false, openPath = listOf(work.steps[1].seq), onOpenPath = {}, onClose = {}) } } + paparazzi.snapshot { Sheet { ToolSheetContent(work, live = false, openPath = listOf(work.steps[1].seq), onOpenPath = {}, onClose = {}) } } } /** The group with an edit and a failed command. */ @@ -129,19 +129,19 @@ class TranscriptRowsParityTest { @Test fun tool_sheet_agent_page() { val group = agentGroup() - paparazzi.snapshot { sheet { ToolSheetContent(group, live = true, openPath = listOf(group.steps[0].seq), onOpenPath = {}, onClose = {}) } } + paparazzi.snapshot { Sheet { ToolSheetContent(group, live = true, openPath = listOf(group.steps[0].seq), onOpenPath = {}, onClose = {}) } } } @Test fun tool_sheet_agent_timeline() { val group = agentGroup() - paparazzi.snapshot { sheet { ToolSheetContent(group, live = true, openPath = emptyList(), onOpenPath = {}, onClose = {}) } } + paparazzi.snapshot { Sheet { ToolSheetContent(group, live = true, openPath = emptyList(), onOpenPath = {}, onClose = {}) } } } @Test fun tool_sheet_plan_page() { val group = agentGroup() - paparazzi.snapshot { sheet { ToolSheetContent(group, live = false, openPath = listOf(group.steps[1].seq), onOpenPath = {}, onClose = {}) } } + paparazzi.snapshot { Sheet { ToolSheetContent(group, live = false, openPath = listOf(group.steps[1].seq), onOpenPath = {}, onClose = {}) } } } private fun activity(): ActivityView { @@ -154,7 +154,7 @@ class TranscriptRowsParityTest { @Test fun activity_sheet() { paparazzi.snapshot { - sheet { ActivitySheetContent(activity(), live = false, canStop = true, onOpenAgent = {}, onStopTask = {}, onClose = {}) } + Sheet { ActivitySheetContent(activity(), live = false, canStop = true, onOpenAgent = {}, onStopTask = {}, onClose = {}) } } } @@ -162,7 +162,7 @@ class TranscriptRowsParityTest { fun activity_bar() { val a = activity() paparazzi.snapshot { - dark { + Dark { Column { ActivityBar(a, live = false, onOpen = {}) // No plan: what the sub-agent does leads. @@ -175,14 +175,14 @@ class TranscriptRowsParityTest { @Test fun diff_card() { val diff = corpus().filterIsInstance().first() - paparazzi.snapshot { dark { DiffRow(diff.path, diff.lines, diff.truncated, expanded = false, onToggle = {}) } } + paparazzi.snapshot { Dark { DiffRow(diff.path, diff.lines, diff.truncated, expanded = false, onToggle = {}) } } } @Test fun permission_card_pending() { val permission = corpus().filterIsInstance().last() paparazzi.snapshot { - dark { PermissionCard(permission, "machine", "session", responded = false, actions = {}) } + Dark { PermissionCard(permission, "machine", "session", responded = false, actions = {}) } } } @@ -190,7 +190,7 @@ class TranscriptRowsParityTest { fun plan_approval_card_pending() { val plan = corpus().filterIsInstance().first() paparazzi.snapshot { - dark { PlanApprovalCard(plan, "machine", "session", responded = false, choice = null, actions = {}) } + Dark { PlanApprovalCard(plan, "machine", "session", responded = false, choice = null, actions = {}) } } } @@ -198,7 +198,7 @@ class TranscriptRowsParityTest { fun question_card_pending() { val question = corpus().filterIsInstance().first() paparazzi.snapshot { - dark { QuestionCard(question, "machine", "session", respondedCards = emptySet(), actions = {}) } + Dark { QuestionCard(question, "machine", "session", respondedCards = emptySet(), actions = {}) } } } @@ -206,7 +206,7 @@ class TranscriptRowsParityTest { fun question_group_card_pending() { val group = corpus().filterIsInstance().first { it.questions.size > 1 } paparazzi.snapshot { - dark { QuestionCard(group, "machine", "session", respondedCards = emptySet(), actions = {}) } + Dark { QuestionCard(group, "machine", "session", respondedCards = emptySet(), actions = {}) } } } } diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui.transcript_MarkdownParityTest_assistant_markdown_gfm_and_code.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui.transcript_MarkdownParityTest_assistant_markdown_gfm_and_code.png index 2a37d782..f29cf4ba 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui.transcript_MarkdownParityTest_assistant_markdown_gfm_and_code.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui.transcript_MarkdownParityTest_assistant_markdown_gfm_and_code.png differ diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 3baa873d..0526bb96 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -3,9 +3,9 @@ # apps/mobile's Tauri-generated gen/android happens to sit on (AGP 8.11/ # Kotlin 1.9.25/compileSdk 36), which is Tauri's own scaffold and is # regenerated on demand rather than kept current. -agp = "9.4.0" +agp = "9.4.1" kotlin = "2.4.20" -coreKtx = "1.19.0" +coreKtx = "1.19.1" lifecycle = "2.11.0" activityCompose = "1.13.0" composeBom = "2026.09.00" @@ -13,25 +13,38 @@ coroutines = "1.11.0" # Decodes the JSON `crates/client-ffi`'s `views.rs` crosses for # the already-grouped transcript (`display_entries_json`) and the pending- # permission summary — see `ui/transcript/DisplayEntries.kt`. -kotlinxSerialization = "1.9.0" +kotlinxSerialization = "1.11.0" junit = "4.13.2" androidxTestExtJunit = "1.3.0" androidxTestRunner = "1.7.0" androidxTestCore = "1.7.0" -robolectric = "4.16" +robolectric = "4.17" # 1.3.5 was proven on an older Kotlin/AGP pair (2.0.21/8.7.3), but its # Gradle plugin calls AGP's # `BaseExtension`, removed in AGP 9 — a hard configuration-time failure, not # a warning. 2.0.0-alpha05 is the first release with AGP 9 / the new # `com.android.application`-without-`BaseExtension` API support; still # pre-1.0, tracked here rather than pinned quietly. -paparazzi = "2.0.0-alpha05" -# 0.43.0: current as of this pass. Since 0.33.0 `Markdown(content)` parses +paparazzi = "2.0.0-alpha05.1" +# Since 0.33.0 `Markdown(content)` parses # asynchronously by default (breaks a static Paparazzi snapshot) — # `rememberMarkdownState(content, immediate = true)` is the documented # synchronous escape hatch; used instead of pinning back to a version that # predates table rendering. -markdownRenderer = "0.43.0" +markdownRenderer = "0.45.0" +# The FFI bridge uniffi-bindgen's generated Kotlin calls crates/client-ffi's +# cdylib through: keep it in step with the `uniffi` version client-ffi pins. +jna = "5.19.1" +# Tink directly, not its deprecated androidx.security:security-crypto wrapper +# (frozen at 1.1.0-alpha07): platform/SecureIdentityStore.kt keeps the +# identity secret encrypted at rest under a Keystore-held master key. +tink = "1.23.0" +# Pairing-QR scan (ui/screens/PairingScanView.kt): CameraX for the preview and +# analysis, ZXing's core decoder for the code. Not ML Kit, which brings Play +# Services, Firebase components and a usage-reporting upload into every +# launch to read one QR code. Neither is covered by the Compose BOM. +camerax = "1.6.2" +zxing = "3.5.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -56,6 +69,14 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "an robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" } markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } +# Versioned by the Compose BOM. +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } [bundles] compose = [ diff --git a/apps/android/gradle/wrapper/gradle-wrapper.jar b/apps/android/gradle/wrapper/gradle-wrapper.jar index eddabd2e..5097068a 100644 Binary files a/apps/android/gradle/wrapper/gradle-wrapper.jar and b/apps/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties index ad7845be..1eca32e7 100644 --- a/apps/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/apps/android/gradlew.bat b/apps/android/gradlew.bat index 8508ef68..03754c4c 100644 --- a/apps/android/gradlew.bat +++ b/apps/android/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/crates/bridge-core/src/session.rs b/crates/bridge-core/src/session.rs index 74ca229d..e338fb9f 100644 --- a/crates/bridge-core/src/session.rs +++ b/crates/bridge-core/src/session.rs @@ -2,7 +2,7 @@ //! through it. //! //! A session is its persisted [`SessionRecord`] plus, while it runs, a -//! [`Runner`]: where it is in its life, the cards waiting on the user, and +//! `Runner`: where it is in its life, the cards waiting on the user, and //! the bookkeeping for its input and output. The agent itself runs in the //! agent host; a runner only mirrors it. diff --git a/crates/bridge-runtime/src/direct.rs b/crates/bridge-runtime/src/direct.rs index 35d6090a..575fb7d8 100644 --- a/crates/bridge-runtime/src/direct.rs +++ b/crates/bridge-runtime/src/direct.rs @@ -8,8 +8,8 @@ //! service to forward to. //! //! A connection must finish its TLS and WebSocket handshakes within -//! [`HANDSHAKE_TIMEOUT`], then answer the challenge with a paired identity's -//! HELLO within [`HELLO_TIMEOUT`]. Then it gets every event the bridge publishes +//! `HANDSHAKE_TIMEOUT`, then answer the challenge with a paired identity's +//! HELLO within `HELLO_TIMEOUT`. Then it gets every event the bridge publishes //! for that identity (and those of the last hour since its resume point), //! and its command events go to the engine exactly as a relay's would: the //! engine drops one it already saw by its id. Unpairing a phone closes its diff --git a/crates/bridge-runtime/src/main.rs b/crates/bridge-runtime/src/main.rs index c7d7450a..e3cc12d4 100644 --- a/crates/bridge-runtime/src/main.rs +++ b/crates/bridge-runtime/src/main.rs @@ -34,7 +34,7 @@ struct Cli { /// Relay URL, repeatable [env: CODEDECK_RELAYS, comma-separated] #[arg(long = "relay", global = true)] relays: Vec, - /// Workspace root sessions may run in, repeatable [env: CODEDECK_WORKSPACE_ROOTS] [default: /workspaces] + /// Workspace root sessions may run in, repeatable [env: CODEDECK_WORKSPACE_ROOTS] (default: the workspaces folder in --home) #[arg(long = "workspace", global = true)] workspaces: Vec, /// Path to the claude executable [env: CODEDECK_CLAUDE_PATH] diff --git a/crates/client-core/src/bridge_api.rs b/crates/client-core/src/bridge_api.rs index e4d73702..143c705e 100644 --- a/crates/client-core/src/bridge_api.rs +++ b/crates/client-core/src/bridge_api.rs @@ -148,7 +148,7 @@ pub enum InvalidStage { Decode, } -/// A dropped payload, kept for diagnostics (capped at [`INVALID_RECORDS_CAP`]). +/// A dropped payload, kept for diagnostics (capped at `INVALID_RECORDS_CAP`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct InvalidPayloadRecord { pub event_id: String, diff --git a/crates/client-ffi/src/signer.rs b/crates/client-ffi/src/signer.rs index 187ff534..c7b8ff71 100644 --- a/crates/client-ffi/src/signer.rs +++ b/crates/client-ffi/src/signer.rs @@ -6,7 +6,7 @@ //! Rust-backed one for a key the app holds itself. //! //! Every call is blocking from Rust's point of view and runs on a worker -//! thread ([`SignerAdapter`] hops to `spawn_blocking` first): an +//! thread (`SignerAdapter` hops to `spawn_blocking` first): an //! implementation may take as long as it needs, e.g. waiting for the user to //! approve a request in the signer app, and must not touch main-thread //! state. diff --git a/crates/nostr-transport/src/ws.rs b/crates/nostr-transport/src/ws.rs index 0fc7fb38..b66a8f82 100644 --- a/crates/nostr-transport/src/ws.rs +++ b/crates/nostr-transport/src/ws.rs @@ -1,5 +1,5 @@ //! `WsTransport` — the real [`Transport`] over WebSockets: one connect + read -//! task per relay, wired through the pure [`frames`](super::frames) codec and +//! task per relay, wired through the pure `frames` codec and //! [`router`](super::router). //! //! Single-threaded by construction. The [`SubCallbacks`] closures a @@ -25,14 +25,14 @@ //! * **ping liveness** — one pinger pings every relay together each //! [`PING_EVERY`] (a host slows it while in the background, see //! [`WsTransport::set_ping_interval`]), and a socket with no traffic for -//! two missed pings ([`DEAD_AFTER`]) is dropped so a silently-rotted relay +//! two missed pings (`DEAD_AFTER`) is dropped so a silently-rotted relay //! is detected, not trusted. [`WsTransport::check_liveness`] runs the same //! test on demand, for a host that wakes the device just to check. Reading and writing //! run concurrently, so a write stuck on a full socket buffer can neither //! stall inbound frames nor postpone that check; a write that stays stuck -//! for [`WRITE_TIMEOUT`] drops the socket too. +//! for `WRITE_TIMEOUT` drops the socket too. //! * **bounded everything** — a dial (TCP, SOCKS5, TLS, WS handshake) has a -//! deadline, and a relay's outbound queue holds at most [`OUTBOUND_QUEUE`] +//! deadline, and a relay's outbound queue holds at most `OUTBOUND_QUEUE` //! frames: a relay that cannot drain it is dropped and redialled rather than //! buffered without limit. //! * **NIP-42 AUTH** answered with the identity key; a REQ or EVENT refused @@ -442,7 +442,7 @@ impl WsTransport { } /// Ping every relay each `interval` from now on, and give a silent socket - /// [`dead_after`] that interval before dropping it. A phone slows this + /// `dead_after` that interval before dropping it. A phone slows this /// down while its app is in the background: every ping is a radio /// wake-up, and a background check is driven by /// [`Self::check_liveness`] instead. @@ -483,7 +483,7 @@ impl WsTransport { /// — reported and redialled like any dead socket. Returns how many /// answered. For a host that wakes the device briefly to check the /// connection instead of holding it awake: a socket a NAT or the relay - /// silently dropped is found at once, not after [`DEAD_AFTER`] of awake + /// silently dropped is found at once, not after `DEAD_AFTER` of awake /// time. pub async fn check_liveness(&self, within: Duration) -> usize { let probes: Vec<(String, u64, Rc>, u64)> = {