Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.codedeck.plus.core

import com.codedeck.plus.platform.CoreHttpFetch
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -122,7 +124,19 @@ class CoreHost(
private val requests = Channel<Unit>(Channel.CONFLATED)

init {
scope.launch { for (request in requests) sink.value = read() }
scope.launch {
for (request in requests) {
// An FFI read failure logs and keeps the loop alive:
// subsequent requests are re-read, not silent drops.
try {
sink.value = read()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("codedeck", "slice read failed: ${e::class.simpleName}: ${e.message?.take(160)}")
}
}
}
}

fun request() {
Expand Down Expand Up @@ -181,7 +195,22 @@ class CoreHost(
/** Whether the device has a usable network — see `Connectivity`. */
fun setOnline(online: Boolean) = core.setOnline(online)

suspend fun dispatch(intent: UniffiIntent) = core.dispatch(intent)
/**
* Hand [intent] to the core. Callers fire and forget from a UI scope, so
* a refused intent (a value the core does not know) or a fault in the
* core is logged here rather than crashing the app; what the user needs
* to see of a failure arrives as a core event.
*/
suspend fun dispatch(intent: UniffiIntent) {
try {
core.dispatch(intent)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// The intent's kind only: its fields may hold secrets.
Log.w("codedeck", "intent ${intent::class.simpleName} refused: ${e::class.simpleName}: ${e.message?.take(160)}")
}
}

/**
* The phone's own Nostr id in bech32 `npub1…` form — derived by the core
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,13 @@ class CoreHttpFetch : UniffiHttpFetch {
* the server at all throws [UniffiHttpException.Failed].
*/
private fun exchange(url: String, method: String, headers: List<UniffiHttpHeader>, body: ByteArray): UniffiHttpResponse {
val conn = URL(url).openConnection(proxy ?: Proxy.NO_PROXY) as HttpURLConnection
val conn: HttpURLConnection = try {
URL(url).openConnection(proxy ?: Proxy.NO_PROXY) as HttpURLConnection
} catch (e: IOException) {
// A malformed URL is a failure to reach the server, same as any
// other connection failure below.
throw UniffiHttpException.Failed("$method $url failed: ${e.javaClass.simpleName}: ${e.message ?: "no detail"}")
}
try {
conn.requestMethod = method
conn.connectTimeout = CONNECT_TIMEOUT_MS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,15 @@ class Nip55Signer(
private fun failed(detail: String) = UniffiSignerException.Failed(detail)

/** A signed event built from `unsigned` and a bare `signature`, for a
* signer that returns only the signature. The core verifies it. */
* signer that returns only the signature. The core verifies it; an
* `unsigned` the JSON parser refuses is a signer failure like any
* other (same type `ask`/`viaActivity` throw). */
private fun withSignature(unsigned: String, signature: String): String =
JSONObject(unsigned).put("sig", signature).toString()
try {
JSONObject(unsigned).put("sig", signature).toString()
} catch (e: Exception) {
throw failed("the signer's answer is not valid JSON: ${e.javaClass.simpleName}")
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
Expand All @@ -24,6 +25,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import com.codedeck.plus.MainActivity
import com.codedeck.plus.R
import com.codedeck.plus.core.CoreHost
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -147,8 +149,17 @@ class StayConnectedService : Service() {
coreJob = job
CoroutineScope(scope.coroutineContext + job).launch(Dispatchers.IO) {
// Only started once a login exists (MainActivity); an OS restart
// after the user lost theirs has nothing to run.
val core = openCore() ?: run {
// after the user lost theirs has nothing to run. A storage or
// key-vault failure during the open is the same "no core to run"
// case, not a crash to die on.
val core = try {
openCore()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("codedeck", "core open failed: ${e::class.simpleName}: ${e.message?.take(160)}")
null
} ?: run {
withContext(Dispatchers.Main) { stopSelf() }
return@launch
}
Expand All @@ -160,7 +171,19 @@ class StayConnectedService : Service() {
core.stop()
return@launch
}
core.start()
// A faulting core at start must not take the process down (the
// earlier crash loop): log, stop the service (its launch was the
// only thing running the core) so a later startForegroundService
// can open a fresh one.
try {
core.start()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("codedeck", "core start failed: ${e::class.simpleName}: ${e.message?.take(160)}")
withContext(Dispatchers.Main) { stopSelf() }
return@launch
}
// Registered only now: adding the observer replays the current
// app visibility at once, which must reach a started core.
withContext(Dispatchers.Main) {
Expand Down Expand Up @@ -229,12 +252,30 @@ class StayConnectedService : Service() {
// Network reachability drives the connection FSM's offline/online
// transitions; the first emission reconciles the state at startup.
connectivity?.let { network ->
scope.launch { network.online.collect { core.setOnline(it) } }
// A refused setOnline (a faulting core) must not kill the
// collector: later emissions still reach the FSM.
scope.launch {
network.online.collect { online ->
try {
core.setOnline(online)
} catch (e: Exception) {
Log.w("codedeck", "setOnline refused: ${e::class.simpleName}: ${e.message?.take(160)}")
}
}
}
// Internet access came back without the network going down (or
// on another network): redial whatever is down now, rather than
// after a backoff that grew through the outage. The replayed
// initial value is not news.
scope.launch { network.regained.drop(1).collect { core.setOnline(true) } }
scope.launch {
network.regained.drop(1).collect {
try {
core.setOnline(true)
} catch (e: Exception) {
Log.w("codedeck", "setOnline refused: ${e::class.simpleName}: ${e.message?.take(160)}")
}
}
}
}
// The stay-connected setting drives THIS service's foreground state —
// the settings screen only flips the stored value. Collecting here is
Expand Down Expand Up @@ -442,7 +483,17 @@ class StayConnectedService : Service() {
.apply { setReferenceCounted(false); acquire(KEEPALIVE_WAKE_MS) }
scope.launch {
try {
withTimeoutOrNull(KEEPALIVE_WAKE_MS - 2_000) { core.keepalive() }
withTimeoutOrNull(KEEPALIVE_WAKE_MS - 2_000) {
try {
core.keepalive()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// A failed check is not worth the process: the next
// alarm probes again.
Log.w("codedeck", "keepalive failed: ${e::class.simpleName}: ${e.message?.take(160)}")
}
}
} finally {
if (core.settings.value?.stayConnected != false) scheduleKeepAlive()
if (wakeLock.isHeld) wakeLock.release()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ fun SettingsScreen(
return
}
val machines = machinesView?.machines.orEmpty().sortedBy { it.name.lowercase() }
val npub = remember { core.identityNpub() }
// An npub that fails to derive shows as absent rather than crashing
// the whole settings hub.
val npub = remember { runCatching { core.identityNpub() }.getOrDefault("") }
val signerLabel = (login as? Login.SignerApp)?.let { remember(it.packageName) { appLabel(context, it.packageName) } }

pages.SaveableStateProvider(pageKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
Expand Down Expand Up @@ -90,6 +91,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -206,6 +208,11 @@ fun SessionScreen(
// Every delta is applied, in order: each builds on the one before.
core.transcriptFlow(machine, sessionId)
.map { view -> ParsedTranscript.of(view, previous).also { previous = it } }
// One row the JSON decoders cannot parse ends this collection
// (the transcript stops updating) rather than crashing the app.
.catch { e ->
Log.w("codedeck", "transcript decode ended: ${e::class.simpleName}: ${e.message?.take(160)}")
}
.flowOn(Dispatchers.Default)
.collect { transcript = it }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,22 @@ internal fun SlashCommandMenu(
* receding, so each row shows why it is listed. */
internal fun highlightedName(name: String, query: String): AnnotatedString = buildAnnotatedString {
val at = if (query.isEmpty()) -1 else name.lowercase().indexOf(query.lowercase())
// Lowercasing can change a string's length (e.g. "I" -> Turkish dotless
// i on some locales), so the match on the lowercased name may extend
// past the original name's end. The match length in the ORIGINAL string
// is what bounds the highlight; fall back to no highlight when the
// bounds are unsound.
val end = (at + query.length).coerceAtMost(name.length)
val match = at >= 0 && at < end
val muted = SpanStyle(color = Tokens.TextMuted)
withStyle(muted) { append("/") }
if (at < 0) {
if (!match) {
withStyle(SpanStyle(color = Tokens.Text)) { append(name) }
return@buildAnnotatedString
}
withStyle(muted) { append(name.substring(0, at)) }
withStyle(SpanStyle(color = Tokens.Text, fontWeight = FontWeight.SemiBold)) { append(name.substring(at, at + query.length)) }
withStyle(muted) { append(name.substring(at + query.length)) }
withStyle(SpanStyle(color = Tokens.Text, fontWeight = FontWeight.SemiBold)) { append(name.substring(at, end)) }
withStyle(muted) { append(name.substring(end)) }
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.codedeck.plus.ui.transcript
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonClassDiscriminator

Expand Down Expand Up @@ -158,12 +159,22 @@ sealed class DisplayEntry {

@Serializable
@SerialName("userMessage")
data class UserMessage(override val seq: Long, val text: String) : DisplayEntry()
data class UserMessage(override val seq: Long, val text: String) : DisplayEntry() {
/** [text] in list-sized blocks (see [markdownBlocks]); split as the
* entry is decoded, off the main thread. */
@Transient
val blocks: List<String> = markdownBlocks(text)
}

/** Agent markdown; `isPlan` frames it as a plan document. */
@Serializable
@SerialName("agentMessage")
data class AgentMessage(override val seq: Long, val text: String, val isPlan: Boolean = false) : DisplayEntry()
data class AgentMessage(override val seq: Long, val text: String, val isPlan: Boolean = false) : DisplayEntry() {
/** [text] in list-sized blocks (see [markdownBlocks]); split as the
* entry is decoded, off the main thread. */
@Transient
val blocks: List<String> = markdownBlocks(text)
}

/** A run of tool activity. `summary` says what it did ("Ran 3
* commands, read a file"); a lone call is its verb, with `subject`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ import com.mikepenz.markdown.model.rememberMarkdownState
* renderer bump silently regressing either one.
*
* `immediate = true`: transcript rows are already-received text, not a
* live-typed editor buffer — synchronous parsing costs one frame on a cold
* row and, unlike the renderer's default async path (`MarkdownState`
* live-typed editor buffer, and a long message reaches this renderer one
* block of a few thousand characters at a time (`markdownBlocks`, each block
* a list item of its own), so a synchronous parse costs a fraction of a
* frame and, unlike the renderer's default async path (`MarkdownState`
* introduced in 0.33.0), is deterministic for Paparazzi's static snapshots.
* Never hand it a whole long message: parsing and laying it out at once on
* the main thread is what froze the app.
*
* Syntax highlighting stays plain monochrome (no `-code` module, no
* Rust-side span generation) — matches the TS renderer's own lazy/
Expand Down
Loading
Loading