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
37 changes: 36 additions & 1 deletion app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
Expand Down Expand Up @@ -40,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
Expand Down Expand Up @@ -73,6 +76,7 @@ object Routes {
const val FILES = "files"
const val TERMINAL = "terminal"
const val SETTINGS = "settings"
const val LOGS = "logs"
}

/**
Expand Down Expand Up @@ -206,6 +210,7 @@ private fun AppNavHost(navController: androidx.navigation.NavHostController, gra
composable(Routes.FILES) { FilesScreen(graph) }
composable(Routes.TERMINAL) { TerminalScreen(graph) }
composable(Routes.SETTINGS) { SettingsScreen(graph) }
composable(Routes.LOGS) { io.github.oustery.opencodephone.ui.logs.LogsScreen(graph) }
}
}

Expand Down Expand Up @@ -285,11 +290,41 @@ private fun AppDrawerContent(
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Quiet version footer: the drawer doubles as an about glance.
// Hidden diagnostics trigger: five taps within four seconds open
// the log viewer (deliberately undiscoverable — a support agent
// tells the user the gesture when debugging is needed).
var taps by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableIntStateOf(0)
}
var firstTapAt by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableLongStateOf(0L)
}
val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
Text(
text = stringResource(R.string.nav_app_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures {
val now = System.currentTimeMillis()
if (now - firstTapAt > 4000) {
firstTapAt = now
taps = 1
} else {
taps += 1
}
if (taps == 5) {
taps = 0
haptics.performHapticFeedback(
androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress,
)
closeDrawer()
navigateTo(navController, Routes.LOGS)
}
}
},
)
}
}
Expand Down
67 changes: 67 additions & 0 deletions app/src/main/java/io/github/oustery/opencodephone/Diagnostics.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package io.github.oustery.opencodephone

import android.content.Context
import android.util.Log
import io.github.oustery.opencodephone.core.model.AppLog
import java.io.File

/**
* Bridges [AppLog] to the platform: every entry is mirrored to Logcat (the
* same priority mapping adb users expect) and appended to a small rotating
* file pair under filesDir/logs. The file survives process death, so a bug
* report can include what happened before the app was restarted.
*/
object Diagnostics {

private const val ROTATE_BYTES: Long = 512 * 1024
private const val TAG_PREFIX = "OC"

@Volatile
private var logDir: File? = null

fun install(context: Context) {
val dir = File(context.filesDir, "logs").apply { mkdirs() }
logDir = dir
AppLog.sink = { entry -> write(entry) }
AppLog.info("App", "Diagnostics installed (logs in ${dir.name}/)")
}

/** Current diagnostic files, oldest first; used by the share action. */
fun files(): List<File> = listOfNotNull(
logDir?.let { File(it, "app.old") },
logDir?.let { File(it, "app.log") },
).filter { it.isFile && it.length() > 0 }

private fun write(entry: AppLog.Entry) {
val priority = when (entry.level) {
AppLog.Level.DEBUG -> Log.DEBUG
AppLog.Level.INFO -> Log.INFO
AppLog.Level.WARN -> Log.WARN
AppLog.Level.ERROR -> Log.ERROR
}
val line = buildString {
append(entry.message)
entry.error?.let { append(" — ").append(it) }
}
Log.println(priority, "$TAG_PREFIX/${entry.tag}", line)

val dir = logDir ?: return
runCatching {
synchronized(Diagnostics) {
val current = File(dir, "app.log")
if (current.length() > ROTATE_BYTES) {
// One-generation rotation: app.log -> app.old (replaced).
if (!current.renameTo(File(dir, "app.old"))) current.delete()
}
current.appendText(format(entry) + "\n")
}
}
}

private fun format(entry: AppLog.Entry): String {
val time = java.text.SimpleDateFormat("MM-dd HH:mm:ss.SSS", java.util.Locale.US)
.format(java.util.Date(entry.timestampMs))
val error = entry.error?.let { " — $it" }.orEmpty()
return "$time ${entry.level.name}/${entry.tag}: ${entry.message}$error"
}
}
27 changes: 13 additions & 14 deletions app/src/main/java/io/github/oustery/opencodephone/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,20 @@ class MainActivity : ComponentActivity() {
OpenCodePhoneTheme(dynamicColor = dynamicColor) {
val resetConfirm = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
if (resetConfirm.value && startup == AppStartup.Failed) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { resetConfirm.value = false },
title = { androidx.compose.material3.Text(androidx.compose.ui.res.stringResource(R.string.startup_reset)) },
text = { androidx.compose.material3.Text(androidx.compose.ui.res.stringResource(R.string.startup_reset_warning)) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = {
resetConfirm.value = false
app.retryStartup(resetSecureSettings = true)
}) { androidx.compose.material3.Text(androidx.compose.ui.res.stringResource(R.string.startup_reset)) }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { resetConfirm.value = false }) {
androidx.compose.material3.Text(androidx.compose.ui.res.stringResource(R.string.action_cancel))
}
// Wiping the Keystore-backed settings is irreversible —
// the destructive voice makes that unmistakable.
io.github.oustery.opencodephone.core.designsystem.OcConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.startup_reset),
text = androidx.compose.ui.res.stringResource(R.string.startup_reset_warning),
confirmLabel = androidx.compose.ui.res.stringResource(R.string.startup_reset),
dismissLabel = androidx.compose.ui.res.stringResource(R.string.action_cancel),
icon = androidx.compose.ui.res.painterResource(R.drawable.ic_shield),
tone = io.github.oustery.opencodephone.core.designsystem.OcDialogTone.DESTRUCTIVE,
onConfirm = {
resetConfirm.value = false
app.retryStartup(resetSecureSettings = true)
},
onDismiss = { resetConfirm.value = false },
)
}
when (val state = startup) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.github.oustery.opencodephone.core.linuxenv.EnvLayout
import io.github.oustery.opencodephone.core.linuxenv.EnvironmentRuntime
import io.github.oustery.opencodephone.core.linuxenv.SslTrust
import io.github.oustery.opencodephone.core.model.AgentActivity
import io.github.oustery.opencodephone.core.model.AppLog
import io.github.oustery.opencodephone.core.opencode.ConnectionConfig
import io.github.oustery.opencodephone.core.opencode.OpenCodeClient
import io.github.oustery.opencodephone.core.opencode.OpenCodeConsoleClient
Expand Down Expand Up @@ -258,6 +259,8 @@ class OpenCodePhoneApp : Application() {

override fun onCreate() {
super.onCreate()
Diagnostics.install(this)
AppLog.info("App", "opencodephone ${BuildConfig.VERSION_NAME} starting")
retryStartup()
}

Expand All @@ -280,6 +283,7 @@ class OpenCodePhoneApp : Application() {
throw e
} catch (e: Exception) {
// Do not delete/recreate encrypted prefs or log credential-bearing data.
AppLog.error("App", "startup failed", e)
android.util.Log.e("OpenCodePhone", "Startup failed (${e.javaClass.simpleName})")
_startup.value = AppStartup.Failed
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.selected
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import io.github.oustery.opencodephone.core.designsystem.OcConfirmDialog
import io.github.oustery.opencodephone.core.designsystem.OcDialogTone
import io.github.oustery.opencodephone.core.designsystem.OcInputDialog
import io.github.oustery.opencodephone.core.designsystem.OcActionChip
import io.github.oustery.opencodephone.core.designsystem.AppFonts
import io.github.oustery.opencodephone.core.designsystem.MarkdownDefaults
Expand Down Expand Up @@ -487,21 +490,17 @@ private fun ChatTimeline(vm: ChatViewModel, state: ChatUiState) {
}

revertFor?.let { target ->
AlertDialog(
onDismissRequest = { revertForId = null },
title = { Text(stringResource(R.string.chat_revert_title)) },
text = { Text(stringResource(R.string.chat_revert_body)) },
confirmButton = {
TextButton(onClick = {
revertForId = null
vm.revertTo(target.id)
}) { Text(stringResource(R.string.chat_revert_action)) }
},
dismissButton = {
TextButton(onClick = { revertForId = null }) {
Text(stringResource(R.string.action_cancel))
}
OcConfirmDialog(
title = stringResource(R.string.chat_revert_title),
text = stringResource(R.string.chat_revert_body),
confirmLabel = stringResource(R.string.chat_revert_action),
dismissLabel = stringResource(R.string.action_cancel),
icon = painterResource(R.drawable.ic_revert),
onConfirm = {
revertForId = null
vm.revertTo(target.id)
},
onDismiss = { revertForId = null },
)
}
}
Expand Down Expand Up @@ -1054,26 +1053,21 @@ private fun SessionsSheet(vm: ChatViewModel, state: ChatUiState) {
Spacer(Modifier.size(24.dp))
}
pendingDelete?.let { session ->
AlertDialog(
onDismissRequest = { pendingDeleteId = null },
title = { Text(stringResource(R.string.chat_session_delete_title)) },
text = {
Text(
stringResource(
R.string.chat_session_delete_body,
session.title.ifBlank { stringResource(R.string.chat_session_untitled) },
),
)
},
confirmButton = {
TextButton(onClick = {
vm.deleteSession(session.id)
pendingDeleteId = null
}) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.action_cancel)) }
OcConfirmDialog(
title = stringResource(R.string.chat_session_delete_title),
text = stringResource(
R.string.chat_session_delete_body,
session.title.ifBlank { stringResource(R.string.chat_session_untitled) },
),
confirmLabel = stringResource(R.string.action_delete),
dismissLabel = stringResource(R.string.action_cancel),
icon = painterResource(R.drawable.ic_delete),
tone = OcDialogTone.DESTRUCTIVE,
onConfirm = {
vm.deleteSession(session.id)
pendingDeleteId = null
},
onDismiss = { pendingDeleteId = null },
)
}
renaming?.let { session ->
Expand All @@ -1090,28 +1084,16 @@ private fun SessionsSheet(vm: ChatViewModel, state: ChatUiState) {

@Composable
private fun RenameSessionDialog(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
var title by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_session_rename)) },
text = {
OutlinedTextField(
value = title,
onValueChange = { title = it },
placeholder = { Text(stringResource(R.string.chat_session_rename_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
)
},
confirmButton = {
TextButton(onClick = { onSave(title) }, enabled = title.isNotBlank()) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
},
OcInputDialog(
title = stringResource(R.string.chat_session_rename),
valueLabel = stringResource(R.string.chat_session_rename),
initial = initial,
placeholder = stringResource(R.string.chat_session_rename_hint),
confirmLabel = stringResource(R.string.action_save),
dismissLabel = stringResource(R.string.action_cancel),
icon = painterResource(R.drawable.ic_edit),
onConfirm = onSave,
onDismiss = onDismiss,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.oustery.opencodephone.AppGraph
import io.github.oustery.opencodephone.core.linuxenv.ServerState
import io.github.oustery.opencodephone.core.model.AppLog
import io.github.oustery.opencodephone.core.linuxenv.PRootCommand
import io.github.oustery.opencodephone.core.model.AgentInfo
import io.github.oustery.opencodephone.core.model.MessageWithParts
Expand Down Expand Up @@ -255,6 +256,7 @@ class ChatViewModel(private val graph: AppGraph) : ViewModel() {
} catch (e: Exception) {
// Convenience affordance (F-006): must not break the
// connection — but the failure must not stay invisible.
AppLog.warn("Chat", "agent list unavailable", e)
_state.update {
it.copy(
notice = graph.appContext.getString(
Expand All @@ -272,6 +274,7 @@ class ChatViewModel(private val graph: AppGraph) : ViewModel() {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLog.error("Chat", "connection failed", e)
_state.update {
it.copy(
connection = ChatConnection.ERROR,
Expand Down Expand Up @@ -343,6 +346,7 @@ class ChatViewModel(private val graph: AppGraph) : ViewModel() {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLog.warn("Chat", "todo plan unavailable", e)
_state.update {
it.copy(
notice = graph.appContext.getString(
Expand All @@ -363,6 +367,7 @@ class ChatViewModel(private val graph: AppGraph) : ViewModel() {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLog.warn("Chat", "pending questions unavailable", e)
_state.update {
it.copy(
notice = graph.appContext.getString(
Expand Down Expand Up @@ -415,6 +420,7 @@ class ChatViewModel(private val graph: AppGraph) : ViewModel() {
} catch (e: CancellationException) { throw e }
catch (e: Exception) {
if (ticket != endpointEpoch) return@launch
AppLog.warn("Chat", "event stream interrupted (attempt $attempts)", e)
_state.update { it.copy(connection = ChatConnection.ERROR, error = errorText(e), busy = false) }
if (e is OpenCodeApiException && e.code in listOf(401, 403)) return@launch
}
Expand Down Expand Up @@ -543,6 +549,7 @@ class ChatViewModel(private val graph: AppGraph) : ViewModel() {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLog.error("Chat", "session reload failed", e)
_state.update { it.copy(error = errorText(e)) }
}
}
Expand Down
Loading
Loading