Skip to content

Commit 8ba2417

Browse files
committed
feat(plugin-api): ADFA-6267 add read-only IdeLogService for App and IDE logs
Plugins read either log tab, filtered by level, text and line count (max 1000, 128KiB). A missing log returns an empty result instead of throwing; there is no clear or write method.
1 parent 57b88c7 commit 8ba2417

15 files changed

Lines changed: 710 additions & 4 deletions

File tree

‎app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import com.itsaky.androidide.activities.projectsRoot
8181
import com.itsaky.androidide.adapters.DiagnosticsAdapter
8282
import com.itsaky.androidide.adapters.SearchListAdapter
8383
import com.itsaky.androidide.api.BuildOutputProvider
84+
import com.itsaky.androidide.api.LogsProvider
8485
import com.itsaky.androidide.app.EdgeToEdgeIDEActivity
8586
import com.itsaky.androidide.app.IDEApplication
8687
import com.itsaky.androidide.databinding.ActivityEditorBinding
@@ -154,6 +155,7 @@ import com.itsaky.androidide.viewmodel.DebuggerViewModel
154155
import com.itsaky.androidide.viewmodel.EditorViewModel
155156
import com.itsaky.androidide.viewmodel.FileManagerViewModel
156157
import com.itsaky.androidide.viewmodel.FileOpResult
158+
import com.itsaky.androidide.viewmodel.IDELogsViewModel
157159
import com.itsaky.androidide.viewmodel.MetricsViewModel
158160
import com.itsaky.androidide.viewmodel.RecentProjectsViewModel
159161
import com.itsaky.androidide.viewmodel.WADBConnectionViewModel
@@ -292,15 +294,18 @@ abstract class BaseEditorActivity :
292294
val wadbConnectionViewModel by viewModels<WADBConnectionViewModel>()
293295

294296
val appLogsViewModel by viewModels<AppLogsViewModel>()
297+
298+
// Same ViewModelStore key as IDELogFragment's activityViewModels(), so both see one instance.
299+
private val ideLogsViewModel by viewModels<IDELogsViewModel>()
295300
var appLogsCoordinator: AppLogsCoordinator? = null
296301

297302
// Mirrors EditorHandlerActivity's/ProjectHandlerActivity's own same-named, independently-tracked
298303
// flags: set only once onCreate reaches its end without bailing out early (the "no matching
299304
// project" doomed-duplicate-instance branch above returns before this runs). preDestroy() checks
300305
// it before touching the process-wide singletons this onCreate registers this instance with
301-
// (BuildOutputProvider, the plugin snippet-refresh listener) -- a doomed instance never actually
302-
// registered as their owner, so clearing them on its teardown would wipe out whatever a
303-
// genuinely live sibling instance set up instead.
306+
// (BuildOutputProvider, LogsProvider, the plugin snippet-refresh listener) -- a doomed instance
307+
// never actually registered as their owner, so clearing them on its teardown would wipe out
308+
// whatever a genuinely live sibling instance set up instead.
304309
private var didCompleteLiveOnCreate = false
305310

