From e250048cdaa8cedc853c002f373f91a4a83e0aa3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 29 Jul 2026 12:01:01 +0200 Subject: [PATCH 1/6] Stop the platform's log filter swallowing our stack traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit android.util.Log drops a throwable's trace whenever anything in its cause chain is an UnknownHostException. getStackTraceString returns "" and Log.e(tag, msg, tr) prints the message alone; AOSP does it on purpose, to "reduce the amount of log spew that apps do in the non-error condition of the network being unavailable". Nothing looks wrong when it happens — only the trace is missing. For an app whose network use is a DoH resolver, a module store and a GitHub feed, that filter removes traces from exactly the failures worth reporting. CrashRecorder was writing 114-byte crash files that held the header and no trace at all, which is how the crash card came to have nothing on it. VectorDns logged "dns: DoH lookup of $host failed" with no trace, DnsOverHttps signalling failure by throwing UnknownHostException. VectorContext.log and XposedBridge.log(Throwable), both module-facing, answered a module's report of a failed request with a blank line. So format traces ourselves and never hand a throwable to the framework: logE/logW/logI in Logging.kt for the manager, and Utils.Log's own getStackTraceString for the framework side. The helpers also break long entries on line boundaries, which Log.e used to do for us and Log.println does not — a deep trace would otherwise lose its tail. With traces to show, the crash card had to stop being a paragraph of monospace. It states four facts now — what threw, what it said, the nearest frame that is ours, and when — and "See the trace" opens a screen where the trace is a list: our frames marked and emphasised, the platform's dimmed, "Caused by" as headings, each frame tappable to copy. Copy all still copies every record, whether or not it could be parsed. --- .../de/robv/android/xposed/XposedBridge.java | 6 +- .../org/matrix/vector/manager/Constants.kt | 29 +- .../org/matrix/vector/manager/Logging.kt | 98 +++++++ .../manager/data/github/CommitArchive.kt | 13 +- .../vector/manager/data/github/GitHubAuth.kt | 10 +- .../manager/data/github/GitHubRepository.kt | 21 +- .../vector/manager/data/log/CrashRecorder.kt | 33 ++- .../vector/manager/data/log/CrashReport.kt | 166 +++++++++++ .../manager/data/model/ModuleDetection.kt | 20 +- .../data/model/ModuleDetectionCache.kt | 5 +- .../manager/data/repository/AppRepository.kt | 12 +- .../data/repository/BackupRepository.kt | 25 +- .../data/repository/FrameworkInstaller.kt | 10 +- .../manager/data/repository/LaunchShortcut.kt | 18 +- .../data/repository/ManagerInstaller.kt | 15 +- .../data/repository/ModuleInstaller.kt | 10 +- .../data/repository/ModuleRepository.kt | 14 +- .../manager/data/repository/RepoRepository.kt | 16 +- .../matrix/vector/manager/ipc/DaemonClient.kt | 12 +- .../vector/manager/ipc/InstallResult.kt | 8 +- .../vector/manager/ipc/PackageBroadcasts.kt | 7 +- .../matrix/vector/manager/net/VectorDns.kt | 6 +- .../org/matrix/vector/manager/ui/VectorApp.kt | 10 +- .../vector/manager/ui/components/Clipboard.kt | 20 ++ .../ui/components/PackageActionMenu.kt | 33 +-- .../vector/manager/ui/navigation/Route.kt | 9 + .../ui/screens/home/CrashTraceScreen.kt | 259 ++++++++++++++++++ .../manager/ui/screens/home/HomeViewModel.kt | 24 +- .../ui/screens/home/SystemStatusScreen.kt | 93 ++++--- .../manager/ui/screens/logs/LogsScreen.kt | 8 +- .../manager/ui/screens/logs/LogsViewModel.kt | 11 +- .../ui/screens/modules/ModulesViewModel.kt | 23 +- .../ui/screens/modules/ScopeViewModel.kt | 46 ++-- .../ui/screens/report/TroubleshootScreen.kt | 8 +- .../manager/ui/screens/splash/SplashScreen.kt | 8 +- .../update/FrameworkUpdateViewModel.kt | 26 +- manager/src/main/res/values/strings.xml | 18 ++ .../java/org/lsposed/lspd/util/Utils.java | 15 +- .../org/matrix/vector/impl/VectorContext.kt | 10 +- 39 files changed, 840 insertions(+), 335 deletions(-) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/Logging.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Clipboard.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java b/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java index 0f59feaa9..0e152909f 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java @@ -5,6 +5,7 @@ import android.content.res.TypedArray; import android.util.Log; +import org.lsposed.lspd.util.Utils; import org.matrix.vector.impl.hooks.VectorNativeHooker; import org.matrix.vector.impl.hooks.VectorLegacyCallback; import org.matrix.vector.nativebridge.HookBridge; @@ -137,7 +138,10 @@ public synchronized static void log(String text) { * @param t The Throwable object for the stack trace. */ public synchronized static void log(Throwable t) { - String logStr = Log.getStackTraceString(t); + // Utils.Log's, not android.util.Log's: the latter returns an empty string for any + // UnknownHostException cause chain, so a module logging a failed request landed an empty + // line in the modules log. + String logStr = Utils.Log.getStackTraceString(t); Log.e(TAG, logStr); } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt b/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt index 726558938..b0d120726 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt @@ -1,7 +1,6 @@ package org.matrix.vector.manager import android.os.IBinder -import android.util.Log import kotlin.system.exitProcess import org.lsposed.lspd.ILSPManagerService import org.matrix.vector.manager.di.ServiceLocator @@ -23,24 +22,8 @@ object Constants { * lines and travels in the zip export — the place a reader already looks. A file-local tag * would be ordinary Android practice and would land nowhere; there are none in this app. * - * A message is `area: lowercase phrase naming the operation and its subject`, where the area - * is one of ipc, dns, apps, modules, backup, restore, scope, store, update, feed, auth, - * status, framework, logs, actions, report, splash. The subject matters: "modules: enable of - * $packageName failed" can be acted on, "failed to enable module" cannot. - * - * The `Throwable` is always the last argument — never `e.message`, which discards the stack, - * and never `Log.getStackTraceString`, which discards the level. Nothing secret is ever - * interpolated: no OAuth token, no SAF `Uri` beyond its authority, no third-party query - * string. - * - * Levels are `e` when something the user asked for did not happen and nothing else will - * explain it, `w` for a degraded path recovered from, and `i` for a one-off milestone worth - * having in a bug report — counts, versions, the endpoint chosen. `d` and `v` are not added, - * because release builds ship them. - * - * A `CancellationException` is never logged. Navigating away from a screen cancels its scope, - * and a log that fires every time someone presses back is a log nobody reads; any - * `runCatching` or broad `catch` that can see one rethrows or skips it first. + * Nothing logs with it directly. [logE], [logW] and [logI] hold it, and the conventions for + * what a message says and which level it says it at are documented on them. */ const val TAG = "VectorManager" @@ -54,17 +37,13 @@ object Constants { // framework is gone". Exiting is blunt but honest. binder.linkToDeath( { - Log.w(TAG, "ipc: daemon binder died, manager exiting") + logW("ipc: daemon binder died, manager exiting") exitProcess(0) }, 0, ) } catch (e: Exception) { - Log.e( - TAG, - "ipc: linkToDeath on the daemon binder failed, exiting the manager process", - e, - ) + logE("ipc: linkToDeath on the daemon binder failed, exiting the manager process", e) exitProcess(0) } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/Logging.kt b/manager/src/main/kotlin/org/matrix/vector/manager/Logging.kt new file mode 100644 index 000000000..3a859191b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/Logging.kt @@ -0,0 +1,98 @@ +package org.matrix.vector.manager + +import android.util.Log +import java.io.PrintWriter +import java.io.StringWriter + +/** + * The manager's logging, which exists because the platform's own quietly loses stack traces. + * + * `android.util.Log.e(tag, msg, tr)` does not print `tr` when anything in its cause chain is an + * [java.net.UnknownHostException]. This is deliberate upstream — `printlns` in AOSP's `Log.java` + * breaks out of the cause walk with the comment "this is to reduce the amount of log spew that + * apps do in the non-error condition of the network being unavailable" — and + * [Log.getStackTraceString] returns `""` for the same reason. The message still prints, so nothing + * looks wrong; only the trace is gone. + * + * For an app whose network use is a DoH resolver, a module store, and a GitHub feed, that filter + * removes traces from precisely the failures worth reporting. Its worst case was `dns: DoH lookup + * of $host failed` — OkHttp's `DnsOverHttps` signals failure by throwing `UnknownHostException`, so + * the one log line whose entire purpose is explaining a DNS failure was the one guaranteed to + * arrive without a trace. + * + * So the trace is formatted here and appended to the message, and only [Log.println] — which has no + * filter — is called. Nothing in the manager calls `android.util.Log` directly; [Constants.TAG] + * belongs to these three functions. + * + * The conventions the tag used to carry: + * + * `logcat.cpp` routes any tag beginning `Vector` into the daemon's **verbose** stream, so with + * verbose logging on everything logged here appears in the Verbose tab beside the daemon's own + * lines and travels in the zip export — the place a reader already looks. A file-local tag would be + * ordinary Android practice and would land nowhere; there are none in this app. + * + * A message is `area: lowercase phrase naming the operation and its subject`, where the area is one + * of ipc, dns, apps, modules, backup, restore, scope, store, update, feed, auth, status, framework, + * logs, actions, report, splash. The subject matters: "modules: enable of $packageName failed" can + * be acted on, "failed to enable module" cannot. + * + * The `Throwable` is always the last argument — never `e.message`, which discards the stack. + * Nothing secret is ever interpolated: no OAuth token, no SAF `Uri` beyond its authority, no + * third-party query string. + * + * Levels are [logE] when something the user asked for did not happen and nothing else will explain + * it, [logW] for a degraded path recovered from, and [logI] for a one-off milestone worth having in + * a bug report — counts, versions, the endpoint chosen. `d` and `v` are not offered, because + * release builds ship them. + * + * A [kotlinx.coroutines.CancellationException] is never logged. Navigating away from a screen + * cancels its scope, and a log that fires every time someone presses back is a log nobody reads; + * any `runCatching` or broad `catch` that can see one rethrows or skips it first. + */ +fun logE(msg: String, tr: Throwable? = null) = emit(Log.ERROR, msg, tr) + +/** A degraded path recovered from. See [logE] for the conventions. */ +fun logW(msg: String, tr: Throwable? = null) = emit(Log.WARN, msg, tr) + +/** A one-off milestone worth having in a bug report. See [logE] for the conventions. */ +fun logI(msg: String, tr: Throwable? = null) = emit(Log.INFO, msg, tr) + +/** + * A throwable as text, cause chain and all, with none of [Log.getStackTraceString]'s filtering. + * + * Shared with [org.matrix.vector.manager.data.log.CrashRecorder], which writes the same text to + * disk and had the same trace go missing for the same reason. + */ +fun stackTraceOf(tr: Throwable): String = + StringWriter().also { tr.printStackTrace(PrintWriter(it)) }.toString().trimEnd() + +/** + * One log entry per [PAYLOAD_LIMIT] characters, split on line boundaries. + * + * `Log.println` hands the whole string to liblog, which truncates it at the kernel logger's payload + * size; the platform's `Log.e(tag, msg, tr)` avoids that by writing through a line-breaking writer. + * Since we are no longer using it, we break the lines: a deep trace — and Compose produces very + * deep ones — would otherwise lose its tail, which is where the frames that name our own code live. + */ +private fun emit(priority: Int, msg: String, tr: Throwable?) { + val text = if (tr == null) msg else "$msg\n${stackTraceOf(tr)}" + if (text.length <= PAYLOAD_LIMIT) { + Log.println(priority, Constants.TAG, text) + return + } + val entry = StringBuilder(PAYLOAD_LIMIT) + for (line in text.lineSequence()) { + if (entry.isNotEmpty() && entry.length + 1 + line.length > PAYLOAD_LIMIT) { + Log.println(priority, Constants.TAG, entry.toString()) + entry.setLength(0) + } + if (entry.isNotEmpty()) entry.append('\n') + // A single line over the limit is passed through and truncated by liblog. Stack frames are + // never that long, and splitting mid-line would corrupt the one thing being preserved. + entry.append(line) + } + if (entry.isNotEmpty()) Log.println(priority, Constants.TAG, entry.toString()) +} + +/** Under the ~4068-byte logger payload, with room for the tag and the two terminators. */ +private const val PAYLOAD_LIMIT = 4000 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt index 01af530f5..10a2403ee 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt @@ -1,11 +1,10 @@ package org.matrix.vector.manager.data.github -import android.util.Log -import org.matrix.vector.manager.Constants import java.io.File import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.matrix.vector.manager.logW /** * Every commit the app has ever seen, kept on disk. @@ -140,7 +139,7 @@ class CommitArchive(private val file: File, private val stateFile: File, private // Exactly one bad line is the truncated tail above and is not worth saying anything about; // more than one is systematic — a renamed field would make the whole archive read as empty. if (skipped > 1) { - Log.w(Constants.TAG, "feed: skipped $skipped of $total archive lines", firstFailure) + logW("feed: skipped $skipped of $total archive lines", firstFailure) // Repaired here, where it is found, rather than left to [compactIfWasteful]: that runs // only during a backfill, which happens only if someone scrolls to the foot of // history, so damage would otherwise be re-skipped on every launch instead of being @@ -173,11 +172,7 @@ class CommitArchive(private val file: File, private val stateFile: File, private ) } .onFailure { e -> - Log.w( - Constants.TAG, - "feed: appending ${commits.size} commits to the archive failed", - e, - ) + logW("feed: appending ${commits.size} commits to the archive failed", e) } } } @@ -219,7 +214,7 @@ class CommitArchive(private val file: File, private val stateFile: File, private lineCount = unique.size } .onFailure { e -> - Log.w(Constants.TAG, "feed: rewriting the archive failed", e) + logW("feed: rewriting the archive failed", e) } } } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt index 9c7b023fd..991bb2df2 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt @@ -1,6 +1,5 @@ package org.matrix.vector.manager.data.github -import android.util.Log -import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.logW import android.content.Context import kotlinx.coroutines.Dispatchers @@ -71,7 +70,7 @@ class GitHubAuth(context: Context, private val client: OkHttpClient) { .getOrElse { // Unreachable is the expected case for a lot of users, not an error worth // shouting about. - Log.w(Constants.TAG, "auth: github device code request failed", it) + logW("auth: github device code request failed", it) _state.value = SignInState.Unavailable(it.message ?: "unreachable") return@withContext } @@ -106,10 +105,7 @@ class GitHubAuth(context: Context, private val client: OkHttpClient) { return@withContext } else -> { - Log.w( - Constants.TAG, - "auth: github device flow refused: ${poll.error ?: "unknown"}", - ) + logW("auth: github device flow refused: ${poll.error ?: "unknown"}") _state.value = SignInState.Unavailable(poll.error ?: "unknown") return@withContext } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt index c11d68847..0a38c0bef 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt @@ -1,6 +1,4 @@ package org.matrix.vector.manager.data.github -import android.util.Log -import org.matrix.vector.manager.Constants import java.io.File import java.util.concurrent.TimeUnit @@ -11,6 +9,8 @@ import kotlinx.serialization.json.Json import okhttp3.CacheControl import okhttp3.OkHttpClient import okhttp3.Request +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** * Activity on the project's GitHub repository: the commits, the people behind them, the repository @@ -136,8 +136,7 @@ class GitHubRepository( // Not on the Cached path: a cold OkHttp cache answers FORCE_CACHE with an // unsatisfiable 504, and every scroll to the foot of the feed would log. if (fresh == null && freshness != Freshness.Cached) { - Log.w( - Constants.TAG, + logW( "feed: github commit fetch failed ($freshness), falling back to disk", fetched.exceptionOrNull(), ) @@ -158,7 +157,7 @@ class GitHubRepository( json.encodeToString(Snapshot(total, fresh.rawCommits, repo)) ) } - .onFailure { e -> Log.w(Constants.TAG, "feed: snapshot write failed", e) } + .onFailure { e -> logW("feed: snapshot write failed", e) } return@withContext build( timeline(fresh.rawCommits, windowStart), repo, @@ -360,11 +359,7 @@ class GitHubRepository( // means the next call tries again rather than declaring the archive finished // because GitHub was rate limiting at the time. if (batch == null) { - Log.w( - Constants.TAG, - "feed: history backfill $url unavailable", - result.exceptionOrNull(), - ) + logW("feed: history backfill $url unavailable", result.exceptionOrNull()) return@repeat } @@ -713,7 +708,7 @@ class GitHubRepository( */ private fun releaseListJson(freshness: Freshness): String? = runCatching { get("$API/$REPO/releases?per_page=$CANARY_FETCH", freshness) } - .onFailure { e -> Log.w(Constants.TAG, "update: github release list unavailable", e) } + .onFailure { e -> logW("update: github release list unavailable", e) } .getOrNull() /** @@ -734,7 +729,7 @@ class GitHubRepository( val body = releaseListJson(freshness) ?: return@withContext emptyList() runCatching { json.decodeFromString>(body) } - .onFailure { e -> Log.e(Constants.TAG, "update: canary release list unreadable", e) } + .onFailure { e -> logE("update: canary release list unreadable", e) } .getOrDefault(emptyList()) .filter { it.prerelease && it.tagName.startsWith(CANARY_TAG_PREFIX) } .take(CANARY_KEEP) @@ -775,7 +770,7 @@ class GitHubRepository( val body = releaseListJson(freshness) ?: return@withContext emptyList() runCatching { json.decodeFromString>(body) } - .onFailure { e -> Log.e(Constants.TAG, "update: release list unreadable", e) } + .onFailure { e -> logE("update: release list unreadable", e) } .getOrDefault(emptyList()) .mapNotNull { release -> val canary = release.prerelease && release.tagName.startsWith(CANARY_TAG_PREFIX) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt index fc384379a..28c7a1de7 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt @@ -6,6 +6,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.stackTraceOf /** * Keeps the manager's own crashes where the log export already looks for them. @@ -20,6 +21,10 @@ import org.matrix.vector.manager.BuildConfig * So the trace is written to disk first, and the platform's own handler runs afterwards. The * system dialog still appears, the tombstone is still written, `logcat -b crash` still has it. * + * The trace is formatted by [stackTraceOf] rather than `Log.getStackTraceString`, which returns + * `""` for any [java.net.UnknownHostException] cause chain and so wrote header-only files for every + * crash on the network path — the class of crash this exists to catch. + * * **The directory is not a choice.** `FileSystem.getLogs`, which builds the zip the log screen * exports, already collects two of them: * ``` @@ -72,9 +77,6 @@ object CrashRecorder { } } - /** Where the daemon's export collects them from. */ - fun directory(context: Context): File = File(context.cacheDir, DIR_NAME) - /** The recorded crashes, newest first, or null when there have been none. */ fun read(context: Context): String? = runCatching { @@ -84,8 +86,15 @@ object CrashRecorder { } .getOrNull() - /** How many crashes are on file. Cheap enough to ask on every visit to a screen. */ - fun count(context: Context): Int = files(context).size + /** + * The newest crash, parsed — what the card summarises and the trace screen lists. + * + * Only the newest: the question the status screen answers is "what just happened", and the + * older records are still on file, still in [read]'s output, and still in the log export. + */ + fun newest(context: Context): CrashReport? = + runCatching { files(context).firstOrNull()?.readText()?.let(::parseCrashReport) } + .getOrNull() fun clear(context: Context) { runCatching { files(context).forEach { it.delete() } } @@ -114,6 +123,9 @@ object CrashRecorder { } } + /** Where the log export collects them from, as named in this class's own documentation. */ + private fun directory(context: Context): File = File(context.cacheDir, DIR_NAME) + /** Newest first, which is the order both the card and the clipboard want. */ private fun files(context: Context): List = directory(context) @@ -128,7 +140,7 @@ object CrashRecorder { val text = buildString { append(header(context, thread, now)) append('\n') - append(android.util.Log.getStackTraceString(throwable)) + append(stackTraceOf(throwable)) append('\n') } runCatching { File(dir, "$now$SUFFIX").writeText(text) } @@ -143,12 +155,17 @@ object CrashRecorder { * platform — the four things that are always asked first and are never in the trace. Written * in [Locale.ROOT] on purpose: this text exists to be pasted into an issue, and a crash report * whose date is formatted for the reporter's locale is a crash report the reader has to parse. + * + * Split across two lines by what a reader can find elsewhere rather than by subject. When and + * on which thread is the first line because nothing else on the status screen answers it; the + * build and the platform are the second because everything there is repeated a few rows down. + * [parseCrashReport] reads the split back, so the two lines are a format, not a layout. */ private fun header(context: Context, thread: Thread, at: Long): String { val parasitic = context.packageName == BuildConfig.INJECTED_PACKAGE_NAME val host = if (parasitic) "parasitic in ${context.packageName}" else "standalone" - return "${TIMESTAMP.format(Date(at))}\n" + - "$BUILD · $host · thread ${thread.name} · " + + return "${TIMESTAMP.format(Date(at))} · thread ${thread.name}\n" + + "$BUILD · $host · " + "android ${android.os.Build.VERSION.RELEASE} (sdk ${android.os.Build.VERSION.SDK_INT})" } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt new file mode 100644 index 000000000..8fdef412c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt @@ -0,0 +1,166 @@ +package org.matrix.vector.manager.data.log + +/** + * A recorded crash, in the shape the screens ask questions of. + * + * The file [CrashRecorder] writes is the record; this is that record read back. Parsing it here + * rather than rendering the text means the UI can answer "what threw", "where", and "is this frame + * ours" without a reader having to find those things in a wall of monospace — and it means the one + * frame that names our own code can be pulled to the front of a summary, which is the single fact a + * bug report is usually missing. + * + * Parsing never decides what is kept. A line the parser does not recognise contributes no frame and + * nothing more; the file on disk is untouched, [CrashRecorder.read] still returns every byte of it, + * and the copy action on the trace screen reads from there rather than from anything here. A trace + * is evidence, and failing to understand it is not a reason to be unable to hand it over. + */ +data class CrashReport( + /** The recorded timestamp, in the fixed format the file was written with. */ + val at: String, + val thread: String, + /** Build, host and platform, as one line. Restated from "What is running" on the same screen. */ + val build: String, + /** The throwable, then whatever it was caused by, in the order `printStackTrace` prints them. */ + val sections: List, +) { + /** The throwable that reached the handler. Null only for a record we could not parse at all. */ + val thrown: CrashSection? + get() = sections.firstOrNull() + + /** + * The innermost cause, which is the thing that actually went wrong. + * + * `RuntimeException: Unable to start activity` is the platform restating where it noticed; the + * end of the chain is the sentence worth putting in a summary. + */ + val root: CrashSection? + get() = sections.lastOrNull() + + /** + * The first frame in code we ship, anywhere in the chain. + * + * A crash inside `ActivityThread` is not a report anyone can act on until it says which of our + * frames led there, and that frame is rarely near the top — the platform's own frames sit above + * it. Null when nothing in the trace is ours, which happens and is itself worth seeing. + */ + val ours: CrashFrame? + get() = sections.firstNotNullOfOrNull { section -> section.frames.firstOrNull { it.ours } } +} + +/** One throwable in the chain: what it was, what it said, and where it had been. */ +data class CrashSection( + /** The fully qualified type, e.g. `java.net.UnknownHostException`. */ + val type: String, + val message: String?, + val frames: List, + /** The `... N more` count, which stands for frames identical to the ones already printed. */ + val elided: Int, + /** False for the throwable that reached the handler, true for everything under `Caused by:`. */ + val isCause: Boolean, +) { + /** The type without its package, which is what a heading has room for. */ + val simpleType: String + get() = type.substringAfterLast('.') +} + +/** One `at ...` line, split at the point where it stops being a name and starts being a place. */ +data class CrashFrame( + /** `org.matrix.vector.manager.ui.MainActivity.onCreate` */ + val method: String, + /** `MainActivity.kt:39`, or null for a native frame, which prints no source. */ + val location: String?, + /** Whether the class belongs to something in this repository rather than to the platform. */ + val ours: Boolean, +) { + /** `MainActivity.onCreate` — the part a reader recognises, without the package. */ + val shortMethod: String + get() { + val method = this.method.substringAfterLast('.', "") + val type = this.method.substringBeforeLast('.').substringAfterLast('.') + return if (method.isEmpty() || type.isEmpty()) this.method else "$type.$method" + } + + /** The line as it was written, for copying a single frame. */ + val line: String + get() = if (location == null) "at $method" else "at $method($location)" +} + +/** + * The packages this project ships, by prefix. + * + * Used only to decide emphasis, so being wrong costs a frame its highlight and nothing else. The + * legacy Xposed prefixes are here because a module's crash goes through them and a reader chasing + * one wants those frames to stand out for the same reason they want ours to. + */ +private val OUR_PACKAGES = + listOf( + "org.matrix.vector", + "org.lsposed.lspd", + "de.robv.android.xposed", + "io.github.libxposed", + ) + +private val FRAME = Regex("""^\s*at (.+?)(?:\(([^)]*)\))?$""") +private val ELIDED = Regex("""^\s*\.\.\. (\d+) more$""") +private const val CAUSED_BY = "Caused by: " +private const val SUPPRESSED = "Suppressed: " + +/** + * Reads back a record written by [CrashRecorder]. + * + * The two header lines are ours; everything after them is `Throwable.printStackTrace` output, whose + * shape is fixed by the JDK: a header line naming the throwable, tab-indented `at` lines, an + * optional `... N more`, and the same again after `Caused by:`. Suppressed exceptions print under + * `Suppressed:` and are treated as another section, since for reading purposes they are one. + * + * Returns null only when there is no header to read, never on a trace it cannot make sense of — an + * unparsed tail simply contributes no frames, and [CrashReport.raw] still has every byte of it. + */ +fun parseCrashReport(record: String): CrashReport? { + val lines = record.trimEnd().lines() + if (lines.size < 2) return null + + val (at, thread) = lines[0].split(" · thread ", limit = 2).let { it[0] to it.getOrElse(1) { "" } } + val sections = mutableListOf() + + var type: String? = null + var message: String? = null + var isCause = false + var frames = mutableListOf() + var elided = 0 + + fun flush() { + val started = type ?: return + sections += CrashSection(started, message, frames.toList(), elided, isCause) + frames = mutableListOf() + elided = 0 + } + + for (line in lines.drop(2)) { + val frame = FRAME.matchEntire(line) + val skipped = ELIDED.matchEntire(line) + when { + frame != null && type != null -> { + val method = frame.groupValues[1] + val location = frame.groupValues[2].takeIf { it.isNotEmpty() } + frames += CrashFrame(method, location, OUR_PACKAGES.any(method::startsWith)) + } + skipped != null -> elided = skipped.groupValues[1].toIntOrNull() ?: 0 + line.isBlank() -> Unit + else -> { + // A header: the throwable itself, or one introduced by Caused by:/Suppressed:. + flush() + val cause = line.startsWith(CAUSED_BY) || line.startsWith(SUPPRESSED) + val header = line.removePrefix(CAUSED_BY).removePrefix(SUPPRESSED).trim() + // "type: message", where the type never contains a space and the message may. + val split = header.indexOf(": ") + type = if (split < 0) header else header.substring(0, split) + message = if (split < 0) null else header.substring(split + 2) + isCause = cause + } + } + } + flush() + + return CrashReport(at = at, thread = thread, build = lines[1], sections = sections) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt index ad90e9890..23e0df5b0 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt @@ -1,11 +1,10 @@ package org.matrix.vector.manager.data.model -import android.util.Log -import org.matrix.vector.manager.Constants import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import java.util.Properties import java.util.zip.ZipFile +import org.matrix.vector.manager.logW /** * Everything the manager can learn about a package by looking at it. @@ -93,8 +92,7 @@ object ModuleDetection { static = props.getProperty("staticScope") == "true" } .onFailure { e -> - Log.w( - Constants.TAG, + logW( "modules: ${info.packageName} module.prop unparsable, " + "api version and static scope unknown", e, @@ -122,10 +120,9 @@ object ModuleDetection { // declaration, so the two sides agree on what such a module is allowed // to hook. if (static && scope.isEmpty()) { - Log.w( - Constants.TAG, + logW( "modules: ${info.packageName} fixes its scope but names " + - "nothing; ignoring staticScope and leaving the scope open", + "nothing; ignoring staticScope and leaving the scope open" ) static = false } @@ -143,8 +140,7 @@ object ModuleDetection { } } .onFailure { e -> - Log.w( - Constants.TAG, + logW( "modules: reading ${info.packageName} " + "${apk.substringAfterLast('/')} failed", e, @@ -214,11 +210,7 @@ object ModuleDetection { } } .onFailure { e -> - Log.w( - Constants.TAG, - "modules: ${info.packageName} legacy xposedscope unreadable", - e, - ) + logW("modules: ${info.packageName} legacy xposedscope unreadable", e) } .getOrNull() ?.filter { it.isNotEmpty() } ?: return emptyList() diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt index ffd50bcbc..31ec0b766 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt @@ -1,12 +1,11 @@ package org.matrix.vector.manager.data.model -import android.util.Log -import org.matrix.vector.manager.Constants import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.util.Base64 import java.io.File import java.util.concurrent.ConcurrentHashMap +import org.matrix.vector.manager.logW /** * Remembers which installed packages are modules, so the answer is computed once per APK. @@ -114,7 +113,7 @@ class ModuleDetectionCache(private val file: File) { } ) } - .onFailure { e -> Log.w(Constants.TAG, "modules: detection cache write failed", e) } + .onFailure { e -> logW("modules: detection cache write failed", e) } dirty = false } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt index d65b4bfb8..270a7ac1f 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt @@ -1,15 +1,13 @@ package org.matrix.vector.manager.data.repository -import android.util.Log -import org.matrix.vector.manager.Constants -import kotlinx.coroutines.CancellationException - import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.matrix.vector.manager.data.model.AppInfo import org.matrix.vector.manager.data.model.ModuleDetectionCache import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logW /** Fetches and caches the list of installed applications from the daemon. */ class AppRepository( @@ -44,11 +42,7 @@ class AppRepository( val failure = result.exceptionOrNull() if (failure != null) { if (failure !is CancellationException) { - Log.w( - Constants.TAG, - "apps: installed package list unavailable from daemon", - failure, - ) + logW("apps: installed package list unavailable from daemon", failure) } return@withContext emptyList() } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt index 464083433..a8c7a9fa2 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt @@ -1,18 +1,17 @@ package org.matrix.vector.manager.data.repository -import android.util.Log -import org.matrix.vector.manager.Constants -import kotlinx.coroutines.CancellationException - import android.content.Context import android.net.Uri import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.lsposed.lspd.models.Application import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** * Backup and restore of which modules are on and what each may hook. @@ -67,8 +66,7 @@ class BackupRepository(private val context: Context, private val daemon: DaemonC daemon .getModuleScope(packageName) .onFailure { e -> - Log.w( - Constants.TAG, + logW( "backup: scope of $packageName unreadable, saved as empty", e, ) @@ -93,8 +91,7 @@ class BackupRepository(private val context: Context, private val daemon: DaemonC } .onFailure { e -> if (e is CancellationException) throw e - Log.e( - Constants.TAG, + logE( "backup: writing a backup of " + (if (only.isEmpty()) "all enabled" else "${only.size} selected") + " modules failed", @@ -124,11 +121,7 @@ class BackupRepository(private val context: Context, private val daemon: DaemonC daemon .setModuleEnabled(module.packageName, module.enabled) .onFailure { e -> - Log.w( - Constants.TAG, - "restore: enabling ${module.packageName} failed, skipping", - e, - ) + logW("restore: enabling ${module.packageName} failed, skipping", e) } .getOrDefault(false) if (!enabledOk) { @@ -145,8 +138,7 @@ class BackupRepository(private val context: Context, private val daemon: DaemonC } val scopeResult = daemon.setModuleScope(module.packageName, scope) if (!scopeResult.getOrDefault(false)) { - Log.e( - Constants.TAG, + logE( "restore: scope of ${module.packageName} not applied " + "(${scope.size} targets)", scopeResult.exceptionOrNull(), @@ -159,8 +151,7 @@ class BackupRepository(private val context: Context, private val daemon: DaemonC } .onFailure { e -> if (e is CancellationException) throw e - Log.e( - Constants.TAG, + logE( "restore: reading or parsing the backup file from ${uri.authority} failed", e, ) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt index 6532555e2..2b39fe6a3 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt @@ -1,7 +1,6 @@ package org.matrix.vector.manager.data.repository import android.content.Context -import android.util.Log import java.io.File import java.io.IOException import kotlinx.coroutines.CancellationException @@ -21,8 +20,9 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.lsposed.lspd.IFrameworkInstallCallback import org.lsposed.lspd.ILSPManagerService -import org.matrix.vector.manager.Constants import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** Where a framework flash has got to. */ sealed interface FlashStep { @@ -133,7 +133,7 @@ class FrameworkInstaller( val call = runCatching { client.newCall(Request.Builder().url(url).build()) } .getOrElse { e -> - Log.w(Constants.TAG, "update: unusable download url $url", e) + logW("update: unusable download url $url", e) append("Download failed: ${e.message}") _state.value = FlashStep.Failed(ILSPManagerService.INSTALL_NO_SUCH_FILE) return @@ -225,7 +225,7 @@ class FrameworkInstaller( // through its call, which arrives above — but a cancellation is a coroutine ending, // not a file that could not be fetched, and it must never be recorded as one. if (e is CancellationException) throw e - Log.w(Constants.TAG, "update: download failed", e) + logW("update: download failed", e) append("Download failed: ${e.message}") _state.value = FlashStep.Failed(ILSPManagerService.INSTALL_NO_SUCH_FILE) return @@ -313,7 +313,7 @@ class FrameworkInstaller( val started = daemon.installFrameworkZip(path, callback) if (started.isFailure) { val cause = started.exceptionOrNull() - Log.e(Constants.TAG, "update: daemon did not start the install of $path", cause) + logE("update: daemon did not start the install of $path", cause) append("The daemon refused the install: ${cause?.message}") _state.value = FlashStep.Failed(ILSPManagerService.INSTALL_NOT_EXECUTED) return diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt index 25a858b02..21a21dcc5 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt @@ -19,11 +19,11 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.Icon import android.graphics.drawable.LayerDrawable import android.os.Build -import android.util.Log import androidx.core.content.ContextCompat import java.util.UUID import org.matrix.vector.manager.BuildConfig -import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW import org.matrix.vector.manager.R /** @@ -69,12 +69,12 @@ object LaunchShortcut { */ fun isSupported(context: Context): Boolean = runCatching { manager(context)?.isRequestPinShortcutSupported == true } - .onFailure { Log.w(Constants.TAG, "actions: pin support query failed", it) } + .onFailure { logW("actions: pin support query failed", it) } .getOrDefault(false) fun isPinned(context: Context): Boolean = runCatching { manager(context)?.pinnedShortcuts.orEmpty().any { it.id == ID } } - .onFailure { Log.w(Constants.TAG, "actions: pinned shortcut query failed", it) } + .onFailure { logW("actions: pinned shortcut query failed", it) } .getOrDefault(false) /** @@ -92,7 +92,7 @@ object LaunchShortcut { return runCatching { manager(context)?.requestPinShortcut(shortcut, callback(context, onPinned)) == true } - .onFailure { Log.e(Constants.TAG, "actions: pin shortcut request failed", it) } + .onFailure { logE("actions: pin shortcut request failed", it) } .getOrDefault(false) } @@ -107,7 +107,7 @@ object LaunchShortcut { if (!isParasitic(context) || !isPinned(context)) return val shortcut = build(context) ?: return runCatching { manager(context)?.updateShortcuts(listOf(shortcut)) } - .onFailure { Log.w(Constants.TAG, "actions: pinned shortcut update failed", it) } + .onFailure { logW("actions: pinned shortcut update failed", it) } } private fun manager(context: Context): ShortcutManager? = @@ -154,7 +154,7 @@ object LaunchShortcut { .setComponent(ComponentName(pkg, it.name)) } } - .onFailure { Log.e(Constants.TAG, "actions: no host activity to point at", it) } + .onFailure { logE("actions: no host activity to point at", it) } .getOrNull() ?: return null @@ -199,7 +199,7 @@ object LaunchShortcut { flat.draw(canvas) Icon.createWithAdaptiveBitmap(bitmap) } - .onFailure { Log.w(Constants.TAG, "actions: shortcut icon could not be rendered", it) } + .onFailure { logW("actions: shortcut icon could not be rendered", it) } .getOrNull() /** @@ -237,7 +237,7 @@ object LaunchShortcut { ) .intentSender } - .onFailure { Log.w(Constants.TAG, "actions: pin confirmation receiver failed", it) } + .onFailure { logW("actions: pin confirmation receiver failed", it) } .getOrNull() } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt index d5b1e37da..abf6733ec 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt @@ -2,7 +2,6 @@ package org.matrix.vector.manager.data.repository import android.content.Context import android.content.pm.PackageInstaller -import android.util.Log import java.io.FileInputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -12,7 +11,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.matrix.vector.manager.BuildConfig -import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW import org.matrix.vector.manager.ipc.DaemonClient import org.matrix.vector.manager.ipc.commitForResult import org.matrix.vector.manager.ipc.requestReplaceExisting @@ -81,7 +81,7 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC val removed = daemon.uninstallPackage(BuildConfig.MANAGER_PACKAGE_NAME, ALL_USERS).getOrDefault(false) if (removed) _state.value = ManagerInstallStep.Idle - else Log.w(Constants.TAG, "actions: could not remove the conflicting manager") + else logW("actions: could not remove the conflicting manager") return removed } @@ -109,7 +109,7 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC if (apk == null) { // Either the daemon is gone, or it refused: the APK is missing from the module // directory or its signature is not the one this framework was built to accept. - Log.e(Constants.TAG, "actions: the daemon served no manager APK to install") + logE("actions: the daemon served no manager APK to install") _state.value = ManagerInstallStep.Failed(null) return@withContext false } @@ -141,10 +141,7 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC val (status, message) = commit(session, sessionId) succeeded = status == PackageInstaller.STATUS_SUCCESS if (!succeeded) { - Log.w( - Constants.TAG, - "actions: manager install failed, status $status: $message", - ) + logW("actions: manager install failed, status $status: $message") } _state.value = if (succeeded) ManagerInstallStep.Done @@ -161,7 +158,7 @@ class ManagerInstaller(private val context: Context, private val daemon: DaemonC } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e(Constants.TAG, "actions: manager install failed", e) + logE("actions: manager install failed", e) _state.value = ManagerInstallStep.Failed(e.message) } finally { runCatching { apk.close() } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt index 5f2887a13..5d408fd73 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt @@ -2,7 +2,6 @@ package org.matrix.vector.manager.data.repository import android.content.Context import android.content.pm.PackageInstaller -import android.util.Log import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -14,10 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import org.matrix.vector.manager.Constants import org.matrix.vector.manager.data.model.ReleaseAsset import org.matrix.vector.manager.ipc.commitForResult import org.matrix.vector.manager.ipc.requestReplaceExisting +import org.matrix.vector.manager.logW /** Where an install has got to. One at a time, because a user installs one module at a time. */ sealed interface InstallStep { @@ -111,10 +110,9 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl val result = commit(session, sessionId, packageName) succeeded = result.first == PackageInstaller.STATUS_SUCCESS if (!succeeded) { - Log.w( - Constants.TAG, + logW( "store: install of $packageName failed, status ${result.first}: " + - "${result.second}", + "${result.second}" ) } _state.value = @@ -126,7 +124,7 @@ class ModuleInstaller(private val context: Context, private val client: OkHttpCl // failed install: reporting it as one would put an error on a screen the reader // has already left, and would race the acknowledge() that cancelled it. if (e is CancellationException) throw e - Log.w(Constants.TAG, "store: install of $packageName failed", e) + logW("store: install of $packageName failed", e) _state.value = InstallStep.Failed(packageName, e.message) } finally { // Without this, a cancelled download leaves a staged session behind — and staged diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt index 5abf39455..94d6679d1 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt @@ -1,6 +1,4 @@ package org.matrix.vector.manager.data.repository -import android.util.Log -import org.matrix.vector.manager.Constants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -10,6 +8,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** * The single source of truth for which modules are enabled. @@ -75,11 +75,7 @@ class ModuleRepository( .getEnabledModules() .onSuccess { enabled -> _enabledModulesState.update { enabled.toSet() } } .onFailure { e -> - Log.w( - Constants.TAG, - "modules: enabled list unavailable, showing none enabled", - e, - ) + logW("modules: enabled list unavailable, showing none enabled", e) } } } @@ -98,9 +94,9 @@ class ModuleRepository( if (!accepted) { val cause = result.exceptionOrNull() if (cause != null) { - Log.e(Constants.TAG, "modules: $verb of $packageName failed", cause) + logE("modules: $verb of $packageName failed", cause) } else { - Log.e(Constants.TAG, "modules: daemon refused to $verb $packageName") + logE("modules: daemon refused to $verb $packageName") } return false } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index 40f023e4c..ad2ec2acc 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -2,7 +2,6 @@ package org.matrix.vector.manager.data.repository import android.content.pm.PackageInfo import android.os.Build -import android.util.Log import com.google.gson.Gson import com.google.gson.JsonParser import com.google.gson.stream.JsonReader @@ -23,12 +22,13 @@ import okhttp3.CacheControl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.matrix.vector.manager.Constants import org.matrix.vector.manager.data.model.OnlineModule import org.matrix.vector.manager.data.model.RepoVersion import org.matrix.vector.manager.data.model.StoreCatalog import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logI +import org.matrix.vector.manager.logW /** * The Store's data: the online catalogue, and what this device already has of it. @@ -180,7 +180,7 @@ class RepoRepository( daemon .getInstalledPackagesFromAllUsers(0, false) .onFailure { e -> - Log.w(Constants.TAG, "store: installed versions unavailable", e) + logW("store: installed versions unavailable", e) } .getOrNull() ?: return val versions = HashMap(packages.size) @@ -206,13 +206,13 @@ class RepoRepository( // The FORCE_CACHE replay synthesises 504 without contacting the mirror, so // only report a status the network actually produced. if (response.networkResponse != null) { - Log.w(Constants.TAG, "store: $url returned HTTP ${response.code}") + logW("store: $url returned HTTP ${response.code}") } return null } val parsed = parseCatalog(response) if (parsed.isEmpty()) return null - Log.i(Constants.TAG, "store: ${parsed.size} modules from $url") + logI("store: ${parsed.size} modules from $url") // `fromCache` is deliberately *not* derived from `response.networkResponse`. A hit // inside the ten-minute freshness window is served from disk without touching the // network, and calling that "the saved catalogue" would put an offline notice on @@ -225,7 +225,7 @@ class RepoRepository( ) } } catch (e: Exception) { - Log.w(Constants.TAG, "store: $url unavailable", e) + logW("store: $url unavailable", e) null } } @@ -242,7 +242,7 @@ class RepoRepository( gson.fromJson(response.body.string(), OnlineModule::class.java) } } catch (e: Exception) { - Log.w(Constants.TAG, "store: $url unavailable", e) + logW("store: $url unavailable", e) null } } @@ -275,7 +275,7 @@ class RepoRepository( } reader.endArray() } - if (rejected > 0) Log.w(Constants.TAG, "store: skipped $rejected unreadable entries") + if (rejected > 0) logW("store: skipped $rejected unreadable entries") return modules } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt index 2187e4090..c2265bff0 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt @@ -6,9 +6,9 @@ import kotlinx.coroutines.withContext import org.lsposed.lspd.IFrameworkInstallCallback import android.content.Intent import android.content.pm.ActivityInfo -import android.util.Log import org.lsposed.lspd.ILSPManagerService -import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** * Every call the manager makes to the daemon, as coroutines. @@ -45,7 +45,7 @@ class DaemonClient(private val serviceState: StateFlow) { // RuntimeException thrown while unparcelling a large ParcelableListSlice are all // reachable here, and any of them escaping fails the calling coroutine — which for // an unhandled failure in a viewModelScope means the process goes down. - Log.w(Constants.TAG, "ipc: daemon transaction failed", e) + logW("ipc: daemon transaction failed", e) Result.failure(e) } } @@ -116,8 +116,7 @@ class DaemonClient(private val serviceState: StateFlow) { val resolved = findAppUi(packageName, userId, companionFirst) val target = resolved.getOrNull() if (target == null) { - Log.e( - Constants.TAG, + logE( "ipc: open resolved no activity for $packageName in user $userId " + "(companionFirst=$companionFirst)", resolved.exceptionOrNull(), @@ -147,8 +146,7 @@ class DaemonClient(private val serviceState: StateFlow) { // START_ABORTED (102) — where nothing came up either. val started = code in 0..99 if (!started) { - Log.e( - Constants.TAG, + logE( "ipc: ${target.packageName}/${target.name} refused by the activity manager " + "in user $userId (code $code)", ) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt index e5618a9e9..fe1bc4d75 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt @@ -7,11 +7,11 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageInstaller import android.os.Build -import android.util.Log import androidx.core.content.IntentCompat import java.util.UUID import kotlinx.coroutines.suspendCancellableCoroutine -import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** * Asks for an install that replaces whatever copy of the package is already on the device. @@ -38,7 +38,7 @@ fun PackageInstaller.SessionParams.requestReplaceExisting() { val flags = PackageInstaller.SessionParams::class.java.getDeclaredField("installFlags") flags.setInt(this, flags.getInt(this) or INSTALL_REPLACE_EXISTING) } - .onFailure { Log.w(Constants.TAG, "ipc: install session could not request a replace", it) } + .onFailure { logW("ipc: install session could not request a replace", it) } } /** @@ -89,7 +89,7 @@ suspend fun Context.commitForResult( runCatching { startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - .onFailure { Log.e(Constants.TAG, promptFailure, it) } + .onFailure { logE(promptFailure, it) } } return } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt index af0b96446..d67af3681 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt @@ -4,14 +4,13 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.util.Log import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import org.matrix.vector.manager.BuildConfig -import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.logW import org.matrix.vector.manager.data.model.PER_USER_RANGE sealed class PackageEvent { @@ -106,7 +105,7 @@ fun Context.daemonPackageEventsFlow(): Flow = callbackFlow { val event = runCatching { intent.daemonPackageEvent() } .onFailure { - Log.w(Constants.TAG, "ipc: unreadable package notification", it) + logW("ipc: unreadable package notification", it) } .getOrNull() if (event != null) trySend(event) @@ -133,7 +132,7 @@ fun Context.daemonPackageEventsFlow(): Flow = callbackFlow { // Throwing here would fail this flow, and the merge it is collected in would take the // platform source down with it — losing every user's package events, on a scope whose // failure ends the process, to save the one this flow adds. - .onFailure { Log.w(Constants.TAG, "ipc: daemon package notifications unavailable", it) } + .onFailure { logW("ipc: daemon package notifications unavailable", it) } .isSuccess awaitClose { if (registered) unregisterReceiver(receiver) } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt index 9d1e5fbf5..acc2fb50e 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt @@ -1,7 +1,5 @@ package org.matrix.vector.manager.net -import org.matrix.vector.manager.Constants -import android.util.Log import java.net.InetAddress import java.net.Proxy import java.net.ProxySelector @@ -14,6 +12,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.logW /** * What the last name lookup of this session actually did. @@ -160,8 +159,7 @@ class VectorDns(private val settings: SettingsRepository, bootstrapClient: OkHtt // OkHttp dispatcher thread, with no coroutine in the stack. dohUnavailable = true _status.value = DohStatus.FellBack(e.describe(hostname)) - Log.w( - Constants.TAG, + logW( "dns: DoH lookup of $hostname failed, using the system resolver for this session", e, ) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt index 592014dab..24191b640 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -27,12 +27,14 @@ import org.matrix.vector.manager.ui.navigation.LocalNavigator import org.matrix.vector.manager.ui.navigation.Navigator import org.matrix.vector.manager.ui.navigation.Scope import org.matrix.vector.manager.ui.navigation.StoreDetail +import org.matrix.vector.manager.ui.navigation.CrashTrace import org.matrix.vector.manager.ui.navigation.SystemStatus import org.matrix.vector.manager.ui.navigation.Web import org.matrix.vector.manager.ui.navigation.TOP_LEVEL_DESTINATIONS import org.matrix.vector.manager.ui.navigation.TopLevelRoute import org.matrix.vector.manager.ui.navigation.rememberNavigator import org.matrix.vector.manager.ui.screens.home.HomeScreen +import org.matrix.vector.manager.ui.screens.home.CrashTraceScreen import org.matrix.vector.manager.ui.screens.home.SystemStatusScreen import org.matrix.vector.manager.ui.screens.logs.LogsScreen import org.matrix.vector.manager.ui.screens.modules.ModulesScreen @@ -155,7 +157,13 @@ private fun EntryProviderScope.registerRoutes(navigator: Navigator) { entry { route -> RepoDetailsScreen(packageName = route.packageName, onNavigateBack = { navigator.back() }) } - entry { SystemStatusScreen(onNavigateBack = { navigator.back() }) } + entry { + SystemStatusScreen( + onNavigateBack = { navigator.back() }, + onOpenCrash = { navigator.go(CrashTrace) }, + ) + } + entry { CrashTraceScreen(onNavigateBack = { navigator.back() }) } entry { TroubleshootScreen( onNavigateBack = { navigator.back() }, diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Clipboard.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Clipboard.kt new file mode 100644 index 000000000..5d532d891 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Clipboard.kt @@ -0,0 +1,20 @@ +package org.matrix.vector.manager.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import org.matrix.vector.manager.BuildConfig + +/** + * Puts text on the clipboard, or does nothing. + * + * Every screen in this app copies for the same reason — the text is on its way into a bug report — + * and so every screen wants the same label on the clip and the same silence when there is no + * clipboard service to hand. Parasitically there may not be: the manager is running inside + * `com.android.shell` then, and a failure to copy is not worth a crash on the screen someone opened + * *because* something had already gone wrong. + */ +fun copyToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(BuildConfig.MANAGER_PACKAGE_NAME, text)) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt index a3367fe8e..92404aa56 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.net.Uri import android.provider.Settings -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -46,7 +45,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW import org.matrix.vector.manager.ui.theme.LocalizedOverlay import org.matrix.vector.manager.R import android.text.format.Formatter @@ -127,11 +127,7 @@ fun PackageActionSheet( ServiceLocator.daemon .findAppUi(packageName, userId, companionFirst = isModule) .onFailure { e -> - Log.w( - Constants.TAG, - "actions: launch target lookup for $packageName u$userId failed", - e, - ) + logW("actions: launch target lookup for $packageName u$userId failed", e) } .getOrNull() != null } @@ -160,7 +156,7 @@ fun PackageActionSheet( onDismiss() scope.launch(Dispatchers.Main) { daemon.softReboot().onFailure { - Log.e(Constants.TAG, "actions: soft reboot request failed", it) + logE("actions: soft reboot request failed", it) } } } @@ -254,8 +250,7 @@ LocalizedOverlay { // The row is only drawn once findAppUi resolved a target, so reaching this // branch contradicts what was rendered. One line for both shapes: a failed // transaction carries a throwable, a resolve that found nothing does not. - Log.e( - Constants.TAG, + logE( "actions: open of $packageName for user $userId did nothing, though the " + "row had resolved a target", result.exceptionOrNull(), @@ -277,8 +272,7 @@ LocalizedOverlay { // a null activity manager or a refused user switch. val code = started.getOrDefault(-1) if (code < 0) { - Log.e( - Constants.TAG, + logE( "actions: opening app info for $packageName as user $userId failed " + "(code $code)", started.exceptionOrNull(), @@ -310,11 +304,7 @@ LocalizedOverlay { finish { val result = daemon.forceStopPackage(packageName, userId).onFailure { e -> - Log.e( - Constants.TAG, - "actions: force stop of $packageName (user $userId) failed", - e, - ) + logE("actions: force stop of $packageName (user $userId) failed", e) } // Unlike uninstall below there is no boolean to weigh: the call answers with // Unit, so the Result itself is the verdict — a failure here means the @@ -354,11 +344,7 @@ LocalizedOverlay { daemon .optimizePackage(packageName) .onFailure { e -> - Log.e( - Constants.TAG, - "actions: re-optimize of $packageName failed", - e, - ) + logE("actions: re-optimize of $packageName failed", e) } .getOrDefault(false) PackageActionResult( @@ -383,8 +369,7 @@ LocalizedOverlay { // On `!ok`: a device-policy refusal and a missing user come back as a plain // `false`, which onFailure would never see. if (!ok) { - Log.e( - Constants.TAG, + logE( "actions: uninstall of $packageName for user $userId failed", result.exceptionOrNull(), ) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt index 78792191d..8e8671f5c 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt @@ -37,6 +37,15 @@ sealed interface TopLevelRoute : Route { @Serializable data object SystemStatus : Route +/** + * The newest recorded crash, frame by frame. + * + * Carries no argument: there is only ever one crash worth opening — the newest — and the screen + * reads it from disk itself, so the route survives the process death that following a crash report + * is unusually likely to involve. + */ +@Serializable data object CrashTrace : Route + /** CI builds, as prereleases anyone can download. */ @Serializable data object Canary : Route diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt new file mode 100644 index 000000000..fabada845 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt @@ -0,0 +1,259 @@ +package org.matrix.vector.manager.ui.screens.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.CrashFrame +import org.matrix.vector.manager.data.log.CrashRecorder +import org.matrix.vector.manager.data.log.CrashSection +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.copyToClipboard +import org.matrix.vector.manager.ui.components.show +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * The newest crash, read as a list rather than as a wall of text. + * + * A stack trace is already a structured thing — a chain of throwables, each with a list of frames — + * and printing it as one string is a format for a terminal, not for a screen someone is scrolling + * on a phone. Rendered as rows it can do what the text cannot: mark the frames that belong to this + * project, separate the name of a method from the file it lives in, and let one frame be lifted to + * the clipboard without a text selection. + * + * Two things are deliberate about what is emphasised. The frames in **our** code are the ones a + * reader is looking for and the platform's are context, so ours carry the weight and a filled + * marker while the platform's are dimmed — the opposite of the printed order, where the platform + * usually comes first. And the chain reads downwards to the *root* cause: `printStackTrace` puts + * the outermost throwable at the top, but "Caused by" is where the answer is, so each cause is + * introduced by a divider rather than buried in the run of frames. + * + * The record is read here rather than passed through the route, because the process is quite likely + * to have died since the card was drawn — that is, after all, the subject. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashTraceScreen(onNavigateBack: () -> Unit) { + val context = LocalContext.current + val report = remember { CrashRecorder.newest(context) } + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.copied) + val frameCopied = stringResource(R.string.crash_frame_copied) + + Scaffold( + snackbarHost = { VectorSnackbarHost(snackbars) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.crash_trace)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + // Every record, not the one on screen. The screen shows the newest because + // that is the one being asked about, but a crash loop writes several and a + // maintainer wants all of them; and this stays enabled when the newest could + // not be parsed, since a record we failed to read is exactly the one worth + // getting off the device by hand. + IconButton( + onClick = { + copyToClipboard(context, CrashRecorder.read(context).orEmpty()) + scope.launch { snackbars.show(copied, SnackbarTone.Success) } + } + ) { + Icon( + Icons.Rounded.ContentCopy, + contentDescription = stringResource(R.string.action_copy_all), + ) + } + }, + ) + }, + ) { padding -> + if (report == null || report.sections.isEmpty()) { + Text( + stringResource(R.string.crash_unreadable), + modifier = Modifier.padding(padding).padding(20.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Scaffold + } + + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 24.dp), + ) { + item(key = "when") { + Text( + stringResource(R.string.crash_when_value, report.at, report.thread), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + report.build, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + } + report.sections.forEachIndexed { index, section -> + item(key = "s:$index") { SectionHeader(section) } + items(section.frames, key = { "f:$index:${it.line}" }) { frame -> + FrameRow( + frame = frame, + onCopy = { + copyToClipboard(context, frame.line) + scope.launch { snackbars.show(frameCopied, SnackbarTone.Success) } + }, + ) + } + if (section.elided > 0) { + item(key = "e:$index") { + Text( + pluralStringResource( + R.plurals.crash_frames_elided, + section.elided, + section.elided, + ), + modifier = Modifier.padding(start = 20.dp, top = 6.dp, bottom = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +/** + * The throwable a run of frames belongs to. + * + * The type is the heading and the message is the sentence under it, which is the way round a reader + * needs them: the type says what kind of failure this is and is short enough to scan, the message + * says what was being attempted and is often a whole line long. A cause is introduced by a labelled + * divider so that the change of subject is visible while scrolling past at speed. + */ +@Composable +private fun SectionHeader(section: CrashSection) { + val colors = MaterialTheme.colorScheme + Column(modifier = Modifier.fillMaxWidth()) { + if (section.isCause) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.crash_caused_by), + style = MaterialTheme.typography.labelMedium, + color = colors.error, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + HorizontalDivider(Modifier.weight(1f), color = colors.error.copy(alpha = 0.3f)) + } + Spacer(Modifier.height(6.dp)) + } + Text( + section.simpleType, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = colors.error, + ) + section.message?.let { message -> + Spacer(Modifier.height(2.dp)) + Text(message, style = MaterialTheme.typography.bodyMedium, color = colors.onSurface) + } + Spacer(Modifier.height(8.dp)) + } +} + +/** + * One frame, as two lines: what ran, and where that is written. + * + * Only the file and line are monospaced. `MainActivity.kt:39` is an identifier a reader compares + * character by character against their editor; `MainActivity.onCreate` is a name they read, and + * reads worse in a typewriter face. The frame stays on one line and scrolls sideways rather than + * wrapping — a wrapped frame reads as two frames. + * + * Tapping copies this frame alone, which is the unit people quote to each other. + */ +@Composable +private fun FrameRow(frame: CrashFrame, onCopy: () -> Unit) { + val colors = MaterialTheme.colorScheme + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onCopy).padding(vertical = 5.dp), + verticalAlignment = Alignment.Top, + ) { + // Filled for our code, hollow for the platform's: the shape carries the distinction where + // colour alone would not, and the column of markers can be scanned without reading a word. + Surface( + modifier = Modifier.padding(top = 6.dp).size(7.dp), + shape = CircleShape, + color = if (frame.ours) colors.primary else colors.onSurfaceVariant.copy(alpha = 0.25f), + content = {}, + ) + Spacer(Modifier.padding(horizontal = 6.dp)) + Column( + modifier = Modifier.horizontalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Text( + frame.shortMethod, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (frame.ours) FontWeight.Medium else FontWeight.Normal, + color = if (frame.ours) colors.onSurface else colors.onSurfaceVariant, + softWrap = false, + maxLines = 1, + ) + Text( + frame.location ?: frame.method, + style = VectorMono.copy(fontSize = 11.sp), + color = colors.onSurfaceVariant.copy(alpha = if (frame.ours) 1f else 0.7f), + softWrap = false, + maxLines = 1, + ) + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt index 74cff4134..7f6089640 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt @@ -1,8 +1,5 @@ package org.matrix.vector.manager.ui.screens.home -import android.util.Log -import org.matrix.vector.manager.Constants import kotlinx.coroutines.CancellationException - import org.matrix.vector.manager.data.repository.FrameworkUpdateState import org.matrix.vector.manager.data.repository.LaunchShortcut import org.matrix.vector.manager.data.repository.ManagerInstallStep @@ -28,6 +25,8 @@ import org.matrix.vector.manager.data.github.GitHubAuth import org.matrix.vector.manager.data.github.GitHubRepository import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW import org.matrix.vector.manager.ui.components.FrameworkState /** A specific reason the framework is degraded, so the UI never has to say merely "something". */ @@ -324,11 +323,7 @@ class HomeViewModel( daemon .getXposedVersionCode() .onFailure { e -> - Log.w( - Constants.TAG, - "status: framework version code unavailable, update check skipped", - e, - ) + logW("status: framework version code unavailable, update check skipped", e) } .getOrDefault(0L) val api = daemon.getXposedApiVersion().getOrNull() @@ -339,8 +334,7 @@ class HomeViewModel( val systemServerResult = daemon.systemServerRequested() val healthFailure = sepolicyResult.exceptionOrNull() ?: systemServerResult.exceptionOrNull() if (healthFailure != null && healthFailure !is CancellationException) { - Log.w( - Constants.TAG, + logW( "status: framework health read failed, defaulting to sepolicy/system_server " + "not loaded", healthFailure, @@ -477,13 +471,13 @@ class HomeViewModel( it.copy(notificationEnabled = enabled, notificationKnown = true) } } - .onFailure { e -> Log.w(Constants.TAG, "status: notification toggle unread", e) } + .onFailure { e -> logW("status: notification toggle unread", e) } // Read rather than assumed: this one is a global system setting, so anything on the device // can have moved it since the manager last wrote it. daemon .forcedLauncherIcons() .onSuccess { _hiddenIcon.value = it } - .onFailure { e -> Log.w(Constants.TAG, "status: launcher-icon toggle unread", e) } + .onFailure { e -> logW("status: launcher-icon toggle unread", e) } } fun setStatusNotification(enabled: Boolean) { @@ -501,11 +495,7 @@ class HomeViewModel( } } .onFailure { e -> - Log.e( - Constants.TAG, - "framework: setting the status notification to $enabled failed", - e, - ) + logE("framework: setting the status notification to $enabled failed", e) } } } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt index 2c6c36332..4b4e52300 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt @@ -1,7 +1,5 @@ package org.matrix.vector.manager.ui.screens.home -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -68,10 +66,12 @@ import org.matrix.vector.manager.R import org.matrix.vector.manager.ui.components.FrameworkState import org.matrix.vector.manager.data.log.CrashRecorder import org.matrix.vector.manager.data.model.XposedApi +import org.matrix.vector.manager.data.log.CrashReport import org.matrix.vector.manager.data.model.buildStamp import org.matrix.vector.manager.data.repository.ManagerInstallStep import org.matrix.vector.manager.ui.components.SnackbarTone import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.copyToClipboard import org.matrix.vector.manager.ui.components.show import kotlinx.coroutines.launch import org.matrix.vector.manager.ui.theme.VectorMono @@ -87,6 +87,7 @@ import org.matrix.vector.manager.ui.theme.VectorMono @Composable fun SystemStatusScreen( onNavigateBack: () -> Unit, + onOpenCrash: () -> Unit, viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory), ) { val status by viewModel.status.collectAsStateWithLifecycle() @@ -122,7 +123,7 @@ fun SystemStatusScreen( } // Read once per visit rather than watched: a crash cannot be recorded while this screen is on // screen, because the process that would record it is the one drawing it. - var crashes by remember { mutableStateOf(CrashRecorder.read(context)) } + var crash by remember { mutableStateOf(CrashRecorder.newest(context)) } // The two switches below belong to the framework, so they are only live while it is. val daemonAlive = status.state != FrameworkState.Inactive val snackbars = remember { SnackbarHostState() } @@ -149,7 +150,7 @@ fun SystemStatusScreen( onClick = { // Copied as it reads, headings and all — this text ends up pasted // into an issue, where the grouping is as useful as it is on screen. - copy( + copyToClipboard( context, englishSections.joinToString("\n\n") { (heading, items) -> heading + @@ -182,17 +183,14 @@ fun SystemStatusScreen( items(status.issues, key = { it.name }) { issue -> IssueCard(issue) } item { Spacer(Modifier.height(4.dp)) } } - if (crashes != null) { + crash?.let { report -> item(key = "crashes") { CrashCard( - report = crashes!!, - onCopy = { - copy(context, crashes!!) - scope.launch { snackbars.show(copied, SnackbarTone.Success) } - }, + report = report, + onOpenTrace = onOpenCrash, onClear = { CrashRecorder.clear(context) - crashes = null + crash = null }, ) Spacer(Modifier.height(4.dp)) @@ -519,17 +517,23 @@ private fun IssueCard(issue: HealthIssue) { * The manager's own crashes, which nothing else on the device keeps. * * On this page rather than under Logs, because every log there is the daemon's and because this is - * the page someone opens when they are about to report something. It previews the newest trace only - * — the older ones are on file and travel with the copy — since the question being asked is "what - * just happened", not "what has ever happened". + * the page someone opens when they are about to report something. It summarises the newest crash + * only — the older ones are on file and travel with the log export — since the question being asked + * is "what just happened", not "what has ever happened". + * + * Four facts, not a trace. This card sits among rows that each state one thing, and a block of + * monospace here would be the only thing on the page a reader has to decode rather than read; the + * trace has its own screen, one tap away, where it can be a list instead of a paragraph. The four + * are chosen as the answers to what a maintainer asks first: what threw, what it said, the nearest + * frame that is ours, and when. "Where" is the one worth having on the card at all — it is the + * fact that decides who picks the report up, and it is buried in the middle of the printed trace. * * The card is absent when there have been no crashes, which is the normal state and deserves no * row of its own. */ @Composable -private fun CrashCard(report: String, onCopy: () -> Unit, onClear: () -> Unit) { +private fun CrashCard(report: CrashReport, onOpenTrace: () -> Unit, onClear: () -> Unit) { val colors = MaterialTheme.colorScheme - val newest = remember(report) { report.trim().lines().take(CRASH_PREVIEW_LINES) } OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.Top) { @@ -546,23 +550,57 @@ private fun CrashCard(report: String, onCopy: () -> Unit, onClear: () -> Unit) { style = MaterialTheme.typography.bodySmall, color = colors.onSurfaceVariant, ) - Spacer(Modifier.height(8.dp)) - Text( - newest.joinToString("\n"), - style = VectorMono.copy(fontSize = 12.sp), - color = colors.onSurfaceVariant, - maxLines = CRASH_PREVIEW_LINES, - overflow = TextOverflow.Ellipsis, + Spacer(Modifier.height(12.dp)) + // The root cause rather than what reached the handler: "RuntimeException: Unable to + // start activity" is the platform saying where it noticed, and the end of the chain is + // the sentence that names what actually failed. + report.root?.let { cause -> + CrashFact(stringResource(R.string.crash_what), cause.simpleType, error = true) + cause.message?.let { CrashFact(stringResource(R.string.crash_message), it) } + } + CrashFact( + stringResource(R.string.crash_where), + report.ours?.shortMethod ?: stringResource(R.string.crash_where_unknown), + monospace = report.ours != null, ) + CrashFact(stringResource(R.string.crash_when), crashWhen(report)) Spacer(Modifier.height(8.dp)) Row { - TextButton(onClick = onCopy) { Text(stringResource(R.string.action_copy_all)) } + TextButton(onClick = onOpenTrace) { + Text(stringResource(R.string.crash_open_trace)) + } TextButton(onClick = onClear) { Text(stringResource(R.string.crash_recorded_clear)) } } } } } +/** + * One line of the summary, laid out as the rows below it are: label above, fact underneath. + * + * Tighter than [InfoRow] because four of these sit inside a card rather than on the page, and + * because none of them is a status anyone needs to spot from across the room. + */ +@Composable +private fun CrashFact( + label: String, + value: String, + monospace: Boolean = false, + error: Boolean = false, +) { + val colors = MaterialTheme.colorScheme + Column(Modifier.padding(bottom = 8.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = colors.onSurfaceVariant) + Text( + value, + style = + if (monospace) VectorMono.copy(fontSize = 14.sp) + else MaterialTheme.typography.bodyMedium, + color = if (error) colors.error else colors.onSurface, + ) + } +} + /** * One fact, at a size meant to be read. * @@ -778,11 +816,6 @@ private fun dex2oatLabel(context: Context, compatibility: Int): String = } ) -private fun copy(context: Context, text: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - clipboard?.setPrimaryClip(ClipData.newPlainText(BuildConfig.MANAGER_PACKAGE_NAME, text)) -} - @Composable private fun FrameworkToggle( title: String, @@ -828,5 +861,3 @@ private fun FrameworkToggle( } } -/** Enough of the newest trace to recognise it; the rest travels in the copy. */ -private const val CRASH_PREVIEW_LINES = 6 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt index 322ff1ac7..197156cd8 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt @@ -1,7 +1,5 @@ package org.matrix.vector.manager.ui.screens.logs -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri @@ -112,6 +110,7 @@ import org.matrix.vector.manager.data.log.LogLevel import org.matrix.vector.manager.ui.components.PanelHeader import org.matrix.vector.manager.ui.components.sheetRowColors import org.matrix.vector.manager.ui.components.SearchField +import org.matrix.vector.manager.ui.components.copyToClipboard import org.matrix.vector.manager.ui.theme.VectorMono /** @@ -987,11 +986,6 @@ LocalizedOverlay { private val FILE_STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") -private fun copyToClipboard(context: Context, text: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - clipboard?.setPrimaryClip(ClipData.newPlainText(BuildConfig.MANAGER_PACKAGE_NAME, text)) -} - private fun shareZip(context: Context, uri: Uri) { val intent = Intent(Intent.ACTION_SEND).apply { diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt index 145731932..a8b935715 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt @@ -1,6 +1,4 @@ package org.matrix.vector.manager.ui.screens.logs -import android.util.Log -import org.matrix.vector.manager.Constants import android.net.Uri import androidx.lifecycle.ViewModel @@ -27,6 +25,8 @@ import org.matrix.vector.manager.data.log.LogRow import org.matrix.vector.manager.data.repository.SettingsRepository import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** The two log streams the daemon keeps. They are read independently and never both at once. */ enum class LogTab { @@ -263,8 +263,7 @@ class LogsViewModel(private val daemon: DaemonClient, private val settings: Sett else daemon.getLogPart(verbose, chosen) val pfd = result.getOrElse { - Log.w( - Constants.TAG, + logW( "logs: ${tab.name.lowercase()} log (${chosen ?: "live"}) unavailable", it, ) @@ -553,7 +552,7 @@ class LogsViewModel(private val daemon: DaemonClient, private val settings: Sett viewModelScope.launch { val result = daemon.clearLogs(tab == LogTab.VERBOSE) result.onFailure { - Log.e(Constants.TAG, "logs: rotating the ${tab.name.lowercase()} log failed", it) + logE("logs: rotating the ${tab.name.lowercase()} log failed", it) } val ok = result.getOrDefault(false) if (ok) reload(tab, Jump.NEWEST) @@ -606,7 +605,7 @@ class LogsViewModel(private val daemon: DaemonClient, private val settings: Sett fun setVerbose(enabled: Boolean) { viewModelScope.launch { daemon.setVerboseLogEnabled(enabled).onFailure { - Log.e(Constants.TAG, "logs: setting verbose logging to $enabled failed", it) + logE("logs: setting verbose logging to $enabled failed", it) } val actual = daemon.isVerboseLogEnabled().getOrDefault(enabled) _verboseEnabled.value = actual diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt index 6a9e3a611..19e65efd4 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt @@ -1,7 +1,5 @@ package org.matrix.vector.manager.ui.screens.modules -import org.matrix.vector.manager.Constants -import android.util.Log import android.os.SystemClock import android.content.pm.PackageManager import androidx.lifecycle.ViewModel @@ -30,6 +28,9 @@ import org.lsposed.lspd.ILSPManagerService import org.matrix.vector.manager.data.model.XposedApi import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logI +import org.matrix.vector.manager.logW /** One tab: a user, and the modules installed for them. */ data class UserModulesState(val user: UserInfo, val modules: List) @@ -362,8 +363,7 @@ class ModulesViewModel( if (ok) removed++ else { failed++ - Log.e( - Constants.TAG, + logE( "modules: uninstall of ${key.packageName} for user ${key.userId} failed", result.exceptionOrNull(), ) @@ -487,11 +487,7 @@ class ModulesViewModel( private suspend fun discover(): List { val usersResult = daemonClient.getUsers() usersResult.onFailure { e -> - Log.w( - Constants.TAG, - "modules: user list unavailable, treating the daemon as unreachable", - e, - ) + logW("modules: user list unavailable, treating the daemon as unreachable", e) } _daemonAvailable.value = usersResult.isSuccess val users = usersResult.getOrNull() ?: emptyList() @@ -505,11 +501,7 @@ class ModulesViewModel( daemonClient .getInstalledPackagesFromAllUsers(flags, filterNoProcess = false) .getOrElse { e -> - Log.e( - Constants.TAG, - "modules: installed package list unavailable, showing no modules", - e, - ) + logE("modules: installed package list unavailable, showing no modules", e) emptyList() } @@ -552,8 +544,7 @@ class ModulesViewModel( // The one number that explains a slow Modules panel: how many APKs this scan had to open. // Everything else is a map lookup, so a large figure here on a second run means the cache // key is wrong rather than that the device is slow. - Log.i( - Constants.TAG, + logI( "modules: scanned ${packages.size} packages in " + "${SystemClock.elapsedRealtime() - startedAt}ms, " + "opened ${detection.inspectedThisRun}", diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt index 3101c6688..b18fca4eb 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt @@ -1,10 +1,7 @@ package org.matrix.vector.manager.ui.screens.modules -import android.util.Log -import org.matrix.vector.manager.Constants -import kotlinx.coroutines.CancellationException - import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -24,6 +21,8 @@ import org.matrix.vector.manager.data.repository.ModuleRepository import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.data.repository.SettingsRepository import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** A package/user pair, as a value type so set arithmetic is correct. */ data class ScopeTarget(val packageName: String, val userId: Int) @@ -469,8 +468,7 @@ class ScopeViewModel( ) } .onFailure { e -> - Log.w( - Constants.TAG, + logW( "scope: package info for $modulePackageName (user $userId) " + "unavailable, no recommended scope", e, @@ -517,7 +515,7 @@ class ScopeViewModel( private suspend fun readSavedScope(): Set? { val result = daemonClient.getModuleScope(modulePackageName) result.exceptionOrNull()?.let { e -> - Log.e(Constants.TAG, "scope: reading the saved scope of $modulePackageName failed", e) + logE("scope: reading the saved scope of $modulePackageName failed", e) } val rows = result.getOrNull() ?: return null return rows.map { ScopeTarget(it.packageName, it.userId) }.toSet().asStored() @@ -676,8 +674,7 @@ class ScopeViewModel( if (stored) { _uiState.value = _uiState.value.copy(includeNewApps = enabled) } else { - Log.e( - Constants.TAG, + logE( "scope: daemon refused include-new-apps=$enabled for " + modulePackageName, ) @@ -685,8 +682,7 @@ class ScopeViewModel( } } .onFailure { e -> - Log.e( - Constants.TAG, + logE( "scope: setting include-new-apps=$enabled for $modulePackageName failed", e, ) @@ -720,7 +716,7 @@ class ScopeViewModel( _frameworkRestartNeeded.value = false viewModelScope.launch { daemonClient.softReboot().onFailure { e -> - Log.e(Constants.TAG, "scope: soft reboot after a framework scope change failed", e) + logE("scope: soft reboot after a framework scope change failed", e) } } } @@ -732,8 +728,7 @@ class ScopeViewModel( daemonClient .findAppUi(modulePackageName, userId, companionFirst = true) .onFailure { e -> - Log.w( - Constants.TAG, + logW( "scope: companion lookup for $modulePackageName user $userId failed", e, ) @@ -759,8 +754,7 @@ class ScopeViewModel( daemonClient .openAppUi(modulePackageName, userId, companionFirst = true) .onFailure { e -> - Log.e( - Constants.TAG, + logE( "scope: companion open of $modulePackageName for user $userId failed", e, ) @@ -812,10 +806,9 @@ class ScopeViewModel( val draft = draftScope.value.asStored() val current = readSavedScope() if (current == null) { - Log.w( - Constants.TAG, + logW( "scope: could not re-read the scope of $modulePackageName before writing; " + - "applying the draft as it stands", + "applying the draft as it stands" ) } val before = current ?: baseline @@ -834,10 +827,7 @@ class ScopeViewModel( // that reaches beyond a scope the module fixes for itself, and moving the saved // set on a refusal would show a scope the framework never took. if (!stored) { - Log.e( - Constants.TAG, - "scope: daemon refused ${merged.size} targets for $modulePackageName", - ) + logE("scope: daemon refused ${merged.size} targets for $modulePackageName") _message.value = ScopeMessage.ApplyFailed } else { // Whether the framework itself just joined or left this scope. Compared @@ -876,11 +866,7 @@ class ScopeViewModel( } } .onFailure { e -> - Log.e( - Constants.TAG, - "scope: apply of ${merged.size} targets to $modulePackageName failed", - e, - ) + logE("scope: apply of ${merged.size} targets to $modulePackageName failed", e) _message.value = ScopeMessage.ApplyFailed } _applying.value = false @@ -916,7 +902,7 @@ class ScopeViewModel( } ?: error("could not open the file") } .onFailure { e -> - Log.e(Constants.TAG, "scope: backup of $modulePackageName failed", e) + logE("scope: backup of $modulePackageName failed", e) } .isSuccess } @@ -940,7 +926,7 @@ class ScopeViewModel( } .onFailure { e -> if (e is CancellationException) throw e - Log.e(Constants.TAG, "scope: restore for $modulePackageName failed", e) + logE("scope: restore for $modulePackageName failed", e) } .getOrNull() } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/report/TroubleshootScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/report/TroubleshootScreen.kt index 2ab31a93c..ee7e261b1 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/report/TroubleshootScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/report/TroubleshootScreen.kt @@ -1,8 +1,4 @@ package org.matrix.vector.manager.ui.screens.report -import android.util.Log -import org.matrix.vector.manager.Constants -import kotlinx.coroutines.CancellationException - import android.content.Context import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -49,12 +45,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.matrix.vector.manager.R import org.matrix.vector.manager.data.github.GitHubRepository import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.logE import org.matrix.vector.manager.ui.components.SnackbarTone import org.matrix.vector.manager.ui.components.VectorSnackbarHost import org.matrix.vector.manager.ui.components.show @@ -108,7 +106,7 @@ fun TroubleshootScreen( } .onFailure { e -> if (e is CancellationException) throw e - Log.e(Constants.TAG, "report: saving the log archive failed", e) + logE("report: saving the log archive failed", e) } .isSuccess } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/splash/SplashScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/splash/SplashScreen.kt index cff04446d..88450db33 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/splash/SplashScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/splash/SplashScreen.kt @@ -1,6 +1,4 @@ package org.matrix.vector.manager.ui.screens.splash -import android.util.Log -import org.matrix.vector.manager.Constants import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState @@ -30,6 +28,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.matrix.vector.manager.R import org.matrix.vector.manager.di.ServiceLocator import kotlinx.coroutines.flow.first +import org.matrix.vector.manager.logW /** How long the animation itself needs, so the statue is never cut off mid-fade. */ private const val ANIMATION_MS = 800L @@ -56,10 +55,7 @@ fun SplashGate(content: @Composable () -> Unit) { val bound = withTimeoutOrNull(DAEMON_TIMEOUT_MS) { ServiceLocator.service.first { it != null } } if (bound == null) { - Log.w( - Constants.TAG, - "splash: no daemon binder after ${DAEMON_TIMEOUT_MS}ms, continuing unactivated", - ) + logW("splash: no daemon binder after ${DAEMON_TIMEOUT_MS}ms, continuing unactivated") } delay(ANIMATION_MS) ready = true diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt index 827df04cd..8c9c9113b 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/update/FrameworkUpdateViewModel.kt @@ -1,6 +1,4 @@ package org.matrix.vector.manager.ui.screens.update -import android.util.Log -import org.matrix.vector.manager.Constants import org.matrix.vector.manager.data.repository.ReleaseDirection import org.matrix.vector.manager.data.github.FrameworkRelease @@ -19,6 +17,8 @@ import org.lsposed.lspd.ILSPManagerService import org.matrix.vector.manager.data.repository.FlashStep import org.matrix.vector.manager.data.repository.FrameworkUpdateState import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW /** Which root implementation is in charge, and whether it can be flashed through. */ data class RootState(val code: Int = ILSPManagerService.ROOT_UNKNOWN, val version: String? = null) { @@ -162,11 +162,7 @@ class FrameworkUpdateViewModel : ViewModel() { // the root version and the framework commit take their default in silence. val code = daemon.getRootImplementation().getOrElse { e -> - Log.w( - Constants.TAG, - "update: root implementation unreadable, screen will say it is unknown", - e, - ) + logW("update: root implementation unreadable, screen will say it is unknown", e) ILSPManagerService.ROOT_UNKNOWN } val version = daemon.getRootImplementationVersion().getOrNull() @@ -175,11 +171,7 @@ class FrameworkUpdateViewModel : ViewModel() { viewModelScope.launch { val installed = daemon.getXposedVersionCode().getOrElse { e -> - Log.w( - Constants.TAG, - "update: installed framework version unavailable, update check skipped", - e, - ) + logW("update: installed framework version unavailable, update check skipped", e) 0L } updates.refresh(installed, daemon.getFrameworkCommit().getOrNull()) @@ -198,8 +190,7 @@ class FrameworkUpdateViewModel : ViewModel() { val zip = chosenZip.value ?: run { - Log.e( - Constants.TAG, + logE( "update: flash pressed with no zip selected, " + "release=${selected.value?.tag}", ) @@ -208,10 +199,7 @@ class FrameworkUpdateViewModel : ViewModel() { val url = zip.downloadUrl ?: run { - Log.e( - Constants.TAG, - "update: flash pressed but zip ${zip.name} has no download url", - ) + logE("update: flash pressed but zip ${zip.name} has no download url") return } installer.start(url, zip.sizeInBytes, zip.name) @@ -238,6 +226,6 @@ class FrameworkUpdateViewModel : ViewModel() { fun acknowledge() = installer.acknowledge() suspend fun reboot() { - daemon.reboot().onFailure { Log.e(Constants.TAG, "update: reboot request failed", it) } + daemon.reboot().onFailure { logE("update: reboot request failed", it) } } } diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 1a92b87c6..81ab3ccd4 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -32,6 +32,24 @@ The manager closed unexpectedly The full trace travels in the log export, under crash_manager. Discard + What + Message + Where + When + + Nowhere in Vector\'s own code + + %1$s on %2$s + See the trace + Crash + Caused by + + + %d more frame, the same as above + %d more frames, the same as above + + Frame copied + This crash could not be read. Copy it and attach it to the report as it is. System status diff --git a/services/daemon-service/src/main/java/org/lsposed/lspd/util/Utils.java b/services/daemon-service/src/main/java/org/lsposed/lspd/util/Utils.java index cfdd92bea..f39533842 100644 --- a/services/daemon-service/src/main/java/org/lsposed/lspd/util/Utils.java +++ b/services/daemon-service/src/main/java/org/lsposed/lspd/util/Utils.java @@ -23,6 +23,8 @@ import android.os.SystemProperties; import android.text.TextUtils; +import java.io.PrintWriter; +import java.io.StringWriter; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.zone.ZoneRulesException; @@ -49,8 +51,19 @@ public static void println(int priority, String tag, String msg) { android.util.Log.println(priority, tag, msg); } + /** + * A throwable as text, without the platform's filtering. + * + * {@code android.util.Log.getStackTraceString} returns an empty string when anything in + * the cause chain is an {@link java.net.UnknownHostException} — deliberately upstream, to + * cut log spew when the network is down, but here it silently turns a module's report of a + * failed request into a message with nothing under it. + */ public static String getStackTraceString(Throwable tr) { - return android.util.Log.getStackTraceString(tr); + if (tr == null) return ""; + StringWriter sw = new StringWriter(); + tr.printStackTrace(new PrintWriter(sw)); + return sw.toString().stripTrailing(); } public static void d(String tag, String msg) { diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt index efb4da78f..1064913b9 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -165,13 +165,21 @@ class VectorContext( log(priority, tag, msg, null) } + /** + * A module's own logging, which is the only channel it has for saying what went wrong. + * + * The trace comes from [Log.getStackTraceString], our own, not `android.util.Log`'s — that one + * discards the trace outright for any [java.net.UnknownHostException] cause chain, so a module + * reporting a failed request got its message and a blank line after it, with nothing to say + * whose code the request was made from. + */ override fun log(priority: Int, tag: String?, msg: String, tr: Throwable?) { val finalTag = tag ?: "VectorContext" val prefix = if (packageName.isNotEmpty()) "$packageName: " else "" val fullMsg = buildString { append(prefix).append(msg) if (tr != null) { - append("\n").append(android.util.Log.getStackTraceString(tr)) + append("\n").append(Log.getStackTraceString(tr)) } } Log.println(priority, finalTag, fullMsg) From 251ec6bb654365a6a92466d54c06703abf876b2b Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 29 Jul 2026 12:38:04 +0200 Subject: [PATCH 2/6] Read a trace in the log the way the crash screen reads one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The log panel was losing half of every stack trace. LogFile.readRows attaches a line to the entry above it only when the line is indented, and printStackTrace writes the throwable's own header flush left, then every "Caused by:" flush left too. Both ended the run, so the cause chain and all of its frames became one LogRow.Marker per line — the half of the trace that names what actually failed was the half that fell out of the entry that owned it, and the "N frames" expander was counting only what was left. So a throwable header counts as a continuation as well, though only where an entry owns the line above, which is what keeps "----part 7 start----" and the daemon's own banners the standalone markers they are. The filter pass had to learn the same rule, or a filtered view would drop what the unfiltered one keeps; and entryStart spends one extra read on a line beginning with a letter, so jumping into the middle of a long trace no longer opens on an orphan. With the whole trace in hand there was no reason to keep rendering it as raw monospace behind an expander. StackTrace, extracted from the crash screen, is now what draws both: our frames marked and emphasised, the platform's dimmed, "Caused by" as a divider, each frame tappable to copy. A trace written by the daemon, by a module or by the manager is the same printStackTrace output whoever wrote it, so it is read one way. Where it opens is a setting. Inline by default, because the log is read with a filter applied and a scroll position worth keeping and a route push costs both; off, the expander opens a screen instead, which is the better answer for a trace long enough that having it in the list is the thing in the way. One correction the parser needed before that worked. parseStackTrace opened a section only on a header line, so a trace that begins at a frame — which is what the panel hands it, the header having landed on the entry's own line — fell to the branch that reads headers and spent the first frame on one. The heading read "java:248)", the tail of the frame it had just eaten, and the count was one short. A frame now opens an untyped section and the renderer draws no heading for it; the row recovers the real type from the entry's message instead, taking only the type, since the line above is already saying the rest in full. --- .../vector/manager/data/log/CrashReport.kt | 99 +++++++-- .../matrix/vector/manager/data/log/LogFile.kt | 46 +++- .../vector/manager/data/log/LogModel.kt | 24 ++- .../data/repository/SettingsRepository.kt | 17 ++ .../org/matrix/vector/manager/ui/VectorApp.kt | 7 +- .../manager/ui/components/StackTrace.kt | 198 ++++++++++++++++++ .../vector/manager/ui/navigation/Route.kt | 9 + .../ui/screens/home/CrashTraceScreen.kt | 156 ++------------ .../vector/manager/ui/screens/logs/LogRows.kt | 94 +++++++-- .../manager/ui/screens/logs/LogTraceScreen.kt | 101 +++++++++ .../manager/ui/screens/logs/LogsScreen.kt | 38 +++- .../manager/ui/screens/logs/LogsViewModel.kt | 4 + manager/src/main/res/values/strings_logs.xml | 3 + 13 files changed, 610 insertions(+), 186 deletions(-) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StackTrace.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogTraceScreen.kt diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt index 8fdef412c..02574f19b 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt @@ -17,16 +17,16 @@ package org.matrix.vector.manager.data.log data class CrashReport( /** The recorded timestamp, in the fixed format the file was written with. */ val at: String, + /** + * The thread that threw, or empty for a record written before the header carried one — the + * cache outlives an update, so the first run after one reads the old shape. + */ val thread: String, - /** Build, host and platform, as one line. Restated from "What is running" on the same screen. */ + /** Build, host and platform, as one line. Restated from "What is running" on that screen. */ val build: String, - /** The throwable, then whatever it was caused by, in the order `printStackTrace` prints them. */ + /** The throwable, then what caused it, in the order `printStackTrace` prints them. */ val sections: List, ) { - /** The throwable that reached the handler. Null only for a record we could not parse at all. */ - val thrown: CrashSection? - get() = sections.firstOrNull() - /** * The innermost cause, which is the thing that actually went wrong. * @@ -108,59 +108,116 @@ private const val SUPPRESSED = "Suppressed: " /** * Reads back a record written by [CrashRecorder]. * - * The two header lines are ours; everything after them is `Throwable.printStackTrace` output, whose - * shape is fixed by the JDK: a header line naming the throwable, tab-indented `at` lines, an - * optional `... N more`, and the same again after `Caused by:`. Suppressed exceptions print under - * `Suppressed:` and are treated as another section, since for reading purposes they are one. + * The two header lines are ours; everything after them is a stack trace, handed to + * [parseStackTrace]. * - * Returns null only when there is no header to read, never on a trace it cannot make sense of — an - * unparsed tail simply contributes no frames, and [CrashReport.raw] still has every byte of it. + * Returns null only when there is no header to read, never on a trace it cannot make sense of. */ fun parseCrashReport(record: String): CrashReport? { val lines = record.trimEnd().lines() if (lines.size < 2) return null + val (at, thread) = + lines[0].split(" · thread ", limit = 2).let { it[0] to it.getOrElse(1) { "" } } + return CrashReport( + at = at, + thread = thread, + build = lines[1], + sections = parseStackTrace(lines.drop(2)), + ) +} + +/** + * `Throwable.printStackTrace` output, as the chain of throwables it describes. + * + * The shape is fixed by the JDK: a header line naming the throwable, tab-indented `at` lines, an + * optional `... N more`, and the same again after `Caused by:`. Suppressed exceptions print under + * `Suppressed:` and are treated as another link, since for reading purposes they are one. + * + * Total by construction. A line it does not recognise contributes nothing and ends nothing; text + * that is not a trace at all yields an empty list, which is how a caller asks "is there a trace + * here" without a second parser to decide it first. Written against the *printed* form rather than + * against our own writer, because the traces it is given come from the daemon, from modules, and + * from the platform's crash handler as readily as from us. + */ +fun parseStackTrace(trace: String): List = parseStackTrace(trace.trimEnd().lines()) - val (at, thread) = lines[0].split(" · thread ", limit = 2).let { it[0] to it.getOrElse(1) { "" } } +/** + * The same, for a caller that already holds the lines. + * + * The log panel does: an entry's continuation lines *are* the trace, so joining them into a string + * for this to split again would be work done twice on every visible row. + */ +fun parseStackTrace(lines: List): List { val sections = mutableListOf() var type: String? = null var message: String? = null var isCause = false + var open = false var frames = mutableListOf() var elided = 0 fun flush() { - val started = type ?: return - sections += CrashSection(started, message, frames.toList(), elided, isCause) + if (!open) return + sections += CrashSection(type.orEmpty(), message, frames.toList(), elided, isCause) frames = mutableListOf() elided = 0 + open = false + type = null + message = null + isCause = false } - for (line in lines.drop(2)) { + for (line in lines) { val frame = FRAME.matchEntire(line) val skipped = ELIDED.matchEntire(line) when { - frame != null && type != null -> { + frame != null -> { + // A frame may arrive before any header, and does whenever the text handed here is + // only the *continuation* of a log entry: `XposedBridge.log(Throwable)` writes the + // whole trace as one message, so the header lands on the entry's own line and the + // frames land under it. Such a trace opens an untyped section. Reading the frame as + // a header instead — which a stricter rule did — spent it on a heading that said + // "java:248)", the tail of the frame it had just eaten. + open = true val method = frame.groupValues[1] val location = frame.groupValues[2].takeIf { it.isNotEmpty() } frames += CrashFrame(method, location, OUR_PACKAGES.any(method::startsWith)) } - skipped != null -> elided = skipped.groupValues[1].toIntOrNull() ?: 0 + skipped != null -> { + open = true + elided = skipped.groupValues[1].toIntOrNull() ?: 0 + } line.isBlank() -> Unit else -> { // A header: the throwable itself, or one introduced by Caused by:/Suppressed:. flush() - val cause = line.startsWith(CAUSED_BY) || line.startsWith(SUPPRESSED) + open = true + isCause = line.startsWith(CAUSED_BY) || line.startsWith(SUPPRESSED) val header = line.removePrefix(CAUSED_BY).removePrefix(SUPPRESSED).trim() // "type: message", where the type never contains a space and the message may. val split = header.indexOf(": ") type = if (split < 0) header else header.substring(0, split) message = if (split < 0) null else header.substring(split + 2) - isCause = cause } } } flush() + return sections +} - return CrashReport(at = at, thread = thread, build = lines[1], sections = sections) +/** + * A line that introduces a throwable, written flush left by `printStackTrace`. + * + * Either a labelled link in the chain, or a bare header: a dotted type name with no spaces in it, + * ending in something that reads as a throwable, optionally followed by `: ` and a message. + * Deliberately narrow, because callers use it to decide whether a line belongs to a trace at all — + * "store: refreshing failed" is rejected on the first test, having no dot in its type. + */ +fun isThrowableHeader(text: String): Boolean { + if (text.startsWith(CAUSED_BY) || text.startsWith(SUPPRESSED)) return true + val type = text.substringBefore(": ") + return type.contains('.') && + type.none { it.isWhitespace() } && + (type.endsWith("Exception") || type.endsWith("Error") || type.endsWith("Throwable")) } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt index 0cd2b1aec..6571e7bfc 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt @@ -105,7 +105,7 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { } forEachLine(index, lines, null) { lineIndex, text, truncated -> - if (traceOwner >= 0 && isContinuationLine(text)) { + if (traceOwner >= 0 && isContinuationLine(text, ownedByEntry = true)) { // A multi-line message reaches the file as one writev, so its continuation lines // carry no prefix. They are frames of the entry above, not entries of their own. (trace ?: ArrayList(8).also { trace = it }).add(text) @@ -132,8 +132,14 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { * Walks back from [line] to the entry that owns it. * * Without this a window boundary landing between an entry and its stack trace opens the page - * on orphan frames with nothing to attach them to. Only the first byte of each candidate line - * is read — up to [TRACE_LOOKBACK] single-byte positional reads, all of them page-cache hits. + * on orphan frames with nothing to attach them to. The first byte of each candidate line + * decides it — up to [TRACE_LOOKBACK] single-byte positional reads, all of them page-cache hits. + * + * A line beginning with a letter costs one more read, because it may be the `Caused by:` or + * bare throwable header that [isContinuationLine] also treats as part of the trace. Walking + * past one is what the extra read buys: stopping there would open the page on the very line the + * rest of this change exists to keep attached, and would do it precisely when someone has + * jumped into the middle of a long trace. */ fun entryStart(index: LogIndex, line: Int): Int { if (line >= index.lineCount) return line @@ -141,7 +147,13 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { var steps = 0 while (at > 0 && steps < TRACE_LOOKBACK) { val first = firstByte(index, at) - if (first != SPACE && first != TAB) break + val continues = + first == SPACE || + first == TAB || + (first > 0 && + first.toChar().isLetter() && + isContinuationLine(lineText(index, at), ownedByEntry = true)) + if (!continues) break at-- steps++ } @@ -165,9 +177,13 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { val tags = HashMap() val levels = HashMap() var previousMatched = false + // Whether the last row was an entry, which is what lets an unindented `Caused by:` be read + // as part of the trace here exactly as [readRows] reads it. Without it the two passes + // disagree, and a filtered view drops the half of the trace the unfiltered one keeps. + var inEntry = false forEachLine(index, null, onProgress) { lineIndex, text, truncated -> - if (isContinuationLine(text)) { + if (isContinuationLine(text, ownedByEntry = inEntry)) { // Frames follow their entry into the filtered view; a stack trace whose header // matched and whose body vanished is a filter actively hiding the answer. if (previousMatched) matches?.add(lineIndex) @@ -178,6 +194,7 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { tags[row.tag] = (tags[row.tag] ?: 0) + 1 levels[row.level] = (levels[row.level] ?: 0) + 1 } + inEntry = row is LogRow.Entry previousMatched = query.matches(row) if (previousMatched) matches?.add(lineIndex) } @@ -267,6 +284,22 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { return total } + /** + * The head of a line, decoded — enough of it to recognise a throwable header. + * + * A type name and the `": "` after it are all [isContinuationLine] inspects, so the read is + * capped rather than following a line of unbounded length. It borrows [block], which is safe + * only because the one caller, [entryStart], runs between block iterations and never during + * one. + */ + private fun lineText(index: LogIndex, line: Int): String { + val from = index.bounds[line] + val length = min(index.bounds[line + 1] - from, HEADER_PROBE.toLong()).toInt() + if (length <= 0) return "" + val read = readAt(from, length) + return if (read <= 0) "" else String(block, 0, read, Charsets.UTF_8).trimEnd('\n', '\r') + } + private fun firstByte(index: LogIndex, line: Int): Int { if (index.bounds[line + 1] <= index.bounds[line]) return -1 oneByte.clear() @@ -289,6 +322,9 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { /** How far back a window start may walk to find the entry that owns a stack frame. */ private const val TRACE_LOOKBACK = 64 + /** Comfortably past the longest throwable type name anyone has written. */ + private const val HEADER_PROBE = 512 + private const val NEWLINE = '\n'.code.toByte() private const val RETURN = '\r'.code.toByte() private const val SPACE = ' '.code diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt index d65049262..dbcbc68b4 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt @@ -117,9 +117,27 @@ private const val DELIMITER = " ] " /** `"[ "` plus the 23-character timestamp; below this the fixed-position checks run off the end. */ private const val MIN_PREFIX = 26 -/** A line is a continuation of the entry above it when it starts with whitespace. */ -fun isContinuationLine(text: String): Boolean = - text.isNotEmpty() && (text[0] == ' ' || text[0] == '\t') +/** + * Whether a line belongs to the entry above it rather than standing on its own. + * + * Indentation is the ordinary signal: a multi-line message reaches the file as one write, so only + * its first line carries a prefix and the rest arrive indented. + * + * A stack trace breaks that rule twice, and both times on the line that matters most. + * `Throwable.printStackTrace` writes the throwable's own header — `java.lang.IllegalStateException: + * store: refreshing the module list failed` — flush left, and every `Caused by:` after it flush + * left too. Treating those as new rows ends the run, which sent the whole cause chain and all of + * its frames to [LogRow.Marker] one line at a time: the half of the trace that names what actually + * failed was the half that fell out of the entry that owned it. + * + * So a header is admitted as a continuation as well — but only when [ownedByEntry], meaning the + * caller has an entry above this line for it to belong to. That is what keeps `----part 7 start----` + * and the daemon's own unprefixed banners the standalone markers they are, and it is why the flag + * is not defaulted to true: a caller has to have decided. + */ +fun isContinuationLine(text: String, ownedByEntry: Boolean = false): Boolean = + text.isNotEmpty() && + (text[0] == ' ' || text[0] == '\t' || (ownedByEntry && isThrowableHeader(text))) /** Parses one raw line, degrading to [LogRow.Marker] rather than failing. */ fun parseLogLine(index: Int, text: String, truncated: Boolean = false): LogRow = diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt index 995d000fd..3c1a99478 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt @@ -340,6 +340,23 @@ class SettingsRepository(context: Context) { _logWordWrap.value = enabled } + /** + * Whether a stack trace in the log opens where it sits, or on a screen of its own. + * + * Inline by default. The log is read with a filter applied and a scroll position worth keeping, + * and pushing a route for one entry costs both — which matters most when the reason you are + * reading the log is to compare one trace against another. The screen is the better answer for + * a trace long enough that having it inside the list is the thing in the way, so which one is + * right depends on the reader, and that is what makes it a setting rather than a decision. + */ + private val _logTracesInline = MutableStateFlow(prefs.getBoolean("log_traces_inline", true)) + val logTracesInline: StateFlow = _logTracesInline.asStateFlow() + + fun setLogTracesInline(inline: Boolean) { + prefs.edit().putBoolean("log_traces_inline", inline).apply() + _logTracesInline.value = inline + } + fun setThemeMode(mode: String) { prefs.edit().putString("theme_mode", mode).apply() _themeMode.value = mode diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt index 24191b640..5cc7316e9 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -28,6 +28,7 @@ import org.matrix.vector.manager.ui.navigation.Navigator import org.matrix.vector.manager.ui.navigation.Scope import org.matrix.vector.manager.ui.navigation.StoreDetail import org.matrix.vector.manager.ui.navigation.CrashTrace +import org.matrix.vector.manager.ui.navigation.LogTrace import org.matrix.vector.manager.ui.navigation.SystemStatus import org.matrix.vector.manager.ui.navigation.Web import org.matrix.vector.manager.ui.navigation.TOP_LEVEL_DESTINATIONS @@ -36,6 +37,7 @@ import org.matrix.vector.manager.ui.navigation.rememberNavigator import org.matrix.vector.manager.ui.screens.home.HomeScreen import org.matrix.vector.manager.ui.screens.home.CrashTraceScreen import org.matrix.vector.manager.ui.screens.home.SystemStatusScreen +import org.matrix.vector.manager.ui.screens.logs.LogTraceScreen import org.matrix.vector.manager.ui.screens.logs.LogsScreen import org.matrix.vector.manager.ui.screens.modules.ModulesScreen import org.matrix.vector.manager.ui.screens.modules.ScopeScreen @@ -145,7 +147,7 @@ private fun EntryProviderScope.registerRoutes(navigator: Navigator) { entry { RepoScreen(onModuleClick = { packageName -> navigator.go(StoreDetail(packageName)) }) } - entry { LogsScreen() } + entry { LogsScreen(onOpenTrace = { text -> navigator.go(LogTrace(text)) }) } entry { route -> ScopeScreen( @@ -164,6 +166,9 @@ private fun EntryProviderScope.registerRoutes(navigator: Navigator) { ) } entry { CrashTraceScreen(onNavigateBack = { navigator.back() }) } + entry { route -> + LogTraceScreen(text = route.text, onNavigateBack = { navigator.back() }) + } entry { TroubleshootScreen( onNavigateBack = { navigator.back() }, diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StackTrace.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StackTrace.kt new file mode 100644 index 000000000..c0b06e13b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StackTrace.kt @@ -0,0 +1,198 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.CrashFrame +import org.matrix.vector.manager.data.log.CrashSection +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * A stack trace, read as a list rather than as a wall of text. + * + * A trace is already a structured thing — a chain of throwables, each with a list of frames — and + * printing it as one string is a format for a terminal, not for a screen someone is scrolling on a + * phone. Rendered as rows it can do what the text cannot: mark the frames that belong to this + * project, separate the name of a method from the file it lives in, and let one frame be lifted to + * the clipboard without a text selection. + * + * Two things are deliberate about the emphasis. The frames in **our** code are the ones a reader is + * looking for and the platform's are context, so ours carry the weight and a filled marker while + * the platform's are dimmed — the opposite of the printed order, where the platform usually comes + * first. And the chain reads downwards to the *root* cause: `printStackTrace` puts the outermost + * throwable at the top, but "Caused by" is where the answer is, so each cause is introduced by a + * divider rather than buried in the run of frames. + * + * Shared by the crash card's trace screen and the log panel, which had been folding raw frames + * behind an expander. The same text arrives at both from the same parser, so it may as well be read + * the same way in both. + * + * Emits a plain column of rows and takes no scrolling of its own, so a caller can drop it into a + * `LazyColumn` item, a card, or an expanded log row without fighting a nested scroll. Long traces + * belong in [stackTraceItems] instead, which spends the caller's lazy list on them. + */ +@Composable +fun StackTrace( + sections: List, + onCopyFrame: (CrashFrame) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + sections.forEach { section -> + StackTraceSectionHeader(section) + section.frames.forEach { frame -> StackTraceFrame(frame) { onCopyFrame(frame) } } + if (section.elided > 0) StackTraceElided(section.elided) + } + } +} + +/** + * The same rows, contributed to a caller's `LazyColumn` instead of composed all at once. + * + * A trace can run to a hundred frames and a screen showing nothing else should not compose them + * all to show eight. + * + * Keys are positions, not frame text. A `StackOverflowError` prints the same frame hundreds of + * times over and a cause chain repeats the frames it shares, so keying on the line would hand + * `LazyColumn` a duplicate key — which it does not tolerate: it throws, on the screen whose whole + * job is showing someone what threw. + */ +fun LazyListScope.stackTraceItems(sections: List, onCopyFrame: (CrashFrame) -> Unit) { + sections.forEachIndexed { index, section -> + item(key = "s:$index") { StackTraceSectionHeader(section) } + items(section.frames.size, key = { "f:$index:$it" }) { at -> + val frame = section.frames[at] + StackTraceFrame(frame) { onCopyFrame(frame) } + } + if (section.elided > 0) { + item(key = "e:$index") { StackTraceElided(section.elided) } + } + } +} + +/** + * The throwable a run of frames belongs to. + * + * The type is the heading and the message is the sentence under it, which is the way round a reader + * needs them: the type says what kind of failure this is and is short enough to scan, the message + * says what was being attempted and is often a whole line long. A cause is introduced by a labelled + * divider so that the change of subject is visible while scrolling past at speed. + */ +@Composable +private fun StackTraceSectionHeader(section: CrashSection) { + val colors = MaterialTheme.colorScheme + Column(modifier = Modifier.fillMaxWidth()) { + if (section.isCause) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.crash_caused_by), + style = MaterialTheme.typography.labelMedium, + color = colors.error, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + HorizontalDivider(Modifier.weight(1f), color = colors.error.copy(alpha = 0.3f)) + } + Spacer(Modifier.height(6.dp)) + } + // Untyped when the text began at its first frame, which is what a trace looks like when its + // header was the log entry's own line. Nothing is drawn then rather than an empty heading: + // the entry above is already showing the sentence this would have repeated. + if (section.simpleType.isNotEmpty()) { + Text( + section.simpleType, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = colors.error, + ) + section.message?.let { message -> + Spacer(Modifier.height(2.dp)) + Text(message, style = MaterialTheme.typography.bodyMedium, color = colors.onSurface) + } + Spacer(Modifier.height(8.dp)) + } + } +} + +/** + * One frame, as two lines: what ran, and where that is written. + * + * Only the file and line are monospaced. `MainActivity.kt:39` is an identifier a reader compares + * character by character against their editor; `MainActivity.onCreate` is a name they read, and + * reads worse in a typewriter face. The frame stays on one line and scrolls sideways rather than + * wrapping — a wrapped frame reads as two frames. + * + * Tapping copies this frame alone, which is the unit people quote to each other. + */ +@Composable +private fun StackTraceFrame(frame: CrashFrame, onCopy: () -> Unit) { + val colors = MaterialTheme.colorScheme + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onCopy).padding(vertical = 5.dp), + verticalAlignment = Alignment.Top, + ) { + // Filled for our code, hollow for the platform's: the shape carries the distinction where + // colour alone would not, and the column of markers can be scanned without reading a word. + Surface( + modifier = Modifier.padding(top = 6.dp).size(7.dp), + shape = CircleShape, + color = if (frame.ours) colors.primary else colors.onSurfaceVariant.copy(alpha = 0.25f), + content = {}, + ) + Spacer(Modifier.padding(horizontal = 6.dp)) + Column( + modifier = Modifier.horizontalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Text( + frame.shortMethod, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (frame.ours) FontWeight.Medium else FontWeight.Normal, + color = if (frame.ours) colors.onSurface else colors.onSurfaceVariant, + softWrap = false, + maxLines = 1, + ) + Text( + frame.location ?: frame.method, + style = VectorMono.copy(fontSize = 11.sp), + color = colors.onSurfaceVariant.copy(alpha = if (frame.ours) 1f else 0.7f), + softWrap = false, + maxLines = 1, + ) + } + } +} + +/** The frames `printStackTrace` replaced with `... N more`, having printed them already. */ +@Composable +private fun StackTraceElided(count: Int) { + Text( + pluralStringResource(R.plurals.crash_frames_elided, count, count), + modifier = Modifier.padding(start = 20.dp, top = 6.dp, bottom = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt index 8e8671f5c..689a817b4 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt @@ -46,6 +46,15 @@ sealed interface TopLevelRoute : Route { */ @Serializable data object CrashTrace : Route +/** + * A stack trace found in the log, on a screen of its own. + * + * Carries the text rather than a line number, because the log window it came from is paged and + * filtered and may have moved on by the time this is opened — and because the text is the whole of + * what the screen needs. A trace is a few kilobytes at worst, which the back stack can hold. + */ +@Serializable data class LogTrace(val text: String) : Route + /** CI builds, as prereleases anyone can download. */ @Serializable data object Canary : Route diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt index fabada845..d7848bfb0 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt @@ -1,70 +1,44 @@ package org.matrix.vector.manager.ui.screens.home -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import org.matrix.vector.manager.R -import org.matrix.vector.manager.data.log.CrashFrame import org.matrix.vector.manager.data.log.CrashRecorder -import org.matrix.vector.manager.data.log.CrashSection +import org.matrix.vector.manager.data.log.CrashReport import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.StackTrace +import org.matrix.vector.manager.ui.components.stackTraceItems import org.matrix.vector.manager.ui.components.VectorSnackbarHost import org.matrix.vector.manager.ui.components.copyToClipboard import org.matrix.vector.manager.ui.components.show -import org.matrix.vector.manager.ui.theme.VectorMono /** * The newest crash, read as a list rather than as a wall of text. * - * A stack trace is already a structured thing — a chain of throwables, each with a list of frames — - * and printing it as one string is a format for a terminal, not for a screen someone is scrolling - * on a phone. Rendered as rows it can do what the text cannot: mark the frames that belong to this - * project, separate the name of a method from the file it lives in, and let one frame be lifted to - * the clipboard without a text selection. - * - * Two things are deliberate about what is emphasised. The frames in **our** code are the ones a - * reader is looking for and the platform's are context, so ours carry the weight and a filled - * marker while the platform's are dimmed — the opposite of the printed order, where the platform - * usually comes first. And the chain reads downwards to the *root* cause: `printStackTrace` puts - * the outermost throwable at the top, but "Caused by" is where the answer is, so each cause is - * introduced by a divider rather than buried in the run of frames. + * The trace itself is [StackTrace]'s doing, and the reasoning about how it is laid out lives + * there; this screen is the header above it and the copy action beside it. * * The record is read here rather than passed through the route, because the process is quite likely * to have died since the card was drawn — that is, after all, the subject. @@ -129,7 +103,7 @@ fun CrashTraceScreen(onNavigateBack: () -> Unit) { ) { item(key = "when") { Text( - stringResource(R.string.crash_when_value, report.at, report.thread), + crashWhen(report), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -140,120 +114,22 @@ fun CrashTraceScreen(onNavigateBack: () -> Unit) { ) Spacer(Modifier.height(12.dp)) } - report.sections.forEachIndexed { index, section -> - item(key = "s:$index") { SectionHeader(section) } - items(section.frames, key = { "f:$index:${it.line}" }) { frame -> - FrameRow( - frame = frame, - onCopy = { - copyToClipboard(context, frame.line) - scope.launch { snackbars.show(frameCopied, SnackbarTone.Success) } - }, - ) - } - if (section.elided > 0) { - item(key = "e:$index") { - Text( - pluralStringResource( - R.plurals.crash_frames_elided, - section.elided, - section.elided, - ), - modifier = Modifier.padding(start = 20.dp, top = 6.dp, bottom = 6.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + stackTraceItems(report.sections) { frame -> + copyToClipboard(context, frame.line) + scope.launch { snackbars.show(frameCopied, SnackbarTone.Success) } } } } } /** - * The throwable a run of frames belongs to. + * When it happened, and on which thread — shared with the card on the status screen. * - * The type is the heading and the message is the sentence under it, which is the way round a reader - * needs them: the type says what kind of failure this is and is short enough to scan, the message - * says what was being attempted and is often a whole line long. A cause is introduced by a labelled - * divider so that the change of subject is visible while scrolling past at speed. + * The thread is dropped rather than left blank when the record does not name one. Only a record + * written before the header carried a thread is in that state, and it outlives the update that + * changed the format, since the crashes are kept in the cache directory. */ @Composable -private fun SectionHeader(section: CrashSection) { - val colors = MaterialTheme.colorScheme - Column(modifier = Modifier.fillMaxWidth()) { - if (section.isCause) { - Spacer(Modifier.height(12.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.crash_caused_by), - style = MaterialTheme.typography.labelMedium, - color = colors.error, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - HorizontalDivider(Modifier.weight(1f), color = colors.error.copy(alpha = 0.3f)) - } - Spacer(Modifier.height(6.dp)) - } - Text( - section.simpleType, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = colors.error, - ) - section.message?.let { message -> - Spacer(Modifier.height(2.dp)) - Text(message, style = MaterialTheme.typography.bodyMedium, color = colors.onSurface) - } - Spacer(Modifier.height(8.dp)) - } -} - -/** - * One frame, as two lines: what ran, and where that is written. - * - * Only the file and line are monospaced. `MainActivity.kt:39` is an identifier a reader compares - * character by character against their editor; `MainActivity.onCreate` is a name they read, and - * reads worse in a typewriter face. The frame stays on one line and scrolls sideways rather than - * wrapping — a wrapped frame reads as two frames. - * - * Tapping copies this frame alone, which is the unit people quote to each other. - */ -@Composable -private fun FrameRow(frame: CrashFrame, onCopy: () -> Unit) { - val colors = MaterialTheme.colorScheme - Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onCopy).padding(vertical = 5.dp), - verticalAlignment = Alignment.Top, - ) { - // Filled for our code, hollow for the platform's: the shape carries the distinction where - // colour alone would not, and the column of markers can be scanned without reading a word. - Surface( - modifier = Modifier.padding(top = 6.dp).size(7.dp), - shape = CircleShape, - color = if (frame.ours) colors.primary else colors.onSurfaceVariant.copy(alpha = 0.25f), - content = {}, - ) - Spacer(Modifier.padding(horizontal = 6.dp)) - Column( - modifier = Modifier.horizontalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(1.dp), - ) { - Text( - frame.shortMethod, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (frame.ours) FontWeight.Medium else FontWeight.Normal, - color = if (frame.ours) colors.onSurface else colors.onSurfaceVariant, - softWrap = false, - maxLines = 1, - ) - Text( - frame.location ?: frame.method, - style = VectorMono.copy(fontSize = 11.sp), - color = colors.onSurfaceVariant.copy(alpha = if (frame.ours) 1f else 0.7f), - softWrap = false, - maxLines = 1, - ) - } - } -} +internal fun crashWhen(report: CrashReport): String = + if (report.thread.isEmpty()) report.at + else stringResource(R.string.crash_when_value, report.at, report.thread) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt index 679e11a59..c75f16ea0 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt @@ -42,6 +42,9 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import kotlin.math.roundToInt import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.isThrowableHeader +import org.matrix.vector.manager.data.log.parseStackTrace +import org.matrix.vector.manager.ui.components.StackTrace import org.matrix.vector.manager.data.log.LogLevel import org.matrix.vector.manager.data.log.LogRow import org.matrix.vector.manager.ui.theme.VectorLogLine @@ -159,13 +162,26 @@ fun LogRowItem( showTag: Boolean, pan: LogPan, query: String, + inlineTraces: Boolean, onTagClick: (String) -> Unit, onCopy: (String) -> Unit, + onOpenTrace: (String) -> Unit, ) { when (row) { is LogRow.DayBreak -> DayBreakRow(row) is LogRow.Marker -> MarkerRow(row, query) - is LogRow.Entry -> EntryRow(row, wordWrap, showTag, pan, query, onTagClick, onCopy) + is LogRow.Entry -> + EntryRow( + row, + wordWrap, + showTag, + pan, + query, + inlineTraces, + onTagClick, + onCopy, + onOpenTrace, + ) } } @@ -176,8 +192,11 @@ private fun EntryRow( showTag: Boolean, pan: LogPan, query: String, + /** Whether a trace opens under the row or on a screen. See `SettingsRepository`. */ + inlineTraces: Boolean, onTagClick: (String) -> Unit, onCopy: (String) -> Unit, + onOpenTrace: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } var framesOpen by remember { mutableStateOf(false) } @@ -249,23 +268,51 @@ private fun EntryRow( } if (entry.trace.isNotEmpty()) { + // Parsed once per row, not per recomposition: the expander is tapped rarely and the + // count on it has to be right whether or not anyone ever taps. + // + // The message is offered to the parser because it is often the trace's first line. + // `XposedBridge.log(Throwable)` writes the whole trace as one message, so the header — + // `java.lang.ClassNotFoundException: Didn't find class …` — is the entry's own text and + // only the frames are continuations. Passing the frames alone left the trace headless + // and the reader without the one line naming what was thrown. It is offered rather + // than prepended: when the entry says something of its own, as `logE(msg, tr)` does, + // the header is the first continuation line and the message is not part of the trace. + // + // Only the *type* is taken from it, not the message after the colon: the entry's line + // is right above, already saying it in full. Passing the whole header printed the same + // sentence twice, once in the log's face and once in the trace's. + val sections = + remember(entry.message, entry.trace) { + val type = throwableTypeOf(entry.message) + parseStackTrace(if (type == null) entry.trace else listOf(type) + entry.trace) + } + val frameCount = remember(sections) { sections.sumOf { it.frames.size } } Text( - pluralStringResource(R.plurals.logs_frames, entry.trace.size, entry.trace.size), + pluralStringResource(R.plurals.logs_frames, frameCount, frameCount), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 18.dp, top = 2.dp) - .combinedClickable(onClick = { framesOpen = !framesOpen }), + .combinedClickable( + onClick = { + if (inlineTraces) framesOpen = !framesOpen + else onOpenTrace(traceText(entry)) + } + ), ) - if (framesOpen) { - entry.trace.forEach { frame -> - Text( - frame.trim(), - style = VectorLogLine, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 26.dp), - ) - } + if (inlineTraces && framesOpen) { + // The full renderer, not a run of monospace lines. A trace in the log is the same + // text as a trace on the crash screen and is read for the same reason, so the one + // that reads better wins in both places. Indented to sit under the expander that + // opened it, and given the row's own width rather than the panned one — a trace is + // read as rows, and rows that slide sideways with the log lines above them would + // be read a column at a time. + StackTrace( + sections = sections, + onCopyFrame = { onCopy(it.line) }, + modifier = Modifier.padding(start = 26.dp, top = 4.dp, bottom = 4.dp), + ) } } } @@ -403,6 +450,29 @@ fun levelLabel(level: LogLevel): String = } ) +/** + * The throwable type an entry's message names, or null if it does not name one. + * + * `XposedBridge.log(Throwable)` writes a whole trace as one message, so the header is the entry's + * own line and only the frames arrive as continuations. This recovers the type from it so the trace + * below can be headed by the thing that was thrown. A `Caused by:` line is refused: it is never the + * first line of a trace, so a message shaped like one is not the header this is looking for. + */ +private fun throwableTypeOf(message: String): String? = + message + .takeIf { isThrowableHeader(it) && !it.startsWith("Caused by: ") } + ?.substringBefore(": ") + +/** + * The entry's trace as `printStackTrace` would have written it. + * + * The whole header, message and all, unlike the inline expander's — a screen shows the trace with + * no log line above it, so the sentence naming what failed has nowhere else to come from. + */ +private fun traceText(entry: LogRow.Entry): String = + if (isThrowableHeader(entry.message)) (listOf(entry.message) + entry.trace).joinToString("\n") + else entry.trace.joinToString("\n") + /** Rebuilds the line exactly as the daemon wrote it, for the clipboard. */ private fun rawText(entry: LogRow.Entry): String = buildString { append("[ ") diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogTraceScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogTraceScreen.kt new file mode 100644 index 000000000..4ccf63ef1 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogTraceScreen.kt @@ -0,0 +1,101 @@ +package org.matrix.vector.manager.ui.screens.logs + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.parseStackTrace +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.copyToClipboard +import org.matrix.vector.manager.ui.components.show +import org.matrix.vector.manager.ui.components.stackTraceItems + +/** + * A trace from the log, given the room the log itself does not have. + * + * The same rows as the crash screen, from the same parser — a trace written by the daemon, by a + * module, or by the manager is the same `printStackTrace` output whichever of them wrote it, so + * there is one way to read it. Reached only when the reader has said they prefer a screen to the + * inline expander; see `SettingsRepository.logTracesInline`. + * + * Copy takes the raw text, exactly as the log holds it, because that is what goes into an issue. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogTraceScreen(text: String, onNavigateBack: () -> Unit) { + val context = LocalContext.current + val sections = remember(text) { parseStackTrace(text) } + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.copied) + val frameCopied = stringResource(R.string.crash_frame_copied) + + Scaffold( + snackbarHost = { VectorSnackbarHost(snackbars) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.logs_trace_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + IconButton( + onClick = { + copyToClipboard(context, text) + scope.launch { snackbars.show(copied, SnackbarTone.Success) } + } + ) { + Icon( + Icons.Rounded.ContentCopy, + contentDescription = stringResource(R.string.action_copy_all), + ) + } + }, + ) + }, + ) { padding -> + if (sections.isEmpty()) { + Text( + text, + modifier = Modifier.padding(padding).padding(20.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Scaffold + } + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 24.dp), + ) { + stackTraceItems(sections) { frame -> + copyToClipboard(context, frame.line) + scope.launch { snackbars.show(frameCopied, SnackbarTone.Success) } + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt index 197156cd8..3d1f1066c 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.rounded.Save import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Visibility @@ -103,13 +104,13 @@ import java.time.format.DateTimeFormatter import kotlinx.coroutines.launch import org.matrix.vector.manager.ui.components.VectorAlertDialog import org.matrix.vector.manager.ui.theme.LocalizedOverlay -import org.matrix.vector.manager.BuildConfig import org.matrix.vector.manager.R import org.matrix.vector.manager.ui.theme.VectorLogLine import org.matrix.vector.manager.data.log.LogLevel import org.matrix.vector.manager.ui.components.PanelHeader import org.matrix.vector.manager.ui.components.sheetRowColors import org.matrix.vector.manager.ui.components.SearchField +import org.matrix.vector.manager.ui.components.ToggleRow import org.matrix.vector.manager.ui.components.copyToClipboard import org.matrix.vector.manager.ui.theme.VectorMono @@ -128,7 +129,10 @@ import org.matrix.vector.manager.ui.theme.VectorMono */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LogsScreen(viewModel: LogsViewModel = viewModel(factory = LogsViewModelFactory())) { +fun LogsScreen( + onOpenTrace: (String) -> Unit, + viewModel: LogsViewModel = viewModel(factory = LogsViewModelFactory()), +) { // One pane, one search field, and the source is a control inside it. Two tabs would mean two // search boxes, two filter states and two scroll positions for what is one question — "what // does the log say" — whose answer often has to be looked for in both streams. @@ -239,7 +243,12 @@ fun LogsScreen(viewModel: LogsViewModel = viewModel(factory = LogsViewModelFacto } }, ) - LogPane(tab = currentTab, viewModel = viewModel, wordWrap = wordWrap) + LogPane( + tab = currentTab, + viewModel = viewModel, + wordWrap = wordWrap, + onOpenTrace = onOpenTrace, + ) } } @@ -294,7 +303,13 @@ fun LogsScreen(viewModel: LogsViewModel = viewModel(factory = LogsViewModelFacto @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun LogPane(tab: LogTab, viewModel: LogsViewModel, wordWrap: Boolean) { +private fun LogPane( + tab: LogTab, + viewModel: LogsViewModel, + wordWrap: Boolean, + onOpenTrace: (String) -> Unit, +) { + val inlineTraces by viewModel.tracesInline.collectAsStateWithLifecycle() val state by viewModel.state(tab).collectAsStateWithLifecycle() val listState = rememberLazyListState() val pan = rememberLogPan() @@ -416,6 +431,8 @@ private fun LogPane(tab: LogTab, viewModel: LogsViewModel, wordWrap: Boolean) { jumpInset = jumpInset, onJumpInset = { jumpInset = it }, onCopy = { copyToClipboard(context, it) }, + inlineTraces = inlineTraces, + onOpenTrace = onOpenTrace, ) } } @@ -436,6 +453,8 @@ private fun LogList( jumpInset: Int, onJumpInset: (Int) -> Unit, onCopy: (String) -> Unit, + inlineTraces: Boolean, + onOpenTrace: (String) -> Unit, ) { // The horizontal gesture goes on the container, not on the list and not on the rows: the list // then owns vertical extent exclusively and each row's sideways extent depends only on its own @@ -472,8 +491,10 @@ private fun LogList( showTag = state.query.tag == null, pan = pan, query = state.query.text, + inlineTraces = inlineTraces, onTagClick = { viewModel.setTag(tab, it) }, onCopy = onCopy, + onOpenTrace = onOpenTrace, ) } } @@ -672,6 +693,7 @@ private fun LogSettingsSheet( ) { val enabled by viewModel.verboseEnabled.collectAsStateWithLifecycle() val enforced by viewModel.verboseEnforced.collectAsStateWithLifecycle() + val inlineTraces by viewModel.tracesInline.collectAsStateWithLifecycle() // Left at the default rather than passing skipPartiallyExpanded, which removes the half-height // stop — the only thing a drag on a sheet can do other than dismiss it. Material adds that stop // only when the content is taller than half the screen, so short sheets still open at their own @@ -724,6 +746,14 @@ LocalizedOverlay { colors = sheetRowColors, ) + ToggleRow( + title = stringResource(R.string.logs_traces_inline), + icon = Icons.AutoMirrored.Rounded.Notes, + checked = inlineTraces, + onCheckedChange = { viewModel.setTracesInline(it) }, + subtitle = stringResource(R.string.logs_traces_inline_summary), + ) + HorizontalDivider(Modifier.padding(vertical = 4.dp)) ListItem( diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt index a8b935715..bd94dc189 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt @@ -174,6 +174,8 @@ class LogsViewModel(private val daemon: DaemonClient, private val settings: Sett val wordWrap: StateFlow = settings.logWordWrap + val tracesInline: StateFlow = settings.logTracesInline + init { viewModelScope.launch { _verboseEnabled.value = daemon.isVerboseLogEnabled().getOrDefault(false) } } @@ -182,6 +184,8 @@ class LogsViewModel(private val daemon: DaemonClient, private val settings: Sett fun setWordWrap(enabled: Boolean) = settings.setLogWordWrap(enabled) + fun setTracesInline(inline: Boolean) = settings.setLogTracesInline(inline) + /** Called when a stream comes on screen. Only the stream on screen is ever read. */ fun open(tab: LogTab) { val pane = panes.getValue(tab) diff --git a/manager/src/main/res/values/strings_logs.xml b/manager/src/main/res/values/strings_logs.xml index a7b01e6e2..f38b01331 100644 --- a/manager/src/main/res/values/strings_logs.xml +++ b/manager/src/main/res/values/strings_logs.xml @@ -91,6 +91,9 @@ Include the framework\'s own lines Show module lines only Turn this on before reporting a bug. Apps can read their own logs, so leaving it on is one way an app can spot Vector. + Stack trace + Open traces in place + A stack trace unfolds under its line. Turn this off to open it on its own screen instead. Writes both logs to a zip you choose Closes the current part and starts a fresh one part %1$d/%2$d From bece58d7724e44c6a81a0cb7de084b3efb760e5e Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 29 Jul 2026 12:55:58 +0200 Subject: [PATCH 3/6] Delete imports that nothing imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteen of them, across five files. The four in RepoRepository are the ones the logging sweep turned up and left alone as not being its business; sweeping the module found the same thing in four more files, so they go together rather than leaving a known-identical problem in place. None of this is reachable code, and the compiler had been saying so on every build. Delegate operators — getValue, setValue — are excluded from the sweep: `by remember` uses them without ever naming them, so a search for the name finds nothing and deleting the import breaks the build. --- .../matrix/vector/manager/data/repository/RepoRepository.kt | 4 ---- .../matrix/vector/manager/ui/screens/canary/CanaryScreen.kt | 4 ---- .../vector/manager/ui/screens/home/HomeAppearanceSheet.kt | 4 ---- .../matrix/vector/manager/ui/screens/home/LanguageSheet.kt | 1 - .../org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt | 2 -- 5 files changed, 15 deletions(-) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index ad2ec2acc..158380f85 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -8,10 +8,6 @@ import com.google.gson.stream.JsonReader import java.util.concurrent.TimeUnit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt index 9bd189355..2f699e128 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.OpenInNew import androidx.compose.material.icons.rounded.Science -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -37,7 +36,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,8 +44,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch import org.matrix.vector.manager.R import org.matrix.vector.manager.data.github.CanaryBuild import org.matrix.vector.manager.data.github.GitHubRepository diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt index 85497604f..c813e7cd1 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -38,13 +37,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -61,7 +58,6 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.matrix.vector.manager.ui.theme.LocalizedOverlay diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt index 6692eb5a0..c8b8a8392 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.semantics.contentDescription import org.matrix.vector.manager.ui.theme.translatorsFor import org.matrix.vector.manager.ui.theme.Translator import org.matrix.vector.manager.ui.theme.CROWDIN_URL -import androidx.compose.material3.ListItem import androidx.compose.material3.AssistChip import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.animation.animateColorAsState diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt index 143f34c59..12b155762 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import org.matrix.vector.manager.ui.components.ChoiceRow import org.matrix.vector.manager.ui.components.SheetHeading -import org.matrix.vector.manager.ui.components.ToggleRow import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Dns import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.SearchOff From af495d4c38989b8466856bce5a09a6ce8cf8ea04 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 29 Jul 2026 14:21:44 +0200 Subject: [PATCH 4/6] Keep a multi-line log message whole, and colour the tag by level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel decided what continued an entry by the look of a line, and lines do not look like anything in particular. Indented ones passed and everything else was cut loose into a marker row of its own, which meant a twenty-line report arrived as twenty rows with a divider drawn between each and no visible connection to the entry that wrote it. zygisk's "--- Parsed Mount Argument Vector Fossil ---" block is the case on screen; a stack trace's Caused by: was the same bug, patched narrowly last time. The writer settles it. logcat.cpp emits an entry as one writev whose first iovec is the prefix, so every line after the first has none — an unprefixed line under an entry is a continuation by construction. The only unprefixed lines that are not are the four LogRaw and the rotation code write, and those can be enumerated from the source rather than guessed at, so the test is now for those. A line carrying a prefix is still an entry, whatever precedes it; asking only whether a line could continue swallowed the entire log into its first entry, 105 continuations deep, which is worth the assertion the predicate's doc now carries. Attaching arbitrary text meant "N frames" was no longer true of most of it, so a block with no frames in it says "N more lines" and opens as plain text, laid out as the writer set it out — the columns in a mount argument report are the whole of its legibility — while a real trace keeps the structured renderer. The tag badge now carries the level's colour rather than one fixed container pair, so a scan down the page separates on the widest thing in the row instead of on the rail at its edge and the letter at its start. A wash rather than a fill, with the tag left onSurface: the palette runs down to outlineVariant for debug, and tag text in that colour on a tint of itself is a badge nobody could read at the two levels a log is mostly made of. --- .../matrix/vector/manager/data/log/LogFile.kt | 80 +++++++++--------- .../vector/manager/data/log/LogModel.kt | 81 ++++++++++++++----- .../vector/manager/ui/screens/logs/LogRows.kt | 71 +++++++++++++--- manager/src/main/res/values/strings_logs.xml | 5 ++ 4 files changed, 170 insertions(+), 67 deletions(-) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt index 6571e7bfc..7ff886e45 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt @@ -93,25 +93,28 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { val rows = ArrayList(lines.size + 8) var lastDate: String? = null var traceOwner = -1 - var trace: ArrayList? = null + var continuation: ArrayList? = null - fun flushTrace() { - val frames = trace - if (traceOwner >= 0 && frames != null) { - rows[traceOwner] = (rows[traceOwner] as LogRow.Entry).copy(trace = frames) + fun flushContinuation() { + val lines = continuation + if (traceOwner >= 0 && lines != null) { + rows[traceOwner] = (rows[traceOwner] as LogRow.Entry).copy(continuation = lines) } - trace = null + continuation = null traceOwner = -1 } forEachLine(index, lines, null) { lineIndex, text, truncated -> - if (traceOwner >= 0 && isContinuationLine(text, ownedByEntry = true)) { - // A multi-line message reaches the file as one writev, so its continuation lines - // carry no prefix. They are frames of the entry above, not entries of their own. - (trace ?: ArrayList(8).also { trace = it }).add(text) + // Parsed first, always. A multi-line message reaches the file as one writev, so its + // continuation lines carry no prefix — but *carrying* one is what makes a line an entry + // of its own, and asking only whether a line could be a continuation swallows the whole + // log into whichever entry happens to be first. + val row = parseLogLine(lineIndex, text, truncated) + if (traceOwner >= 0 && row !is LogRow.Entry && isContinuationLine(text)) { + (continuation ?: ArrayList(8).also { continuation = it }).add(text) } else { - flushTrace() - when (val row = parseLogLine(lineIndex, text, truncated)) { + flushContinuation() + when (row) { is LogRow.Entry -> { if (row.date != lastDate) { lastDate = row.date @@ -124,22 +127,26 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { } } } - flushTrace() + flushContinuation() return rows } /** * Walks back from [line] to the entry that owns it. * - * Without this a window boundary landing between an entry and its stack trace opens the page - * on orphan frames with nothing to attach them to. The first byte of each candidate line - * decides it — up to [TRACE_LOOKBACK] single-byte positional reads, all of them page-cache hits. + * Without this a window boundary landing inside a multi-line message opens the page on its tail + * with nothing to attach it to. It stops on the first line that is an entry — the owner — or on + * one of the daemon's raw banners, which own nothing. + * + * Indented lines, which are most of them, still cost one byte: nothing else in the format + * begins with a space or a tab, so they are continuations without being read. Anything else + * costs a capped read of the line's head, which is what the general rule needs — a continuation + * is no longer recognisable from its first character, and pretending otherwise is what left the + * `Caused by:` and the `--- Parsed Mount Argument ---` lines stranded. * - * A line beginning with a letter costs one more read, because it may be the `Caused by:` or - * bare throwable header that [isContinuationLine] also treats as part of the trace. Walking - * past one is what the extra read buys: stopping there would open the page on the very line the - * rest of this change exists to keep attached, and would do it precisely when someone has - * jumped into the middle of a long trace. + * A line that is *neither* is also where the walk has to stop: it is a marker the scanner could + * not read, and stepping over it would hand the window a start belonging to some earlier + * entry. */ fun entryStart(index: LogIndex, line: Int): Int { if (line >= index.lineCount) return line @@ -147,13 +154,11 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { var steps = 0 while (at > 0 && steps < TRACE_LOOKBACK) { val first = firstByte(index, at) - val continues = - first == SPACE || - first == TAB || - (first > 0 && - first.toChar().isLetter() && - isContinuationLine(lineText(index, at), ownedByEntry = true)) - if (!continues) break + if (first != SPACE && first != TAB) { + val text = lineText(index, at) + if (parseLogLine(at, text) is LogRow.Entry) break + if (!isContinuationLine(text)) break + } at-- steps++ } @@ -177,19 +182,21 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { val tags = HashMap() val levels = HashMap() var previousMatched = false - // Whether the last row was an entry, which is what lets an unindented `Caused by:` be read - // as part of the trace here exactly as [readRows] reads it. Without it the two passes - // disagree, and a filtered view drops the half of the trace the unfiltered one keeps. + // Whether the last row was an entry, which is what lets an unprefixed line be read as part + // of its message here exactly as [readRows] reads it. Without it the two passes disagree, + // and a filtered view drops the half of a trace the unfiltered one keeps. var inEntry = false forEachLine(index, null, onProgress) { lineIndex, text, truncated -> - if (isContinuationLine(text, ownedByEntry = inEntry)) { + // Parsed before the continuation test, for the reason given in [readRows]: a line that + // carries a prefix is an entry whatever precedes it. + val row = parseLogLine(lineIndex, text, truncated) + if (inEntry && row !is LogRow.Entry && isContinuationLine(text)) { // Frames follow their entry into the filtered view; a stack trace whose header // matched and whose body vanished is a filter actively hiding the answer. if (previousMatched) matches?.add(lineIndex) return@forEachLine } - val row = parseLogLine(lineIndex, text, truncated) if (row is LogRow.Entry) { tags[row.tag] = (tags[row.tag] ?: 0) + 1 levels[row.level] = (levels[row.level] ?: 0) + 1 @@ -285,10 +292,11 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { } /** - * The head of a line, decoded — enough of it to recognise a throwable header. + * The head of a line, decoded — enough of it to tell an entry and a banner from a continuation. * - * A type name and the `": "` after it are all [isContinuationLine] inspects, so the read is - * capped rather than following a line of unbounded length. It borrows [block], which is safe + * Only the head is needed: [parseLogLine] decides on the prefix, which is fixed-width and far + * shorter than this, and [isContinuationLine] on a banner, which is shorter still. So the read + * is capped rather than following a line of unbounded length. It borrows [block], which is safe * only because the one caller, [entryStart], runs between block iterations and never during * one. */ diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt index dbcbc68b4..be7ebc31a 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt @@ -16,12 +16,14 @@ package org.matrix.vector.manager.data.log * future pid can exceed six, so the prefix has to be *scanned*. Slicing at constant offsets * works right up until the day it silently does not. * 2. **A message containing newlines is still one `writev`.** Its continuation lines therefore - * carry no prefix at all — a Java stack trace arrives as one entry followed by N raw lines each - * beginning with a tab. Those frames belong to the entry above them, not to nothing. + * carry no prefix at all, and *nothing* marks them as continuations — a stack trace's frames + * happen to be indented, but a `Caused by:` and `zygisk-core64`'s mount-argument report are + * flush left. They belong to the entry above them, not to nothing. See [isContinuationLine]. * 3. **Not every line is an entry.** `----part N start----` / `-----part N end----` mark the * daemon rotating to a fresh file, and the watchdog writes its own banners. Those are real * information about the log rather than noise, so they survive as [LogRow.Marker] instead of - * being dropped. + * being dropped. They are also the *only* unprefixed lines that are not continuations, which + * is what makes rule 2 decidable. * * Anything the scanner cannot make sense of degrades to a marker carrying the raw text. A log * viewer that hides a line it failed to understand is worse than useless during a diagnosis. @@ -85,8 +87,12 @@ sealed interface LogRow { val level: LogLevel, val tag: String, val message: String, - /** Continuation lines of a multi-line message — in practice, stack frames. */ - val trace: List = emptyList(), + /** + * The rest of a multi-line message: every line of it after the first, which the writer + * emitted in the same `writev` and so left without a prefix. Often a stack trace, often + * not — `zygisk-core64` reports a parsed mount argument this way. + */ + val continuation: List = emptyList(), /** Set when the line exceeded [MAX_LINE_BYTES] and was cut. */ val truncated: Boolean = false, ) : LogRow @@ -120,24 +126,59 @@ private const val MIN_PREFIX = 26 /** * Whether a line belongs to the entry above it rather than standing on its own. * - * Indentation is the ordinary signal: a multi-line message reaches the file as one write, so only - * its first line carries a prefix and the rest arrive indented. + * The answer follows from the writer, not from the look of the line. `logcat.cpp` emits an entry as + * a single `writev` whose first `iovec` is the prefix, so **every** line of a multi-line message + * after the first arrives without one. An unprefixed line under an entry is therefore a + * continuation by construction, whatever it looks like. + * + * Testing the look instead is what this used to do, and it was wrong in both directions of the same + * failure. Indented lines passed; anything else was cut loose into [LogRow.Marker], one row per + * line. `Throwable.printStackTrace` writes its header and every `Caused by:` flush left, so a cause + * chain shredded. `zygisk-core64` prints a mount-argument block flush left, so a twenty-line report + * of what it had parsed shredded too — a divider drawn between every line of it, and the whole + * thing visually detached from the entry that produced it. + * + * So the rule is inverted: a line is a continuation unless it is one of the four things the daemon + * writes raw, which [isRawBanner] names. + * + * **Two things the caller must have established first**, neither of which this can see: + * 1. That [text] is not itself an entry. This answers only "does this *unprefixed* line belong to + * the entry above", and a prefixed line is not unprefixed — asked without that check it says yes + * to every line in the file, and the log collapses into its first entry with a hundred + * continuations hanging off it. [parseLogLine] is the check. + * 2. That there is an entry above at all. Before the first one of a file, and after a banner, there + * is nothing to continue. + * + * Both guards are the caller's because both are facts about position in the file rather than about + * the line, and a reader that got either wrong would disagree with the other pass over the same + * bytes — which is how a filtered view comes to drop the half of a trace the unfiltered one keeps. + */ +fun isContinuationLine(text: String): Boolean = !isRawBanner(text) + +/** + * The lines the daemon writes to the file itself, outside any entry. * - * A stack trace breaks that rule twice, and both times on the line that matters most. - * `Throwable.printStackTrace` writes the throwable's own header — `java.lang.IllegalStateException: - * store: refreshing the module list failed` — flush left, and every `Caused by:` after it flush - * left too. Treating those as new rows ends the run, which sent the whole cause chain and all of - * its frames to [LogRow.Marker] one line at a time: the half of the trace that names what actually - * failed was the half that fell out of the entry that owned it. + * `Logcat::LogRaw` has exactly two callers and the rotation code exactly two more, so this list is + * closed and can be enumerated rather than guessed at: + * ``` + * "----part %zu start----\n" // OpenFd + * "-----part %zu end----\n" // CloseFd + * "\nLogd crashed too many times, trying manually start...\n" // OnCrash + * "\nLogd maybe crashed (err=%s), retrying in 1s...\n" // OnCrash + * ``` + * Both crash banners are written with a leading newline, so a blank line is one of their parts and + * not a line of its own. * - * So a header is admitted as a continuation as well — but only when [ownedByEntry], meaning the - * caller has an entry above this line for it to belong to. That is what keeps `----part 7 start----` - * and the daemon's own unprefixed banners the standalone markers they are, and it is why the flag - * is not defaulted to true: a caller has to have decided. + * Anything added to that file has to be added here too, or it will be swallowed into whichever + * entry precedes it. The alternative — guessing from shape — is what shredded the traces. */ -fun isContinuationLine(text: String, ownedByEntry: Boolean = false): Boolean = - text.isNotEmpty() && - (text[0] == ' ' || text[0] == '\t' || (ownedByEntry && isThrowableHeader(text))) +private fun isRawBanner(text: String): Boolean = + text.isEmpty() || + PART_BANNER.matches(text) || + text.startsWith("Logd crashed too many times") || + text.startsWith("Logd maybe crashed (err=") + +private val PART_BANNER = Regex("""-{4,}part \d+ (start|end)-{4,}""") /** Parses one raw line, degrading to [LogRow.Marker] rather than failing. */ fun parseLogLine(index: Int, text: String, truncated: Boolean = false): LogRow = diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt index c75f16ea0..a5725604a 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt @@ -207,8 +207,16 @@ private fun EntryRow( val railWidth = with(LocalDensity.current) { 4.5.dp.toPx() } val muted = MaterialTheme.colorScheme.outline - val tagBackground = MaterialTheme.colorScheme.secondaryContainer - val tagForeground = MaterialTheme.colorScheme.onSecondaryContainer + // The badge is washed with the level's own colour rather than one fixed container pair, so the + // level reads from the widest thing on the row and not only from the rail at its edge and the + // single letter at its start. A scan down a page now separates on a band of colour. + // + // A wash, not a fill, and the tag itself stays `onSurface`. The level palette runs from `error` + // to `outlineVariant` — deliberately, since debug and verbose are noise and are meant to recede + // — and tag text in those colours on a tint of themselves would be a badge nobody can read at + // the two levels the log is mostly made of. The colour identifies; the text stays legible. + val tagBackground = accent.copy(alpha = TAG_TINT) + val tagForeground = MaterialTheme.colorScheme.onSurface val hit = MaterialTheme.colorScheme.primaryContainer val onHit = MaterialTheme.colorScheme.onPrimaryContainer val line = @@ -267,7 +275,7 @@ private fun EntryRow( ) } - if (entry.trace.isNotEmpty()) { + if (entry.continuation.isNotEmpty()) { // Parsed once per row, not per recomposition: the expander is tapped rarely and the // count on it has to be right whether or not anyone ever taps. // @@ -283,25 +291,40 @@ private fun EntryRow( // is right above, already saying it in full. Passing the whole header printed the same // sentence twice, once in the log's face and once in the trace's. val sections = - remember(entry.message, entry.trace) { + remember(entry.message, entry.continuation) { val type = throwableTypeOf(entry.message) - parseStackTrace(if (type == null) entry.trace else listOf(type) + entry.trace) + val lines = + if (type == null) entry.continuation else listOf(type) + entry.continuation + parseStackTrace(lines) } val frameCount = remember(sections) { sections.sumOf { it.frames.size } } + // Only a trace gets the trace treatment. Now that any unprefixed line is attached to + // its entry, most of these blocks are not traces at all — zygisk's mount-argument + // report is a page of plain text — and calling it "20 frames" would be a lie told by + // an expander that then rendered nothing under it. + val isTrace = frameCount > 0 Text( - pluralStringResource(R.plurals.logs_frames, frameCount, frameCount), + if (isTrace) pluralStringResource(R.plurals.logs_frames, frameCount, frameCount) + else + pluralStringResource( + R.plurals.logs_more_lines, + entry.continuation.size, + entry.continuation.size, + ), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 18.dp, top = 2.dp) .combinedClickable( onClick = { - if (inlineTraces) framesOpen = !framesOpen + // The screen is a *stack trace* screen; sending a mount-argument + // dump to it would be sending it somewhere that cannot read it. + if (inlineTraces || !isTrace) framesOpen = !framesOpen else onOpenTrace(traceText(entry)) } ), ) - if (inlineTraces && framesOpen) { + if (framesOpen && isTrace && inlineTraces) { // The full renderer, not a run of monospace lines. A trace in the log is the same // text as a trace on the crash screen and is read for the same reason, so the one // that reads better wins in both places. Indented to sit under the expander that @@ -313,6 +336,23 @@ private fun EntryRow( onCopyFrame = { onCopy(it.line) }, modifier = Modifier.padding(start = 26.dp, top = 4.dp, bottom = 4.dp), ) + } else if (framesOpen) { + // Plain text, kept as the writer set it out — the alignment in a mount-argument + // report or a table of properties is the whole of its legibility. So it pans + // sideways with the log lines above it rather than wrapping, under the same + // switch, and joins the shared pan extent so the columns stay lined up with them. + Text( + entry.continuation.joinToString("\n"), + style = VectorLogLine, + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = wordWrap, + modifier = + Modifier.padding(start = 26.dp, top = 2.dp, bottom = 2.dp) + .then( + if (wordWrap) Modifier.fillMaxWidth() + else Modifier.panContent(pan) + ), + ) } } } @@ -421,6 +461,14 @@ private fun tagRangeOf(entry: LogRow.Entry): IntRange { return start until start + entry.tag.length + 2 } +/** + * How much of the level's colour the tag badge carries. + * + * Enough to name the level at a glance across a scrolling page, little enough that the tag on top + * of it is read as text rather than as decoration. + */ +private const val TAG_TINT = 0.22f + /** * Colour is reinforcement here, never the signal: the level letter carries the meaning, because * under Material You the hues come from the wallpaper. @@ -470,8 +518,9 @@ private fun throwableTypeOf(message: String): String? = * no log line above it, so the sentence naming what failed has nowhere else to come from. */ private fun traceText(entry: LogRow.Entry): String = - if (isThrowableHeader(entry.message)) (listOf(entry.message) + entry.trace).joinToString("\n") - else entry.trace.joinToString("\n") + if (isThrowableHeader(entry.message)) + (listOf(entry.message) + entry.continuation).joinToString("\n") + else entry.continuation.joinToString("\n") /** Rebuilds the line exactly as the daemon wrote it, for the clipboard. */ private fun rawText(entry: LogRow.Entry): String = buildString { @@ -491,7 +540,7 @@ private fun rawText(entry: LogRow.Entry): String = buildString { append(entry.tag) append(" ] ") append(entry.message) - entry.trace.forEach { + entry.continuation.forEach { append('\n') append(it) } diff --git a/manager/src/main/res/values/strings_logs.xml b/manager/src/main/res/values/strings_logs.xml index f38b01331..00c7dc814 100644 --- a/manager/src/main/res/values/strings_logs.xml +++ b/manager/src/main/res/values/strings_logs.xml @@ -45,6 +45,11 @@ %1$d frame %1$d frames + + + %1$d more line + %1$d more lines + The daemon is not reachable From d2b72a6f3663543e307c5eb196678aea31dd9bcd Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sat, 1 Aug 2026 12:06:19 +0200 Subject: [PATCH 5/6] Put back together a message the logger made the writer cut in two A stack trace past the logger's payload arrives as two entries, not one. android.util.Log.e(tag, msg, tr) does not hand liblog an oversized message: printlns walks the string and emits it in pieces, breaking at the last newline that fits, and each piece is a real entry with its own prefix. logd stores them separately -- `logcat -b crash -v long` counts two for one crash -- so the panel showed a 5.7 KB crash as a row ending mid-frame and a second row opening on "at androidx.compose...", 40 frames and 28 frames where there are 69. The manager's own logE splits the same way and for the same reason. So the split is undone. This is the one place the "a prefixed line is an entry" rule is knowingly overruled, and it is kept narrow to earn that: same tag, process, thread and level, immediately adjacent, and a message that opens the way a continuation does rather than the way a message does. Timestamps are deliberately not compared -- the pieces leave printlns microseconds apart and the format keeps milliseconds, so a boundary straddling a tick would strand exactly the long trace this exists for. All three passes learned it together, because they disagreeing is the bug this keeps reintroducing: readRows folds the tail in, scan skips it for the facets and lets it follow its entry into a filtered view, and entryStart steps over it so a window landing on the tail still opens on the entry that owns it. entryStart tests only the message, since the entry it would be compared against is further back than the line above and walking one line too far only widens the window. Copying an entry no longer reproduces the second prefix. What is copied is the message that was written rather than the transport that carried it, and a trace with a timestamp wedged into the middle of it is one nobody can paste anywhere useful. --- .../matrix/vector/manager/data/log/LogFile.kt | 56 +++++++++++++++---- .../vector/manager/data/log/LogModel.kt | 51 +++++++++++++++++ .../vector/manager/ui/screens/logs/LogRows.kt | 9 ++- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt index 7ff886e45..bb898ea95 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt @@ -94,24 +94,41 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { var lastDate: String? = null var traceOwner = -1 var continuation: ArrayList? = null + // Set when a line folded into the entry above had been cut. The flag belongs on the row a + // reader can see, and after a join the cut line may no longer be one of them. + var continuationCut = false fun flushContinuation() { val lines = continuation if (traceOwner >= 0 && lines != null) { - rows[traceOwner] = (rows[traceOwner] as LogRow.Entry).copy(continuation = lines) + val owner = rows[traceOwner] as LogRow.Entry + rows[traceOwner] = + owner.copy(continuation = lines, truncated = owner.truncated || continuationCut) } continuation = null + continuationCut = false traceOwner = -1 } + fun addContinuation(text: String, cut: Boolean) { + (continuation ?: ArrayList(8).also { continuation = it }).add(text) + if (cut) continuationCut = true + } + forEachLine(index, lines, null) { lineIndex, text, truncated -> // Parsed first, always. A multi-line message reaches the file as one writev, so its // continuation lines carry no prefix — but *carrying* one is what makes a line an entry // of its own, and asking only whether a line could be a continuation swallows the whole // log into whichever entry happens to be first. val row = parseLogLine(lineIndex, text, truncated) - if (traceOwner >= 0 && row !is LogRow.Entry && isContinuationLine(text)) { - (continuation ?: ArrayList(8).also { continuation = it }).add(text) + val owner = if (traceOwner >= 0) rows[traceOwner] as LogRow.Entry else null + if (owner != null && row !is LogRow.Entry && isContinuationLine(text)) { + addContinuation(text, truncated) + } else if (owner != null && row is LogRow.Entry && isSplitChunk(owner, row)) { + // A tail the writer was forced to cut off. Its prefix is dropped and its message + // rejoins the entry above, which is where it was written; the lines after it need + // no special case, because [traceOwner] never moved. + addContinuation(row.message, truncated) } else { flushContinuation() when (row) { @@ -147,6 +164,12 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { * A line that is *neither* is also where the walk has to stop: it is a marker the scanner could * not read, and stepping over it would hand the window a start belonging to some earlier * entry. + * + * An entry that [looksLikeSplitChunk] is stepped over too, since [readRows] is about to fold it + * into the entry above and stopping on it would open the page on half a trace again. Only the + * message is examined, not [isSplitChunk]'s full test: the entry this one would be compared + * against is further back than the line above, and walking one line too far only widens the + * window, which is free — whereas stopping one line too early is the bug. */ fun entryStart(index: LogIndex, line: Int): Int { if (line >= index.lineCount) return line @@ -156,8 +179,10 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { val first = firstByte(index, at) if (first != SPACE && first != TAB) { val text = lineText(index, at) - if (parseLogLine(at, text) is LogRow.Entry) break - if (!isContinuationLine(text)) break + val row = parseLogLine(at, text) + if (row is LogRow.Entry) { + if (!looksLikeSplitChunk(row.message)) break + } else if (!isContinuationLine(text)) break } at-- steps++ @@ -182,18 +207,25 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { val tags = HashMap() val levels = HashMap() var previousMatched = false - // Whether the last row was an entry, which is what lets an unprefixed line be read as part - // of its message here exactly as [readRows] reads it. Without it the two passes disagree, - // and a filtered view drops the half of a trace the unfiltered one keeps. - var inEntry = false + // The last row that was an entry, which is what lets a line be read as part of its message + // here exactly as [readRows] reads it — both the unprefixed kind and the split-off tail. + // Without it the two passes disagree, and a filtered view drops the half of a trace the + // unfiltered one keeps. It is the row rather than a flag because [isSplitChunk] compares + // against the entry itself. + var lastEntry: LogRow.Entry? = null forEachLine(index, null, onProgress) { lineIndex, text, truncated -> // Parsed before the continuation test, for the reason given in [readRows]: a line that // carries a prefix is an entry whatever precedes it. val row = parseLogLine(lineIndex, text, truncated) - if (inEntry && row !is LogRow.Entry && isContinuationLine(text)) { + val owner = lastEntry + val joins = + owner != null && + if (row is LogRow.Entry) isSplitChunk(owner, row) else isContinuationLine(text) + if (joins) { // Frames follow their entry into the filtered view; a stack trace whose header - // matched and whose body vanished is a filter actively hiding the answer. + // matched and whose body vanished is a filter actively hiding the answer. A joined + // tail counts for neither facet, because the row it belongs to was counted once. if (previousMatched) matches?.add(lineIndex) return@forEachLine } @@ -201,7 +233,7 @@ class LogFile(pfd: ParcelFileDescriptor) : Closeable { tags[row.tag] = (tags[row.tag] ?: 0) + 1 levels[row.level] = (levels[row.level] ?: 0) + 1 } - inEntry = row is LogRow.Entry + lastEntry = row as? LogRow.Entry previousMatched = query.matches(row) if (previousMatched) matches?.add(lineIndex) } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt index be7ebc31a..bb6ee3469 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt @@ -19,6 +19,8 @@ package org.matrix.vector.manager.data.log * carry no prefix at all, and *nothing* marks them as continuations — a stack trace's frames * happen to be indented, but a `Caused by:` and `zygisk-core64`'s mount-argument report are * flush left. They belong to the entry above them, not to nothing. See [isContinuationLine]. + * The exception is a message past the logger's payload, which the *writer* splits into several + * entries before liblog ever sees it; [isSplitChunk] puts those back together. * 3. **Not every line is an entry.** `----part N start----` / `-----part N end----` mark the * daemon rotating to a fresh file, and the watchdog writes its own banners. Those are real * information about the log rather than noise, so they survive as [LogRow.Marker] instead of @@ -155,6 +157,55 @@ private const val MIN_PREFIX = 26 */ fun isContinuationLine(text: String): Boolean = !isRawBanner(text) +/** + * Whether [row] is the tail of a message the writer had to cut in two, rather than an entry. + * + * Rule 2 holds only up to a point. `android.util.Log.e(tag, msg, tr)` does not hand liblog a + * message longer than the logger's payload — `printlns` in AOSP's `Log.java` walks the string and + * emits it as *several* entries, breaking at the last newline that fits. Each of those is a real + * entry with its own prefix, written by its own `writev`, and logd stores them separately: a 5.7 KB + * crash arrives as one entry ending mid-trace and another beginning `\tat …`, both stamped the same + * millisecond. The manager's own [org.matrix.vector.manager.logE] splits the same way and for the + * same reason, since liblog would otherwise truncate the tail. + * + * So this is not a guess at structure — it is the *undo* of a split the writer did not choose, and + * it is the one place the "a prefixed line is an entry" rule is knowingly overruled. It is kept + * narrow to earn that: same tag, same process, same thread, same level, immediately adjacent, and a + * message that reads as a tail rather than a beginning. Four writers would have to collide inside + * one thread, at one level, on one tag, with the second opening on whitespace, before this joined + * two things that were never one. + * + * Timestamps are deliberately **not** compared. The chunks leave `printlns` microseconds apart and + * the format keeps milliseconds, so they usually match — usually is not a rule, and a boundary that + * happened to straddle a tick would strand exactly the long trace this exists for. + * + * Being wrong costs a divider between two rows that stay legible either way; nothing is discarded. + */ +fun isSplitChunk(previous: LogRow.Entry, row: LogRow.Entry): Boolean = + previous.tag == row.tag && + previous.pid == row.pid && + previous.tid == row.tid && + previous.level == row.level && + looksLikeSplitChunk(row.message) + +/** + * Whether a message opens the way a continuation does rather than the way a message does. + * + * `printlns` breaks on a newline, so a tail begins with whatever line followed the break: inside a + * stack trace that is an indented frame, or a `Caused by:`/`Suppressed:` written flush left. A + * writer starting its *own* message with whitespace is close enough to unheard of to be worth + * trading against joining the traces this exists for. + * + * The other case — `printlns` hard-splitting a single line too long to break — is deliberately not + * recognised. Its tail begins mid-token, which is indistinguishable from a message, and stack + * frames never reach that length anyway. + */ +fun looksLikeSplitChunk(message: String): Boolean = + message.isNotEmpty() && + (message[0].isWhitespace() || + message.startsWith("Caused by: ") || + message.startsWith("Suppressed: ")) + /** * The lines the daemon writes to the file itself, outside any entry. * diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt index a5725604a..db8cb59ec 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt @@ -522,7 +522,14 @@ private fun traceText(entry: LogRow.Entry): String = (listOf(entry.message) + entry.continuation).joinToString("\n") else entry.continuation.joinToString("\n") -/** Rebuilds the line exactly as the daemon wrote it, for the clipboard. */ +/** + * Rebuilds the entry as the daemon wrote it, for the clipboard. + * + * One prefix, then the message and everything under it. Where the writer had to cut a long message + * into several entries the second prefix does not come back, because what is being copied is the + * message that was written rather than the transport that carried it — and a stack trace with a + * timestamp wedged into the middle of it is one nobody can paste anywhere useful. + */ private fun rawText(entry: LogRow.Entry): String = buildString { append("[ ") append(entry.date) From 6f9ad020c2667c157f5d024fa477ccb01b41caa9 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sat, 1 Aug 2026 11:10:09 +0200 Subject: [PATCH 6/6] Translate the crash summary and the trace screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen units across eighteen languages: the four labels on the crash card, the sentence for a trace with none of our frames in it, the two screen titles, "Caused by", the elided-frame and extra-line counts, the copied-frame confirmation, the unreadable-record notice, and the setting that decides where a trace in the log opens. Each plural takes the quantity set its language already uses in this file, which is the full CLDR set where the count is genuinely numeric — six forms in Arabic, four in Hebrew, Polish, Russian and Ukrainian, one in the languages that do not inflect for number. The terms follow the ones already chosen there rather than being coined again: the word each translator picked for a frame, and the word they picked for a trace in crash_recorded_summary. crash_frames_elided moves from %d to %1$d, which is what every other counted plural in these files uses and what the translations were written against. --- manager/src/main/res/values-ar/strings.xml | 19 +++++++++++++++++++ .../src/main/res/values-ar/strings_logs.xml | 11 +++++++++++ manager/src/main/res/values-de/strings.xml | 15 +++++++++++++++ .../src/main/res/values-de/strings_logs.xml | 7 +++++++ manager/src/main/res/values-es/strings.xml | 15 +++++++++++++++ .../src/main/res/values-es/strings_logs.xml | 7 +++++++ manager/src/main/res/values-fa/strings.xml | 15 +++++++++++++++ .../src/main/res/values-fa/strings_logs.xml | 7 +++++++ manager/src/main/res/values-fr/strings.xml | 15 +++++++++++++++ .../src/main/res/values-fr/strings_logs.xml | 7 +++++++ manager/src/main/res/values-in/strings.xml | 14 ++++++++++++++ .../src/main/res/values-in/strings_logs.xml | 6 ++++++ manager/src/main/res/values-it/strings.xml | 15 +++++++++++++++ .../src/main/res/values-it/strings_logs.xml | 7 +++++++ manager/src/main/res/values-iw/strings.xml | 17 +++++++++++++++++ .../src/main/res/values-iw/strings_logs.xml | 9 +++++++++ manager/src/main/res/values-ja/strings.xml | 14 ++++++++++++++ .../src/main/res/values-ja/strings_logs.xml | 6 ++++++ manager/src/main/res/values-ko/strings.xml | 14 ++++++++++++++ .../src/main/res/values-ko/strings_logs.xml | 6 ++++++ manager/src/main/res/values-pl/strings.xml | 17 +++++++++++++++++ .../src/main/res/values-pl/strings_logs.xml | 9 +++++++++ .../src/main/res/values-pt-rBR/strings.xml | 15 +++++++++++++++ .../main/res/values-pt-rBR/strings_logs.xml | 7 +++++++ manager/src/main/res/values-ru/strings.xml | 17 +++++++++++++++++ .../src/main/res/values-ru/strings_logs.xml | 9 +++++++++ manager/src/main/res/values-tr/strings.xml | 15 +++++++++++++++ .../src/main/res/values-tr/strings_logs.xml | 7 +++++++ manager/src/main/res/values-uk/strings.xml | 17 +++++++++++++++++ .../src/main/res/values-uk/strings_logs.xml | 9 +++++++++ manager/src/main/res/values-vi/strings.xml | 14 ++++++++++++++ .../src/main/res/values-vi/strings_logs.xml | 6 ++++++ .../src/main/res/values-zh-rCN/strings.xml | 14 ++++++++++++++ .../main/res/values-zh-rCN/strings_logs.xml | 6 ++++++ .../src/main/res/values-zh-rTW/strings.xml | 14 ++++++++++++++ .../main/res/values-zh-rTW/strings_logs.xml | 6 ++++++ manager/src/main/res/values/strings.xml | 4 ++-- 37 files changed, 410 insertions(+), 2 deletions(-) diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml index bd7d336a2..9745f380d 100644 --- a/manager/src/main/res/values-ar/strings.xml +++ b/manager/src/main/res/values-ar/strings.xml @@ -428,6 +428,25 @@ أُغلق المدير على نحو غير متوقع التتبّع الكامل يخرج ضمن ملف السجلات المُصدَّر، في مجلد crash_manager. تجاهل + ماذا + الرسالة + أين + متى + ليس في أي موضع من كود Vector نفسه + %1$s في %2$s + عرض التتبّع + تعطّل + سببه + + بلا إطارات أخرى + إطار آخر، مثل ما سبق + إطاران آخران، مثل ما سبق + %1$d إطارات أخرى، مثل ما سبق + %1$d إطارًا آخر، مثل ما سبق + %1$d إطار آخر، مثل ما سبق + + نُسخ الإطار + تعذّرت قراءة هذا التعطّل. انسخه وأرفقه بالتقرير كما هو. منع التطبيقات من إخفاء أيقوناتها في المشغّل منذ Android 10، يستعيد التطبيق الذي يخفي أيقونته في المشغّل أيقونةً تفتح صفحة معلومات التطبيق. أوقف هذا الخيار ليبقى مخفيًا — وقد لا يلحق المشغّل بالتغيير حتى يُعاد تشغيله. أما التطبيقات التي لم تكن لها أيقونة أصلًا، ومنها معظم الوحدات، فلا يمسّها هذا. تعذّر إيقاف %1$s. diff --git a/manager/src/main/res/values-ar/strings_logs.xml b/manager/src/main/res/values-ar/strings_logs.xml index af269aa47..9a70e4a8e 100644 --- a/manager/src/main/res/values-ar/strings_logs.xml +++ b/manager/src/main/res/values-ar/strings_logs.xml @@ -41,6 +41,14 @@ %1$d إطارًا %1$d إطار + + بلا أسطر أخرى + سطر آخر + سطران آخران + %1$d أسطر أخرى + %1$d سطرًا آخر + %1$d سطر آخر + تعذّر الوصول إلى الخدمة لا يُعرف شيء عن السجل ما دام Vector لا يعمل. وللسبب نفسه ليس شريط الحالة في الرئيسية أخضر. @@ -81,6 +89,9 @@ تضمين أسطر الإطار نفسه إظهار أسطر الوحدات فقط فعّل هذا قبل الإبلاغ عن خلل. تستطيع التطبيقات قراءة سجلاتها، لذا فإبقاؤه مفعّلًا أحد السبل التي يكتشف بها تطبيقٌ وجود Vector. + تتبّع المكدّس + فتح التتبّعات في مكانها + يُفتح التتبّع أسفل سطره. أوقف هذا ليُفتح في شاشة مستقلة. يكتب السجلين في ملف zip تختاره يغلق الجزء الحالي ويبدأ جزءًا جديدًا الجزء %1$d/%2$d diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml index cc40acbe0..35e42a730 100644 --- a/manager/src/main/res/values-de/strings.xml +++ b/manager/src/main/res/values-de/strings.xml @@ -384,6 +384,21 @@ Der Manager wurde unerwartet beendet Der vollständige Stacktrace geht im Protokoll-Export mit, unter crash_manager. Verwerfen + Was + Meldung + Wo + Wann + Nirgends in Vectors eigenem Code + %1$s auf %2$s + Stacktrace ansehen + Absturz + Verursacht durch + + %1$d weiterer Frame, wie oben + %1$d weitere Frames, wie oben + + Frame kopiert + Dieser Absturz ließ sich nicht lesen. Kopiere ihn und hänge ihn unverändert an den Bericht an. Apps daran hindern, ihr Launcher-Symbol zu verstecken Seit Android 10 bekommt eine App, die ihr eigenes Launcher-Symbol versteckt, eines zurück, das ihre App-Info-Seite öffnet. Schalte das aus, damit sie versteckt bleiben kann — dein Launcher merkt das womöglich erst nach einem Neustart. Apps, die nie ein Launcher-Symbol hatten, darunter die meisten Module, sind nicht betroffen. %1$s konnte nicht beendet werden. diff --git a/manager/src/main/res/values-de/strings_logs.xml b/manager/src/main/res/values-de/strings_logs.xml index a01d797b1..7780e9dd9 100644 --- a/manager/src/main/res/values-de/strings_logs.xml +++ b/manager/src/main/res/values-de/strings_logs.xml @@ -33,6 +33,10 @@ %1$d Frame %1$d Frames + + %1$d weitere Zeile + %1$d weitere Zeilen + Der Daemon ist nicht erreichbar Über das Protokoll ist nichts bekannt, solange Vector nicht läuft. Aus demselben Grund ist das Statusband auf der Startseite nicht grün. @@ -73,6 +77,9 @@ Auch die Zeilen des Frameworks zeigen Nur Modulzeilen zeigen Schalte dies ein, bevor du einen Fehler meldest. Apps können ihre eigenen Protokolle lesen; dauerhaft eingeschaltet ist dies also ein Weg, auf dem eine App Vector entdecken kann. + Stacktrace + Stacktraces an Ort und Stelle öffnen + Ein Stacktrace klappt unter seiner Zeile auf. Schalte dies aus, um ihn stattdessen auf einem eigenen Bildschirm zu öffnen. Schreibt beide Protokolle in eine ZIP-Datei deiner Wahl Schließt den aktuellen Teil und beginnt einen neuen Teil %1$d/%2$d diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml index 0b8b70039..d8f16a39f 100644 --- a/manager/src/main/res/values-es/strings.xml +++ b/manager/src/main/res/values-es/strings.xml @@ -384,6 +384,21 @@ El gestor se cerró de forma inesperada La traza completa va en el zip de registros, en la carpeta crash_manager. Descartar + Qué + Mensaje + Dónde + Cuándo + En ningún punto del código de Vector + %1$s en %2$s + Ver la traza + Fallo + Causado por + + %1$d marco más, igual que arriba + %1$d marcos más, iguales que arriba + + Marco copiado + No se ha podido leer este fallo. Cópialo y adjúntalo al informe tal cual. Impedir que las apps oculten su icono del launcher Desde Android 10, a una app que oculta su propio icono del launcher se le devuelve uno que abre su página de información de la app. Desactiva esta opción para que pueda seguir oculta: puede que tu launcher no se dé cuenta hasta que se reinicie. Las apps que nunca tuvieron icono en el launcher, incluida la mayoría de los módulos, no se ven afectadas. No se pudo detener %1$s. diff --git a/manager/src/main/res/values-es/strings_logs.xml b/manager/src/main/res/values-es/strings_logs.xml index d7e35639a..3eaa9bd8c 100644 --- a/manager/src/main/res/values-es/strings_logs.xml +++ b/manager/src/main/res/values-es/strings_logs.xml @@ -33,6 +33,10 @@ %1$d marco %1$d marcos + + %1$d línea más + %1$d líneas más + No se puede contactar con el daemon No se sabe nada del registro mientras Vector no esté en marcha. Es la misma razón por la que la cinta de estado de Inicio no está verde. @@ -73,6 +77,9 @@ Incluir también las líneas del framework Mostrar solo las líneas de los módulos Actívalo antes de informar de un error. Las apps pueden leer sus propios registros, así que dejarlo activado es una vía por la que una app puede detectar Vector. + Traza de pila + Abrir las trazas en el sitio + La traza se despliega bajo su línea. Desactívalo para abrirla en una pantalla propia. Escribe ambos registros en un zip que tú elijas Cierra la parte actual y abre una nueva parte %1$d/%2$d diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml index fedff0a2c..c9e79786b 100644 --- a/manager/src/main/res/values-fa/strings.xml +++ b/manager/src/main/res/values-fa/strings.xml @@ -384,6 +384,21 @@ مدیر به‌طور غیرمنتظره بسته شد رد کامل خطا در خروجی گزارش‌ها، درون پوشهٔ crash_manager، گنجانده می‌شود. دور انداختن + چه + پیام + کجا + چه زمانی + در هیچ‌جای کد خود Vector + %1$s در %2$s + دیدن رد خطا + خرابی + ناشی از + + %1$d قاب دیگر، مانند بالا + %1$d قاب دیگر، مانند بالا + + قاب رونوشت شد + این خرابی خوانده نشد. آن را همان‌گونه که هست رونوشت کنید و به گزارش پیوست کنید. جلوگیری از پنهان کردن نماد برنامه‌ها در لانچر از Android 10 به بعد، برنامه‌ای که نماد خود را در لانچر پنهان می‌کند، نمادی جایگزین می‌گیرد که صفحهٔ اطلاعات برنامه را باز می‌کند. این را خاموش کنید تا پنهان بماند — شاید لانچر شما تا راه‌اندازی دوبارهٔ خودش به‌روز نشود. برنامه‌هایی که هرگز نماد لانچر نداشته‌اند، از جمله بیشتر ماژول‌ها، تحت تأثیر قرار نمی‌گیرند. توقف %1$s ممکن نشد. diff --git a/manager/src/main/res/values-fa/strings_logs.xml b/manager/src/main/res/values-fa/strings_logs.xml index ae8ec27b0..6f52deaff 100644 --- a/manager/src/main/res/values-fa/strings_logs.xml +++ b/manager/src/main/res/values-fa/strings_logs.xml @@ -33,6 +33,10 @@ %1$d قاب %1$d قاب + + %1$d سطر دیگر + %1$d سطر دیگر + سرویس در دسترس نیست تا وقتی Vector اجرا نشود چیزی دربارهٔ گزارش دانسته نیست. نوار وضعیت در خانه هم به همین دلیل سبز نیست. @@ -73,6 +77,9 @@ سطرهای خودِ چارچوب هم نشان داده شود تنها سطرهای ماژول‌ها پیش از گزارش اشکال این را روشن کنید. برنامه‌ها می‌توانند گزارش خودشان را بخوانند، پس روشن ماندنش یکی از راه‌هایی است که یک برنامه می‌تواند Vector را تشخیص دهد. + رد پشته + باز کردن رد خطا در همان‌جا + رد خطا زیر سطر خودش باز می‌شود. این را خاموش کنید تا در صفحه‌ای جداگانه باز شود. هر دو گزارش را در zip دلخواه شما می‌نویسد بخش کنونی را می‌بندد و بخشی تازه می‌گشاید بخش %1$d/%2$d diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml index 60a6b6fb1..78e5e544a 100644 --- a/manager/src/main/res/values-fr/strings.xml +++ b/manager/src/main/res/values-fr/strings.xml @@ -384,6 +384,21 @@ Le gestionnaire s\'est arrêté de façon inattendue La trace complète est incluse dans l\'export des journaux, sous crash_manager. Effacer + Quoi + Message + + Quand + Nulle part dans le code de Vector + %1$s sur %2$s + Voir la trace + Plantage + Causé par + + %1$d trame de plus, identique à ci-dessus + %1$d trames de plus, identiques à ci-dessus + + Trame copiée + Ce plantage n\'a pas pu être lu. Copiez-le et joignez-le tel quel au rapport. Empêcher les applications de masquer leur icône de lancement Depuis Android 10, une application qui masque sa propre icône de lancement en récupère une, qui ouvre sa page d\'infos sur l\'application. Désactivez cette option pour la laisser masquée — votre lanceur ne s\'en apercevra peut-être qu\'après son redémarrage. Les applications qui n\'ont jamais eu d\'icône de lancement, dont la plupart des modules, ne sont pas concernées. Impossible d\'arrêter %1$s. diff --git a/manager/src/main/res/values-fr/strings_logs.xml b/manager/src/main/res/values-fr/strings_logs.xml index 88e8e9a9a..5b19a6ada 100644 --- a/manager/src/main/res/values-fr/strings_logs.xml +++ b/manager/src/main/res/values-fr/strings_logs.xml @@ -33,6 +33,10 @@ %1$d trame %1$d trames + + %1$d ligne de plus + %1$d lignes de plus + Le démon est injoignable Rien n\'est connu du journal tant que Vector ne fonctionne pas. C\'est la même raison pour laquelle le bandeau d\'état de l\'accueil n\'est pas vert. @@ -73,6 +77,9 @@ Inclure les lignes du framework Afficher seulement les lignes des modules Activez ceci avant de signaler un bug. Les applications peuvent lire leurs propres journaux : le laisser activé est donc un moyen pour une application de repérer Vector. + Trace de pile + Ouvrir les traces sur place + Une trace se déplie sous sa ligne. Désactivez ceci pour l\'ouvrir sur un écran dédié. Écrit les deux journaux dans un zip de votre choix Ferme la partie en cours et en ouvre une neuve partie %1$d/%2$d diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml index 987229e67..66249d237 100644 --- a/manager/src/main/res/values-in/strings.xml +++ b/manager/src/main/res/values-in/strings.xml @@ -377,6 +377,20 @@ Pengelola berhenti tak terduga Jejak lengkapnya ikut terbawa dalam ekspor log, di folder crash_manager. Buang + Apa + Pesan + Di mana + Kapan + Tidak ada di kode Vector sendiri + %1$s di %2$s + Lihat jejaknya + Crash + Disebabkan oleh + + %1$d bingkai lagi, sama seperti di atas + + Bingkai disalin + Crash ini tidak terbaca. Salin dan lampirkan apa adanya ke laporan. Cegah aplikasi menyembunyikan ikon launcher-nya Sejak Android 10, aplikasi yang menyembunyikan ikon launcher-nya sendiri diberi ikon pengganti yang membuka halaman info aplikasinya. Matikan ini agar aplikasi tersebut tetap tersembunyi — launcher Anda mungkin baru menyusul setelah dimulai ulang. Aplikasi yang memang tidak pernah punya ikon launcher, termasuk sebagian besar modul, tidak terpengaruh. %1$s tidak bisa dihentikan. diff --git a/manager/src/main/res/values-in/strings_logs.xml b/manager/src/main/res/values-in/strings_logs.xml index 2ce4de49d..e18ad2e73 100644 --- a/manager/src/main/res/values-in/strings_logs.xml +++ b/manager/src/main/res/values-in/strings_logs.xml @@ -31,6 +31,9 @@ %1$d bingkai + + %1$d baris lagi + Daemon tidak dapat dihubungi Tidak ada yang diketahui tentang log selama Vector belum berjalan. Alasan yang sama membuat pita status di Beranda tidak hijau. @@ -71,6 +74,9 @@ Sertakan juga baris milik framework Tampilkan hanya baris modul Nyalakan ini sebelum melaporkan bug. Aplikasi bisa membaca lognya sendiri, jadi membiarkannya menyala adalah satu cara sebuah aplikasi mengenali Vector. + Jejak tumpukan + Buka jejak di tempat + Jejak tumpukan terbuka di bawah barisnya. Matikan ini untuk membukanya di layar tersendiri. Menulis kedua log ke zip pilihan Anda Menutup bagian yang sekarang dan membuka yang baru bagian %1$d/%2$d diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml index 0c955e9e7..cc3a0f6cb 100644 --- a/manager/src/main/res/values-it/strings.xml +++ b/manager/src/main/res/values-it/strings.xml @@ -384,6 +384,21 @@ Il gestore si è chiuso in modo imprevisto La traccia completa viaggia nello zip dei log, nella cartella crash_manager. Scarta + Cosa + Messaggio + Dove + Quando + In nessun punto del codice di Vector + %1$s su %2$s + Vedi la traccia + Arresto + Causato da + + %1$d frame in più, uguale a sopra + %1$d frame in più, uguali a sopra + + Frame copiato + Non è stato possibile leggere questo arresto. Copialo e allegalo al report così com\'è. Impedisci alle app di nascondere la loro icona nel launcher Da Android 10, un\'app che nasconde la propria icona nel launcher ne riceve una che apre la sua pagina Info app. Disattiva questa opzione per lasciarla nascosta — il tuo launcher potrebbe non accorgersene finché non si riavvia. Le app che non hanno mai avuto un\'icona nel launcher, inclusa la maggior parte dei moduli, non sono interessate. Impossibile arrestare %1$s. diff --git a/manager/src/main/res/values-it/strings_logs.xml b/manager/src/main/res/values-it/strings_logs.xml index 3439ccb95..2af0ac1ed 100644 --- a/manager/src/main/res/values-it/strings_logs.xml +++ b/manager/src/main/res/values-it/strings_logs.xml @@ -33,6 +33,10 @@ %1$d frame %1$d frame + + %1$d riga in più + %1$d righe in più + Il daemon non è raggiungibile Del log non si sa nulla finché Vector non è in esecuzione. È lo stesso motivo per cui la fascia di stato nella Home non è verde. @@ -73,6 +77,9 @@ Includi anche le righe del framework Mostra solo le righe dei moduli Attivala prima di segnalare un bug. Le app possono leggere i propri log, quindi lasciarla attiva è un modo in cui un\'app può accorgersi di Vector. + Stack trace + Apri le tracce sul posto + Una traccia si apre sotto la sua riga. Disattivalo per aprirla invece su una schermata dedicata. Scrive entrambi i log in uno zip che scegli tu Chiude la parte attuale e ne apre una nuova parte %1$d/%2$d diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index 9af3878b4..fe88b79b2 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -410,6 +410,23 @@ המנהל נסגר באופן בלתי צפוי דוח הקריסה המלא נכלל בייצוא היומנים, בתיקייה crash_manager. מחיקה + מה + הודעה + איפה + מתי + בשום מקום בקוד של Vector עצמו + %1$s ב־%2$s + הצגת המעקב + קריסה + נגרם על ידי + + מסגרת אחת נוספת, זהה לאלה שלמעלה + שתי מסגרות נוספות, זהות לאלה שלמעלה + %1$d מסגרות נוספות, זהות לאלה שלמעלה + %1$d מסגרות נוספות, זהות לאלה שלמעלה + + המסגרת הועתקה + לא הצלחנו לקרוא את הקריסה הזאת. העתיקו אותה וצרפו אותה לדיווח כמות שהיא. למנוע מאפליקציות להסתיר את הסמל שלהן במסך הבית החל מ-Android 10, אפליקציה שמסתירה את סמל מסך הבית שלה מקבלת סמל חלופי שפותח את פרטי האפליקציה שלה. כבו את האפשרות הזאת כדי לתת לה להישאר מוסתרת — ייתכן שאפליקציית מסך הבית תתעדכן רק לאחר הפעלה מחדש. אפליקציות שמעולם לא היה להן סמל במסך הבית, ובהן רוב המודולים, אינן מושפעות. לא ניתן היה לעצור את %1$s. diff --git a/manager/src/main/res/values-iw/strings_logs.xml b/manager/src/main/res/values-iw/strings_logs.xml index 3404c62cc..3ffe81942 100644 --- a/manager/src/main/res/values-iw/strings_logs.xml +++ b/manager/src/main/res/values-iw/strings_logs.xml @@ -37,6 +37,12 @@ %1$d מסגרות %1$d מסגרות + + שורה אחת נוספת + שתי שורות נוספות + %1$d שורות נוספות + %1$d שורות נוספות + אין גישה לשירות כל עוד Vector לא פועל, לא ידוע דבר על היומן. מאותה סיבה פס המצב בבית אינו ירוק. @@ -77,6 +83,9 @@ לכלול גם את השורות של המסגרת עצמה להציג רק שורות של מודולים הפעילו את זה לפני דיווח על תקלה. אפליקציות יכולות לקרוא את היומנים של עצמן, ולכן השארה במצב מופעל היא אחת הדרכים שבהן אפליקציה יכולה לזהות את Vector. + מעקב מחסנית + פתיחת מעקבים במקום + המעקב נפתח מתחת לשורה שלו. כבו את זה כדי לפתוח אותו במסך נפרד. כותב את שני היומנים לקובץ zip לבחירתכם סוגר את החלק הנוכחי ופותח חדש חלק %1$d/%2$d diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml index ddb54d5a3..1b840a45a 100644 --- a/manager/src/main/res/values-ja/strings.xml +++ b/manager/src/main/res/values-ja/strings.xml @@ -373,6 +373,20 @@ マネージャーが予期せず終了しました 完全なスタックトレースは、ログの書き出しの crash_manager に含まれます。 破棄 + 種類 + メッセージ + 場所 + 発生時刻 + Vector 自身のコードには見当たりません + %1$s(スレッド %2$s) + スタックトレースを見る + クラッシュ + 原因 + + 上と同じスタックがさらに %1$d 段 + + スタックの 1 段をコピーしました + このクラッシュは読み取れませんでした。そのままコピーして報告に添えてください。 アプリがランチャーアイコンを隠すのを防ぐ Android 10 以降、自分のランチャーアイコンを隠したアプリには、代わりにアプリ情報の画面を開くアイコンが与えられます。オフにすると隠れたままにできます — ランチャー側は再起動するまで古い一覧を表示し続けることがあります。もともとランチャーアイコンを持たないアプリ(ほとんどのモジュールがそうです)には影響しません。 %1$s を停止できませんでした。 diff --git a/manager/src/main/res/values-ja/strings_logs.xml b/manager/src/main/res/values-ja/strings_logs.xml index d28a47262..49ae1a3ee 100644 --- a/manager/src/main/res/values-ja/strings_logs.xml +++ b/manager/src/main/res/values-ja/strings_logs.xml @@ -31,6 +31,9 @@ %1$d 段のスタック + + 続きが %1$d 行 + デーモンに接続できません Vector が動作していない間はログの状態が分かりません。ホームの状態バーが緑にならないのも同じ理由です。 @@ -71,6 +74,9 @@ フレームワーク自身の行も表示する モジュールの行だけ表示する 不具合を報告する前に有効にしてください。アプリは自分のログを読めるため、有効のままにしておくとアプリが Vector に気づく手がかりになります。 + スタックトレース + トレースをその場で開く + スタックトレースはその行の下に展開されます。オフにすると専用の画面で開きます。 両方のログを、選んだ zip に書き出します 現在の区切りを閉じて新しいものを始めます %2$d 分割中 %1$d 番目 diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml index 45e48ce93..42b8ac8a2 100644 --- a/manager/src/main/res/values-ko/strings.xml +++ b/manager/src/main/res/values-ko/strings.xml @@ -373,6 +373,20 @@ 관리자가 예기치 않게 종료되었습니다 전체 스택 트레이스는 로그를 내보낼 때 crash_manager 아래에 함께 담깁니다. 지우기 + 종류 + 메시지 + 위치 + 시각 + Vector 자체 코드에는 없음 + %1$s (%2$s 스레드) + 스택 트레이스 보기 + 충돌 + 원인 + + 위와 같은 스택 %1$d단 더 + + 스택 한 단을 복사했습니다 + 이 충돌은 읽을 수 없었습니다. 그대로 복사해 신고에 첨부하세요. 앱이 런처 아이콘을 숨기지 못하게 막기 Android 10부터, 자기 런처 아이콘을 숨긴 앱에는 앱 정보 페이지를 여는 아이콘이 대신 주어집니다. 이 설정을 끄면 숨긴 채로 둘 수 있습니다 — 런처는 다시 시작하기 전까지 예전 목록을 그대로 보여줄 수 있습니다. 처음부터 런처 아이콘이 없던 앱은 영향을 받지 않으며, 대부분의 모듈이 여기에 해당합니다. %1$s을(를) 중지하지 못했습니다. diff --git a/manager/src/main/res/values-ko/strings_logs.xml b/manager/src/main/res/values-ko/strings_logs.xml index 8fe6d1982..fd21c00ba 100644 --- a/manager/src/main/res/values-ko/strings_logs.xml +++ b/manager/src/main/res/values-ko/strings_logs.xml @@ -31,6 +31,9 @@ 스택 %1$d단 + + %1$d줄 더 + 데몬에 연결할 수 없음 Vector가 실행 중이 아니면 로그에 대해 알 수 있는 것이 없습니다. 홈의 상태 띠가 초록색이 아닌 이유도 같습니다. @@ -71,6 +74,9 @@ 프레임워크 자체의 줄도 포함 모듈의 줄만 표시 버그를 신고하기 전에 켜세요. 앱은 자기 로그를 읽을 수 있으므로, 계속 켜 두면 앱이 Vector를 알아챌 통로가 됩니다. + 스택 트레이스 + 트레이스를 제자리에서 열기 + 스택 트레이스가 해당 줄 아래에 펼쳐집니다. 끄면 별도 화면에서 엽니다. 두 로그를 원하는 zip에 기록합니다 지금 조각을 닫고 새 조각을 엽니다 %2$d개 중 %1$d번째 조각 diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml index e11ef9f2c..52f4c1271 100644 --- a/manager/src/main/res/values-pl/strings.xml +++ b/manager/src/main/res/values-pl/strings.xml @@ -406,6 +406,23 @@ Menedżer zamknął się nieoczekiwanie Pełny ślad stosu znajdziesz w pliku zip z dziennikami, w katalogu crash_manager. Odrzuć + Co + Komunikat + Gdzie + Kiedy + Nigdzie we własnym kodzie Vectora + %1$s w %2$s + Zobacz ślad stosu + Awaria + Spowodowane przez + + %1$d ramka więcej, taka sama jak powyżej + %1$d ramki więcej, takie same jak powyżej + %1$d ramek więcej, takich samych jak powyżej + %1$d ramki więcej, takie same jak powyżej + + Skopiowano ramkę + Nie udało się odczytać tej awarii. Skopiuj ją i dołącz do zgłoszenia w niezmienionej postaci. Nie pozwalaj aplikacjom ukrywać ikon w launcherze Od wersji Android 10 aplikacja, która ukrywa własną ikonę w launcherze, dostaje z powrotem ikonę otwierającą jej stronę informacji o aplikacji. Wyłącz to, aby mogła pozostać ukryta — Twój launcher może pokazywać starą listę, dopóki się nie zrestartuje. Nie dotyczy to aplikacji, które nigdy nie miały ikony w launcherze, w tym większości modułów. Nie udało się zatrzymać %1$s. diff --git a/manager/src/main/res/values-pl/strings_logs.xml b/manager/src/main/res/values-pl/strings_logs.xml index da9a235b8..d031bf6fb 100644 --- a/manager/src/main/res/values-pl/strings_logs.xml +++ b/manager/src/main/res/values-pl/strings_logs.xml @@ -37,6 +37,12 @@ %1$d ramek %1$d ramki + + %1$d wiersz więcej + %1$d wiersze więcej + %1$d wierszy więcej + %1$d wiersza więcej + Usługa jest nieosiągalna Dopóki Vector nie działa, o dzienniku nic nie wiadomo. Z tego samego powodu pasek stanu na stronie startowej nie jest zielony. @@ -77,6 +83,9 @@ Pokaż też wiersze samego szkieletu Pokaż tylko wiersze modułów Włącz to przed zgłoszeniem błędu. Aplikacje mogą czytać własne dzienniki, więc pozostawienie tej opcji włączonej to jeden ze sposobów, w jaki aplikacja może wykryć Vectora. + Ślad stosu + Otwieraj ślady na miejscu + Ślad stosu rozwija się pod swoim wierszem. Wyłącz to, aby otwierać go na osobnym ekranie. Zapisuje oba dzienniki do wybranego pliku zip Zamyka bieżącą część i otwiera nową część %1$d/%2$d diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml index 92ceed5be..7db68ded0 100644 --- a/manager/src/main/res/values-pt-rBR/strings.xml +++ b/manager/src/main/res/values-pt-rBR/strings.xml @@ -384,6 +384,21 @@ O gerenciador fechou inesperadamente O rastreamento completo vai junto no zip dos registros, na pasta crash_manager. Descartar + O quê + Mensagem + Onde + Quando + Em nenhum ponto do código do Vector + %1$s em %2$s + Ver o rastreamento + Falha + Causado por + + mais %1$d quadro, igual aos de cima + mais %1$d quadros, iguais aos de cima + + Quadro copiado + Não foi possível ler esta falha. Copie e anexe ao relato como está. Impedir que apps escondam o ícone do launcher Desde o Android 10, um app que esconde o próprio ícone do launcher recebe um de volta, que abre a página de informações do app. Desligue isto para deixá-lo continuar escondido — seu launcher pode seguir mostrando a lista antiga até reiniciar. Apps que nunca tiveram ícone no launcher, incluindo a maioria dos módulos, não são afetados. Não foi possível parar %1$s. diff --git a/manager/src/main/res/values-pt-rBR/strings_logs.xml b/manager/src/main/res/values-pt-rBR/strings_logs.xml index ece66e760..707e5303b 100644 --- a/manager/src/main/res/values-pt-rBR/strings_logs.xml +++ b/manager/src/main/res/values-pt-rBR/strings_logs.xml @@ -33,6 +33,10 @@ %1$d quadro %1$d quadros + + mais %1$d linha + mais %1$d linhas + O daemon está inacessível Não se sabe nada do registro enquanto o Vector não estiver em execução. É o mesmo motivo pelo qual a faixa de status no Início não está verde. @@ -73,6 +77,9 @@ Incluir também as linhas do framework Mostrar só as linhas dos módulos Ligue isto antes de relatar um erro. Os apps conseguem ler os próprios registros, então deixar ligado é um jeito de um app perceber o Vector. + Rastreamento de pilha + Abrir os rastreamentos no lugar + O rastreamento se abre embaixo da linha dele. Desligue para abri-lo em uma tela própria. Grava os dois registros em um zip escolhido por você Fecha a parte atual e abre uma nova parte %1$d/%2$d diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml index bd4999540..2ca7c4f55 100644 --- a/manager/src/main/res/values-ru/strings.xml +++ b/manager/src/main/res/values-ru/strings.xml @@ -387,6 +387,23 @@ Менеджер неожиданно завершил работу Полная трассировка стека попадает в экспорт журналов, в папку crash_manager. Убрать + Что + Сообщение + Где + Когда + Нигде в коде самого Vector + %1$s в потоке %2$s + Посмотреть трассировку + Сбой + Причина + + ещё %1$d кадр стека, такой же, как выше + ещё %1$d кадра стека, такие же, как выше + ещё %1$d кадров стека, таких же, как выше + ещё %1$d кадров стека, таких же, как выше + + Кадр скопирован + Этот сбой не удалось прочитать. Скопируйте его и приложите к отчёту как есть. Не давать приложениям скрывать значок в лаунчере Начиная с Android 10, приложение, которое скрывает свой значок в лаунчере, получает его обратно, и этот значок открывает страницу сведений о приложении. Выключите это, чтобы значок оставался скрытым — лаунчер может обновить свой список только после перезапуска. Приложений, у которых значка в лаунчере никогда не было, включая большинство модулей, это не касается. Не удалось остановить %1$s. diff --git a/manager/src/main/res/values-ru/strings_logs.xml b/manager/src/main/res/values-ru/strings_logs.xml index fb19b772d..26cbed2fa 100644 --- a/manager/src/main/res/values-ru/strings_logs.xml +++ b/manager/src/main/res/values-ru/strings_logs.xml @@ -32,6 +32,12 @@ %1$d кадров стека %1$d кадров стека + + ещё %1$d строка + ещё %1$d строки + ещё %1$d строк + ещё %1$d строки + Служба недоступна Пока Vector не запущен, о журнале ничего не известно. По той же причине индикатор состояния на главном экране не зелёный. Файла журнала пока нет @@ -65,6 +71,9 @@ Показывать и строки самого фреймворка Только строки модулей Включите перед тем, как сообщать об ошибке. Приложение может читать собственный журнал, поэтому постоянно включённое логирование — один из способов заметить Vector. + Трассировка стека + Открывать трассировки на месте + Трассировка разворачивается под своей строкой. Выключите, чтобы открывать её на отдельном экране. Записывает оба журнала в выбранный вами zip Закрывает текущую часть и начинает новую часть %1$d/%2$d diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml index f690e8635..f2a0f2bac 100644 --- a/manager/src/main/res/values-tr/strings.xml +++ b/manager/src/main/res/values-tr/strings.xml @@ -384,6 +384,21 @@ Yönetici beklenmedik biçimde kapandı Yığın izinin tamamı, günlük dışa aktarımında crash_manager altında yer alır. Sil + Ne + İleti + Nerede + Ne zaman + Vector\'ün kendi kodunun hiçbir yerinde + %1$s, %2$s iş parçacığında + Yığın izini gör + Çökme + Nedeni + + yukarıdakinin aynısı %1$d çerçeve daha + yukarıdakilerin aynısı %1$d çerçeve daha + + Çerçeve kopyalandı + Bu çökme okunamadı. Olduğu gibi kopyalayıp bildirime ekleyin. Uygulamaların başlatıcı simgesini gizlemesini engelle Android 10\'dan beri, kendi başlatıcı simgesini gizleyen bir uygulamaya, uygulama bilgisi sayfasını açan bir simge geri verilir. Gizli kalabilmesi için bunu kapatın — başlatıcınız yeniden başlatılana kadar durumu yansıtmayabilir. Hiçbir zaman başlatıcı simgesi olmamış uygulamalar, çoğu modül dahil, bundan etkilenmez. %1$s durdurulamadı. diff --git a/manager/src/main/res/values-tr/strings_logs.xml b/manager/src/main/res/values-tr/strings_logs.xml index 61ab69292..f4217ec8f 100644 --- a/manager/src/main/res/values-tr/strings_logs.xml +++ b/manager/src/main/res/values-tr/strings_logs.xml @@ -33,6 +33,10 @@ %1$d çerçeve %1$d çerçeve + + %1$d satır daha + %1$d satır daha + Art alan sürecine ulaşılamıyor Vector çalışmadığı sürece günlük hakkında hiçbir şey bilinemez. Ana sayfadaki durum şeridinin yeşil olmamasının nedeni de aynıdır. @@ -73,6 +77,9 @@ Çatının kendi satırlarını da göster Yalnızca modül satırlarını göster Bir hatayı bildirmeden önce bunu açın. Uygulamalar kendi günlüklerini okuyabilir; açık bırakmak, bir uygulamanın Vector\'ü fark etmesinin yollarından biridir. + Yığın izi + İzleri yerinde aç + Yığın izi kendi satırının altında açılır. Bunu kapatırsanız kendi ekranında açılır. İki günlüğü de seçtiğiniz bir zip dosyasına yazar Mevcut parçayı kapatıp yenisini açar parça %1$d/%2$d diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml index 403d500a9..16c4b3d50 100644 --- a/manager/src/main/res/values-uk/strings.xml +++ b/manager/src/main/res/values-uk/strings.xml @@ -406,6 +406,23 @@ Менеджер несподівано завершив роботу Повне трасування стека потрапляє в експорт журналів, у теку crash_manager. Відкинути + Що + Повідомлення + Де + Коли + Ніде у власному коді Vector + %1$s у потоці %2$s + Переглянути трасування + Збій + Причина + + ще %1$d кадр, такий самий, як вище + ще %1$d кадри, такі самі, як вище + ще %1$d кадрів, таких самих, як вище + ще %1$d кадра, такі самі, як вище + + Кадр скопійовано + Цей збій не вдалося прочитати. Скопіюйте його та додайте до звіту як є. Не дозволяти застосункам ховати свій значок у лаунчері Починаючи з Android 10, застосунок, який ховає свій значок у лаунчері, отримує його назад, і цей значок відкриває сторінку відомостей про застосунок. Вимкніть це, щоб значок лишався прихованим — лаунчер може оновити свій перелік лише після перезапуску. Застосунків, які ніколи не мали значка в лаунчері, зокрема більшість модулів, це не стосується. Не вдалося зупинити %1$s. diff --git a/manager/src/main/res/values-uk/strings_logs.xml b/manager/src/main/res/values-uk/strings_logs.xml index 671d9b778..9915738a0 100644 --- a/manager/src/main/res/values-uk/strings_logs.xml +++ b/manager/src/main/res/values-uk/strings_logs.xml @@ -37,6 +37,12 @@ %1$d кадрів %1$d кадра + + ще %1$d рядок + ще %1$d рядки + ще %1$d рядків + ще %1$d рядка + Служба недоступна Поки Vector не працює, про журнал нічого не відомо. Із тієї самої причини смужка стану на головній не зелена. @@ -77,6 +83,9 @@ Показувати й рядки самого фреймворку Показувати лише рядки модулів Увімкніть це перед тим, як повідомляти про помилку. Застосунки можуть читати власні журнали, тож постійно ввімкнене журналювання — один зі шляхів, якими застосунок помічає Vector. + Трасування стека + Відкривати трасування на місці + Трасування розгортається під своїм рядком. Вимкніть, щоб відкривати його на окремому екрані. Записує обидва журнали в обраний вами zip Закриває поточну частину й починає нову частина %1$d/%2$d diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml index 6d17602ca..a0d7fa29f 100644 --- a/manager/src/main/res/values-vi/strings.xml +++ b/manager/src/main/res/values-vi/strings.xml @@ -373,6 +373,20 @@ Trình quản lý đã đóng đột ngột Toàn bộ vết lỗi nằm trong bản xuất nhật ký, ở thư mục crash_manager. Xoá + Cái gì + Thông báo + Ở đâu + Khi nào + Không ở đâu trong mã của chính Vector + %1$s trên %2$s + Xem vết lỗi + Sự cố + Nguyên nhân + + thêm %1$d khung nữa, giống như trên + + Đã sao chép khung + Không đọc được sự cố này. Hãy sao chép và đính kèm nguyên trạng vào báo cáo. Không cho ứng dụng ẩn biểu tượng trên trình khởi chạy Từ Android 10, ứng dụng tự ẩn biểu tượng của mình trên trình khởi chạy sẽ được cấp lại một biểu tượng mở trang thông tin ứng dụng. Tắt mục này để nó vẫn được ẩn — trình khởi chạy có thể chưa cập nhật cho tới khi nó khởi động lại. Những ứng dụng vốn không có biểu tượng khởi chạy, gồm hầu hết mô-đun, không bị ảnh hưởng. Không dừng được %1$s. diff --git a/manager/src/main/res/values-vi/strings_logs.xml b/manager/src/main/res/values-vi/strings_logs.xml index 4cc11bd2a..8f45bb1c5 100644 --- a/manager/src/main/res/values-vi/strings_logs.xml +++ b/manager/src/main/res/values-vi/strings_logs.xml @@ -31,6 +31,9 @@ %1$d khung + + thêm %1$d dòng nữa + Không liên lạc được tiến trình nền Không biết gì về nhật ký khi Vector chưa chạy. Cũng vì lý do đó mà dải trạng thái ở trang chính không xanh. @@ -71,6 +74,9 @@ Kèm cả các dòng của framework Chỉ hiện các dòng của mô-đun Hãy bật trước khi báo lỗi. Ứng dụng đọc được nhật ký của chính nó, nên để bật lâu dài là một cách để ứng dụng nhận ra Vector. + Vết ngăn xếp + Mở vết lỗi tại chỗ + Vết lỗi bung ra ngay dưới dòng của nó. Tắt mục này để mở nó trên màn hình riêng. Ghi cả hai nhật ký vào tệp zip bạn chọn Đóng phần hiện tại và mở một phần mới phần %1$d/%2$d diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 97ffbea3b..f40802a94 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -374,6 +374,20 @@ 管理器意外退出 完整的堆栈信息会随日志导出一起保存,位于其中的 crash_manager 文件夹。 丢弃 + 什么 + 消息 + 在哪里 + 何时 + 不在 Vector 自己的代码中 + %1$s,线程 %2$s + 查看调用栈 + 崩溃 + 起因 + + 还有 %1$d 层调用栈,与上面相同 + + 已复制该层调用栈 + 无法读取这次崩溃。请原样复制并附在报告里。 阻止应用隐藏自己的桌面图标 从 Android 10 起,隐藏了自己桌面图标的应用会被补上一个图标,点击它会打开该应用的应用信息页面。关闭此项可以让它继续保持隐藏——桌面可能要等重启之后才会更新。本来就没有桌面图标的应用不受影响,大多数模块都属于此类。 无法停止 %1$s。 diff --git a/manager/src/main/res/values-zh-rCN/strings_logs.xml b/manager/src/main/res/values-zh-rCN/strings_logs.xml index 9bdce65bd..945ce9f0f 100644 --- a/manager/src/main/res/values-zh-rCN/strings_logs.xml +++ b/manager/src/main/res/values-zh-rCN/strings_logs.xml @@ -31,6 +31,9 @@ %1$d 层调用栈 + + 还有 %1$d 行 + 无法连接到守护进程 Vector 未运行时无法获知日志情况。主页上的状态标识不是绿色,也是同一个原因。 @@ -71,6 +74,9 @@ 同时显示框架自身的日志 仅显示模块日志 上报问题前请先开启。应用可以读取自己的日志,因此保持开启会让应用有机会发现 Vector。 + 调用栈 + 就地展开调用栈 + 调用栈在所属行下方展开。关闭后改为在单独的页面打开。 把两份日志写入你选择的 zip 文件 关闭当前分段并新建一个 第 %1$d/%2$d 段 diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml index 7b4cab9b8..3e6461696 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -374,6 +374,20 @@ 管理器意外結束 完整的堆疊追蹤會隨日誌匯出一起保存,位於其中的 crash_manager 資料夾。 捨棄 + 什麼 + 訊息 + 在哪裡 + 何時 + 不在 Vector 自己的程式碼中 + %1$s,執行緒 %2$s + 查看呼叫堆疊 + 當機 + 起因 + + 還有 %1$d 層呼叫堆疊,與上面相同 + + 已複製該層呼叫堆疊 + 無法讀取這次當機。請原樣複製並附在回報中。 阻止應用程式隱藏自己的啟動器圖示 自 Android 10 起,隱藏了自己啟動器圖示的應用程式會被補上一個圖示,點按它會開啟該應用程式的應用程式資訊頁面。關閉此項可讓它維持隱藏——你的啟動器可能要等重新啟動之後才會更新。本來就沒有啟動器圖示的應用程式不受影響,大多數模組都屬於此類。 無法停止 %1$s。 diff --git a/manager/src/main/res/values-zh-rTW/strings_logs.xml b/manager/src/main/res/values-zh-rTW/strings_logs.xml index b41f7f9bd..1f7357cf2 100644 --- a/manager/src/main/res/values-zh-rTW/strings_logs.xml +++ b/manager/src/main/res/values-zh-rTW/strings_logs.xml @@ -31,6 +31,9 @@ %1$d 層呼叫堆疊 + + 還有 %1$d 行 + 無法連線到常駐程式 Vector 未執行時無從得知日誌狀況。主畫面上的狀態標示不是綠色,也是同樣的原因。 @@ -71,6 +74,9 @@ 一併顯示框架自身的日誌 僅顯示模組日誌 回報問題前請先開啟。應用程式可以讀取自己的日誌,因此持續開啟會讓應用程式有機會察覺 Vector。 + 呼叫堆疊 + 就地展開呼叫堆疊 + 呼叫堆疊會在所屬行下方展開。關閉後改為在獨立畫面開啟。 將兩份日誌寫入你選擇的 zip 檔案 關閉目前的分段並新建一個 第 %1$d/%2$d 段 diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 81ab3ccd4..38348b3d7 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -45,8 +45,8 @@ Caused by - %d more frame, the same as above - %d more frames, the same as above + %1$d more frame, the same as above + %1$d more frames, the same as above Frame copied This crash could not be read. Copy it and attach it to the report as it is.