diff --git a/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt b/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt index ab7c57d..4e29be3 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/AppRoot.kt @@ -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 @@ -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 @@ -73,6 +76,7 @@ object Routes { const val FILES = "files" const val TERMINAL = "terminal" const val SETTINGS = "settings" + const val LOGS = "logs" } /** @@ -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) } } } @@ -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) + } + } + }, ) } } diff --git a/app/src/main/java/io/github/oustery/opencodephone/Diagnostics.kt b/app/src/main/java/io/github/oustery/opencodephone/Diagnostics.kt new file mode 100644 index 0000000..6194a7a --- /dev/null +++ b/app/src/main/java/io/github/oustery/opencodephone/Diagnostics.kt @@ -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 = 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" + } +} diff --git a/app/src/main/java/io/github/oustery/opencodephone/MainActivity.kt b/app/src/main/java/io/github/oustery/opencodephone/MainActivity.kt index a872221..e4b1cd0 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/MainActivity.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt b/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt index 0b2fb23..e9a9ba1 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/OpenCodePhoneApp.kt @@ -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 @@ -258,6 +259,8 @@ class OpenCodePhoneApp : Application() { override fun onCreate() { super.onCreate() + Diagnostics.install(this) + AppLog.info("App", "opencodephone ${BuildConfig.VERSION_NAME} starting") retryStartup() } @@ -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 } diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatScreen.kt index 5f8f0ba..cc07115 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatScreen.kt @@ -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 @@ -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 }, ) } } @@ -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 -> @@ -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, ) } diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt index ae22755..480392b 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/chat/ChatViewModel.kt @@ -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 @@ -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( @@ -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, @@ -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( @@ -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( @@ -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 } @@ -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)) } } } diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt index d409528..1684a7d 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesScreen.kt @@ -19,6 +19,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +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.AppFonts import io.github.oustery.opencodephone.core.designsystem.CodeTypography import androidx.compose.foundation.layout.Arrangement @@ -130,6 +133,8 @@ fun FilesScreen(graph: AppGraph) { project = state.gitProject, branch = branch, changes = state.gitChanges, + ahead = state.gitAhead, + behind = state.gitBehind, busy = state.busy != null, onOpenCommit = { vm.showCommit(true) }, onPull = { vm.pull() }, @@ -252,25 +257,20 @@ fun FilesScreen(graph: AppGraph) { ) } deleteFor?.let { entry -> - AlertDialog( - onDismissRequest = { deleteFor = null }, - title = { Text(stringResource(R.string.files_delete_title, entry.name)) }, - text = { - Text( - stringResource( - if (entry.isDir) R.string.files_delete_dir_body else R.string.files_delete_file_body, - ), - ) - }, - confirmButton = { - Button(onClick = { - vm.deleteEntry(entry) - deleteFor = null - }) { Text(stringResource(R.string.action_delete)) } - }, - dismissButton = { - TextButton(onClick = { deleteFor = null }) { Text(stringResource(R.string.action_cancel)) } + OcConfirmDialog( + title = stringResource(R.string.files_delete_title, entry.name), + text = stringResource( + if (entry.isDir) R.string.files_delete_dir_body else R.string.files_delete_file_body, + ), + confirmLabel = stringResource(R.string.action_delete), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_delete), + tone = OcDialogTone.DESTRUCTIVE, + onConfirm = { + deleteFor = null + vm.deleteEntry(entry) }, + onDismiss = { deleteFor = null }, ) } state.editing?.let { editing -> @@ -287,21 +287,18 @@ fun FilesScreen(graph: AppGraph) { }, ) if (confirmDiscard) { - AlertDialog( - onDismissRequest = { confirmDiscard = false }, - title = { Text(stringResource(R.string.editor_discard_title)) }, - text = { Text(stringResource(R.string.editor_discard_body, editing.file.name)) }, - confirmButton = { - TextButton(onClick = { - confirmDiscard = false - vm.closeEditor() - }) { Text(stringResource(R.string.editor_discard_confirm)) } - }, - dismissButton = { - TextButton(onClick = { confirmDiscard = false }) { - Text(stringResource(R.string.action_cancel)) - } + OcConfirmDialog( + title = stringResource(R.string.editor_discard_title), + text = stringResource(R.string.editor_discard_body, editing.file.name), + confirmLabel = stringResource(R.string.editor_discard_confirm), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_revert), + tone = OcDialogTone.DESTRUCTIVE, + onConfirm = { + confirmDiscard = false + vm.closeEditor() }, + onDismiss = { confirmDiscard = false }, ) } } @@ -316,6 +313,8 @@ private fun GitBar( project: String?, branch: String, changes: Int, + ahead: Int, + behind: Int, busy: Boolean, onOpenCommit: () -> Unit, onPull: () -> Unit, @@ -353,6 +352,14 @@ private fun GitBar( }, tone = if (changes == 0) OcChipTone.NEUTRAL else OcChipTone.TERTIARY, ) + // Sync state at a glance: unpushed / unpulled commits from + // the status tracking header (hidden while zero). + if (ahead > 0) { + OcStatusChip(text = stringResource(R.string.git_ahead, ahead), tone = OcChipTone.TERTIARY) + } + if (behind > 0) { + OcStatusChip(text = stringResource(R.string.git_behind, behind), tone = OcChipTone.NEUTRAL) + } Button(onClick = onOpenCommit, enabled = changes > 0) { Text(stringResource(R.string.git_commit)) } @@ -525,53 +532,41 @@ private fun FileRow( @Composable private fun CloneDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) { - var url by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.files_clone_title)) }, - text = { - OutlinedTextField( - value = url, - onValueChange = { url = it }, - placeholder = { Text("https://github.com/user/repo.git") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - Button(onClick = { onConfirm(url) }, enabled = url.isNotBlank()) { - Text(stringResource(R.string.files_clone)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + // Only HTTPS repositories are supported (GitAuth), so reject other + // schemes in the field instead of after a failed clone attempt. + val httpsError = stringResource(R.string.git_clone_https_needed) + OcInputDialog( + title = stringResource(R.string.files_clone_title), + valueLabel = stringResource(R.string.files_clone_url_label), + initial = "", + placeholder = "https://github.com/user/repo.git", + confirmLabel = stringResource(R.string.files_clone), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_file), + validate = { value -> + if (value.trim().startsWith("https://")) null else httpsError }, + onConfirm = onConfirm, + onDismiss = onDismiss, ) } @Composable private fun NewFileDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) { - var name by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.files_new_file)) }, - text = { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - placeholder = { Text(stringResource(R.string.files_new_file_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - Button(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { - Text(stringResource(R.string.action_create)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + val nameError = stringResource(R.string.files_new_file_hint) + OcInputDialog( + title = stringResource(R.string.files_new_file), + valueLabel = stringResource(R.string.files_new_file), + initial = "", + placeholder = stringResource(R.string.files_new_file_hint), + confirmLabel = stringResource(R.string.action_create), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_add), + validate = { value -> + if (value.trim().isEmpty() || value.trim() in listOf(".", "..")) nameError else null }, + onConfirm = onConfirm, + onDismiss = onDismiss, ) } @@ -586,6 +581,7 @@ private fun CommitDialog( var selected by remember { mutableStateOf(changes.map { it.path }.toSet()) } AlertDialog( onDismissRequest = onDismiss, + icon = { Icon(painterResource(R.drawable.ic_check), contentDescription = null) }, title = { Text(stringResource(R.string.git_commit_title)) }, text = { Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -656,30 +652,20 @@ private fun RenameFileDialog( onConfirm: (String) -> Unit, onDismiss: () -> Unit, ) { - var name by remember { mutableStateOf(initial) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.files_rename)) }, - text = { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - placeholder = { Text(stringResource(R.string.files_rename_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - Button( - onClick = { onConfirm(name) }, - enabled = name.isNotBlank() && name.trim() != initial, - ) { - Text(stringResource(R.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + val renameError = stringResource(R.string.files_rename_hint) + OcInputDialog( + title = stringResource(R.string.files_rename), + valueLabel = stringResource(R.string.files_rename), + initial = initial, + placeholder = stringResource(R.string.files_rename_hint), + confirmLabel = stringResource(R.string.action_save), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_edit), + validate = { value -> + if (value.trim() == initial) renameError else null }, + onConfirm = onConfirm, + onDismiss = onDismiss, ) } diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt index 9428f30..dc9d178 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/files/FilesViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import io.github.oustery.opencodephone.AppGraph import io.github.oustery.opencodephone.R import io.github.oustery.opencodephone.core.linuxenv.* +import io.github.oustery.opencodephone.core.model.AppLog import io.github.oustery.opencodephone.core.settings.DraftStore import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -32,6 +33,8 @@ import java.util.concurrent.atomic.AtomicLong /** True once loaded and the visible tree is empty (empty-state CTA). */ val workspaceEmpty: Boolean = false, val gitBranch: String? = null, val gitChanges: Int = 0, + /** Unpushed/unpulled commit counts from the status tracking section. */ + val gitAhead: Int = 0, val gitBehind: Int = 0, val changeList: List = emptyList(), val gitProject: String? = null, val editing: EditingFile? = null, val showCloneDialog: Boolean = false, val showNewFileDialog: Boolean = false, @@ -218,6 +221,9 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { try { block() } catch (e: CancellationException) { throw e } catch (e: Exception) { + // Every failed user action reaches both the message bar and + // the diagnostic log — the bar is transient, the log is not. + AppLog.error("Files", label ?: "task", e) _state.update { it.copy( message = e.message ?: graph.appContext.getString(R.string.files_op_failed), @@ -355,6 +361,9 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { } private suspend fun exec(repo: File, args: List, env: Map = emptyMap()): ExecResult { check(graph.layout.isInstalled()) { "Environment is not ready" } + // Full command trace (argv only — credentials travel in the + // environment and never enter this list). + AppLog.debug("Git", "cwd=${cwd(repo)} -- ${args.joinToString(" ")}") return graph.runtime.exec(args, env = env, cwd = cwd(repo)) } @@ -370,11 +379,40 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { // nothing (exception audit). val detail = result.output.trim().lineSequence() .lastOrNull { it.isNotBlank() }?.take(300).orEmpty() + AppLog.warn("Git", "exit ${result.exitCode} on ${detail.ifBlank { "no output" }}") val context = graph.appContext - return IllegalStateException( - context.getString(R.string.git_action_failed, context.getString(actionRes), result.exitCode) + - if (detail.isEmpty()) "" else ": $detail", - ) + val base = context.getString(R.string.git_action_failed, context.getString(actionRes), result.exitCode) + + if (detail.isEmpty()) "" else ": $detail" + // Every recognised failure class gets an actionable hint so the next + // step is obvious instead of a dead end. + val hint = hintFor(detail)?.let { "\n${context.getString(it)}" }.orEmpty() + return IllegalStateException(base + hint) + } + + /** + * Recognise git's most common failure classes and map them to a localized + * next-step hint (strings: git_hint_*). + */ + private fun hintFor(detail: String): Int? = when { + detail.contains("Please tell me who you are", ignoreCase = true) -> R.string.git_hint_identity + detail.contains("Authentication failed", ignoreCase = true) || + detail.contains("could not read Username", ignoreCase = true) || + detail.contains("Invalid username or password", ignoreCase = true) || + detail.contains("403 (", ignoreCase = false) || + detail.contains("401 (", ignoreCase = false) -> R.string.git_hint_auth + detail.contains("non-fast-forward", ignoreCase = true) || + detail.contains("fetch first", ignoreCase = true) || + detail.contains("behind its remote counterpart", ignoreCase = true) || + detail.contains("Not possible to fast-forward", ignoreCase = true) || + detail.contains("Diverging", ignoreCase = true) -> R.string.git_hint_diverged + detail.contains("does not have any commits yet", ignoreCase = true) -> R.string.git_hint_unborn + detail.contains("Could not resolve host", ignoreCase = true) || + detail.contains("Failed to connect", ignoreCase = true) || + detail.contains("Connection", ignoreCase = false) || + detail.contains("timed out", ignoreCase = true) -> R.string.git_hint_network + detail.contains("SSL CA cert", ignoreCase = true) || + detail.contains("certificate verify failed", ignoreCase = true) -> R.string.git_hint_ssl + else -> null } private suspend fun migrate(repo: File) = GitAuth.migrateRemotes { exec(repo, it) } @@ -399,9 +437,20 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { val result = exec(repo, listOf("git", "status", "--porcelain", "-z", "-b")) if (result.exitCode != 0) throw gitFailure(R.string.git_action_status, result) val entries = result.output.split('\u0000').filter { it.isNotBlank() } - val branch = entries.firstOrNull()?.takeIf { it.startsWith("## ") }?.removePrefix("## ") + val branchLine = entries.firstOrNull()?.takeIf { it.startsWith("## ") }?.removePrefix("## ") + // The status -b header carries tracking info: + // "main...origin/main [ahead 1, behind 2]" — split it into a clean + // branch name for the chip and unpushed/unpulled counters for the bar. + var branch = branchLine.orEmpty() + var ahead = 0 + var behind = 0 + if (branchLine != null) { + Regex("\\[ahead (\\d+)").find(branchLine)?.groupValues?.get(1)?.toIntOrNull()?.let { ahead = it } + Regex("\\[behind (\\d+)").find(branchLine)?.groupValues?.get(1)?.toIntOrNull()?.let { behind = it } + branch = branchLine.substringBefore("...").substringBefore(' ').ifBlank { branchLine } + } val changes = entries - .drop(if (branch != null) 1 else 0) + .drop(if (branchLine != null) 1 else 0) .map { entry -> var path = entry.drop(3) // Rename entries read "XY old -> new"; staging the new path @@ -412,6 +461,7 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { _state.update { it.copy( gitProject = repo.name, gitBranch = branch, + gitAhead = ahead, gitBehind = behind, changeList = changes, gitChanges = changes.size, ) } @@ -438,6 +488,7 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { GitAuth.environment(clean, graph.settings.gitConfig().githubToken), ) if (result.exitCode != 0) throw gitFailure(R.string.git_action_clone, result) + AppLog.info("Git", "cloned ${name} from ${GitAuth.cleanUrl(url)}") project = target publishProject(target) refreshGit() @@ -451,15 +502,21 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { task(graph.appContext.getString(R.string.files_busy_committing)) { gitLock.withLock { val repo = checkNotNull(project) { graph.appContext.getString(R.string.files_no_repo) } migrate(repo) + val identity = graph.settings.gitConfig() + // Fail with a setting-destination hint before git's cryptic + // "Please tell me who you are" can appear (git hardening). + require(identity.userName.isNotBlank() && identity.userEmail.isNotBlank()) { + graph.appContext.getString(R.string.git_need_identity) + } // Stage only what the user picked instead of a blind `git add -A` // (functional audit F-008). require(selectedPaths.isNotEmpty()) { graph.appContext.getString(R.string.git_commit_no_files) } val added = exec(repo, listOf("git", "add", "--") + selectedPaths.toList()) if (added.exitCode != 0) throw gitFailure(R.string.git_action_add, added) - val identity = graph.settings.gitConfig() val args = listOf("-c", "user.name=${identity.userName}", "-c", "user.email=${identity.userEmail}", "commit", "-m", message) val committed = exec(repo, GitAuth.command(*args.toTypedArray())) if (committed.exitCode != 0) throw gitFailure(R.string.git_action_commit, committed) + AppLog.info("Git", "committed ${selectedPaths.size} file(s) on ${_state.value.gitBranch.orEmpty()}") refreshGit() } } } @@ -532,9 +589,19 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { val clean = GitAuth.cleanUrl(remote.output.trim()) val result = exec(repo, GitAuth.command(gitNet(), "push", clean, "HEAD"), GitAuth.environment(clean, graph.settings.gitConfig().githubToken)) if (result.exitCode != 0) throw gitFailure(R.string.git_action_push, result) + // git says "Everything up-to-date" with exit 0 when the remote + // already has HEAD; report that instead of a fake success line. + val upToDate = result.output.contains("up to date", ignoreCase = true) || + result.output.contains("up-to-date", ignoreCase = true) + AppLog.info( + "Git", + if (upToDate) "push: remote already up to date" else "pushed HEAD to $clean", + ) _state.update { it.copy( - message = graph.appContext.getString(R.string.files_pushed), + message = graph.appContext.getString( + if (upToDate) R.string.files_push_uptodate else R.string.files_pushed, + ), messageIsError = false, ) } @@ -564,6 +631,7 @@ class FilesViewModel(private val graph: AppGraph) : ViewModel() { GitAuth.environment(clean, graph.settings.gitConfig().githubToken), ) if (result.exitCode != 0) throw gitFailure(R.string.git_action_pull, result) + AppLog.info("Git", "fast-forwarded $branch on ${GitAuth.cleanUrl(remote.output.trim())}") _state.update { it.copy( message = graph.appContext.getString(R.string.files_pulled), diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/logs/LogsScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/logs/LogsScreen.kt new file mode 100644 index 0000000..a79bd37 --- /dev/null +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/logs/LogsScreen.kt @@ -0,0 +1,257 @@ +package io.github.oustery.opencodephone.ui.logs + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.oustery.opencodephone.AppGraph +import io.github.oustery.opencodephone.R +import io.github.oustery.opencodephone.ScreenBody +import io.github.oustery.opencodephone.ScreenHeader +import io.github.oustery.opencodephone.core.designsystem.AppFonts +import io.github.oustery.opencodephone.core.designsystem.OcConfirmDialog +import io.github.oustery.opencodephone.core.designsystem.OcDialogTone +import io.github.oustery.opencodephone.core.designsystem.OcEmptyState +import io.github.oustery.opencodephone.core.model.AppLog + +/** + * Hidden diagnostics screen (drawer → tap the version footer 5 times). + * Tails the in-memory [AppLog] buffer live so a user can read or share what + * actually happened — git commands with exit codes, bootstrap phases, server + * errors — instead of a truncated message bar. + */ +@Composable +fun LogsScreen(graph: io.github.oustery.opencodephone.AppGraph) { + val entries by AppLog.entries.collectAsStateWithLifecycle() + var minLevel by remember { mutableStateOf(AppLog.Level.DEBUG) } + var query by remember { mutableStateOf("") } + var autoScroll by remember { mutableStateOf(true) } + var confirmClear by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + val listState = rememberLazyListState() + + val filtered = remember(entries, minLevel, query) { + entries.filter { entry -> + entry.level >= minLevel && + (query.isBlank() || + entry.message.contains(query, ignoreCase = true) || + entry.tag.contains(query, ignoreCase = true) || + entry.error?.contains(query, ignoreCase = true) == true) + } + } + val atBottom by derivedStateOf { + val last = listState.layoutInfo.visibleItemsInfo.lastOrNull() + last == null || last.index >= listState.layoutInfo.totalItemsCount - 2 + } + LaunchedEffect(filtered.size, autoScroll) { + if (autoScroll && filtered.isNotEmpty() && atBottom) { + listState.scrollToItem(filtered.size - 1) + } + } + + val export = remember(filtered) { AppLog.exportText() } + val context = androidx.compose.ui.platform.LocalContext.current + + ScreenHeader( + title = stringResource(R.string.logs_title), + actions = { + IconButton(onClick = { clipboard.setText(AnnotatedString(export)) }) { + Icon( + androidx.compose.ui.res.painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.logs_copy), + ) + } + IconButton(onClick = { + // Share as plain text through the system sheet (email, chat, + // editor — whatever the user prefers for bug reports). + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, export.take(90_000)) + putExtra( + android.content.Intent.EXTRA_SUBJECT, + context.getString(R.string.logs_share_subject), + ) + } + context.startActivity( + android.content.Intent.createChooser(intent, context.getString(R.string.logs_share)), + ) + }) { + Icon( + androidx.compose.ui.res.painterResource(R.drawable.ic_send), + contentDescription = stringResource(R.string.logs_share), + ) + } + IconButton(onClick = { confirmClear = true }) { + Icon( + androidx.compose.ui.res.painterResource(R.drawable.ic_delete), + contentDescription = stringResource(R.string.logs_clear), + ) + } + }, + ) + ScreenBody { + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) { + // Filters: minimum level chips, substring search, tail toggle. + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppLog.Level.entries.forEach { level -> + FilterChip( + selected = minLevel == level, + onClick = { minLevel = level }, + label = { Text(stringResource(logsLevelRes(level))) }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text(stringResource(R.string.logs_search_hint)) }, + singleLine = true, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.small, + ) + Text( + stringResource(R.string.logs_auto_scroll), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Switch(checked = autoScroll, onCheckedChange = { autoScroll = it }) + } + Text( + stringResource(R.string.logs_entries, filtered.size, entries.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + if (filtered.isEmpty()) { + OcEmptyState( + title = stringResource(R.string.logs_empty), + description = stringResource(R.string.logs_empty_hint), + modifier = Modifier.fillMaxSize().weight(1f), + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(filtered, key = { "${it.timestampMs}-${it.hashCode()}" }) { entry -> + LogRow(entry) + } + } + } + } + } + + if (confirmClear) { + OcConfirmDialog( + title = stringResource(R.string.logs_clear), + text = stringResource(R.string.logs_clear_confirm), + confirmLabel = stringResource(R.string.action_delete), + dismissLabel = stringResource(R.string.action_cancel), + icon = androidx.compose.ui.res.painterResource(R.drawable.ic_delete), + tone = OcDialogTone.DESTRUCTIVE, + onConfirm = { + AppLog.clear() + confirmClear = false + }, + onDismiss = { confirmClear = false }, + ) + } +} + +private fun logsLevelRes(level: AppLog.Level): Int = when (level) { + AppLog.Level.DEBUG -> R.string.logs_level_debug + AppLog.Level.INFO -> R.string.logs_level_info + AppLog.Level.WARN -> R.string.logs_level_warn + AppLog.Level.ERROR -> R.string.logs_level_error +} + +@Composable +private fun LogRow(entry: AppLog.Entry) { + val levelColor = when (entry.level) { + AppLog.Level.DEBUG -> MaterialTheme.colorScheme.onSurfaceVariant + AppLog.Level.INFO -> MaterialTheme.colorScheme.primary + AppLog.Level.WARN -> MaterialTheme.colorScheme.tertiary + AppLog.Level.ERROR -> MaterialTheme.colorScheme.error + } + val time = remember(entry.timestampMs) { + java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date(entry.timestampMs)) + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + time, + style = MaterialTheme.typography.labelSmall, + fontFamily = AppFonts.Code, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + entry.level.name.first().toString(), + style = MaterialTheme.typography.labelSmall, + fontFamily = AppFonts.Code, + color = levelColor, + modifier = Modifier.padding(top = 2.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + "${entry.tag}: ${entry.message}", + style = MaterialTheme.typography.bodySmall, + fontFamily = AppFonts.Code, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + entry.error?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + fontFamily = AppFonts.Code, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt index ad8ab69..33b30c7 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsScreen.kt @@ -73,6 +73,8 @@ import io.github.oustery.opencodephone.BuildConfig import io.github.oustery.opencodephone.NavMenuButton import io.github.oustery.opencodephone.R import io.github.oustery.opencodephone.ScreenBody +import io.github.oustery.opencodephone.core.designsystem.OcConfirmDialog +import io.github.oustery.opencodephone.core.designsystem.OcDialogTone import io.github.oustery.opencodephone.core.designsystem.AppFonts import io.github.oustery.opencodephone.core.designsystem.CodeTypography import io.github.oustery.opencodephone.core.designsystem.OcChipTone @@ -587,22 +589,18 @@ private fun ProvidersPage(vm: SettingsViewModel, state: SettingsUiState, onBack: pendingKeyRemoval?.let { providerId -> // Destructive action confirmation (UI audit B2): erasing stored // credentials must never be a single tap. - AlertDialog( - onDismissRequest = { pendingKeyRemoval = null }, - title = { Text(stringResource(R.string.settings_providers_remove_title)) }, - text = { Text(stringResource(R.string.settings_providers_remove_warning, providerId)) }, - confirmButton = { - TextButton( - onClick = { - pendingKeyRemoval = null - vm.removeProviderKey(providerId) - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { Text(stringResource(R.string.action_delete)) } - }, - dismissButton = { - TextButton(onClick = { pendingKeyRemoval = null }) { Text(stringResource(R.string.action_cancel)) } + OcConfirmDialog( + title = stringResource(R.string.settings_providers_remove_title), + text = stringResource(R.string.settings_providers_remove_warning, providerId), + confirmLabel = stringResource(R.string.action_delete), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_delete), + tone = OcDialogTone.DESTRUCTIVE, + onConfirm = { + pendingKeyRemoval = null + vm.removeProviderKey(providerId) }, + onDismiss = { pendingKeyRemoval = null }, ) } state.accountAuth?.let { auth -> @@ -613,19 +611,17 @@ private fun ProvidersPage(vm: SettingsViewModel, state: SettingsUiState, onBack: onOpen = { vm.openVerificationUrl(auth.verificationUrl) }, onCancel = { vm.dismissAccountAuth() }, ) - is AccountAuthUi.Failed -> AlertDialog( - onDismissRequest = { vm.dismissAccountAuth() }, - title = { Text(stringResource(R.string.settings_account_failed_title)) }, - text = { Text(auth.message) }, - confirmButton = { - Button(onClick = { - vm.dismissAccountAuth() - vm.startConsoleSignIn() - }) { Text(stringResource(R.string.settings_account_retry)) } - }, - dismissButton = { - TextButton(onClick = { vm.dismissAccountAuth() }) { Text(stringResource(R.string.action_cancel)) } + is AccountAuthUi.Failed -> OcConfirmDialog( + title = stringResource(R.string.settings_account_failed_title), + text = auth.message, + confirmLabel = stringResource(R.string.settings_account_retry), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_shield), + onConfirm = { + vm.dismissAccountAuth() + vm.startConsoleSignIn() }, + onDismiss = { vm.dismissAccountAuth() }, ) } } @@ -644,19 +640,17 @@ private fun ProvidersPage(vm: SettingsViewModel, state: SettingsUiState, onBack: onCode = { vm.submitProviderOAuthCode(it) }, onDismiss = { vm.dismissProviderOAuth() }, ) - is ProviderOAuthUi.Failed -> AlertDialog( - onDismissRequest = { vm.dismissProviderOAuth() }, - title = { Text(stringResource(R.string.settings_account_failed_title)) }, - text = { Text(oauth.message) }, - confirmButton = { - Button(onClick = { - vm.dismissProviderOAuth() - vm.startProviderOAuth(oauth.providerId) - }) { Text(stringResource(R.string.settings_account_retry)) } - }, - dismissButton = { - TextButton(onClick = { vm.dismissProviderOAuth() }) { Text(stringResource(R.string.action_cancel)) } + is ProviderOAuthUi.Failed -> OcConfirmDialog( + title = stringResource(R.string.settings_account_failed_title), + text = oauth.message, + confirmLabel = stringResource(R.string.settings_account_retry), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_shield), + onConfirm = { + vm.dismissProviderOAuth() + vm.startProviderOAuth(oauth.providerId) }, + onDismiss = { vm.dismissProviderOAuth() }, ) } } @@ -910,20 +904,17 @@ private fun EnvironmentPage(vm: SettingsViewModel, state: SettingsUiState, onBac } if (state.showReinstallConfirm) { - AlertDialog( - onDismissRequest = { vm.showReinstallConfirm(false) }, - title = { Text(stringResource(R.string.settings_env_reinstall)) }, - text = { Text(stringResource(R.string.settings_env_reinstall_confirm)) }, - confirmButton = { - Button(onClick = { vm.reinstallEnvironment() }) { - Text(stringResource(R.string.settings_env_reinstall)) - } - }, - dismissButton = { - TextButton(onClick = { vm.showReinstallConfirm(false) }) { - Text(stringResource(R.string.action_cancel)) - } - }, + // Deleting the whole Debian environment is the most destructive + // action in the app — destructive tone is mandatory here. + OcConfirmDialog( + title = stringResource(R.string.settings_env_reinstall), + text = stringResource(R.string.settings_env_reinstall_confirm), + confirmLabel = stringResource(R.string.settings_env_reinstall), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_delete), + tone = OcDialogTone.DESTRUCTIVE, + onConfirm = { vm.reinstallEnvironment() }, + onDismiss = { vm.showReinstallConfirm(false) }, ) } } @@ -1073,48 +1064,39 @@ private fun AppPage( } if (state.showImportConfirm) { - AlertDialog( - onDismissRequest = { vm.dismissImportConfirm() }, - title = { Text(stringResource(R.string.settings_import_confirm_title)) }, - text = { Text(stringResource(R.string.settings_import_confirm_body)) }, - confirmButton = { - Button(onClick = { vm.confirmImport() }) { - Text(stringResource(R.string.settings_backup_import)) - } - }, - dismissButton = { - TextButton(onClick = { vm.dismissImportConfirm() }) { Text(stringResource(R.string.action_cancel)) } - }, + OcConfirmDialog( + title = stringResource(R.string.settings_import_confirm_title), + text = stringResource(R.string.settings_import_confirm_body), + confirmLabel = stringResource(R.string.settings_backup_import), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_file), + onConfirm = { vm.confirmImport() }, + onDismiss = { vm.dismissImportConfirm() }, ) } confirmClear?.let { kind -> - AlertDialog( - onDismissRequest = { confirmClear = null }, - title = { Text(stringResource(R.string.settings_data_clear_title)) }, - text = { - Text( - stringResource( - when (kind) { - "drafts" -> R.string.settings_data_confirm_drafts - "uploads" -> R.string.settings_data_confirm_uploads - else -> R.string.settings_data_confirm_cache - }, - ), - ) - }, - confirmButton = { - Button(onClick = { - when (kind) { - "drafts" -> vm.clearDrafts() - "uploads" -> vm.clearUploads() - else -> vm.clearInstallerCache() - } - confirmClear = null - }) { Text(stringResource(R.string.action_delete)) } - }, - dismissButton = { - TextButton(onClick = { confirmClear = null }) { Text(stringResource(R.string.action_cancel)) } + OcConfirmDialog( + title = stringResource(R.string.settings_data_clear_title), + text = stringResource( + when (kind) { + "drafts" -> R.string.settings_data_confirm_drafts + "uploads" -> R.string.settings_data_confirm_uploads + else -> R.string.settings_data_confirm_cache + }, + ), + confirmLabel = stringResource(R.string.action_delete), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_delete), + tone = OcDialogTone.DESTRUCTIVE, + onConfirm = { + when (kind) { + "drafts" -> vm.clearDrafts() + "uploads" -> vm.clearUploads() + else -> vm.clearInstallerCache() + } + confirmClear = null }, + onDismiss = { confirmClear = null }, ) } } diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt index 83b86a5..1839de1 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/settings/SettingsViewModel.kt @@ -7,6 +7,7 @@ import io.github.oustery.opencodephone.R import io.github.oustery.opencodephone.core.linuxenv.ServerState import io.github.oustery.opencodephone.core.model.BootstrapState import io.github.oustery.opencodephone.core.model.Pins +import io.github.oustery.opencodephone.core.model.AppLog import io.github.oustery.opencodephone.core.opencode.ConsolePollResult import io.github.oustery.opencodephone.core.opencode.OpenCodeClient import io.github.oustery.opencodephone.core.opencode.OpenCodeConsoleClient @@ -222,7 +223,10 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { it.copy(serverDirty = changed, server = if (changed) it.server else cfg, connectionTest = "Applied") } } catch (e: kotlinx.coroutines.CancellationException) { throw e } - catch (e: Exception) { _state.update { it.copy(connectionTest = e.message) } } + catch (e: Exception) { + AppLog.warn("Settings", "connection test failed", e) + _state.update { it.copy(connectionTest = e.message) } + } } } fun cancelServerChanges() = reload() @@ -234,7 +238,10 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { graph.refreshClient() reload() } catch (e: kotlinx.coroutines.CancellationException) { throw e } - catch (e: Exception) { _state.update { it.copy(connectionTest = e.message) } } + catch (e: Exception) { + AppLog.warn("Settings", "connection test failed", e) + _state.update { it.copy(connectionTest = e.message) } + } } } @@ -262,6 +269,7 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { } catch (e: CancellationException) { throw e } catch (e: Exception) { + AppLog.warn("Settings", "sign-in methods fetch failed", e) methodsNote = (e.message ?: e.javaClass.simpleName).take(200) emptyMap() } @@ -627,7 +635,10 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { graph.bootstrap.reset() graph.bootstrap.start(settings.opencodeVersionPin()) } catch (e: kotlinx.coroutines.CancellationException) { throw e } - catch (e: Exception) { _state.update { it.copy(connectionTest = e.message) } } + catch (e: Exception) { + AppLog.warn("Settings", "connection test failed", e) + _state.update { it.copy(connectionTest = e.message) } + } } } @@ -755,6 +766,7 @@ class SettingsViewModel(private val graph: AppGraph) : ViewModel() { } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { + AppLog.warn("Settings", "server connection test failed", e) _state.update { it.copy(dataMessage = "Cleanup failed: ${e.message?.take(200) ?: e.javaClass.simpleName}") } diff --git a/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt b/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt index 2ea1d93..f2339a8 100644 --- a/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt +++ b/app/src/main/java/io/github/oustery/opencodephone/ui/terminal/TerminalScreen.kt @@ -60,6 +60,8 @@ import io.github.oustery.opencodephone.AppGraph import io.github.oustery.opencodephone.R import io.github.oustery.opencodephone.ScreenBody import io.github.oustery.opencodephone.ScreenHeader +import io.github.oustery.opencodephone.core.designsystem.OcConfirmDialog +import io.github.oustery.opencodephone.core.designsystem.OcDialogTone import io.github.oustery.opencodephone.core.designsystem.CodeTypography import io.github.oustery.opencodephone.core.designsystem.OcChipTone import io.github.oustery.opencodephone.core.designsystem.OcEmptyState @@ -106,21 +108,20 @@ fun TerminalScreen(graph: AppGraph) { ) if (confirmRestart) { - AlertDialog( - onDismissRequest = { confirmRestart = false }, - title = { Text(stringResource(R.string.terminal_restart)) }, - text = { Text(stringResource(R.string.terminal_restart_confirm)) }, - confirmButton = { - TextButton(onClick = { - confirmRestart = false - vm.restart() - }) { Text(stringResource(R.string.terminal_restart)) } - }, - dismissButton = { - TextButton(onClick = { confirmRestart = false }) { - Text(stringResource(R.string.action_cancel)) - } + // Restarting kills the running shell and its state — destructive + // tone keeps that consequence visible (modal-dialogs pass). + OcConfirmDialog( + title = stringResource(R.string.terminal_restart), + text = stringResource(R.string.terminal_restart_confirm), + confirmLabel = stringResource(R.string.terminal_restart), + dismissLabel = stringResource(R.string.action_cancel), + icon = painterResource(R.drawable.ic_refresh), + tone = OcDialogTone.DESTRUCTIVE, + onConfirm = { + confirmRestart = false + vm.restart() }, + onDismiss = { confirmRestart = false }, ) } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b7cba57..898cc99 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -205,6 +205,33 @@ HTTP-прокси для git хост:порт Применяется к clone, pull и push. Пусто — прямое соединение. + Поддерживаются только HTTPS-адреса (https://…). + URL репозитория + Сначала укажите имя и email в Настройки → Git. + Откройте Настройки → Git, заполните имя пользователя и email, затем повторите. + Проверьте токен GitHub в Настройки → Git: возможно, он истёк, не имеет нужных прав или не относится к этому репозиторию. + Истории разошлись. Push и pull не переписывают историю — pull работает только в режиме fast-forward; выполните слияние в терминале или сбросьте до состояния удалённого репозитория. + В этой ветке ещё нет коммитов — сначала создайте коммит, затем push. + Удалённый хост недоступен: проверьте интернет, настройки прокси (Настройки → Git) или адрес репозитория. + Набор сертификатов отсутствовал; приложение восстанавливает его автоматически. Если ошибка повторяется, проверьте настройку SSL (Настройки → Git) или зеркало/прокси. + Удалённый репозиторий уже в этом состоянии — пушить нечего. + ↑ %1$d + ↓ %1$d + Диагностика + Скопировать журнал + Поделиться журналом + диагностика opencodephone + Очистить журнал + Стереть все собранные записи диагностики? Файлы на диске сохранятся. + Поиск по тегу или тексту + Автопрокрутка + %1$d из %2$d записей + Записей пока нет + Записи появляются по мере работы: git-команды, фазы установки, события сервера и ошибки. + Отладка + Инфо + Предупр. + Ошибки Окружение Linux Установка Рабочая папка diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9baef25..323444d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -238,6 +238,33 @@ HTTP proxy for git host:port Applies to clone, pull and push. Leave empty for a direct connection. + Only HTTPS URLs are supported (https://…). + Repository URL + Set your git user name and email in Settings → Git first. + Open Settings → Git and fill in your user name and email, then retry. + Check the GitHub token in Settings → Git: it may be expired, lack the required scopes, or not belong to this repository. + The histories have diverged. Push and pull refuse to rewrite history — pull is fast-forward only; merge manually in a terminal or reset to the remote. + This branch has no commits yet — create a commit first, then push. + The remote host is unreachable: check the internet connection, proxy settings (Settings → Git) or the repository URL. + The certificate store was missing; the app repairs it automatically. If this persists, check the SSL setting (Settings → Git) or the mirror/proxy. + The remote already has this state — nothing to push. + ↑ %1$d + ↓ %1$d + Diagnostics + Copy log + Share log + opencodephone diagnostics + Clear log + Erase all collected diagnostic entries? Files on disk are kept. + Search tag or text + Tail + %1$d of %2$d entries + Nothing logged yet + Entries appear here as you use the app: git commands, bootstrap phases, server events and failures. + Debug + Info + Warn + Errors Linux environment Bootstrap Workspace diff --git a/core/designsystem/src/main/java/io/github/oustery/opencodephone/core/designsystem/OcDialogs.kt b/core/designsystem/src/main/java/io/github/oustery/opencodephone/core/designsystem/OcDialogs.kt new file mode 100644 index 0000000..1361143 --- /dev/null +++ b/core/designsystem/src/main/java/io/github/oustery/opencodephone/core/designsystem/OcDialogs.kt @@ -0,0 +1,191 @@ +package io.github.oustery.opencodephone.core.designsystem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp + +/** + * Shared dialog voice for the whole app (modal-dialogs pass): every + * confirmation carries a tinted leading emblem, one clear primary action and + * one quiet dismissal, and destructive actions are rendered in the error + * color so they can never be confused with safe ones. + */ + +/** Visual voice of a dialog: calm, destructive, or accent (multi-step flows). */ +enum class OcDialogTone { NEUTRAL, DESTRUCTIVE, ACCENT } + +/** Tinted circular emblem shown above the dialog title. */ +@Composable +private fun OcDialogEmblem(icon: Painter, tone: OcDialogTone) { + val container = when (tone) { + OcDialogTone.NEUTRAL -> MaterialTheme.colorScheme.primaryContainer + OcDialogTone.DESTRUCTIVE -> MaterialTheme.colorScheme.errorContainer + OcDialogTone.ACCENT -> MaterialTheme.colorScheme.tertiaryContainer + } + val content = when (tone) { + OcDialogTone.NEUTRAL -> MaterialTheme.colorScheme.onPrimaryContainer + OcDialogTone.DESTRUCTIVE -> MaterialTheme.colorScheme.onErrorContainer + OcDialogTone.ACCENT -> MaterialTheme.colorScheme.onTertiaryContainer + } + Surface(shape = CircleShape, color = container, modifier = Modifier.size(40.dp)) { + Box(contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, tint = content, modifier = Modifier.size(20.dp)) + } + } +} + +/** + * The standard confirmation dialog. Replaces the ad-hoc AlertDialogs whose + * button hierarchy drifted between screens. + */ +@Composable +fun OcConfirmDialog( + title: String, + text: String, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + icon: Painter? = null, + tone: OcDialogTone = OcDialogTone.NEUTRAL, + dismissLabel: String? = null, + confirmEnabled: Boolean = true, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + icon = icon?.let { { OcDialogEmblem(it, tone) } }, + title = { Text(title) }, + text = { Text(text) }, + confirmButton = { + if (tone == OcDialogTone.DESTRUCTIVE) { + Button( + onClick = onConfirm, + enabled = confirmEnabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { Text(confirmLabel) } + } else { + Button(onClick = onConfirm, enabled = confirmEnabled) { Text(confirmLabel) } + } + }, + dismissButton = dismissLabel?.let { + { TextButton(onClick = onDismiss) { Text(it) } } + }, + ) +} + +/** + * Standard single-field input dialog: autofocused field, IME-friendly + * (Done/Go confirms), optional inline validation shown as supporting text + * with a disabled confirm button. + */ +@Composable +fun OcInputDialog( + title: String, + valueLabel: String, + initial: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + icon: Painter? = null, + placeholder: String? = null, + supportingText: String? = null, + confirmLabel: String, + dismissLabel: String, + /** Returns an error message for the current value, or null when valid. */ + validate: ((String) -> String?)? = null, + singleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + var value by remember { mutableStateOf(TextFieldValue(initial)) } + val error = validate?.invoke(value.text) + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + val valid = value.text.isNotBlank() && error == null + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier.imePadding(), + icon = icon?.let { { OcDialogEmblem(it, OcDialogTone.NEUTRAL) } }, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(valueLabel) }, + placeholder = placeholder?.let { { Text(it) } }, + supportingText = error?.let { reason -> { Text(reason) } } + ?: supportingText?.let { { Text(it) } }, + isError = error != null, + singleLine = singleLine, + keyboardOptions = keyboardOptions, + // The IME action confirms — the dialog never traps the + // keyboard behind an extra tap. + keyboardActions = KeyboardActions( + onDone = { if (valid) onConfirm(value.text.trim()) }, + onGo = { if (valid) onConfirm(value.text.trim()) }, + ), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + shape = MaterialTheme.shapes.small, + ) + } + }, + confirmButton = { + Button( + onClick = { onConfirm(value.text.trim()) }, + enabled = valid, + ) { Text(confirmLabel) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(dismissLabel) } }, + ) +} + +/** + * Two-line helper for dialog bodies that pair a headline sentence with a + * quieter supporting paragraph. + */ +@Composable +fun OcDialogBody(headline: String, detail: String? = null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(headline, style = MaterialTheme.typography.bodyMedium) + detail?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt index d422eae..1bf2e6c 100644 --- a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt +++ b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/BootstrapManager.kt @@ -5,6 +5,7 @@ import android.system.Os import android.system.OsConstants import io.github.oustery.opencodephone.core.model.BootstrapPhase import io.github.oustery.opencodephone.core.model.BootstrapState +import io.github.oustery.opencodephone.core.model.AppLog import io.github.oustery.opencodephone.core.model.DeviceAbi import io.github.oustery.opencodephone.core.model.Pins import kotlinx.coroutines.CancellationException @@ -126,6 +127,7 @@ class BootstrapManager( } private fun log(line: String) { + AppLog.info("Bootstrap", line) val stamped = "[${dateFormat.format(Date())}] $line" _state.update { it.copy(log = (it.log + stamped).takeLast(LOG_LIMIT)) } runCatching { diff --git a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ManagedServer.kt b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ManagedServer.kt index f828664..116dd6a 100644 --- a/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ManagedServer.kt +++ b/core/linuxenv/src/main/java/io/github/oustery/opencodephone/core/linuxenv/ManagedServer.kt @@ -1,6 +1,7 @@ package io.github.oustery.opencodephone.core.linuxenv import kotlinx.coroutines.CancellationException +import io.github.oustery.opencodephone.core.model.AppLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -68,7 +69,10 @@ class ManagedServer( throw e } catch (e: Exception) { synchronized(guard) { - if (generation == ticket) _state.value = ServerState.Failed("Cannot start OpenCode: ${e.message}") + if (generation == ticket) { + AppLog.error("Server", "cannot start OpenCode", e) + _state.value = ServerState.Failed("Cannot start OpenCode: ${e.message}") + } } return@withLock } @@ -94,6 +98,7 @@ class ManagedServer( if (process === proc) { process = null config = null + AppLog.error("Server", "OpenCode exited (${proc.exitValue()})") _state.value = ServerState.Failed("OpenCode exited (${proc.exitValue()}). ${_log.value.takeLast(1500)}") } } diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 5ca2e05..7409ce6 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -26,6 +26,7 @@ kotlin { dependencies { api(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) } diff --git a/core/model/src/main/java/io/github/oustery/opencodephone/core/model/AppLog.kt b/core/model/src/main/java/io/github/oustery/opencodephone/core/model/AppLog.kt new file mode 100644 index 0000000..69203e4 --- /dev/null +++ b/core/model/src/main/java/io/github/oustery/opencodephone/core/model/AppLog.kt @@ -0,0 +1,94 @@ +package io.github.oustery.opencodephone.core.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Application-wide diagnostic log. + * + * A bounded in-memory ring buffer every module can append to without any + * Android dependency, mirrored to optional sinks (rotating file, Logcat) + * installed by the app graph. The hidden diagnostics screen (Settings → + * About → tap the version footer) tails the same buffer live, so a user can + * capture what happened before reporting a bug — including the exact git + * commands, bootstrap phases and server errors that message bars truncate. + * + * Appending is synchronized and cheap; entries carry wall-clock time so an + * export stays readable without a shared clock reference. + */ +object AppLog { + + enum class Level { DEBUG, INFO, WARN, ERROR } + + data class Entry( + val timestampMs: Long, + val level: Level, + val tag: String, + val message: String, + /** Single-line `Throwable` summary, when one was attached. */ + val error: String? = null, + ) + + const val CAPACITY = 1500 + + private val lock = Any() + private val buffer = ArrayDeque(CAPACITY) + private val _entries = MutableStateFlow>(emptyList()) + + /** Latest-first view for UI tailing; oldest entries fall out at [CAPACITY]. */ + val entries: StateFlow> + @Suppress("MemberVisibilityCanBePrivate") get() = _entries + + /** Installed by the app graph; must never throw. */ + @Volatile + var sink: ((Entry) -> Unit)? = null + + fun debug(tag: String, message: String, throwable: Throwable? = null) = + append(Level.DEBUG, tag, message, throwable) + + fun info(tag: String, message: String, throwable: Throwable? = null) = + append(Level.INFO, tag, message, throwable) + + fun warn(tag: String, message: String, throwable: Throwable? = null) = + append(Level.WARN, tag, message, throwable) + + fun error(tag: String, message: String, throwable: Throwable? = null) = + append(Level.ERROR, tag, message, throwable) + + private fun append(level: Level, tag: String, message: String, throwable: Throwable?) { + val entry = Entry( + timestampMs = System.currentTimeMillis(), + level = level, + tag = tag.take(24), + message = message.take(600), + error = throwable?.let { "${it.javaClass.simpleName}: ${it.message.orEmpty().take(300)}" }, + ) + synchronized(lock) { + buffer.addLast(entry) + while (buffer.size > CAPACITY) buffer.removeFirst() + // Publish oldest-first for a natural reading order; the copy is + // small (≤ CAPACITY) and appends are rare relative to UI frames. + _entries.value = buffer.toList() + } + runCatching { sink?.invoke(entry) } + } + + fun clear() { + synchronized(lock) { + buffer.clear() + _entries.value = emptyList() + } + } + + /** Human-readable export (chronological), used by copy/share. */ + fun exportText(minLevel: Level = Level.DEBUG): String { + val snapshot = synchronized(lock) { buffer.toList() } + val format = java.text.SimpleDateFormat("MM-dd HH:mm:ss.SSS", java.util.Locale.US) + return snapshot + .filter { it.level >= minLevel } + .joinToString("\n") { e -> + val error = e.error?.let { " — $it" }.orEmpty() + "${format.format(java.util.Date(e.timestampMs))} ${e.level.name.first()}/${e.tag}: ${e.message}$error" + } + } +} diff --git a/core/model/src/test/java/io/github/oustery/opencodephone/core/model/AppLogTest.kt b/core/model/src/test/java/io/github/oustery/opencodephone/core/model/AppLogTest.kt new file mode 100644 index 0000000..98f1dbb --- /dev/null +++ b/core/model/src/test/java/io/github/oustery/opencodephone/core/model/AppLogTest.kt @@ -0,0 +1,49 @@ +package io.github.oustery.opencodephone.core.model + +import kotlinx.coroutines.flow.first +import org.junit.Assert.* +import org.junit.Test + +class AppLogTest { + + @Test fun `buffer is bounded and keeps the newest entries in order`() { + // The global buffer may already carry entries from other tests; + // record the watermark and only reason about what this test adds. + val before = AppLog.entries.value.size + repeat(AppLog.CAPACITY + 50) { AppLog.info("Test", "line $it") } + val after = AppLog.entries.value + assertEquals(before + AppLog.CAPACITY, after.size) + val lines = after.map { it.message } + assertTrue(lines.contains("line 50")) + assertTrue(lines.contains("line ${AppLog.CAPACITY + 49}")) + assertFalse(lines.contains("line 49")) + // Chronological order preserved. + assertEquals(after.size, after.map { it.timestampMs }.zipWithNext().count { (a, b) -> a <= b } + 1) + } + + @Test fun `throwables become one-line summaries and never throw`() { + AppLog.error("Test", "boom", IllegalStateException("root cause details")) + val entry = AppLog.entries.value.last { it.message == "boom" } + assertEquals(AppLog.Level.ERROR, entry.level) + assertTrue(entry.error!!.contains("IllegalStateException: root cause details")) + AppLog.sink = { throw RuntimeException("broken sink must not break logging") } + AppLog.info("Test", "still alive") + assertEquals("still alive", AppLog.entries.value.last().message) + AppLog.sink = null + } + + @Test fun `export is chronological human-readable text with levels`() { + AppLog.warn("ExportTest", "something to export") + val text = AppLog.exportText() + val line = text.lineSequence().last { it.contains("something to export") } + assertTrue(line.contains("W/ExportTest")) + assertTrue(Regex("\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}").containsMatchIn(line)) + } + + @Test fun `clear empties the visible buffer`() { + AppLog.clear() + assertTrue(AppLog.entries.value.isEmpty()) + AppLog.info("Test", "after clear") + assertEquals(1, AppLog.entries.value.size) + } +} diff --git a/core/opencode/src/main/java/io/github/oustery/opencodephone/core/opencode/OpenCodeClient.kt b/core/opencode/src/main/java/io/github/oustery/opencodephone/core/opencode/OpenCodeClient.kt index 2a16bac..9edc2f8 100644 --- a/core/opencode/src/main/java/io/github/oustery/opencodephone/core/opencode/OpenCodeClient.kt +++ b/core/opencode/src/main/java/io/github/oustery/opencodephone/core/opencode/OpenCodeClient.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.serialization.SerializationException import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer +import io.github.oustery.opencodephone.core.model.AppLog import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -183,6 +184,9 @@ class OpenCodeClient( transport(snapshot).newCall(request).consumeCancellable { response: Response -> val text = response.body?.string().orEmpty() if (!response.isSuccessful) { + // Every server rejection lands in the diagnostic log with the + // full context the UI message bar truncates. + AppLog.warn("OpenCode", "HTTP ${response.code} $method $path: ${text.take(200)}") throw OpenCodeApiException( response.code, "HTTP ${response.code} ${method} $path: ${text.take(300)}", @@ -190,6 +194,7 @@ class OpenCodeClient( } val contentType = response.header("Content-Type").orEmpty().lowercase() if (text.isNotBlank() && (contentType.contains("text/html") || text.trimStart().startsWith("<"))) { + AppLog.warn("OpenCode", "HTML instead of JSON at $path (wrong server?)") throw OpenCodeApiException( response.code, "Expected OpenCode V1 JSON at $path, but received HTML. " + @@ -211,6 +216,7 @@ class OpenCodeClient( return try { json.decodeFromString(serializer, text) } catch (e: SerializationException) { + AppLog.warn("OpenCode", "unparsable V1 JSON for $path: ${text.take(120)}") throw OpenCodeApiException(-1, "Invalid OpenCode V1 JSON response for $path. Check the server version and address.") } }