306311
// The editor-side counterpart of MainActivity.consumedDeepLinkRequests, for the two deep-link
@@ -578,6 +583,9 @@ abstract class BaseEditorActivity :
578583
protected open fun preDestroy() {
579584
if (didCompleteLiveOnCreate) {
580585
BuildOutputProvider.clearBottomSheet()
586+
// A rotation keeps both view models and re-attaches them in the new onCreate; detaching
587+
// here would leave plugin reads empty in between.
588+
if (!isChangingConfigurations) LogsProvider.detach(appLogsViewModel, ideLogsViewModel)
581589

582590
IDEApplication.getPluginManager()?.setSnippetRefreshListener(null)
583591
}
@@ -917,6 +925,7 @@ abstract class BaseEditorActivity :
917925
appLogsCoordinator =
918926
AppLogsCoordinator(appLogsViewModel)
919927
.also(lifecycle::addObserver)
928+
LogsProvider.attach(appLogsViewModel, ideLogsViewModel)
920929

921930
this.optionsMenuInvalidator = Runnable { super.invalidateOptionsMenu() }
922931

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.itsaky.androidide.api
2+
3+
import androidx.annotation.VisibleForTesting
4+
import com.itsaky.androidide.logs.LogBuffer
5+
import com.itsaky.androidide.models.LogFilter
6+
import com.itsaky.androidide.plugins.services.LogEntry
7+
import com.itsaky.androidide.plugins.services.LogLevel
8+
import com.itsaky.androidide.plugins.services.LogQuery
9+
import com.itsaky.androidide.plugins.services.LogReadResult
10+
import com.itsaky.androidide.plugins.services.LogSource
11+
import com.itsaky.androidide.utils.ILogger
12+
import com.itsaky.androidide.viewmodel.LogViewModel
13+
import java.lang.ref.WeakReference
14+
15+
/**
16+
* Serves the App Logs and IDE Logs tabs' retained history to plugins
17+
* ([com.itsaky.androidide.plugins.services.IdeLogService]). The history lives in the editor
18+
* activity's view models, so the activity attaches them here; with none attached, reads are empty.
19+
*/
20+
object LogsProvider {
21+
// Written on the main thread, read on whichever thread a plugin calls in on.
22+
@Volatile
23+
private var appLogsRef: WeakReference<LogViewModel>? = null
24+
25+
@Volatile
26+
private var ideLogsRef: WeakReference<LogViewModel>? = null
27+
28+
fun attach(
29+
appLogs: LogViewModel,
30+
ideLogs: LogViewModel,
31+
) {
32+
synchronized(this) {
33+
appLogsRef = WeakReference(appLogs)
34+
ideLogsRef = WeakReference(ideLogs)
35+
}
36+
}
37+
38+
/**
39+
* Detaches [appLogs] and [ideLogs] if they are still the attached pair. A no-op otherwise, so
40+
* one editor instance's teardown cannot wipe out the view models a live sibling attached.
41+
*/
42+
fun detach(
43+
appLogs: LogViewModel,
44+
ideLogs: LogViewModel,
45+
) {
46+
synchronized(this) {
47+
if (appLogsRef?.get() === appLogs) appLogsRef = null
48+
if (ideLogsRef?.get() === ideLogs) ideLogsRef = null
49+
}
50+
}
51+
52+
fun read(
53+
source: LogSource,
54+
query: LogQuery,
55+
): LogReadResult {
56+
val ref =
57+
when (source) {
58+
LogSource.APP -> appLogsRef
59+
LogSource.IDE -> ideLogsRef
60+
}
61+
val viewModel = ref?.get() ?: return LogReadResult.EMPTY
62+
val (entries, truncated) = viewModel.tailFiltered(query.toLogFilter(), query.effectiveMaxLines)
63+
return boundToChars(entries, truncated, LogQuery.MAX_CHARS)
64+
}
65+
66+
/**
67+
* Drops the oldest entries until the texts fit in [maxChars]. A single entry larger than that
68+
* is cut to its first [maxChars] characters: the head of a stack trace is the part that names
69+
* the exception.
70+
*/
71+
@VisibleForTesting
72+
internal fun boundToChars(
73+
entries: List<LogBuffer.Entry>,
74+
truncated: Boolean,
75+
maxChars: Int,
76+
): LogReadResult {
77+
require(maxChars > 0) { "maxChars must be positive" }
78+
if (entries.isEmpty()) {
79+
return if (truncated) LogReadResult(emptyList(), truncated = true) else LogReadResult.EMPTY
80+
}
81+
var start = entries.size
82+
var total = 0
83+
while (start > 0) {
84+
val length = contentLength(entries[start - 1].text)
85+
if (total + length > maxChars) break
86+
total += length
87+
start--
88+
}
89+
if (start == entries.size) {
90+
val newest = entries.last()
91+
return LogReadResult(
92+
listOf(LogEntry(newest.level?.toPluginLevel(), newest.text.stripLineEnd().take(maxChars))),
93+
truncated = true,
94+
)
95+
}
96+
val kept = entries.subList(start, entries.size).map { LogEntry(it.level?.toPluginLevel(), it.text.stripLineEnd()) }
97+
return LogReadResult(kept, truncated = truncated || start > 0)
98+
}
99+
100+
/** Length of [text] without its line terminator, LF or CRLF, and without allocating. */
101+
private fun contentLength(text: String): Int {
102+
var end = text.length
103+
while (end > 0 && (text[end - 1] == '\n' || text[end - 1] == '\r')) end--
104+
return end
105+
}
106+
107+
private fun String.stripLineEnd(): String = substring(0, contentLength(this))
108+
109+
private fun LogQuery.toLogFilter(): LogFilter =
110+
LogFilter(
111+
enabledLevels =
112+
if (levels.isEmpty()) {
113+
LogFilter.ALL_LEVELS
114+
} else {
115+
levels.mapTo(mutableSetOf()) { it.toILoggerLevel() }
116+
},
117+
text = text.trim(),
118+
)
119+
120+
private fun LogLevel.toILoggerLevel(): ILogger.Level =
121+
when (this) {
122+
LogLevel.VERBOSE -> ILogger.Level.VERBOSE
123+
LogLevel.DEBUG -> ILogger.Level.DEBUG
124+
LogLevel.INFO -> ILogger.Level.INFO
125+
LogLevel.WARNING -> ILogger.Level.WARNING
126+
LogLevel.ERROR -> ILogger.Level.ERROR
127+
}
128+
129+
private fun ILogger.Level.toPluginLevel(): LogLevel =
130+
when (this) {
131+
ILogger.Level.VERBOSE -> LogLevel.VERBOSE
132+
ILogger.Level.DEBUG -> LogLevel.DEBUG
133+
ILogger.Level.INFO -> LogLevel.INFO
134+
ILogger.Level.WARNING -> LogLevel.WARNING
135+
ILogger.Level.ERROR -> LogLevel.ERROR
136+
}
137+
}

‎app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.google.android.material.color.DynamicColors
1111
import com.itsaky.androidide.activities.CrashHandlerActivity
1212
import com.itsaky.androidide.activities.editor.IDELogcatReader
1313
import com.itsaky.androidide.api.BuildOutputProvider
14+
import com.itsaky.androidide.api.LogsProvider
1415
import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider
1516
import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent
1617
import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent
@@ -19,6 +20,7 @@ import com.itsaky.androidide.managers.ToolsManager
1920
import com.itsaky.androidide.plugins.PluginLogger
2021
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
2122
import com.itsaky.androidide.plugins.manager.core.PluginManager
23+
import com.itsaky.androidide.plugins.manager.services.IdeLogServiceImpl
2224
import com.itsaky.androidide.preferences.internal.DevOpsPreferences
2325
import com.itsaky.androidide.preferences.internal.GeneralPreferences
2426
import com.itsaky.androidide.resources.localization.LocaleProvider
@@ -383,6 +385,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
383385
manager.setActivityProvider { application.foregroundActivity }
384386
setupBuildServiceProviders()
385387
setupProjectManipulationProviders()
388+
IdeLogServiceImpl.getInstance().setLogReader(LogsProvider::read)
386389
logger.info("Plugin services configured successfully")
387390
}
388391
}

‎app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ class LogBuffer(
9393
return text to lastSeq
9494
}
9595

96+
/**
97+
* The newest [maxEntries] entries matching [filter], oldest first.
98+
*
99+
* @return The entries, and whether older matching entries were left out.
100+
*/
101+
@Synchronized
102+
fun tailFiltered(
103+
filter: LogFilter,
104+
maxEntries: Int,
105+
): Pair<List<Entry>, Boolean> {
106+
require(maxEntries > 0) { "maxEntries must be positive" }
107+
val tail = ArrayDeque<Entry>(minOf(maxEntries, entries.size))
108+
var truncated = false
109+
for (i in entries.indices.reversed()) {
110+
val entry = entries[i]
111+
if (!filter.matches(entry.level, entry.text)) continue
112+
if (tail.size == maxEntries) {
113+
truncated = true
114+
break
115+
}
116+
tail.addFirst(entry)
117+
}
118+
return tail to truncated
119+
}
120+
96121
@Synchronized
97122
fun snapshotAll(): String =
98123
buildString {

‎app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,15 @@ abstract class LogViewModel : ViewModel() {
198198

199199
/** The full retained history, ignoring the active filter. */
200200
fun snapshotUnfiltered(): String = buffer.snapshotAll()
201+
202+
/**
203+
* The newest [maxEntries] retained entries matching [filter], independent of the view's own
204+
* [filter]. Safe from any thread.
205+
*/
206+
fun tailFiltered(
207+
filter: LogFilter,
208+
maxEntries: Int,
209+
): Pair<List<LogBuffer.Entry>, Boolean> = buffer.tailFiltered(filter, maxEntries)
201210
}
202211

203212
/**

0 commit comments

Comments
 (0)