From a01ef24db596d6e4f3df5c0740610e3348a5f644 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 13:43:51 +0100 Subject: [PATCH 01/14] ADFA-936: Prepare log layouts and search bar for output search/filter Mechanical prep for search & filter in the output tabs: - Wrap the log/build-output editors in a vertical LinearLayout with a ViewStub slot for the upcoming filter bar; the editor keeps its id. - NonEditableEditorFragment now resolves the editor via binding.editor instead of the (formerly editor-typed) layout root. - EditorSearchLayout gains opt-out flags for read-only hosts: hide the replace action and skip the collapsed-sheet bottom margin. Also adds refreshSearch()/isSearchModeActive() so hosts can re-run a query after replacing editor content. Existing call site is unchanged (defaults). - New layout_log_filter_bar.xml (text filter input + level filter chips) and its strings. --- .../output/NonEditableEditorFragment.java | 2 +- app/src/main/res/layout/fragment_log.xml | 20 ++- .../layout/fragment_non_editable_editor.xml | 64 +++++---- .../main/res/layout/layout_log_filter_bar.xml | 121 ++++++++++++++++++ .../editor/ui/EditorSearchLayout.kt | 43 ++++++- resources/src/main/res/values/strings.xml | 9 ++ 6 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 app/src/main/res/layout/layout_log_filter_bar.xml diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java b/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java index acb0526a25..48427ca2b7 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/NonEditableEditorFragment.java @@ -81,7 +81,7 @@ public String getShareableFilename() { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); getEmptyStateViewModel().setEmptyMessage(createEmptyStateMessage()); - final var editor = getBinding().getRoot(); + final var editor = getBinding().editor; editor.setEditable(false); editor.setDividerWidth(0); editor.setEditorLanguage(new EmptyLanguage()); diff --git a/app/src/main/res/layout/fragment_log.xml b/app/src/main/res/layout/fragment_log.xml index f48ff2f8a3..99b55a8139 100644 --- a/app/src/main/res/layout/fragment_log.xml +++ b/app/src/main/res/layout/fragment_log.xml @@ -17,9 +17,23 @@ ~ along with AndroidIDE. If not, see . --> - + android:orientation="vertical"> + + + + + diff --git a/app/src/main/res/layout/fragment_non_editable_editor.xml b/app/src/main/res/layout/fragment_non_editable_editor.xml index 90bcc26919..99b55a8139 100644 --- a/app/src/main/res/layout/fragment_non_editable_editor.xml +++ b/app/src/main/res/layout/fragment_non_editable_editor.xml @@ -1,25 +1,39 @@ - - - - - + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_log_filter_bar.xml b/app/src/main/res/layout/layout_log_filter_bar.xml new file mode 100644 index 0000000000..a1bfcbed67 --- /dev/null +++ b/app/src/main/res/layout/layout_log_filter_bar.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt index 80a2da92c5..69ff759d21 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt @@ -49,9 +49,20 @@ import java.util.regex.Pattern * @author Akash Yadav */ @SuppressLint("ViewConstructor") // Always created dynamically -class EditorSearchLayout(context: Context, val editor: IDEEditor) : FrameLayout(context) { +class EditorSearchLayout( + context: Context, + val editor: IDEEditor, + showReplaceAction: Boolean = true, + applyCollapsedSheetMargin: Boolean = true, +) : FrameLayout(context) { + // Hosts above the collapsed bottom sheet need a margin so the action row stays visible; + // hosts inside the sheet itself don't. private val collapsedSheetMargin = - context.resources.getDimensionPixelSize(R.dimen.editor_sheet_peek_height) + if (applyCollapsedSheetMargin) { + context.resources.getDimensionPixelSize(R.dimen.editor_sheet_peek_height) + } else { + 0 + } var onSearchModeChanged: ((isActive: Boolean) -> Unit)? = null @@ -65,6 +76,10 @@ class EditorSearchLayout(context: Context, val editor: IDEEditor) : FrameLayout( findInFileBinding.next.setOnClickListener(::onSearchActionClick) findInFileBinding.replace.setOnClickListener(::onSearchActionClick) findInFileBinding.close.setOnClickListener(::onSearchActionClick) + if (!showReplaceAction) { + // Read-only hosts (log/output views) cannot replace text + findInFileBinding.replace.visibility = GONE + } findInFileBinding.root.applyLongPressRecursively { TooltipManager.showIdeCategoryTooltip( context = this.context, @@ -226,6 +241,30 @@ class EditorSearchLayout(context: Context, val editor: IDEEditor) : FrameLayout( } } + fun isSearchModeActive(): Boolean = searchInputTextWatcher != null + + /** + * Re-run the current query against the editor's (possibly changed) content, + * or stop the search if there is no query. + */ + fun refreshSearch() { + if (!isSearchModeActive()) { + return + } + + val query = findInFileBinding.searchInput.text?.toString() ?: "" + val isValidQuery = + query.isNotBlank() && + (searchOptions.type != SearchOptions.TYPE_REGULAR_EXPRESSION || + runCatching { Pattern.compile(query) }.isSuccess) + + if (isValidQuery) { + editor.searcher.search(query, searchOptions) + } else { + editor.searcher.stopSearch() + } + } + private fun onSearchActionClick(v: View) { val searcher = editor.searcher if (v.id == findInFileBinding.close.id) { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 32a90dfd87..104748b98e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -678,6 +678,15 @@ LogSender is disabled. You can enable it in preferences. Logs from the IDE are shown here. Open a file to show its diagnostic results + Filter lines + Hide filter bar + Verbose + Debug + Info + Warning + Error + Search in output + Filter output "Build the application or run a task to see its build output here. " Failed to perform code action Enable LogSender From 9864e44ab3951461a6d11d82b10300fb31556cfb Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 13:53:08 +0100 Subject: [PATCH 02/14] ADFA-936: Retain log history with level metadata for retroactive filtering LogLine level/tag metadata used to be discarded (and the pooled instance recycled) when a line was flattened to a string in LogViewModel.submit(), which made level filtering impossible and relied on SharedFlow replay to restore logs on re-collection. - New LogFilter (enabled-levels set + case-insensitive text match) and LogBuffer (bounded, thread-safe entry history with sequence numbers, trimmed like the editor: 5000 -> 4700). - LogViewModel now emits snapshot-then-tail UiEvents: on every (re)collection or filter change, a SetText snapshot of the filtered history, then Appends for matching live lines. The snapshot is taken in the live stream's onSubscription and stitched by sequence number, so no line is missed or duplicated. This replaces replay-as-history and also fixes cleared logs resurrecting after rotation. - GlobalBufferAppender.Consumer now receives the logback Level (the appender already stored it); IDELogFragment maps it to ILogger.Level so IDE log lines carry level metadata. - LogViewFragment handles SetText, clears the ViewModel history in clearOutput(), and shares the unfiltered history instead of the (possibly filtered) editor text. --- .../fragments/output/IDELogFragment.kt | 15 ++- .../fragments/output/LogViewFragment.kt | 25 +++- .../com/itsaky/androidide/logs/LogBuffer.kt | 105 ++++++++++++++++ .../com/itsaky/androidide/models/LogFilter.kt | 51 ++++++++ .../androidide/viewmodel/LogViewModel.kt | 113 ++++++++++++++---- .../logging/GlobalBufferAppender.kt | 4 +- 6 files changed, 280 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt create mode 100644 app/src/main/java/com/itsaky/androidide/models/LogFilter.kt diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt index 4975928514..c710a7ad6c 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt @@ -25,6 +25,7 @@ import com.itsaky.androidide.R import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.logging.GlobalBufferAppender import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.ILogger import com.itsaky.androidide.viewmodel.IDELogsViewModel /** @@ -56,10 +57,22 @@ class IDELogFragment : GlobalBufferAppender.registerConsumer(this) } - override fun consume(message: String) = appendLine(message) + override fun consume( + level: Level, + message: String, + ) = viewModel.submit(level.toILoggerLevel(), message) override fun onDestroyView() { GlobalBufferAppender.unregisterConsumer(this) super.onDestroyView() } } + +private fun Level.toILoggerLevel(): ILogger.Level = + when { + levelInt <= Level.TRACE_INT -> ILogger.Level.VERBOSE + levelInt <= Level.DEBUG_INT -> ILogger.Level.DEBUG + levelInt <= Level.INFO_INT -> ILogger.Level.INFO + levelInt <= Level.WARN_INT -> ILogger.Level.WARNING + else -> ILogger.Level.ERROR + } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index e8fd937d24..3ac98e5e28 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -80,15 +80,13 @@ abstract class LogViewFragment : } override fun getShareableContent(): String { - val editorText = - this._binding - ?.editor - ?.text - ?.toString() ?: "" - return "${BasicBuildInfo.shareableBuildInfo()}${System.lineSeparator()}$editorText" + // Share the full retained history, not the (possibly filtered) editor text + val logText = viewModel.snapshotUnfiltered() + return "${BasicBuildInfo.shareableBuildInfo()}${System.lineSeparator()}$logText" } override fun clearOutput() { + viewModel.clear() _binding?.editor?.setText("")?.also { emptyStateViewModel.setEmpty(true) } @@ -121,6 +119,10 @@ abstract class LogViewFragment : viewModel.uiEvents.collect { event -> when (event) { + is LogViewModel.UiEvent.SetText -> { + setText(event.text) + } + is LogViewModel.UiEvent.Append -> { append(event.text) trimLinesAtStart() @@ -129,6 +131,9 @@ abstract class LogViewFragment : } } + /** Called after the editor content has been replaced wholesale (e.g. on a filter change). */ + protected open fun onContentReplaced() {} + private fun setupEditor() { val editor = this.binding.editor editor.props.autoIndent = false @@ -162,6 +167,14 @@ abstract class LogViewFragment : } } + @UiThread + private fun setText(text: String) { + val editor = _binding?.editor ?: return + editor.setText(text) + emptyStateViewModel.setEmpty(text.isBlank()) + onContentReplaced() + } + @UiThread private fun append(chars: CharSequence?) { if (chars == null) { diff --git a/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt new file mode 100644 index 0000000000..15312d4630 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt @@ -0,0 +1,105 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.logs + +import com.itsaky.androidide.models.LogFilter +import com.itsaky.androidide.utils.ILogger + +/** + * A bounded, thread-safe history of log entries. Retains the level metadata that is + * lost once lines are flattened into the editor, so the view can be re-rendered when + * the user changes the active [LogFilter]. + * + * @param trimOnEntryCount Trim the buffer once it grows past this many entries. + * @param maxEntryCount The number of most-recent entries kept after a trim. + */ +class LogBuffer( + private val trimOnEntryCount: Int, + private val maxEntryCount: Int, +) { + /** + * A single submitted log line. + * + * @property seq Monotonically increasing sequence number, used to stitch a + * buffer snapshot together with the live entry stream without gaps or duplicates. + * @property text The rendered line, always terminated with a newline. + */ + data class Entry( + val seq: Long, + val level: ILogger.Level?, + val text: String, + ) + + init { + require(maxEntryCount in 1..trimOnEntryCount) { + "maxEntryCount must be in 1..trimOnEntryCount" + } + } + + private val entries = ArrayDeque() + private var nextSeq = 1L + + @Synchronized + fun append( + level: ILogger.Level?, + text: String, + ): Entry { + val entry = Entry(nextSeq++, level, text) + entries.addLast(entry) + if (entries.size > trimOnEntryCount) { + repeat(entries.size - maxEntryCount) { + entries.removeFirst() + } + } + return entry + } + + /** + * Render all entries matching [filter] into a single string. + * + * @return The rendered text and the sequence number of the newest entry in the + * buffer at snapshot time (0 if the buffer is empty), regardless of whether + * that entry matched the filter. + */ + @Synchronized + fun snapshotFiltered(filter: LogFilter): Pair { + val lastSeq = entries.lastOrNull()?.seq ?: 0L + val text = + buildString { + for (entry in entries) { + if (filter.matches(entry.level, entry.text)) { + append(entry.text) + } + } + } + return text to lastSeq + } + + @Synchronized + fun snapshotAll(): String = + buildString { + for (entry in entries) { + append(entry.text) + } + } + + @Synchronized + fun clear() { + entries.clear() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/models/LogFilter.kt b/app/src/main/java/com/itsaky/androidide/models/LogFilter.kt new file mode 100644 index 0000000000..a104e16f16 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/models/LogFilter.kt @@ -0,0 +1,51 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.models + +import com.itsaky.androidide.utils.ILogger +import java.util.EnumSet + +/** + * A filter for log lines shown in the output views. + * + * @property enabledLevels Log levels to show. Lines without a known level always pass. + * @property text Case-insensitive substring that a line must contain to be shown. + */ +data class LogFilter( + val enabledLevels: Set = ALL_LEVELS, + val text: String = "", +) { + companion object { + val ALL_LEVELS: Set = EnumSet.allOf(ILogger.Level::class.java) + + val NONE = LogFilter() + } + + val isActive: Boolean + get() = text.isNotEmpty() || enabledLevels.size < ALL_LEVELS.size + + fun matches( + level: ILogger.Level?, + line: String, + ): Boolean { + if (level != null && level !in enabledLevels) { + return false + } + return text.isEmpty() || line.contains(text, ignoreCase = true) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt index 53e7466101..1db2cf7b36 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt @@ -1,23 +1,31 @@ package com.itsaky.androidide.viewmodel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.logs.LogBuffer +import com.itsaky.androidide.models.LogFilter import com.itsaky.androidide.models.LogLine +import com.itsaky.androidide.utils.ILogger import com.itsaky.androidide.viewmodel.LogViewModel.Companion.LOG_FREQUENCY import com.itsaky.androidide.viewmodel.LogViewModel.Companion.MAX_CHUNK_SIZE import com.itsaky.androidide.viewmodel.LogViewModel.Companion.MAX_LINE_COUNT import com.itsaky.androidide.viewmodel.LogViewModel.Companion.TRIM_ON_LINE_COUNT +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -54,35 +62,62 @@ abstract class LogViewModel : ViewModel() { const val MAX_LINE_COUNT = TRIM_ON_LINE_COUNT - 300 /** - * The number of log events that are replayed to consumers. + * The number of live log events that may be buffered for a slow collector. */ - const val EVENT_REPLAY_COUNT = TRIM_ON_LINE_COUNT + const val EVENT_BUFFER_COUNT = TRIM_ON_LINE_COUNT } sealed interface UiEvent { + /** Replace the entire log view content. */ + data class SetText( + val text: String, + ) : UiEvent + data class Append( val text: String, ) : UiEvent } - private val logs = - MutableSharedFlow( + private val buffer = LogBuffer(TRIM_ON_LINE_COUNT, MAX_LINE_COUNT) + + private val liveEntries = + MutableSharedFlow( replay = 0, - extraBufferCapacity = EVENT_REPLAY_COUNT, + extraBufferCapacity = EVENT_BUFFER_COUNT, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - @OptIn(FlowPreview::class) - val uiEvents: SharedFlow = - logs - .map { if (it.endsWith("\n")) it else "$it\n" } - .chunkedBySizeOrTime(MAX_CHUNK_SIZE, LOG_FREQUENCY) - .map { UiEvent.Append(it) } - .shareIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - replay = EVENT_REPLAY_COUNT, - ) + private val _filter = MutableStateFlow(LogFilter.NONE) + val filter: StateFlow = _filter.asStateFlow() + + // Bumped when the buffer is cleared, to force a re-render without a filter change + private val generation = MutableStateFlow(0) + + /** + * The log view content as UI events: on every (re)collection or filter change, a + * [UiEvent.SetText] snapshot of the retained history filtered by the current [filter], + * followed by [UiEvent.Append]s for matching live lines. The snapshot is taken after + * subscribing to the live stream and stitched by sequence number, so no line is + * missed or duplicated in between. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val uiEvents: Flow = + combine(_filter, generation) { filter, _ -> filter } + .flatMapLatest { filter -> + channelFlow { + var snapshotSeq = 0L + liveEntries + .onSubscription { + val (text, lastSeq) = buffer.snapshotFiltered(filter) + snapshotSeq = lastSeq + send(UiEvent.SetText(text)) + }.filter { entry -> + entry.seq > snapshotSeq && filter.matches(entry.level, entry.text) + }.map { entry -> entry.text } + .chunkedBySizeOrTime(MAX_CHUNK_SIZE, LOG_FREQUENCY) + .collect { chunk -> send(UiEvent.Append(chunk)) } + } + }.flowOn(Dispatchers.Default) /** * Submit a log line. @@ -94,6 +129,8 @@ abstract class LogViewModel : ViewModel() { line: LogLine, simpleFormattingEnabled: Boolean = false, ) { + // Copy what we need before recycling -- LogLine instances are pooled + val level = line.level val lineString = if (simpleFormattingEnabled) { line.toSimpleString() @@ -102,17 +139,45 @@ abstract class LogViewModel : ViewModel() { } line.recycle() - submit(lineString) + submit(level, lineString) } /** - * Submit a log line. + * Submit a log line without level metadata. * * @param line The log line to submit. */ fun submit(line: String) { - logs.tryEmit(line) + submit(level = null, line = line) + } + + /** + * Submit a log line. + * + * @param level The severity of the line, or `null` if unknown. + * @param line The log line to submit. + */ + fun submit( + level: ILogger.Level?, + line: String, + ) { + val text = if (line.endsWith("\n")) line else "$line\n" + val entry = buffer.append(level, text) + liveEntries.tryEmit(entry) + } + + fun setFilter(filter: LogFilter) { + _filter.value = filter } + + /** Clear the retained log history and re-render the (now empty) view. */ + fun clear() { + buffer.clear() + generation.update { it + 1 } + } + + /** The full retained history, ignoring the active filter. */ + fun snapshotUnfiltered(): String = buffer.snapshotAll() } /** diff --git a/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt index d672af33b5..90c9653032 100644 --- a/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt +++ b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt @@ -36,7 +36,7 @@ class GlobalBufferAppender : AppenderBase() { interface Consumer { val logLevel: Level - fun consume(message: String) + fun consume(level: Level, message: String) } private data class LogEvent(val level: Level, val message: String) @@ -83,7 +83,7 @@ class GlobalBufferAppender : AppenderBase() { message: String ) { if (level.levelInt < consumer.logLevel.levelInt) return - runCatching { consumer.consume(message) } + runCatching { consumer.consume(level, message) } } } From a9db4468cb91c49602fda1a26659a5d42723b61b Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 13:53:09 +0100 Subject: [PATCH 03/14] ADFA-936: Add text filtering to the Build Output view Build output has no level metadata, so it gets the text filter only. The filter applies to the editor view; the session file always receives the unfiltered text, so clearing the filter (or sharing) restores everything. - BuildOutputViewModel: filterText state + pure filterLines() helper. - BuildOutputFragment: re-renders the 512KB editor window from the session file when the filter changes, applies the filter to live batches in flushToEditor() and to the restore path. A mutex serializes re-renders against batch appends so neither misses nor duplicates concurrently flushed output. --- .../fragments/output/BuildOutputFragment.kt | 77 +++++++++++++++---- .../viewmodel/BuildOutputViewModel.kt | 23 ++++++ 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index ef4b5ef38f..0b9754c09f 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -28,7 +28,11 @@ import com.itsaky.androidide.viewmodel.BuildOutputViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -44,6 +48,10 @@ class BuildOutputFragment : NonEditableEditorFragment() { private val logChannel = Channel(Channel.UNLIMITED) + // Serializes editor-content mutations (filtered re-renders vs live batch appends) + // so a re-render never misses or duplicates a concurrently flushed batch. + private val editorContentMutex = Mutex() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT @@ -56,11 +64,36 @@ class BuildOutputFragment : NonEditableEditorFragment() { val content = buildOutputViewModel.getFullContent() buildOutputViewModel.setCachedSnapshot(content) } + launch { + buildOutputViewModel.filterText.drop(1).collectLatest { query -> + renderFiltered(query) + } + } + } + } + + /** Re-renders the editor window from the session file, filtered by [query]. */ + private suspend fun renderFiltered(query: String) { + editorContentMutex.withLock { + val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + val filtered = + withContext(Dispatchers.Default) { + BuildOutputViewModel.filterLines(window, query) + } + withContext(Dispatchers.Main) { + editor?.setText(filtered) + emptyStateViewModel.setEmpty(filtered.isBlank()) + onContentReplaced() + } } } + /** Called after the editor content has been replaced wholesale (e.g. on a filter change). */ + private fun onContentReplaced() {} + private suspend fun restoreWindowFromViewModel() { - val content = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } + val content = BuildOutputViewModel.filterLines(window, buildOutputViewModel.filterText.value) if (content.isEmpty()) return withContext(Dispatchers.Main) { val editor = this@BuildOutputFragment.editor ?: return@withContext @@ -166,22 +199,32 @@ class BuildOutputFragment : NonEditableEditorFragment() { * before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`. */ private suspend fun flushToEditor(text: String) { - buildOutputViewModel.append(text) - withContext(Dispatchers.Main) { - editor?.run { - val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { - awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - } - if (layoutCompleted != null) { - appendBatch(text) - emptyStateViewModel.setEmpty(false) - } else { - // Timeout: defer append until layout is ready (same as restoreWindowFromViewModel) - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { - editor?.run { - awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - appendBatch(text) - emptyStateViewModel.setEmpty(false) + editorContentMutex.withLock { + buildOutputViewModel.append(text) + + // The session file always gets the full text; the editor only shows matching lines + val visibleText = + BuildOutputViewModel.filterLines(text, buildOutputViewModel.filterText.value) + if (visibleText.isEmpty()) { + return + } + + withContext(Dispatchers.Main) { + editor?.run { + val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) + } + if (layoutCompleted != null) { + appendBatch(visibleText) + emptyStateViewModel.setEmpty(false) + } else { + // Timeout: defer append until layout is ready (same as restoreWindowFromViewModel) + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + editor?.run { + awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) + appendBatch(visibleText) + emptyStateViewModel.setEmpty(false) + } } } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt index 96508977b7..e350bd5d22 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -26,6 +26,7 @@ import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock import kotlin.math.max import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext /** @@ -40,6 +41,12 @@ class BuildOutputViewModel(application: Application) : AndroidViewModel(applicat private val lock = ReentrantLock() + /** + * Case-insensitive line filter applied to the *editor view* of the build output. + * The session file always receives the unfiltered text. + */ + val filterText = MutableStateFlow("") + /** * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. @@ -163,6 +170,22 @@ class BuildOutputViewModel(application: Application) : AndroidViewModel(applicat } companion object { + + /** + * Returns only the lines of [content] containing [query] (case-insensitive), each terminated + * with a newline. Returns [content] unchanged when [query] is empty. + */ + fun filterLines(content: String, query: String): String { + if (query.isEmpty() || content.isEmpty()) return content + return buildString { + for (line in content.lineSequence()) { + if (line.contains(query, ignoreCase = true)) { + append(line).append('\n') + } + } + } + } + private const val SESSION_FILE_NAME = "build_output_session.txt" private const val WINDOW_SIZE_CHARS = 512 * 1024 /** Max length of [cachedContentSnapshot] to bound memory. */ From 4bff0e821fbd1201715b200d22cdd3e8b1be8d38 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 13:57:02 +0100 Subject: [PATCH 04/14] ADFA-936: Wire search and filter UI into the output tabs - New SearchableOutputFragment interface (beginSearch/toggleFilterBar), implemented by the log fragments and BuildOutputFragment. - Each output tab attaches the existing EditorSearchLayout below its editor (replace hidden, no collapsed-sheet margin) and lazily inflates the filter bar from the ViewStub. LogFilterBarController wires the bar: level chips apply immediately, text input debounced 250ms; level chips are hidden for Build Output. - Active searches refresh (or stop) after a filter re-render replaces the editor content. - Bottom sheet: new mini search/filter FABs shown on Searchable tabs; both expand the sheet before acting since the UI lives inside it. Tooltip tags OUTPUT_SEARCH/OUTPUT_FILTER added. --- .../fragments/output/BuildOutputFragment.kt | 65 ++++++++++++- .../output/LogFilterBarController.kt | 96 +++++++++++++++++++ .../fragments/output/LogViewFragment.kt | 63 +++++++++++- .../output/SearchableOutputFragment.kt | 30 ++++++ .../itsaky/androidide/ui/EditorBottomSheet.kt | 36 +++++++ .../res/layout/layout_editor_bottom_sheet.xml | 29 ++++++ .../androidide/idetooltips/TooltipTag.kt | 2 + 7 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt create mode 100644 app/src/main/java/com/itsaky/androidide/fragments/output/SearchableOutputFragment.kt diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 0b9754c09f..1f11ce5d03 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -18,11 +18,15 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View +import android.widget.LinearLayout import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding +import com.itsaky.androidide.editor.ui.EditorSearchLayout import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.models.LogFilter import com.itsaky.androidide.utils.BasicBuildInfo import com.itsaky.androidide.viewmodel.BuildOutputViewModel import kotlinx.coroutines.Dispatchers @@ -36,7 +40,9 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -class BuildOutputFragment : NonEditableEditorFragment() { +class BuildOutputFragment : + NonEditableEditorFragment(), + SearchableOutputFragment { private val buildOutputViewModel: BuildOutputViewModel by activityViewModels() @@ -48,6 +54,9 @@ class BuildOutputFragment : NonEditableEditorFragment() { private val logChannel = Channel(Channel.UNLIMITED) + private var searchLayout: EditorSearchLayout? = null + private var filterBar: LogFilterBarController? = null + // Serializes editor-content mutations (filtered re-renders vs live batch appends) // so a re-render never misses or duplicates a concurrently flushed batch. private val editorContentMutex = Mutex() @@ -56,6 +65,7 @@ class BuildOutputFragment : NonEditableEditorFragment() { super.onViewCreated(view, savedInstanceState) editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput)) + setupSearchLayout() viewLifecycleOwner.lifecycleScope.launch { restoreWindowFromViewModel() @@ -89,7 +99,56 @@ class BuildOutputFragment : NonEditableEditorFragment() { } /** Called after the editor content has been replaced wholesale (e.g. on a filter change). */ - private fun onContentReplaced() {} + private fun onContentReplaced() { + val searchLayout = this.searchLayout ?: return + if (searchLayout.isSearchModeActive()) { + searchLayout.refreshSearch() + } else { + editor?.searcher?.stopSearch() + } + } + + override fun beginSearch() { + searchLayout?.beginSearchMode() + } + + override fun toggleFilterBar() { + (filterBar ?: createFilterBar())?.toggle() + } + + private fun setupSearchLayout() { + val editor = this.editor ?: return + val root = _binding?.root ?: return + val searchLayout = + EditorSearchLayout( + context = requireContext(), + editor = editor, + showReplaceAction = false, + applyCollapsedSheetMargin = false, + ) + root.addView( + searchLayout, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + this.searchLayout = searchLayout + } + + private fun createFilterBar(): LogFilterBarController? { + val stub = _binding?.filterBarStub ?: return null + val barBinding = LayoutLogFilterBarBinding.bind(stub.inflate()) + return LogFilterBarController( + binding = barBinding, + coroutineScope = viewLifecycleOwner.lifecycleScope, + showLevelChips = false, + initialText = buildOutputViewModel.filterText.value, + initialLevels = LogFilter.ALL_LEVELS, + ) { _, text -> + buildOutputViewModel.filterText.value = text.trim() + }.also { filterBar = it } + } private suspend fun restoreWindowFromViewModel() { val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } @@ -119,6 +178,8 @@ class BuildOutputFragment : NonEditableEditorFragment() { } override fun onDestroyView() { + searchLayout = null + filterBar = null editor?.release() super.onDestroyView() } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt new file mode 100644 index 0000000000..dc3bf2ad0c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt @@ -0,0 +1,96 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.output + +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding +import com.itsaky.androidide.utils.ILogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.EnumSet + +/** + * Wires a [layout_log_filter_bar][LayoutLogFilterBarBinding] to a filter-change callback: + * level chips apply immediately, text input is debounced. + * + * @param showLevelChips Whether the level chip row is shown (outputs without level + * metadata, like the build output, only get the text filter). + * @param onFilterChanged Called with the enabled levels and the (untrimmed) filter text. + */ +class LogFilterBarController( + private val binding: LayoutLogFilterBarBinding, + coroutineScope: CoroutineScope, + showLevelChips: Boolean, + initialText: String, + initialLevels: Set, + private val onFilterChanged: (levels: Set, text: String) -> Unit, +) { + companion object { + private const val FILTER_TEXT_DEBOUNCE_MS = 250L + } + + private val chipsByLevel = + mapOf( + ILogger.Level.VERBOSE to binding.chipLevelVerbose, + ILogger.Level.DEBUG to binding.chipLevelDebug, + ILogger.Level.INFO to binding.chipLevelInfo, + ILogger.Level.WARNING to binding.chipLevelWarning, + ILogger.Level.ERROR to binding.chipLevelError, + ) + + private var textDebounceJob: Job? = null + + init { + binding.levelChipsScroll.isVisible = showLevelChips + binding.filterInput.setText(initialText) + chipsByLevel.forEach { (level, chip) -> + chip.isChecked = level in initialLevels + chip.setOnCheckedChangeListener { _, _ -> notifyFilterChanged() } + } + binding.filterInput.doAfterTextChanged { + textDebounceJob?.cancel() + textDebounceJob = + coroutineScope.launch { + delay(FILTER_TEXT_DEBOUNCE_MS) + notifyFilterChanged() + } + } + binding.closeFilterBar.setOnClickListener { hide() } + } + + fun toggle() { + binding.root.isVisible = !binding.root.isVisible + } + + fun hide() { + binding.root.isVisible = false + } + + private fun notifyFilterChanged() { + val levels = EnumSet.noneOf(ILogger.Level::class.java) + chipsByLevel.forEach { (level, chip) -> + if (chip.isChecked) { + levels.add(level) + } + } + onFilterChanged(levels, binding.filterInput.text?.toString().orEmpty()) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index 3ac98e5e28..ce879ca6f3 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -19,17 +19,21 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View +import android.widget.LinearLayout import androidx.annotation.UiThread import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle import com.itsaky.androidide.R import com.itsaky.androidide.databinding.FragmentLogBinding +import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding import com.itsaky.androidide.editor.language.treesitter.LogLanguage import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguageProvider import com.itsaky.androidide.editor.schemes.IDEColorScheme import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider +import com.itsaky.androidide.editor.ui.EditorSearchLayout import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.fragments.EmptyStateFragment +import com.itsaky.androidide.models.LogFilter import com.itsaky.androidide.models.LogLine import com.itsaky.androidide.utils.BasicBuildInfo import com.itsaky.androidide.utils.isTestMode @@ -47,7 +51,8 @@ import org.slf4j.LoggerFactory */ abstract class LogViewFragment : EmptyStateFragment(R.layout.fragment_log, FragmentLogBinding::bind), - ShareableOutputFragment { + ShareableOutputFragment, + SearchableOutputFragment { companion object { private val log = LoggerFactory.getLogger(LogViewFragment::class.java) } @@ -58,6 +63,9 @@ abstract class LogViewFragment : abstract val viewModel: V + private var searchLayout: EditorSearchLayout? = null + private var filterBar: LogFilterBarController? = null + /** * Append a log line to the log view. * @@ -75,10 +83,35 @@ abstract class LogViewFragment : abstract fun isSimpleFormattingEnabled(): Boolean override fun onDestroyView() { + searchLayout = null + filterBar = null _binding?.editor?.release() super.onDestroyView() } + override fun beginSearch() { + searchLayout?.beginSearchMode() + } + + override fun toggleFilterBar() { + (filterBar ?: createFilterBar())?.toggle() + } + + private fun createFilterBar(): LogFilterBarController? { + val stub = _binding?.filterBarStub ?: return null + val barBinding = LayoutLogFilterBarBinding.bind(stub.inflate()) + val currentFilter = viewModel.filter.value + return LogFilterBarController( + binding = barBinding, + coroutineScope = viewLifecycleScope, + showLevelChips = true, + initialText = currentFilter.text, + initialLevels = currentFilter.enabledLevels, + ) { levels, text -> + viewModel.setFilter(LogFilter(levels, text.trim())) + }.also { filterBar = it } + } + override fun getShareableContent(): String { // Share the full retained history, not the (possibly filtered) editor text val logText = viewModel.snapshotUnfiltered() @@ -99,6 +132,7 @@ abstract class LogViewFragment : super.onViewCreated(view, savedInstanceState) setupEditor() + setupSearchLayout() viewLifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -132,7 +166,32 @@ abstract class LogViewFragment : } /** Called after the editor content has been replaced wholesale (e.g. on a filter change). */ - protected open fun onContentReplaced() {} + private fun onContentReplaced() { + val searchLayout = this.searchLayout ?: return + if (searchLayout.isSearchModeActive()) { + searchLayout.refreshSearch() + } else { + _binding?.editor?.searcher?.stopSearch() + } + } + + private fun setupSearchLayout() { + val searchLayout = + EditorSearchLayout( + context = requireContext(), + editor = binding.editor, + showReplaceAction = false, + applyCollapsedSheetMargin = false, + ) + binding.root.addView( + searchLayout, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + this.searchLayout = searchLayout + } private fun setupEditor() { val editor = this.binding.editor diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/SearchableOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/SearchableOutputFragment.kt new file mode 100644 index 0000000000..a659d08d11 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/SearchableOutputFragment.kt @@ -0,0 +1,30 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.output + +/** + * An output fragment whose content can be searched and filtered from the + * editor bottom sheet's action buttons. + */ +interface SearchableOutputFragment { + /** Show the search bar and focus its input. */ + fun beginSearch() + + /** Show or hide the filter bar. */ + fun toggleFilterBar() +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 66dae0a76a..9be9031df8 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -54,6 +54,7 @@ import com.itsaky.androidide.adapters.DiagnosticsAdapter import com.itsaky.androidide.adapters.EditorBottomSheetTabAdapter import com.itsaky.androidide.adapters.SearchListAdapter import com.itsaky.androidide.databinding.LayoutEditorBottomSheetBinding +import com.itsaky.androidide.fragments.output.SearchableOutputFragment import com.itsaky.androidide.fragments.output.ShareableOutputFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag @@ -189,6 +190,14 @@ constructor( binding.shareOutputFab.hide() } + if (fragment is SearchableOutputFragment) { + binding.searchOutputFab.show() + binding.filterOutputFab.show() + } else { + binding.searchOutputFab.hide() + binding.filterOutputFab.hide() + } + if (tab.position == EditorBottomSheetTabAdapter.TAB_DIAGNOSTICS) { binding.copyDiagnosticsFab.show() } else { @@ -251,6 +260,29 @@ constructor( copyDiagnosticsToClipboard() } + binding.searchOutputFab.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is SearchableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener + } + // Search happens inside the sheet, so it must be expanded to be usable + viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + fragment.beginSearch() + } + binding.searchOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) + + binding.filterOutputFab.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is SearchableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener + } + viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + fragment.toggleFilterBar() + } + binding.filterOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) + binding.headerContainer.setOnClickListener { viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) } @@ -274,6 +306,10 @@ constructor( binding.shareOutputFab.setOnLongClickListener(null) binding.clearFab.setOnClickListener(null) binding.clearFab.setOnLongClickListener(null) + binding.searchOutputFab.setOnClickListener(null) + binding.searchOutputFab.setOnLongClickListener(null) + binding.filterOutputFab.setOnClickListener(null) + binding.filterOutputFab.setOnLongClickListener(null) binding.copyDiagnosticsFab.setOnClickListener(null) binding.headerContainer.setOnClickListener(null) ViewCompat.setOnApplyWindowInsetsListener(this, null) diff --git a/app/src/main/res/layout/layout_editor_bottom_sheet.xml b/app/src/main/res/layout/layout_editor_bottom_sheet.xml index 58ddca1bfc..2739f81d25 100644 --- a/app/src/main/res/layout/layout_editor_bottom_sheet.xml +++ b/app/src/main/res/layout/layout_editor_bottom_sheet.xml @@ -19,6 +19,7 @@ + + + + Date: Wed, 15 Jul 2026 14:00:08 +0100 Subject: [PATCH 05/14] ADFA-936: Unit-test the log filter, buffer, and snapshot-then-tail pipeline - LogFilterTest: level sets, null-level pass-through, case-insensitive text match, combined filters. - LogBufferTest: seq monotonicity, filtered snapshots with lastSeq, trim behavior, clear. - LogViewModelTest: snapshot on collection, gap/duplicate-free live tail, filter re-render, ingestion filtering, clear, re-collection idempotence, LogLine capture-before-recycle. - BuildOutputFilterTest: pure filterLines() cases including a batch without a trailing newline. --- .../itsaky/androidide/logs/LogBufferTest.kt | 97 +++++++++ .../itsaky/androidide/models/LogFilterTest.kt | 74 +++++++ .../viewmodel/BuildOutputFilterTest.kt | 64 ++++++ .../androidide/viewmodel/LogViewModelTest.kt | 199 ++++++++++++++++++ 4 files changed, 434 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt diff --git a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt new file mode 100644 index 0000000000..53fcec729f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt @@ -0,0 +1,97 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.logs + +import com.itsaky.androidide.models.LogFilter +import com.itsaky.androidide.utils.ILogger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogBufferTest { + + @Test + fun `sequence numbers increase monotonically`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + val first = buffer.append(null, "a\n") + val second = buffer.append(null, "b\n") + assertTrue(second.seq > first.seq) + } + + @Test + fun `snapshot returns all entries and the last sequence`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + buffer.append(ILogger.Level.DEBUG, "debug\n") + val last = buffer.append(ILogger.Level.ERROR, "error\n") + + val (text, lastSeq) = buffer.snapshotFiltered(LogFilter.NONE) + assertEquals("debug\nerror\n", text) + assertEquals(last.seq, lastSeq) + } + + @Test + fun `snapshot applies the filter but reports the newest seq regardless`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + buffer.append(ILogger.Level.ERROR, "error\n") + val last = buffer.append(ILogger.Level.DEBUG, "debug\n") + + val (text, lastSeq) = + buffer.snapshotFiltered(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + assertEquals("error\n", text) + assertEquals(last.seq, lastSeq) + } + + @Test + fun `empty buffer snapshots to empty text and seq 0`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + val (text, lastSeq) = buffer.snapshotFiltered(LogFilter.NONE) + assertEquals("", text) + assertEquals(0L, lastSeq) + } + + @Test + fun `buffer trims to maxEntryCount once trimOnEntryCount is exceeded`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + repeat(11) { buffer.append(null, "line$it\n") } + + val (text, _) = buffer.snapshotFiltered(LogFilter.NONE) + val lines = text.trim().lines() + assertEquals(5, lines.size) + assertEquals("line6", lines.first()) + assertEquals("line10", lines.last()) + } + + @Test + fun `clear empties the buffer but keeps sequence increasing`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + val beforeClear = buffer.append(null, "a\n") + buffer.clear() + + assertEquals("", buffer.snapshotAll()) + val afterClear = buffer.append(null, "b\n") + assertTrue(afterClear.seq > beforeClear.seq) + } + + @Test + fun `snapshotAll ignores filters`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + buffer.append(ILogger.Level.DEBUG, "debug\n") + buffer.append(ILogger.Level.ERROR, "error\n") + assertEquals("debug\nerror\n", buffer.snapshotAll()) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt b/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt new file mode 100644 index 0000000000..bcfbf21a83 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt @@ -0,0 +1,74 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.models + +import com.itsaky.androidide.utils.ILogger +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogFilterTest { + + @Test + fun `default filter matches everything and is inactive`() { + val filter = LogFilter.NONE + assertFalse(filter.isActive) + assertTrue(filter.matches(ILogger.Level.VERBOSE, "anything")) + assertTrue(filter.matches(null, "anything")) + } + + @Test + fun `level filter hides disabled levels`() { + val filter = LogFilter(enabledLevels = setOf(ILogger.Level.ERROR)) + assertTrue(filter.isActive) + assertTrue(filter.matches(ILogger.Level.ERROR, "boom")) + assertFalse(filter.matches(ILogger.Level.DEBUG, "noise")) + assertFalse(filter.matches(ILogger.Level.WARNING, "warn")) + } + + @Test + fun `lines without level always pass the level check`() { + val filter = LogFilter(enabledLevels = setOf(ILogger.Level.ERROR)) + assertTrue(filter.matches(null, "gradle output line")) + } + + @Test + fun `text filter is case-insensitive contains`() { + val filter = LogFilter(text = "NullPointer") + assertTrue(filter.isActive) + assertTrue(filter.matches(null, "java.lang.nullpointerexception at ...")) + assertTrue(filter.matches(ILogger.Level.ERROR, "NULLPOINTER")) + assertFalse(filter.matches(null, "IllegalStateException")) + } + + @Test + fun `level and text filters combine`() { + val filter = LogFilter(enabledLevels = setOf(ILogger.Level.ERROR), text = "boom") + assertTrue(filter.matches(ILogger.Level.ERROR, "boom happened")) + assertFalse(filter.matches(ILogger.Level.ERROR, "other error")) + assertFalse(filter.matches(ILogger.Level.DEBUG, "boom happened")) + } + + @Test + fun `empty enabled levels hides all leveled lines but keeps unleveled ones`() { + val filter = LogFilter(enabledLevels = emptySet()) + assertTrue(filter.isActive) + assertFalse(filter.matches(ILogger.Level.INFO, "info")) + assertTrue(filter.matches(null, "plain")) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt new file mode 100644 index 0000000000..bb24892bcb --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt @@ -0,0 +1,64 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.viewmodel + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class BuildOutputFilterTest { + + @Test + fun `empty query returns content unchanged`() { + val content = "> Task :app:compileDebugKotlin\nBUILD SUCCESSFUL\n" + assertSame(content, BuildOutputViewModel.filterLines(content, "")) + } + + @Test + fun `only matching lines are kept, case-insensitively`() { + val content = "> Task :app:compileDebugKotlin\nwarning: deprecated API\nBUILD SUCCESSFUL\n" + assertEquals( + "> Task :app:compileDebugKotlin\n", + BuildOutputViewModel.filterLines(content, "task"), + ) + } + + @Test + fun `no matches yields empty text`() { + val content = "BUILD SUCCESSFUL in 1s\n" + assertEquals("", BuildOutputViewModel.filterLines(content, "error")) + } + + @Test + fun `multi-line batch keeps every matching line`() { + val content = "> Task :a\nnoise\n> Task :b\nnoise\n" + assertEquals( + "> Task :a\n> Task :b\n", + BuildOutputViewModel.filterLines(content, "> Task"), + ) + } + + @Test + fun `batch without trailing newline still terminates matched lines`() { + val content = "> Task :a\n> Task :b" + assertEquals( + "> Task :a\n> Task :b\n", + BuildOutputViewModel.filterLines(content, "> Task"), + ) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt new file mode 100644 index 0000000000..2d079eb5fe --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt @@ -0,0 +1,199 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.viewmodel + +import com.itsaky.androidide.models.LogFilter +import com.itsaky.androidide.models.LogLine +import com.itsaky.androidide.utils.ILogger +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for the snapshot-then-tail pipeline in [LogViewModel]. Uses [runBlocking] with real + * dispatchers because [LogViewModel.uiEvents] runs on [kotlinx.coroutines.Dispatchers.Default] + * and chunks output on a real 50ms cadence. + */ +class LogViewModelTest { + + private class TestLogViewModel : LogViewModel() + + companion object { + private const val EVENT_TIMEOUT_MS = 5000L + } + + /** Collects [LogViewModel.uiEvents] into a channel inside [block]'s scope. */ + private fun withCollectedEvents( + viewModel: LogViewModel, + block: suspend (events: Channel) -> Unit, + ) = runBlocking { + coroutineScope { + val events = Channel(Channel.UNLIMITED) + launch { viewModel.uiEvents.collect { events.send(it) } } + try { + withTimeout(EVENT_TIMEOUT_MS) { + block(events) + } + } finally { + coroutineContext.cancelChildren() + } + } + } + + @Test + fun `collection starts with a snapshot of submitted lines`() { + val viewModel = TestLogViewModel() + viewModel.submit(ILogger.Level.ERROR, "first") + viewModel.submit(ILogger.Level.DEBUG, "second") + + withCollectedEvents(viewModel) { events -> + val snapshot = events.receive() + assertTrue(snapshot is LogViewModel.UiEvent.SetText) + assertEquals("first\nsecond\n", (snapshot as LogViewModel.UiEvent.SetText).text) + } + } + + @Test + fun `live lines arrive as appends after the snapshot with no gaps or duplicates`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "old") + + withCollectedEvents(viewModel) { events -> + assertEquals("old\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + + repeat(5) { viewModel.submit(null, "live$it") } + + val appended = StringBuilder() + while (!appended.endsWith("live4\n")) { + val event = events.receive() + assertTrue(event is LogViewModel.UiEvent.Append) + appended.append((event as LogViewModel.UiEvent.Append).text) + } + assertEquals("live0\nlive1\nlive2\nlive3\nlive4\n", appended.toString()) + } + } + + @Test + fun `changing the filter re-renders history with only matching lines`() { + val viewModel = TestLogViewModel() + viewModel.submit(ILogger.Level.ERROR, "error line") + viewModel.submit(ILogger.Level.DEBUG, "debug line") + viewModel.setFilter(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + + withCollectedEvents(viewModel) { events -> + assertEquals("error line\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + } + + @Test + fun `live lines failing the filter are not appended`() { + val viewModel = TestLogViewModel() + viewModel.setFilter(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + + withCollectedEvents(viewModel) { events -> + assertEquals("", (events.receive() as LogViewModel.UiEvent.SetText).text) + + viewModel.submit(ILogger.Level.DEBUG, "hidden") + viewModel.submit(ILogger.Level.ERROR, "visible") + + val append = events.receive() + assertTrue(append is LogViewModel.UiEvent.Append) + assertEquals("visible\n", (append as LogViewModel.UiEvent.Append).text) + } + } + + @Test + fun `filter change while collecting emits a new snapshot`() { + val viewModel = TestLogViewModel() + viewModel.submit(ILogger.Level.ERROR, "error line") + viewModel.submit(ILogger.Level.DEBUG, "debug line") + + withCollectedEvents(viewModel) { events -> + assertEquals( + "error line\ndebug line\n", + (events.receive() as LogViewModel.UiEvent.SetText).text, + ) + + viewModel.setFilter(LogFilter(text = "debug")) + + var event = events.receive() + // Skip any in-flight appends from the previous generation + while (event !is LogViewModel.UiEvent.SetText) { + event = events.receive() + } + assertEquals("debug line\n", event.text) + } + } + + @Test + fun `clear empties history and re-renders`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "stale") + + withCollectedEvents(viewModel) { events -> + assertEquals("stale\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + + viewModel.clear() + + var event = events.receive() + while (event !is LogViewModel.UiEvent.SetText) { + event = events.receive() + } + assertEquals("", event.text) + assertEquals("", viewModel.snapshotUnfiltered()) + } + } + + @Test + fun `re-collection replays history as a snapshot without duplicates`() { + val viewModel = TestLogViewModel() + viewModel.submit(null, "line") + + // First collection (e.g. before a configuration change) + withCollectedEvents(viewModel) { events -> + assertEquals("line\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + + // Second collection sees the same content once, via the snapshot + withCollectedEvents(viewModel) { events -> + assertEquals("line\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + } + + @Test + fun `LogLine level and text are captured before the instance is recycled`() { + val viewModel = TestLogViewModel() + val line = LogLine.obtain(ILogger.Level.ERROR, "MyTag", "kaboom") + val expected = line.toSimpleString() + + viewModel.submit(line, simpleFormattingEnabled = true) + // The pooled instance was recycled inside submit(); the retained entry must not change + viewModel.setFilter(LogFilter(enabledLevels = setOf(ILogger.Level.ERROR))) + + assertEquals("$expected\n", viewModel.snapshotUnfiltered()) + withCollectedEvents(viewModel) { events -> + assertEquals("$expected\n", (events.receive() as LogViewModel.UiEvent.SetText).text) + } + } +} From 7631a393b519fcde87e2803bdf4a7577aedbca20 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 14:07:20 +0100 Subject: [PATCH 06/14] ADFA-936: Apply Spotless formatting Mechanical: gradlew spotlessApply over the files changed on this branch. --- .../fragments/output/BuildOutputFragment.kt | 42 +- .../output/LogFilterBarController.kt | 7 +- .../itsaky/androidide/ui/EditorBottomSheet.kt | 860 +++++++++--------- .../viewmodel/BuildOutputViewModel.kt | 329 +++---- app/src/main/res/layout/fragment_log.xml | 57 +- .../layout/fragment_non_editable_editor.xml | 57 +- .../res/layout/layout_editor_bottom_sheet.xml | 286 +++--- .../main/res/layout/layout_log_filter_bar.xml | 203 ++--- .../itsaky/androidide/logs/LogBufferTest.kt | 1 - .../itsaky/androidide/models/LogFilterTest.kt | 1 - .../viewmodel/BuildOutputFilterTest.kt | 1 - .../androidide/viewmodel/LogViewModelTest.kt | 1 - .../editor/ui/EditorSearchLayout.kt | 575 ++++++------ .../androidide/idetooltips/TooltipTag.kt | 305 ++++--- .../logging/GlobalBufferAppender.kt | 21 +- 15 files changed, 1374 insertions(+), 1372 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 1f11ce5d03..d39c539ea5 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.withTimeoutOrNull class BuildOutputFragment : NonEditableEditorFragment(), SearchableOutputFragment { - private val buildOutputViewModel: BuildOutputViewModel by activityViewModels() companion object { @@ -61,7 +60,10 @@ class BuildOutputFragment : // so a re-render never misses or duplicates a concurrently flushed batch. private val editorContentMutex = Mutex() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { super.onViewCreated(view, savedInstanceState) editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput)) @@ -156,9 +158,10 @@ class BuildOutputFragment : if (content.isEmpty()) return withContext(Dispatchers.Main) { val editor = this@BuildOutputFragment.editor ?: return@withContext - val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { - editor.awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - } + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + editor.awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) + } if (layoutCompleted != null) { editor.appendBatch(content) emptyStateViewModel.setEmpty(false) @@ -212,8 +215,7 @@ class BuildOutputFragment : * Ensures the string ends with a newline character (`\n`). * Useful for maintaining correct formatting when concatenating log lines. */ - private fun String.ensureNewline(): String = - if (endsWith('\n')) this else "$this\n" + private fun String.ensureNewline(): String = if (endsWith('\n')) this else "$this\n" /** * Immediately drains (consumes) all available messages from the channel into the [buffer]. @@ -239,18 +241,19 @@ class BuildOutputFragment : * 2. Wakes up and drains the entire queue (Batching). * 3. Sends the complete block to the UI in a single pass. */ - private suspend fun processLogs() = with(StringBuilder()) { - for (firstLine in logChannel) { - append(firstLine.ensureNewline()) - logChannel.drainTo(this) + private suspend fun processLogs() = + with(StringBuilder()) { + for (firstLine in logChannel) { + append(firstLine.ensureNewline()) + logChannel.drainTo(this) - if (isNotEmpty()) { - val batchText = toString() - clear() - flushToEditor(batchText) + if (isNotEmpty()) { + val batchText = toString() + clear() + flushToEditor(batchText) + } } } - } /** * Performs the safe UI update on the Main Thread. @@ -272,9 +275,10 @@ class BuildOutputFragment : withContext(Dispatchers.Main) { editor?.run { - val layoutCompleted = withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { - awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - } + val layoutCompleted = + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) + } if (layoutCompleted != null) { appendBatch(visibleText) emptyStateViewModel.setEmpty(false) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt index dc3bf2ad0c..6c27f22a34 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt @@ -91,6 +91,11 @@ class LogFilterBarController( levels.add(level) } } - onFilterChanged(levels, binding.filterInput.text?.toString().orEmpty()) + onFilterChanged( + levels, + binding.filterInput.text + ?.toString() + .orEmpty(), + ) } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 9be9031df8..48b98fd69f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -58,14 +58,14 @@ import com.itsaky.androidide.fragments.output.SearchableOutputFragment import com.itsaky.androidide.fragments.output.ShareableOutputFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.IDELanguageClientImpl import com.itsaky.androidide.models.LogLine import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.DiagnosticsFormatter import com.itsaky.androidide.utils.IntentUtils.shareFile import com.itsaky.androidide.utils.Symbols.forFile -import com.itsaky.androidide.utils.DiagnosticsFormatter import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.lsp.IDELanguageClientImpl import com.itsaky.androidide.viewmodel.ApkInstallationViewModel import com.itsaky.androidide.viewmodel.BottomSheetViewModel import com.itsaky.androidide.viewmodel.BuildOutputViewModel @@ -89,533 +89,539 @@ import kotlin.math.roundToInt * @author Akash Yadav */ class EditorBottomSheet -@JvmOverloads -constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0, -) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes) { - private val collapsedHeight: Float by lazy { - val localContext = getContext() ?: return@lazy 0f - localContext.resources.getDimension(R.dimen.editor_sheet_collapsed_height) - } - private val behavior: BottomSheetBehavior by lazy { - BottomSheetBehavior.from(this).apply { - isFitToContents = false - skipCollapsed = true + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, + ) : RelativeLayout(context, attrs, defStyleAttr, defStyleRes) { + private val collapsedHeight: Float by lazy { + val localContext = getContext() ?: return@lazy 0f + localContext.resources.getDimension(R.dimen.editor_sheet_collapsed_height) + } + private val behavior: BottomSheetBehavior by lazy { + BottomSheetBehavior.from(this).apply { + isFitToContents = false + skipCollapsed = true + } } - } - @JvmField - var binding: LayoutEditorBottomSheetBinding - val pagerAdapter: EditorBottomSheetTabAdapter + @JvmField + var binding: LayoutEditorBottomSheetBinding + val pagerAdapter: EditorBottomSheetTabAdapter - private var anchorOffset = 0 - private var isImeVisible = false - private var isSearchModeActive = false - private var windowInsets: Insets? = null + private var anchorOffset = 0 + private var isImeVisible = false + private var isSearchModeActive = false + private var windowInsets: Insets? = null - private val insetBottom: Int - get() = if (isImeVisible) 0 else windowInsets?.bottom ?: 0 + private val insetBottom: Int + get() = if (isImeVisible) 0 else windowInsets?.bottom ?: 0 - private val viewModel by (context as FragmentActivity).viewModels() - private val apkViewModel by (context as FragmentActivity).viewModels() - private val buildOutputViewModel by (context as FragmentActivity).viewModels() - private lateinit var mediator: TabLayoutMediator - private var shareJob: Job? = null + private val viewModel by (context as FragmentActivity).viewModels() + private val apkViewModel by (context as FragmentActivity).viewModels() + private val buildOutputViewModel by (context as FragmentActivity).viewModels() + private lateinit var mediator: TabLayoutMediator + private var shareJob: Job? = null - companion object { - private val log = LoggerFactory.getLogger(EditorBottomSheet::class.java) + companion object { + private val log = LoggerFactory.getLogger(EditorBottomSheet::class.java) - const val CHILD_HEADER = 0 - const val CHILD_SYMBOL_INPUT = 1 - const val CHILD_ACTION = 2 - } + const val CHILD_HEADER = 0 + const val CHILD_SYMBOL_INPUT = 1 + const val CHILD_ACTION = 2 + } - init { - require(context is FragmentActivity) + init { + require(context is FragmentActivity) - val inflater = LayoutInflater.from(context) - binding = LayoutEditorBottomSheetBinding.inflate(inflater) - pagerAdapter = EditorBottomSheetTabAdapter(context) - binding.pager.adapter = pagerAdapter + val inflater = LayoutInflater.from(context) + binding = LayoutEditorBottomSheetBinding.inflate(inflater) + pagerAdapter = EditorBottomSheetTabAdapter(context) + binding.pager.adapter = pagerAdapter - removeAllViews() - addView(binding.root) + removeAllViews() + addView(binding.root) - initialize(context) + initialize(context) - context.lifecycleScope.launch { - context.repeatOnLifecycle(Lifecycle.State.STARTED) { - apkViewModel.sessionState.collectLatest { state -> - onApkInstallationSessionChanged(state) + context.lifecycleScope.launch { + context.repeatOnLifecycle(Lifecycle.State.STARTED) { + apkViewModel.sessionState.collectLatest { state -> + onApkInstallationSessionChanged(state) + } } } } - } - private fun initialize(context: FragmentActivity) { - mediator = TabLayoutMediator(binding.tabs, binding.pager, true, true) { tab, position -> - tab.text = pagerAdapter.getTitle(position) - tab.view.setOnLongClickListener { view -> - val tooltipTag = - pagerAdapter.getTooltipTag(position) ?: return@setOnLongClickListener true - TooltipManager.showTooltip( - context = context, - anchorView = view, - category = pagerAdapter.getTooltipCategory(position), - tag = tooltipTag, - ) - true + private fun initialize(context: FragmentActivity) { + mediator = + TabLayoutMediator(binding.tabs, binding.pager, true, true) { tab, position -> + tab.text = pagerAdapter.getTitle(position) + tab.view.setOnLongClickListener { view -> + val tooltipTag = + pagerAdapter.getTooltipTag(position) ?: return@setOnLongClickListener true + TooltipManager.showTooltip( + context = context, + anchorView = view, + category = pagerAdapter.getTooltipCategory(position), + tag = tooltipTag, + ) + true + } } - } - mediator.attach() - binding.pager.isUserInputEnabled = false - - binding.tabs.addOnTabSelectedListener( - object : OnTabSelectedListener { - override fun onTabSelected(tab: Tab) { - // update view model in case the tab was selected - // by user input - viewModel.setSheetState(currentTab = tab.position) - - val fragment = pagerAdapter.getFragmentAtIndex(tab.position) - if (fragment is ShareableOutputFragment) { - binding.clearFab.show() - binding.shareOutputFab.show() - } else { - binding.clearFab.hide() - binding.shareOutputFab.hide() - } + mediator.attach() + binding.pager.isUserInputEnabled = false + + binding.tabs.addOnTabSelectedListener( + object : OnTabSelectedListener { + override fun onTabSelected(tab: Tab) { + // update view model in case the tab was selected + // by user input + viewModel.setSheetState(currentTab = tab.position) + + val fragment = pagerAdapter.getFragmentAtIndex(tab.position) + if (fragment is ShareableOutputFragment) { + binding.clearFab.show() + binding.shareOutputFab.show() + } else { + binding.clearFab.hide() + binding.shareOutputFab.hide() + } - if (fragment is SearchableOutputFragment) { - binding.searchOutputFab.show() - binding.filterOutputFab.show() - } else { - binding.searchOutputFab.hide() - binding.filterOutputFab.hide() - } + if (fragment is SearchableOutputFragment) { + binding.searchOutputFab.show() + binding.filterOutputFab.show() + } else { + binding.searchOutputFab.hide() + binding.filterOutputFab.hide() + } - if (tab.position == EditorBottomSheetTabAdapter.TAB_DIAGNOSTICS) { - binding.copyDiagnosticsFab.show() - } else { - binding.copyDiagnosticsFab.hide() + if (tab.position == EditorBottomSheetTabAdapter.TAB_DIAGNOSTICS) { + binding.copyDiagnosticsFab.show() + } else { + binding.copyDiagnosticsFab.hide() + } } - } - override fun onTabUnselected(tab: Tab) {} + override fun onTabUnselected(tab: Tab) {} - override fun onTabReselected(tab: Tab) {} - }, - ) + override fun onTabReselected(tab: Tab) {} + }, + ) - binding.shareOutputFab.setOnClickListener { - val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) - if (fragment !is ShareableOutputFragment) { - log.error("Unknown fragment: {}", fragment) - return@setOnClickListener + binding.shareOutputFab.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is ShareableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener + } + if (shareJob?.isActive == true) return@setOnClickListener + + binding.shareOutputFab.isEnabled = false + binding.clearFab.isEnabled = false + + shareJob = + context.lifecycleScope.launch { + try { + val (filename, content) = + withContext(Dispatchers.IO) { + fragment.getShareableFilename() to fragment.getShareableContent() + } + + if (!isAttachedToWindow) return@launch + shareText(text = content, type = filename) + } catch (t: Throwable) { + if (isAttachedToWindow) { + Log.w("EditorBottomSheet", "Share failed", t) + flashError(context.getString(R.string.unknown_error)) + } + } finally { + if (isAttachedToWindow) { + binding.shareOutputFab.isEnabled = true + binding.clearFab.isEnabled = true + } + } + } } - if (shareJob?.isActive == true) return@setOnClickListener - - binding.shareOutputFab.isEnabled = false - binding.clearFab.isEnabled = false + binding.shareOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) + + binding.clearFab.setOnClickListener { + val fragment = + pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is ShareableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener + } + (fragment as ShareableOutputFragment).clearOutput() + } + binding.clearFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) - shareJob = context.lifecycleScope.launch { - try { - val (filename, content) = withContext(Dispatchers.IO) { - fragment.getShareableFilename() to fragment.getShareableContent() - } + binding.copyDiagnosticsFab.setOnClickListener { + copyDiagnosticsToClipboard() + } - if (!isAttachedToWindow) return@launch - shareText(text = content, type = filename) - } catch (t: Throwable) { - if (isAttachedToWindow) { - Log.w("EditorBottomSheet", "Share failed", t) - flashError(context.getString(R.string.unknown_error)) - } - } finally { - if (isAttachedToWindow) { - binding.shareOutputFab.isEnabled = true - binding.clearFab.isEnabled = true - } + binding.searchOutputFab.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is SearchableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener } + // Search happens inside the sheet, so it must be expanded to be usable + viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + fragment.beginSearch() } - } - binding.shareOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) - - binding.clearFab.setOnClickListener { - val fragment = - pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) - if (fragment !is ShareableOutputFragment) { - log.error("Unknown fragment: {}", fragment) - return@setOnClickListener + binding.searchOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) + + binding.filterOutputFab.setOnClickListener { + val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + if (fragment !is SearchableOutputFragment) { + log.error("Unknown fragment: {}", fragment) + return@setOnClickListener + } + viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + fragment.toggleFilterBar() } - (fragment as ShareableOutputFragment).clearOutput() - } - binding.clearFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) + binding.filterOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) - binding.copyDiagnosticsFab.setOnClickListener { - copyDiagnosticsToClipboard() - } + binding.headerContainer.setOnClickListener { + viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + } - binding.searchOutputFab.setOnClickListener { - val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) - if (fragment !is SearchableOutputFragment) { - log.error("Unknown fragment: {}", fragment) - return@setOnClickListener + ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + this.windowInsets = + insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()) + insets } - // Search happens inside the sheet, so it must be expanded to be usable - viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) - fragment.beginSearch() } - binding.searchOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) - binding.filterOutputFab.setOnClickListener { - val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) - if (fragment !is SearchableOutputFragment) { - log.error("Unknown fragment: {}", fragment) - return@setOnClickListener + override fun onDetachedFromWindow() { + shareJob?.cancel() + shareJob = null + if (this::mediator.isInitialized) { + mediator.detach() } - viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) - fragment.toggleFilterBar() - } - binding.filterOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) - binding.headerContainer.setOnClickListener { - viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) + binding.tabs.clearOnTabSelectedListeners() + binding.shareOutputFab.setOnClickListener(null) + binding.shareOutputFab.setOnLongClickListener(null) + binding.clearFab.setOnClickListener(null) + binding.clearFab.setOnLongClickListener(null) + binding.searchOutputFab.setOnClickListener(null) + binding.searchOutputFab.setOnLongClickListener(null) + binding.filterOutputFab.setOnClickListener(null) + binding.filterOutputFab.setOnLongClickListener(null) + binding.copyDiagnosticsFab.setOnClickListener(null) + binding.headerContainer.setOnClickListener(null) + ViewCompat.setOnApplyWindowInsetsListener(this, null) + + binding.pager.adapter = null + + pagerAdapter.clearAll() + super.onDetachedFromWindow() } - ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> - this.windowInsets = - insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()) - insets - } - } + private fun onApkInstallationSessionChanged(state: ApkInstallationViewModel.SessionState) { + when (state) { + ApkInstallationViewModel.SessionState.Idle -> { + setActionProgress(0) + showChild(CHILD_HEADER) + } - override fun onDetachedFromWindow() { - shareJob?.cancel() - shareJob = null - if (this::mediator.isInitialized) { - mediator.detach() - } - - binding.tabs.clearOnTabSelectedListeners() - binding.shareOutputFab.setOnClickListener(null) - binding.shareOutputFab.setOnLongClickListener(null) - binding.clearFab.setOnClickListener(null) - binding.clearFab.setOnLongClickListener(null) - binding.searchOutputFab.setOnClickListener(null) - binding.searchOutputFab.setOnLongClickListener(null) - binding.filterOutputFab.setOnClickListener(null) - binding.filterOutputFab.setOnLongClickListener(null) - binding.copyDiagnosticsFab.setOnClickListener(null) - binding.headerContainer.setOnClickListener(null) - ViewCompat.setOnApplyWindowInsetsListener(this, null) - - binding.pager.adapter = null - - pagerAdapter.clearAll() - super.onDetachedFromWindow() - } - - private fun onApkInstallationSessionChanged(state: ApkInstallationViewModel.SessionState) { - when (state) { - ApkInstallationViewModel.SessionState.Idle -> { - setActionProgress(0) - showChild(CHILD_HEADER) - } + is ApkInstallationViewModel.SessionState.InProgress -> { + setActionText(context.getString(R.string.msg_installing_apk)) + setActionProgress(state.progress) + showChild(CHILD_ACTION) + } - is ApkInstallationViewModel.SessionState.InProgress -> { - setActionText(context.getString(R.string.msg_installing_apk)) - setActionProgress(state.progress) - showChild(CHILD_ACTION) - } + is ApkInstallationViewModel.SessionState.Finished -> { + setActionProgress(0) + showChild(CHILD_HEADER) + if (!state.isSuccess) { + flashError(context.getString(R.string.title_installation_failed)) + } - is ApkInstallationViewModel.SessionState.Finished -> { - setActionProgress(0) - showChild(CHILD_HEADER) - if (!state.isSuccess) { - flashError(context.getString(R.string.title_installation_failed)) + apkViewModel.resetState() } - - apkViewModel.resetState() } } - } - private fun generateTooltipListener(tooltipTag: String): OnLongClickListener = - OnLongClickListener { view: View -> - TooltipManager.showIdeCategoryTooltip( - context = context, - anchorView = view, - tag = tooltipTag, - ) + private fun generateTooltipListener(tooltipTag: String): OnLongClickListener = + OnLongClickListener { view: View -> + TooltipManager.showIdeCategoryTooltip( + context = context, + anchorView = view, + tag = tooltipTag, + ) - // A long-click listener must return true to indicate it has consumed the event. - true - } - - fun setCurrentTab( - @BottomSheetViewModel.TabDef tabIndex: Int, - ) { - if (binding.tabs.selectedTabPosition == tabIndex) { - return - } + // A long-click listener must return true to indicate it has consumed the event. + true + } - if (tabIndex < 0 || tabIndex > binding.tabs.tabCount) { - return - } + fun setCurrentTab( + @BottomSheetViewModel.TabDef tabIndex: Int, + ) { + if (binding.tabs.selectedTabPosition == tabIndex) { + return + } - binding.tabs.getTabAt(tabIndex)?.select() - } + if (tabIndex < 0 || tabIndex > binding.tabs.tabCount) { + return + } - /** - * Set whether the input method is visible. - */ - fun setImeVisible(isVisible: Boolean) { - isImeVisible = isVisible - behavior.isGestureInsetBottomIgnored = true - applyPeekHeight() - } + binding.tabs.getTabAt(tabIndex)?.select() + } + /** + * Set whether the input method is visible. + */ + fun setImeVisible(isVisible: Boolean) { + isImeVisible = isVisible + behavior.isGestureInsetBottomIgnored = true + applyPeekHeight() + } - fun setSearchModeActive(isActive: Boolean) { - isSearchModeActive = isActive - if (isActive && behavior.state != BottomSheetBehavior.STATE_COLLAPSED) { - behavior.state = BottomSheetBehavior.STATE_COLLAPSED + fun setSearchModeActive(isActive: Boolean) { + isSearchModeActive = isActive + if (isActive && behavior.state != BottomSheetBehavior.STATE_COLLAPSED) { + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + applyPeekHeight() } - applyPeekHeight() - } - private fun applyPeekHeight() { - behavior.peekHeight = if (isSearchModeActive) 0 else collapsedHeight.roundToInt() - } + private fun applyPeekHeight() { + behavior.peekHeight = if (isSearchModeActive) 0 else collapsedHeight.roundToInt() + } - fun setOffsetAnchor(view: View) { - val listener = - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - anchorOffset = view.height + SizeUtils.dp2px(1f) - - behavior.peekHeight = collapsedHeight.roundToInt() - behavior.expandedOffset = anchorOffset - behavior.isGestureInsetBottomIgnored = true - - binding.root.updatePadding(bottom = anchorOffset + insetBottom) - binding.headerContainer.apply { - updatePaddingRelative(bottom = paddingBottom + insetBottom) - updateLayoutParams { - height = (collapsedHeight + insetBottom).roundToInt() + fun setOffsetAnchor(view: View) { + val listener = + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + anchorOffset = view.height + SizeUtils.dp2px(1f) + + behavior.peekHeight = collapsedHeight.roundToInt() + behavior.expandedOffset = anchorOffset + behavior.isGestureInsetBottomIgnored = true + + binding.root.updatePadding(bottom = anchorOffset + insetBottom) + binding.headerContainer.apply { + updatePaddingRelative(bottom = paddingBottom + insetBottom) + updateLayoutParams { + height = (collapsedHeight + insetBottom).roundToInt() + } } } } - } - view.viewTreeObserver.addOnGlobalLayoutListener(listener) - } + view.viewTreeObserver.addOnGlobalLayoutListener(listener) + } - fun resetOffsetAnchor() { - anchorOffset = 0 - behavior.peekHeight = collapsedHeight.roundToInt() - behavior.expandedOffset = 0 - binding.root.updatePadding(bottom = insetBottom) - binding.headerContainer.apply { - updatePaddingRelative(bottom = insetBottom) - updateLayoutParams { - height = (collapsedHeight + insetBottom).roundToInt() + fun resetOffsetAnchor() { + anchorOffset = 0 + behavior.peekHeight = collapsedHeight.roundToInt() + behavior.expandedOffset = 0 + binding.root.updatePadding(bottom = insetBottom) + binding.headerContainer.apply { + updatePaddingRelative(bottom = insetBottom) + updateLayoutParams { + height = (collapsedHeight + insetBottom).roundToInt() + } } } - } - fun onSlide(sheetOffset: Float) { - val safeOffset = sheetOffset.coerceIn(0f, 1f) + fun onSlide(sheetOffset: Float) { + val safeOffset = sheetOffset.coerceIn(0f, 1f) - val heightScale = 1f - safeOffset + val heightScale = 1f - safeOffset - val paddingScale = if (!isImeVisible) { - 1f - safeOffset - } else 0f + val paddingScale = + if (!isImeVisible) { + 1f - safeOffset + } else { + 0f + } - val padding = insetBottom * paddingScale - binding.headerContainer.apply { - updateLayoutParams { - height = ((collapsedHeight + padding) * heightScale).roundToInt() + val padding = insetBottom * paddingScale + binding.headerContainer.apply { + updateLayoutParams { + height = ((collapsedHeight + padding) * heightScale).roundToInt() + } + updatePaddingRelative( + bottom = padding.roundToInt(), + ) } - updatePaddingRelative( - bottom = padding.roundToInt(), - ) } - } - fun showChild(index: Int) { - binding.headerContainer.displayedChild = index - } + fun showChild(index: Int) { + binding.headerContainer.displayedChild = index + } - fun setActionText(text: CharSequence) { - binding.bottomAction.actionText.text = text - } + fun setActionText(text: CharSequence) { + binding.bottomAction.actionText.text = text + } - fun setActionProgress(progress: Int) { - binding.bottomAction.progress.setProgressCompat(progress, true) - } + fun setActionProgress(progress: Int) { + binding.bottomAction.progress.setProgressCompat(progress, true) + } - fun appendApkLog(line: LogLine) { - pagerAdapter.logFragment?.appendLog(line) - } + fun appendApkLog(line: LogLine) { + pagerAdapter.logFragment?.appendLog(line) + } - fun appendBuildOut(str: String?) { - if (str != null && shouldFilter(str)) return - pagerAdapter.buildOutputFragment?.appendOutput(str) - } + fun appendBuildOut(str: String?) { + if (str != null && shouldFilter(str)) return + pagerAdapter.buildOutputFragment?.appendOutput(str) + } - private val suppressedGradleWarnings = - listOf( - "The option setting 'android.aapt2FromMavenOverride=/data/data/com.itsaky.androidide/files/home/android-sdk/build-tools/35.0.0/aapt2' is experimental", - "The org.gradle.api.plugins.BasePluginConvention type has been deprecated.", - "The org.gradle.api.plugins.Convention type has been deprecated.", - "The BasePluginExtension.archivesBaseName property has been deprecated.", - "The Provider.forUseAtConfigurationTime method has been deprecated.", - "The BuildIdentifier.getName() method has been deprecated.", - "Deprecated Gradle features were used in this build", - "The StartParameter.isConfigurationCacheRequested property has been deprecated.", - "Retrieving attribute with a null key. This behavior has been deprecated.", - ) - - private fun shouldFilter(msg: String): Boolean = - suppressedGradleWarnings.any { msg.contains(it) } - - fun clearBuildOutput() { - pagerAdapter.buildOutputFragment?.takeIf { it.isAdded }?.clearOutput() - } + private val suppressedGradleWarnings = + listOf( + "The option setting 'android.aapt2FromMavenOverride=/data/data/com.itsaky.androidide/files/home/android-sdk/build-tools/35.0.0/aapt2' is experimental", + "The org.gradle.api.plugins.BasePluginConvention type has been deprecated.", + "The org.gradle.api.plugins.Convention type has been deprecated.", + "The BasePluginExtension.archivesBaseName property has been deprecated.", + "The Provider.forUseAtConfigurationTime method has been deprecated.", + "The BuildIdentifier.getName() method has been deprecated.", + "Deprecated Gradle features were used in this build", + "The StartParameter.isConfigurationCacheRequested property has been deprecated.", + "Retrieving attribute with a null key. This behavior has been deprecated.", + ) - fun handleDiagnosticsResultVisibility(errorVisible: Boolean) { - runOnUiThread { - val fragment = pagerAdapter.diagnosticsFragment - if (fragment == null || !fragment.isAdded || fragment.isDetached) { - return@runOnUiThread - } + private fun shouldFilter(msg: String): Boolean = suppressedGradleWarnings.any { msg.contains(it) } - fragment.isEmpty = errorVisible + fun clearBuildOutput() { + pagerAdapter.buildOutputFragment?.takeIf { it.isAdded }?.clearOutput() } - } - fun handleSearchResultVisibility(errorVisible: Boolean) { - runOnUiThread { - val fragment = pagerAdapter.searchResultFragment - if (fragment == null || !fragment.isAdded || fragment.isDetached) { - return@runOnUiThread + fun handleDiagnosticsResultVisibility(errorVisible: Boolean) { + runOnUiThread { + val fragment = pagerAdapter.diagnosticsFragment + if (fragment == null || !fragment.isAdded || fragment.isDetached) { + return@runOnUiThread + } + + fragment.isEmpty = errorVisible } - fragment.isEmpty = errorVisible } - } - fun setDiagnosticsAdapter(adapter: DiagnosticsAdapter) { - runOnUiThread { pagerAdapter.diagnosticsFragment?.setAdapter(adapter) } - } + fun handleSearchResultVisibility(errorVisible: Boolean) { + runOnUiThread { + val fragment = pagerAdapter.searchResultFragment + if (fragment == null || !fragment.isAdded || fragment.isDetached) { + return@runOnUiThread + } + fragment.isEmpty = errorVisible + } + } - fun setSearchResultAdapter(adapter: SearchListAdapter) { - runOnUiThread { pagerAdapter.searchResultFragment?.setAdapter(adapter) } - } + fun setDiagnosticsAdapter(adapter: DiagnosticsAdapter) { + runOnUiThread { pagerAdapter.diagnosticsFragment?.setAdapter(adapter) } + } - fun refreshSymbolInput(editor: CodeEditorView) { - binding.symbolInput.refresh(editor.editor, forFile(editor.file)) - } + fun setSearchResultAdapter(adapter: SearchListAdapter) { + runOnUiThread { pagerAdapter.searchResultFragment?.setAdapter(adapter) } + } - fun onSoftInputChanged() { - if (context !is Activity) { - log.error("Bottom sheet is not attached to an activity!") - return + fun refreshSymbolInput(editor: CodeEditorView) { + binding.symbolInput.refresh(editor.editor, forFile(editor.file)) } - binding.symbolInput.itemAnimator?.endAnimations() + fun onSoftInputChanged() { + if (context !is Activity) { + log.error("Bottom sheet is not attached to an activity!") + return + } - TransitionManager.beginDelayedTransition( - binding.root, - MaterialSharedAxis(MaterialSharedAxis.Y, false), - ) + binding.symbolInput.itemAnimator?.endAnimations() - val activity = context as Activity - if (KeyboardUtils.isSoftInputVisible(activity)) { - binding.headerContainer.displayedChild = CHILD_SYMBOL_INPUT - } else { - binding.headerContainer.displayedChild = CHILD_HEADER - } - } + TransitionManager.beginDelayedTransition( + binding.root, + MaterialSharedAxis(MaterialSharedAxis.Y, false), + ) - fun setStatus( - text: CharSequence, - @GravityInt gravity: Int, - ) { - runOnUiThread { - binding.buildStatus.let { - it.statusText.gravity = gravity - it.statusText.text = text + val activity = context as Activity + if (KeyboardUtils.isSoftInputVisible(activity)) { + binding.headerContainer.displayedChild = CHILD_SYMBOL_INPUT + } else { + binding.headerContainer.displayedChild = CHILD_HEADER } } - } - private fun shareFile(file: File) { - shareFile(context, file, "text/plain") - } + fun setStatus( + text: CharSequence, + @GravityInt gravity: Int, + ) { + runOnUiThread { + binding.buildStatus.let { + it.statusText.gravity = gravity + it.statusText.text = text + } + } + } - private suspend fun shareText( - text: String?, - type: String, - ) { - val content = text?.takeIf { it.isNotBlank() } ?: run { - flashError(context.getString(string.msg_output_text_extraction_failed)) - return + private fun shareFile(file: File) { + shareFile(context, file, "text/plain") } - try { - val file = withContext(Dispatchers.IO) { - writeTempFile(content, type) + private suspend fun shareText( + text: String?, + type: String, + ) { + val content = + text?.takeIf { it.isNotBlank() } ?: run { + flashError(context.getString(string.msg_output_text_extraction_failed)) + return + } + + try { + val file = + withContext(Dispatchers.IO) { + writeTempFile(content, type) + } + shareFile(file) + } catch (e: IOException) { + Log.w("EditorBottomSheet", "Failed to write temp file for sharing", e) + flashError(context.getString(string.msg_output_text_extraction_failed)) } - shareFile(file) - } catch (e: IOException) { - Log.w("EditorBottomSheet", "Failed to write temp file for sharing", e) - flashError(context.getString(string.msg_output_text_extraction_failed)) } - } - private fun writeTempFile( - text: String, - type: String, - ): File { - // use a common name to avoid multiple files - val path: Path = context.filesDir.toPath().resolve("$type.txt") - if (Files.exists(path)) { - Files.delete(path) + private fun writeTempFile( + text: String, + type: String, + ): File { + // use a common name to avoid multiple files + val path: Path = context.filesDir.toPath().resolve("$type.txt") + if (Files.exists(path)) { + Files.delete(path) + } + Files.write(path, text.toByteArray(StandardCharsets.UTF_8), CREATE_NEW, WRITE) + + return path.toFile() } - Files.write(path, text.toByteArray(StandardCharsets.UTF_8), CREATE_NEW, WRITE) - return path.toFile() - } + private fun copyDiagnosticsToClipboard() { + if (!IDELanguageClientImpl.isInitialized()) { + flashError(context.getString(string.msg_no_diagnostics_to_copy)) + return + } - private fun copyDiagnosticsToClipboard() { - if (!IDELanguageClientImpl.isInitialized()) { - flashError(context.getString(string.msg_no_diagnostics_to_copy)) - return - } + val diagnostics = IDELanguageClientImpl.getInstance().allDiagnostics + if (diagnostics.isEmpty()) { + flashError(context.getString(string.msg_no_diagnostics_to_copy)) + return + } - val diagnostics = IDELanguageClientImpl.getInstance().allDiagnostics - if (diagnostics.isEmpty()) { - flashError(context.getString(string.msg_no_diagnostics_to_copy)) - return - } + val formatted = DiagnosticsFormatter.format(diagnostics) + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard == null) { + flashError(context.getString(string.msg_clipboard_copy_failed)) + return + } - val formatted = DiagnosticsFormatter.format(diagnostics) - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - if (clipboard == null) { - flashError(context.getString(string.msg_clipboard_copy_failed)) - return + runCatching { clipboard.setPrimaryClip(ClipData.newPlainText("diagnostics", formatted)) } + .onSuccess { flashSuccess(context.getString(string.msg_diagnostics_copied)) } + .onFailure { flashError(context.getString(string.msg_clipboard_copy_failed)) } } - - runCatching { clipboard.setPrimaryClip(ClipData.newPlainText("diagnostics", formatted)) } - .onSuccess { flashSuccess(context.getString(string.msg_diagnostics_copied)) } - .onFailure { flashError(context.getString(string.msg_clipboard_copy_failed)) } } -} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt index e350bd5d22..505041cc8f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -18,6 +18,9 @@ package com.itsaky.androidide.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile @@ -25,9 +28,6 @@ import java.nio.charset.StandardCharsets import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock import kotlin.math.max -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.withContext /** * File-backed build output with a moving window in memory. All output is appended to a session @@ -37,159 +37,172 @@ import kotlinx.coroutines.withContext * * Append/clear are intended to be called from the main thread (from [BuildOutputFragment]). */ -class BuildOutputViewModel(application: Application) : AndroidViewModel(application) { - - private val lock = ReentrantLock() - - /** - * Case-insensitive line filter applied to the *editor view* of the build output. - * The session file always receives the unfiltered text. - */ - val filterText = MutableStateFlow("") - - /** - * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. - * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. - * Capped at [CACHE_SNAPSHOT_MAX_CHARS] to bound memory. - */ - @Volatile - private var cachedContentSnapshot: String = "" - - /** Returns the current cached snapshot for share/copy (non-blocking). */ - fun getCachedContentSnapshot(): String = cachedContentSnapshot - - /** Updates the cached snapshot (e.g. after loading full content on restore). Capped to [CACHE_SNAPSHOT_MAX_CHARS]. */ - fun setCachedSnapshot(content: String) { - cachedContentSnapshot = - if (content.length <= CACHE_SNAPSHOT_MAX_CHARS) content - else content.takeLast(CACHE_SNAPSHOT_MAX_CHARS) - } - - private val sessionFile: File - get() = File(getApplication().cacheDir, SESSION_FILE_NAME) - - /** - * Appends text to the session file. File I/O is performed on a background dispatcher; call from - * any thread. Prefer calling before switching to Main so disk write does not block the UI. - */ - suspend fun append(text: String) { - if (text.isEmpty()) return - withContext(Dispatchers.IO) { - lock.withLock { - try { - FileOutputStream(sessionFile, true).use { - it.write(text.toByteArray(StandardCharsets.UTF_8)) - } - cachedContentSnapshot = - (cachedContentSnapshot + text).takeLast(CACHE_SNAPSHOT_MAX_CHARS) - } catch (e: Exception) { - log.error("Failed to append build output to session file", e) - } - } - } - } - - /** - * Returns the last [WINDOW_SIZE_CHARS] characters from the session file for the editor to - * display (e.g. initial view or after rotation). Returns empty string if no content. - */ - fun getWindowForEditor(): String = - lock.withLock { - readTailFromFile(sessionFile, WINDOW_SIZE_CHARS) - } - - /** - * Returns the full build output from the session file. Used for [BuildOutputProvider.getBuildOutputContent] - * and share/copy. Returns empty string if no content. File I/O is performed on [Dispatchers.IO]. - */ - suspend fun getFullContent(): String = - withContext(Dispatchers.IO) { - lock.withLock { - if (!sessionFile.exists()) return@withContext "" - try { - sessionFile.readText() - } catch (e: Exception) { - log.error("Failed to read full build output from session file", e) - "" - } - } - } - - /** - * Reads a range from the session file (for future scroll/windowed UI). [offset] and [length] are - * in characters; implementation reads the corresponding byte range and decodes. - */ - fun getRange(offset: Int, length: Int): String = - lock.withLock { - if (!sessionFile.exists()) return "" - try { - val content = sessionFile.readText() - val start = max(0, offset).coerceAtMost(content.length) - val end = (start + length).coerceAtMost(content.length) - content.substring(start, end) - } catch (e: Exception) { - log.error("Failed to read range from build output session file", e) - "" - } - } - - /** - * Clears the session: deletes the session file and resets state. Call when a new build starts. - */ - fun clear() { - lock.withLock { - cachedContentSnapshot = "" - try { - if (sessionFile.exists()) { - sessionFile.delete() - } - } catch (e: Exception) { - log.error("Failed to delete build output session file", e) - } - } - } - - private fun readTailFromFile(file: File, maxChars: Int): String { - if (!file.exists()) return "" - try { - RandomAccessFile(file, "r").use { raf -> - val len = raf.length() - if (len == 0L) return "" - // UTF-8: up to 4 bytes per char; read enough bytes for maxChars, then decode and take last maxChars - val maxBytes = minOf(len, maxChars * 4L) - raf.seek(max(0, len - maxBytes)) - val bytes = ByteArray(maxBytes.toInt()) - raf.readFully(bytes) - val decoded = String(bytes, Charsets.UTF_8) - return if (decoded.length <= maxChars) decoded else decoded.takeLast(maxChars) - } - } catch (e: Exception) { - log.error("Failed to read tail from build output session file", e) - return "" - } - } - - companion object { - - /** - * Returns only the lines of [content] containing [query] (case-insensitive), each terminated - * with a newline. Returns [content] unchanged when [query] is empty. - */ - fun filterLines(content: String, query: String): String { - if (query.isEmpty() || content.isEmpty()) return content - return buildString { - for (line in content.lineSequence()) { - if (line.contains(query, ignoreCase = true)) { - append(line).append('\n') - } - } - } - } - - private const val SESSION_FILE_NAME = "build_output_session.txt" - private const val WINDOW_SIZE_CHARS = 512 * 1024 - /** Max length of [cachedContentSnapshot] to bound memory. */ - private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS - private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java) - } +class BuildOutputViewModel( + application: Application, +) : AndroidViewModel(application) { + private val lock = ReentrantLock() + + /** + * Case-insensitive line filter applied to the *editor view* of the build output. + * The session file always receives the unfiltered text. + */ + val filterText = MutableStateFlow("") + + /** + * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking. + * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot]. + * Capped at [CACHE_SNAPSHOT_MAX_CHARS] to bound memory. + */ + @Volatile + private var cachedContentSnapshot: String = "" + + /** Returns the current cached snapshot for share/copy (non-blocking). */ + fun getCachedContentSnapshot(): String = cachedContentSnapshot + + /** Updates the cached snapshot (e.g. after loading full content on restore). Capped to [CACHE_SNAPSHOT_MAX_CHARS]. */ + fun setCachedSnapshot(content: String) { + cachedContentSnapshot = + if (content.length <= CACHE_SNAPSHOT_MAX_CHARS) { + content + } else { + content.takeLast(CACHE_SNAPSHOT_MAX_CHARS) + } + } + + private val sessionFile: File + get() = File(getApplication().cacheDir, SESSION_FILE_NAME) + + /** + * Appends text to the session file. File I/O is performed on a background dispatcher; call from + * any thread. Prefer calling before switching to Main so disk write does not block the UI. + */ + suspend fun append(text: String) { + if (text.isEmpty()) return + withContext(Dispatchers.IO) { + lock.withLock { + try { + FileOutputStream(sessionFile, true).use { + it.write(text.toByteArray(StandardCharsets.UTF_8)) + } + cachedContentSnapshot = + (cachedContentSnapshot + text).takeLast(CACHE_SNAPSHOT_MAX_CHARS) + } catch (e: Exception) { + log.error("Failed to append build output to session file", e) + } + } + } + } + + /** + * Returns the last [WINDOW_SIZE_CHARS] characters from the session file for the editor to + * display (e.g. initial view or after rotation). Returns empty string if no content. + */ + fun getWindowForEditor(): String = + lock.withLock { + readTailFromFile(sessionFile, WINDOW_SIZE_CHARS) + } + + /** + * Returns the full build output from the session file. Used for [BuildOutputProvider.getBuildOutputContent] + * and share/copy. Returns empty string if no content. File I/O is performed on [Dispatchers.IO]. + */ + suspend fun getFullContent(): String = + withContext(Dispatchers.IO) { + lock.withLock { + if (!sessionFile.exists()) return@withContext "" + try { + sessionFile.readText() + } catch (e: Exception) { + log.error("Failed to read full build output from session file", e) + "" + } + } + } + + /** + * Reads a range from the session file (for future scroll/windowed UI). [offset] and [length] are + * in characters; implementation reads the corresponding byte range and decodes. + */ + fun getRange( + offset: Int, + length: Int, + ): String = + lock.withLock { + if (!sessionFile.exists()) return "" + try { + val content = sessionFile.readText() + val start = max(0, offset).coerceAtMost(content.length) + val end = (start + length).coerceAtMost(content.length) + content.substring(start, end) + } catch (e: Exception) { + log.error("Failed to read range from build output session file", e) + "" + } + } + + /** + * Clears the session: deletes the session file and resets state. Call when a new build starts. + */ + fun clear() { + lock.withLock { + cachedContentSnapshot = "" + try { + if (sessionFile.exists()) { + sessionFile.delete() + } + } catch (e: Exception) { + log.error("Failed to delete build output session file", e) + } + } + } + + private fun readTailFromFile( + file: File, + maxChars: Int, + ): String { + if (!file.exists()) return "" + try { + RandomAccessFile(file, "r").use { raf -> + val len = raf.length() + if (len == 0L) return "" + // UTF-8: up to 4 bytes per char; read enough bytes for maxChars, then decode and take last maxChars + val maxBytes = minOf(len, maxChars * 4L) + raf.seek(max(0, len - maxBytes)) + val bytes = ByteArray(maxBytes.toInt()) + raf.readFully(bytes) + val decoded = String(bytes, Charsets.UTF_8) + return if (decoded.length <= maxChars) decoded else decoded.takeLast(maxChars) + } + } catch (e: Exception) { + log.error("Failed to read tail from build output session file", e) + return "" + } + } + + companion object { + /** + * Returns only the lines of [content] containing [query] (case-insensitive), each terminated + * with a newline. Returns [content] unchanged when [query] is empty. + */ + fun filterLines( + content: String, + query: String, + ): String { + if (query.isEmpty() || content.isEmpty()) return content + return buildString { + for (line in content.lineSequence()) { + if (line.contains(query, ignoreCase = true)) { + append(line).append('\n') + } + } + } + } + + private const val SESSION_FILE_NAME = "build_output_session.txt" + private const val WINDOW_SIZE_CHARS = 512 * 1024 + + /** Max length of [cachedContentSnapshot] to bound memory. */ + private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS + private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java) + } } diff --git a/app/src/main/res/layout/fragment_log.xml b/app/src/main/res/layout/fragment_log.xml index 99b55a8139..5e92538eba 100644 --- a/app/src/main/res/layout/fragment_log.xml +++ b/app/src/main/res/layout/fragment_log.xml @@ -1,39 +1,30 @@ - + - + - + - + diff --git a/app/src/main/res/layout/fragment_non_editable_editor.xml b/app/src/main/res/layout/fragment_non_editable_editor.xml index 99b55a8139..5e92538eba 100644 --- a/app/src/main/res/layout/fragment_non_editable_editor.xml +++ b/app/src/main/res/layout/fragment_non_editable_editor.xml @@ -1,39 +1,30 @@ - + - + - + - + diff --git a/app/src/main/res/layout/layout_editor_bottom_sheet.xml b/app/src/main/res/layout/layout_editor_bottom_sheet.xml index 2739f81d25..05fe7709d7 100644 --- a/app/src/main/res/layout/layout_editor_bottom_sheet.xml +++ b/app/src/main/res/layout/layout_editor_bottom_sheet.xml @@ -1,148 +1,138 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_log_filter_bar.xml b/app/src/main/res/layout/layout_log_filter_bar.xml index a1bfcbed67..fa634ddad0 100644 --- a/app/src/main/res/layout/layout_log_filter_bar.xml +++ b/app/src/main/res/layout/layout_log_filter_bar.xml @@ -1,121 +1,112 @@ - + - + - + - + - - + + - - + + - + - + - + - + - + - + - - - + + + diff --git a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt index 53fcec729f..3c18615aae 100644 --- a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt +++ b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt @@ -24,7 +24,6 @@ import org.junit.Assert.assertTrue import org.junit.Test class LogBufferTest { - @Test fun `sequence numbers increase monotonically`() { val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) diff --git a/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt b/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt index bcfbf21a83..a7fa8fa3b2 100644 --- a/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt +++ b/app/src/test/java/com/itsaky/androidide/models/LogFilterTest.kt @@ -23,7 +23,6 @@ import org.junit.Assert.assertTrue import org.junit.Test class LogFilterTest { - @Test fun `default filter matches everything and is inactive`() { val filter = LogFilter.NONE diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt index bb24892bcb..7d9d11c237 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputFilterTest.kt @@ -22,7 +22,6 @@ import org.junit.Assert.assertSame import org.junit.Test class BuildOutputFilterTest { - @Test fun `empty query returns content unchanged`() { val content = "> Task :app:compileDebugKotlin\nBUILD SUCCESSFUL\n" diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt index 2d079eb5fe..ba72d57c83 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt @@ -36,7 +36,6 @@ import org.junit.Test * and chunks output on a real 50ms cadence. */ class LogViewModelTest { - private class TestLogViewModel : LogViewModel() companion object { diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt index 69ff759d21..86a622829b 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt @@ -50,289 +50,296 @@ import java.util.regex.Pattern */ @SuppressLint("ViewConstructor") // Always created dynamically class EditorSearchLayout( - context: Context, - val editor: IDEEditor, - showReplaceAction: Boolean = true, - applyCollapsedSheetMargin: Boolean = true, + context: Context, + val editor: IDEEditor, + showReplaceAction: Boolean = true, + applyCollapsedSheetMargin: Boolean = true, ) : FrameLayout(context) { - // Hosts above the collapsed bottom sheet need a margin so the action row stays visible; - // hosts inside the sheet itself don't. - private val collapsedSheetMargin = - if (applyCollapsedSheetMargin) { - context.resources.getDimensionPixelSize(R.dimen.editor_sheet_peek_height) - } else { - 0 - } - - var onSearchModeChanged: ((isActive: Boolean) -> Unit)? = null - - private var searchInputTextWatcher: TextWatcher? = null - private var searchOptions = SearchOptions(true, false) - private val findInFileBinding: LayoutFindInFileBinding = LayoutFindInFileBinding.inflate(LayoutInflater.from(context)) - private val optionsMenu: PopupMenu - - init { - findInFileBinding.prev.setOnClickListener(::onSearchActionClick) - findInFileBinding.next.setOnClickListener(::onSearchActionClick) - findInFileBinding.replace.setOnClickListener(::onSearchActionClick) - findInFileBinding.close.setOnClickListener(::onSearchActionClick) - if (!showReplaceAction) { - // Read-only hosts (log/output views) cannot replace text - findInFileBinding.replace.visibility = GONE - } - findInFileBinding.root.applyLongPressRecursively { - TooltipManager.showIdeCategoryTooltip( - context = this.context, - anchorView = this, - tag = TooltipTag.DIALOG_FIND_IN_FILE - ) - true - } - - optionsMenu = PopupMenu(context, findInFileBinding.moreOptions, Gravity.TOP) - optionsMenu.menu.add(0, 0, 0, R.string.msg_ignore_case).apply { - isCheckable = true - isChecked = true - } - - optionsMenu.menu.add(0, 1, 0, R.string.msg_use_regex).apply { - isCheckable = true - isChecked = false - } - - optionsMenu.setOnMenuItemClickListener { - return@setOnMenuItemClickListener if (it.isCheckable) { - it.isChecked = !it.isChecked - - val caseInsensitive = searchOptions.caseInsensitive - val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION - searchOptions = - when (it.itemId) { - 0 -> SearchOptions(it.isChecked, regex) - 1 -> SearchOptions(caseInsensitive, it.isChecked) - else -> searchOptions - } - editor.searcher.updateSearchOptions(searchOptions) - - true - } else false - } - - findInFileBinding.root.visibility = GONE - findInFileBinding.moreOptions.apply { - setOnClickListener { - showPopupMenu(findInFileBinding.moreOptions) - } - setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip( - context = this@EditorSearchLayout.context, - anchorView = this, - tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS - ) - true - } - } - ViewCompat.setOnApplyWindowInsetsListener(findInFileBinding.root) { _, insets -> - updateActionsBottomMargin(insets.isVisible(WindowInsetsCompat.Type.ime())) - insets - } - - addView( - findInFileBinding.root, - LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - ) - } - - private fun showPopupMenu(anchorView: View) { - val binding = - SearchOptionsPopupMenuBinding.inflate(LayoutInflater.from(context), null, false) - - val popupWindow = PopupWindow( - binding.root, - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT, - ).apply { - elevation = 2f - isOutsideTouchable = true - softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - } - - val tooltipListener = OnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS - ) - popupWindow.dismiss() - true - } - - binding.layoutSearchOptions.setOnLongClickListener(tooltipListener) - - val caseInsensitive = searchOptions.caseInsensitive - val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION - - fun onIgnoreCaseToggled() { - binding.checkboxIgnoreCase.isChecked = !binding.checkboxIgnoreCase.isChecked - searchOptions = SearchOptions(binding.checkboxIgnoreCase.isChecked, regex) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - fun handleIgnoreCaseCheckedChange(isChecked: Boolean) { - searchOptions = SearchOptions(isChecked, regex) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - binding.layoutIgnoreCase.apply { - setOnClickListener { onIgnoreCaseToggled() } - setOnLongClickListener(tooltipListener) - } - - binding.checkboxIgnoreCase.apply { - isChecked = caseInsensitive - setOnCheckedChangeListener { _, isChecked -> handleIgnoreCaseCheckedChange(isChecked) } - setOnLongClickListener(tooltipListener) - } - - fun onUseRegexToggled() { - binding.checkboxUseRegex.isChecked = !binding.checkboxUseRegex.isChecked - searchOptions = SearchOptions(caseInsensitive, binding.checkboxUseRegex.isChecked) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - fun handleRegexCheckedChange(isChecked: Boolean) { - searchOptions = SearchOptions(caseInsensitive, isChecked) - editor.searcher.updateSearchOptions(searchOptions) - popupWindow.dismiss() - } - - binding.layoutUseRegex.apply { - setOnClickListener { onUseRegexToggled() } - setOnLongClickListener(tooltipListener) - } - - binding.checkboxUseRegex.apply { - isChecked = regex - setOnCheckedChangeListener { _, isChecked -> handleRegexCheckedChange(isChecked) } - setOnLongClickListener(tooltipListener) - } - - popupWindow.showAsDropDown(anchorView, 0, -anchorView.height) - } - - fun beginSearchMode() { - searchInputTextWatcher = SearchInputTextChangeListener(editor) - findInFileBinding.searchInput.addTextChangedListener(searchInputTextWatcher) - findInFileBinding.searchInput.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_NEXT) { - onSearchActionClick(findInFileBinding.next) - } - false - } - findInFileBinding.root.visibility = VISIBLE - onSearchModeChanged?.invoke(true) - - findInFileBinding.searchInput.requestFocus() - findInFileBinding.searchInput.post { - ViewCompat.getWindowInsetsController(findInFileBinding.searchInput)?.show(WindowInsetsCompat.Type.ime()) - } - } - - fun isSearchModeActive(): Boolean = searchInputTextWatcher != null - - /** - * Re-run the current query against the editor's (possibly changed) content, - * or stop the search if there is no query. - */ - fun refreshSearch() { - if (!isSearchModeActive()) { - return - } - - val query = findInFileBinding.searchInput.text?.toString() ?: "" - val isValidQuery = - query.isNotBlank() && - (searchOptions.type != SearchOptions.TYPE_REGULAR_EXPRESSION || - runCatching { Pattern.compile(query) }.isSuccess) - - if (isValidQuery) { - editor.searcher.search(query, searchOptions) - } else { - editor.searcher.stopSearch() - } - } - - private fun onSearchActionClick(v: View) { - val searcher = editor.searcher - if (v.id == findInFileBinding.close.id) { - if (this.searchInputTextWatcher == null) { - return - } - findInFileBinding.searchInput.removeTextChangedListener(this.searchInputTextWatcher) - findInFileBinding.root.visibility = GONE - this.searchInputTextWatcher = null - searcher.onClose() - onSearchModeChanged?.invoke(false) - } - if (!searcher.hasQuery()) { - return - } - if (v.id == findInFileBinding.prev.id) { - searcher.gotoPrevious() - return - } - if (v.id == findInFileBinding.next.id) { - searcher.gotoNext() - return - } - if (v.id == findInFileBinding.replace.id) { - doReplace(editor) - } - } - - inner class SearchInputTextChangeListener(val editor: IDEEditor?) : SingleTextWatcher() { - - override fun onTextChanged( - s: CharSequence, - start: Int, - before: Int, - count: Int, - ) { - if (editor == null) { - return - } - if (TextUtils.isEmpty(s)) { - editor.searcher.stopSearch() - return - } - - // Handle bad regexp - val query = - s.toString().let { - if (searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION) { - try { - Pattern.compile(it) - it - } catch (_: Throwable) { - "" - } - } else { - it - } - } - - if (query.isNotBlank()) { - editor.searcher.search(query, searchOptions) - } - } - } - - private fun updateActionsBottomMargin(isImeVisible: Boolean) { - findInFileBinding.actionsContainer.updateLayoutParams { - bottomMargin = if (isImeVisible) 0 else collapsedSheetMargin - } - } + // Hosts above the collapsed bottom sheet need a margin so the action row stays visible; + // hosts inside the sheet itself don't. + private val collapsedSheetMargin = + if (applyCollapsedSheetMargin) { + context.resources.getDimensionPixelSize(R.dimen.editor_sheet_peek_height) + } else { + 0 + } + + var onSearchModeChanged: ((isActive: Boolean) -> Unit)? = null + + private var searchInputTextWatcher: TextWatcher? = null + private var searchOptions = SearchOptions(true, false) + private val findInFileBinding: LayoutFindInFileBinding = LayoutFindInFileBinding.inflate(LayoutInflater.from(context)) + private val optionsMenu: PopupMenu + + init { + findInFileBinding.prev.setOnClickListener(::onSearchActionClick) + findInFileBinding.next.setOnClickListener(::onSearchActionClick) + findInFileBinding.replace.setOnClickListener(::onSearchActionClick) + findInFileBinding.close.setOnClickListener(::onSearchActionClick) + if (!showReplaceAction) { + // Read-only hosts (log/output views) cannot replace text + findInFileBinding.replace.visibility = GONE + } + findInFileBinding.root.applyLongPressRecursively { + TooltipManager.showIdeCategoryTooltip( + context = this.context, + anchorView = this, + tag = TooltipTag.DIALOG_FIND_IN_FILE, + ) + true + } + + optionsMenu = PopupMenu(context, findInFileBinding.moreOptions, Gravity.TOP) + optionsMenu.menu.add(0, 0, 0, R.string.msg_ignore_case).apply { + isCheckable = true + isChecked = true + } + + optionsMenu.menu.add(0, 1, 0, R.string.msg_use_regex).apply { + isCheckable = true + isChecked = false + } + + optionsMenu.setOnMenuItemClickListener { + return@setOnMenuItemClickListener if (it.isCheckable) { + it.isChecked = !it.isChecked + + val caseInsensitive = searchOptions.caseInsensitive + val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION + searchOptions = + when (it.itemId) { + 0 -> SearchOptions(it.isChecked, regex) + 1 -> SearchOptions(caseInsensitive, it.isChecked) + else -> searchOptions + } + editor.searcher.updateSearchOptions(searchOptions) + + true + } else { + false + } + } + + findInFileBinding.root.visibility = GONE + findInFileBinding.moreOptions.apply { + setOnClickListener { + showPopupMenu(findInFileBinding.moreOptions) + } + setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip( + context = this@EditorSearchLayout.context, + anchorView = this, + tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS, + ) + true + } + } + ViewCompat.setOnApplyWindowInsetsListener(findInFileBinding.root) { _, insets -> + updateActionsBottomMargin(insets.isVisible(WindowInsetsCompat.Type.ime())) + insets + } + + addView( + findInFileBinding.root, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT), + ) + } + + private fun showPopupMenu(anchorView: View) { + val binding = + SearchOptionsPopupMenuBinding.inflate(LayoutInflater.from(context), null, false) + + val popupWindow = + PopupWindow( + binding.root, + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + ).apply { + elevation = 2f + isOutsideTouchable = true + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + } + + val tooltipListener = + OnLongClickListener { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = TooltipTag.DIALOG_FIND_IN_FILE_OPTIONS, + ) + popupWindow.dismiss() + true + } + + binding.layoutSearchOptions.setOnLongClickListener(tooltipListener) + + val caseInsensitive = searchOptions.caseInsensitive + val regex = searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION + + fun onIgnoreCaseToggled() { + binding.checkboxIgnoreCase.isChecked = !binding.checkboxIgnoreCase.isChecked + searchOptions = SearchOptions(binding.checkboxIgnoreCase.isChecked, regex) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + fun handleIgnoreCaseCheckedChange(isChecked: Boolean) { + searchOptions = SearchOptions(isChecked, regex) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + binding.layoutIgnoreCase.apply { + setOnClickListener { onIgnoreCaseToggled() } + setOnLongClickListener(tooltipListener) + } + + binding.checkboxIgnoreCase.apply { + isChecked = caseInsensitive + setOnCheckedChangeListener { _, isChecked -> handleIgnoreCaseCheckedChange(isChecked) } + setOnLongClickListener(tooltipListener) + } + + fun onUseRegexToggled() { + binding.checkboxUseRegex.isChecked = !binding.checkboxUseRegex.isChecked + searchOptions = SearchOptions(caseInsensitive, binding.checkboxUseRegex.isChecked) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + fun handleRegexCheckedChange(isChecked: Boolean) { + searchOptions = SearchOptions(caseInsensitive, isChecked) + editor.searcher.updateSearchOptions(searchOptions) + popupWindow.dismiss() + } + + binding.layoutUseRegex.apply { + setOnClickListener { onUseRegexToggled() } + setOnLongClickListener(tooltipListener) + } + + binding.checkboxUseRegex.apply { + isChecked = regex + setOnCheckedChangeListener { _, isChecked -> handleRegexCheckedChange(isChecked) } + setOnLongClickListener(tooltipListener) + } + + popupWindow.showAsDropDown(anchorView, 0, -anchorView.height) + } + + fun beginSearchMode() { + searchInputTextWatcher = SearchInputTextChangeListener(editor) + findInFileBinding.searchInput.addTextChangedListener(searchInputTextWatcher) + findInFileBinding.searchInput.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + onSearchActionClick(findInFileBinding.next) + } + false + } + findInFileBinding.root.visibility = VISIBLE + onSearchModeChanged?.invoke(true) + + findInFileBinding.searchInput.requestFocus() + findInFileBinding.searchInput.post { + ViewCompat.getWindowInsetsController(findInFileBinding.searchInput)?.show(WindowInsetsCompat.Type.ime()) + } + } + + fun isSearchModeActive(): Boolean = searchInputTextWatcher != null + + /** + * Re-run the current query against the editor's (possibly changed) content, + * or stop the search if there is no query. + */ + fun refreshSearch() { + if (!isSearchModeActive()) { + return + } + + val query = findInFileBinding.searchInput.text?.toString() ?: "" + val isValidQuery = + query.isNotBlank() && + ( + searchOptions.type != SearchOptions.TYPE_REGULAR_EXPRESSION || + runCatching { Pattern.compile(query) }.isSuccess + ) + + if (isValidQuery) { + editor.searcher.search(query, searchOptions) + } else { + editor.searcher.stopSearch() + } + } + + private fun onSearchActionClick(v: View) { + val searcher = editor.searcher + if (v.id == findInFileBinding.close.id) { + if (this.searchInputTextWatcher == null) { + return + } + findInFileBinding.searchInput.removeTextChangedListener(this.searchInputTextWatcher) + findInFileBinding.root.visibility = GONE + this.searchInputTextWatcher = null + searcher.onClose() + onSearchModeChanged?.invoke(false) + } + if (!searcher.hasQuery()) { + return + } + if (v.id == findInFileBinding.prev.id) { + searcher.gotoPrevious() + return + } + if (v.id == findInFileBinding.next.id) { + searcher.gotoNext() + return + } + if (v.id == findInFileBinding.replace.id) { + doReplace(editor) + } + } + + inner class SearchInputTextChangeListener( + val editor: IDEEditor?, + ) : SingleTextWatcher() { + override fun onTextChanged( + s: CharSequence, + start: Int, + before: Int, + count: Int, + ) { + if (editor == null) { + return + } + if (TextUtils.isEmpty(s)) { + editor.searcher.stopSearch() + return + } + + // Handle bad regexp + val query = + s.toString().let { + if (searchOptions.type == SearchOptions.TYPE_REGULAR_EXPRESSION) { + try { + Pattern.compile(it) + it + } catch (_: Throwable) { + "" + } + } else { + it + } + } + + if (query.isNotBlank()) { + editor.searcher.search(query, searchOptions) + } + } + } + + private fun updateActionsBottomMargin(isImeVisible: Boolean) { + findInFileBinding.actionsContainer.updateLayoutParams { + bottomMargin = if (isImeVisible) 0 else collapsedSheetMargin + } + } } diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index f43a8da869..9218728690 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -16,176 +16,175 @@ object TooltipTag { const val EDITOR_TOOLBAR_PASTE = "editor.toolbar.paste" const val EDITOR_TOOLBAR_SELECT_ALL = "editor.toolbar.selectall" const val EDITOR_TOOLBAR_OUTPUT_SELECT_ALL = "output.selection.selectall" - const val EDITOR_TOOLBAR_FORMAT_CODE = "editor.toolbar.formatcode" + const val EDITOR_TOOLBAR_FORMAT_CODE = "editor.toolbar.formatcode" const val EDITOR_TOOLBAR_HELP = "editor.toolbar.help" const val EDITOR_TOOLBAR_AI = "editor.toolbar.ai" const val PROJECT_APP_LOGS = "project.applogs" const val PROJECT_IDE_LOGS = "project.idelogs" const val PROJECT_SEARCH_RESULTS = "project.searchresults" const val PROJECT_DIAGNOSTICS = "project.diagnostics" - const val PROJECT_AGENT = "project.agent" + const val PROJECT_AGENT = "project.agent" const val OUTPUT_CLEAR = "output.clear" const val OUTPUT_SHARE_EXTERNAL = "output.share.external" const val OUTPUT_SEARCH = "output.search" const val OUTPUT_FILTER = "output.filter" const val PROJECT_BUILD_OUTPUT = "project.buildoutput" const val PROJECT_GRADLE_TASKS = "project.gradle.tasks" - const val PROJECT_RUN_GRADLE_TASKS = "project.run.gradle.tasks" - - // General Preferences - const val PREFS_TOP = "prefs.top" - const val PREFS_EDITOR = "prefs.editor" - const val PREFS_GENERAL = "prefs.general" - const val PREFS_BUILD_RUN = "prefs.buildrun" - const val PREFS_GRADLE = "prefs.gradle" - const val PREFS_TERMUX = "prefs.termux" - const val PREFS_EDITOR_XML = "prefs.editor.xml" - const val PREFS_DEVELOPER = "prefs.developer" - const val PLUGIN_MANAGER = "plugin.manager" - const val TEMPLATE_TABBED_ACTIVITY = "template.tabbed.activity" - const val TEMPLATE_LEGACY_PROJECT = "template.legacy.project" - const val TEMPLATE_EMPTY_ACTIVITY = "template.empty.activity" - const val TEMPLATE_BOTTOM_NAV_ACTIVITY = "template.bottomnav.activity" - const val TEMPLATE_COMPOSE_ACTIVITY = "template.compose.activity" - const val TEMPLATE_BASIC_ACTIVITY = "template.basic.activity" - const val TEMPLATE_NO_ACTIVITY = "template.no.activity" - const val TEMPLATE_NAV_DRAWER_ACTIVITY = "template.navdrawer.activity" - const val TEMPLATE_NDK_ACTIVITY = "template.ndk.activity" - - // Editor screen - const val EDITOR_PROJECT_OVERVIEW = "project.overview" - const val EDITOR_BUILD_STATUS = "project.status" - - // Code Actions menu - const val EDITOR_CODE_ACTIONS_COMMENT = "editor.codeactions.comment" - const val EDITOR_CODE_ACTIONS_UNCOMMENT = "editor.codeactions.uncomment" - const val EDITOR_CODE_ACTIONS_GOTO_DEF = "editor.codeactions.gotodef" - const val EDITOR_CODE_ACTIONS_FIND_REFS = "editor.codeactions.findrefs" - const val EDITOR_CODE_ACTIONS_FIX_IMPORTS = "editor.codeactions.fiximports" - const val EDITOR_CODE_ACTIONS_SETTER_GETTER = "editor.codeactions.settergetter" - const val EDITOR_CODE_ACTIONS_SETTER_GETTER_DIALOG = "editor.codeactions.settergetter.dialog" - const val EDITOR_CODE_ACTIONS_OVERRIDE_SUPER = "editor.codeactions.overridesuper" - const val EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG = "editor.codeactions.overridesuper.dialog" - const val EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR = "editor.codeactions.genconstructor" - const val EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR_DIALOG = "editor.codeactions.genconstructor.dialog" - const val EDITOR_CODE_ACTIONS_GEN_TO_STRING = "editor.codeactions.gentostring" - const val EDITOR_CODE_ACTIONS_GEN_TO_STRING_DIALOG = "editor.codeactions.gentostring.dialog" - const val EDITOR_CODE_ACTIONS_UNUSED_IMPORTS = "editor.codeactions.unusedimports" - const val EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS = "editor.codeactions.organizeimports" - - const val EXIT_TO_MAIN = "exit.to.main" - - // Main Page - const val MAIN_GET_STARTED = "main.get.started" - const val PROJECT_OPEN = "project.open" - const val MAIN_PROJECT_DELETE = "main.project.delete" - const val MAIN_HELP = "main.help" - const val FEEDBACK = "feedback" - const val MAIN_PREFERENCES = "main.prefs" - const val MAIN_TERMINAL = "main.terminal" - const val MAIN_GIT = "main.git" - - // Editor toolbar - const val EDITOR_TOOLBAR_NAV_ICON = "project.menu" - const val EDITOR_TOOLBAR_QUICK_RUN = "project.run" - const val EDITOR_TOOLBAR_DEBUG = "project.debug" - const val EDITOR_TOOLBAR_FIND = "project.find.top" - const val EDITOR_TOOLBAR_FIND_IN_PROJECT = "project.find.in.project" - const val EDITOR_TOOLBAR_FIND_IN_FILE = "editor.find.in.file" - const val EDITOR_TOOLBAR_SYNC = "project.sync" - const val EDITOR_TOOLBAR_LAUNCH_APP = "project.launch.app" - const val EDITOR_TOOLBAR_UNDO = "editor.undo" - const val EDITOR_TOOLBAR_REDO = "editor.redo" - const val EDITOR_TOOLBAR_QUICK_SAVE = "project.save" - const val EDITOR_TOOLBAR_PREVIEW_LAYOUT = "editor.layout.preview" - const val EDITOR_TOOLBAR_PREVIEW_COMPOSE = "editor.compose.preview" - const val EDITOR_TOOLBAR_COMPUTER_VISION = "project.layout.vision" - const val EDITOR_TOOLBAR_LOG_SENDER = "editor.disconnect.logsenders" - - // Floating window chrome - const val WINDOW_MINIMIZE = "window-min" - const val WINDOW_MAXIMIZE = "window-max" - const val WINDOW_DOCK = "window-dock" - const val WINDOW_UNDOCK = "window-undock" - - // Delete project - const val DELETE_PROJECT = "project.delete" - const val DELETE_PROJECT_SELECT = "project.delete.select" - const val DELETE_PROJECT_BUTTON = "project.delete.button" - const val DELETE_PROJECT_CONFIRM = "project.delete.confirm" - const val DELETE_PROJECT_DIALOG = "project.delete.dialog" - - - const val PROJECT_NEW = "project.new" + const val PROJECT_RUN_GRADLE_TASKS = "project.run.gradle.tasks" + + // General Preferences + const val PREFS_TOP = "prefs.top" + const val PREFS_EDITOR = "prefs.editor" + const val PREFS_GENERAL = "prefs.general" + const val PREFS_BUILD_RUN = "prefs.buildrun" + const val PREFS_GRADLE = "prefs.gradle" + const val PREFS_TERMUX = "prefs.termux" + const val PREFS_EDITOR_XML = "prefs.editor.xml" + const val PREFS_DEVELOPER = "prefs.developer" + const val PLUGIN_MANAGER = "plugin.manager" + const val TEMPLATE_TABBED_ACTIVITY = "template.tabbed.activity" + const val TEMPLATE_LEGACY_PROJECT = "template.legacy.project" + const val TEMPLATE_EMPTY_ACTIVITY = "template.empty.activity" + const val TEMPLATE_BOTTOM_NAV_ACTIVITY = "template.bottomnav.activity" + const val TEMPLATE_COMPOSE_ACTIVITY = "template.compose.activity" + const val TEMPLATE_BASIC_ACTIVITY = "template.basic.activity" + const val TEMPLATE_NO_ACTIVITY = "template.no.activity" + const val TEMPLATE_NAV_DRAWER_ACTIVITY = "template.navdrawer.activity" + const val TEMPLATE_NDK_ACTIVITY = "template.ndk.activity" + + // Editor screen + const val EDITOR_PROJECT_OVERVIEW = "project.overview" + const val EDITOR_BUILD_STATUS = "project.status" + + // Code Actions menu + const val EDITOR_CODE_ACTIONS_COMMENT = "editor.codeactions.comment" + const val EDITOR_CODE_ACTIONS_UNCOMMENT = "editor.codeactions.uncomment" + const val EDITOR_CODE_ACTIONS_GOTO_DEF = "editor.codeactions.gotodef" + const val EDITOR_CODE_ACTIONS_FIND_REFS = "editor.codeactions.findrefs" + const val EDITOR_CODE_ACTIONS_FIX_IMPORTS = "editor.codeactions.fiximports" + const val EDITOR_CODE_ACTIONS_SETTER_GETTER = "editor.codeactions.settergetter" + const val EDITOR_CODE_ACTIONS_SETTER_GETTER_DIALOG = "editor.codeactions.settergetter.dialog" + const val EDITOR_CODE_ACTIONS_OVERRIDE_SUPER = "editor.codeactions.overridesuper" + const val EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG = "editor.codeactions.overridesuper.dialog" + const val EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR = "editor.codeactions.genconstructor" + const val EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR_DIALOG = "editor.codeactions.genconstructor.dialog" + const val EDITOR_CODE_ACTIONS_GEN_TO_STRING = "editor.codeactions.gentostring" + const val EDITOR_CODE_ACTIONS_GEN_TO_STRING_DIALOG = "editor.codeactions.gentostring.dialog" + const val EDITOR_CODE_ACTIONS_UNUSED_IMPORTS = "editor.codeactions.unusedimports" + const val EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS = "editor.codeactions.organizeimports" + + const val EXIT_TO_MAIN = "exit.to.main" + + // Main Page + const val MAIN_GET_STARTED = "main.get.started" + const val PROJECT_OPEN = "project.open" + const val MAIN_PROJECT_DELETE = "main.project.delete" + const val MAIN_HELP = "main.help" + const val FEEDBACK = "feedback" + const val MAIN_PREFERENCES = "main.prefs" + const val MAIN_TERMINAL = "main.terminal" + const val MAIN_GIT = "main.git" + + // Editor toolbar + const val EDITOR_TOOLBAR_NAV_ICON = "project.menu" + const val EDITOR_TOOLBAR_QUICK_RUN = "project.run" + const val EDITOR_TOOLBAR_DEBUG = "project.debug" + const val EDITOR_TOOLBAR_FIND = "project.find.top" + const val EDITOR_TOOLBAR_FIND_IN_PROJECT = "project.find.in.project" + const val EDITOR_TOOLBAR_FIND_IN_FILE = "editor.find.in.file" + const val EDITOR_TOOLBAR_SYNC = "project.sync" + const val EDITOR_TOOLBAR_LAUNCH_APP = "project.launch.app" + const val EDITOR_TOOLBAR_UNDO = "editor.undo" + const val EDITOR_TOOLBAR_REDO = "editor.redo" + const val EDITOR_TOOLBAR_QUICK_SAVE = "project.save" + const val EDITOR_TOOLBAR_PREVIEW_LAYOUT = "editor.layout.preview" + const val EDITOR_TOOLBAR_PREVIEW_COMPOSE = "editor.compose.preview" + const val EDITOR_TOOLBAR_COMPUTER_VISION = "project.layout.vision" + const val EDITOR_TOOLBAR_LOG_SENDER = "editor.disconnect.logsenders" + + // Floating window chrome + const val WINDOW_MINIMIZE = "window-min" + const val WINDOW_MAXIMIZE = "window-max" + const val WINDOW_DOCK = "window-dock" + const val WINDOW_UNDOCK = "window-undock" + + // Delete project + const val DELETE_PROJECT = "project.delete" + const val DELETE_PROJECT_SELECT = "project.delete.select" + const val DELETE_PROJECT_BUTTON = "project.delete.button" + const val DELETE_PROJECT_CONFIRM = "project.delete.confirm" + const val DELETE_PROJECT_DIALOG = "project.delete.dialog" + + const val PROJECT_NEW = "project.new" const val PROJECT_RECENT_TOP = "project.recent.top" const val PROJECT_INFO_TOOLTIP = "project.recent.info" const val PROJECT_RECENT_RENAME = "project.recent.rename" const val PROJECT_OPEN_FOLDER = "project.open.folder" - const val PROJECT_ITEM_COPYPATH = "project.item.copypath" - const val PROJECT_ITEM_DELETE = "project.item.delete" - const val PROJECT_CONFIRM_DELETE = "project.confirm.delete" - const val PROJECT_FOLDER_NEWFILE = "project.folder.newfile" - const val PROJECT_FOLDER_NEW_FOLDER = "project.folder.newfolder" - const val PROJECT_NEWFILE_DIALOG = "project.newfile.dialog" - const val PROJECT_FOLDER_NEWTYPE = "project.folder.newtype" - const val PROJECT_FOLDER_NEWXML = "project.folder.newxml" - const val PROJECT_ITEM_RENAME = "project.item.rename" - const val PROJECT_RENAME_DIALOG = "project.rename.dialog" - const val PROJECT_NEW_FOLDER_DIALOG = "project.newfolder.dialog" - const val PROJECT_FOLDER_HELP = "project.folder.help" - const val PROJECT_FILE_OPENWITH = "project.file.openwith" - - // Template Details - const val SETUP_PREVIOUS = "setup.previous" - const val SETUP_OVERVIEW = "setup.overview" + const val PROJECT_ITEM_COPYPATH = "project.item.copypath" + const val PROJECT_ITEM_DELETE = "project.item.delete" + const val PROJECT_CONFIRM_DELETE = "project.confirm.delete" + const val PROJECT_FOLDER_NEWFILE = "project.folder.newfile" + const val PROJECT_FOLDER_NEW_FOLDER = "project.folder.newfolder" + const val PROJECT_NEWFILE_DIALOG = "project.newfile.dialog" + const val PROJECT_FOLDER_NEWTYPE = "project.folder.newtype" + const val PROJECT_FOLDER_NEWXML = "project.folder.newxml" + const val PROJECT_ITEM_RENAME = "project.item.rename" + const val PROJECT_RENAME_DIALOG = "project.rename.dialog" + const val PROJECT_NEW_FOLDER_DIALOG = "project.newfolder.dialog" + const val PROJECT_FOLDER_HELP = "project.folder.help" + const val PROJECT_FILE_OPENWITH = "project.file.openwith" + + // Template Details + const val SETUP_PREVIOUS = "setup.previous" + const val SETUP_OVERVIEW = "setup.overview" const val SETUP_CREATE_PROJECT = "setup.create.project" - // Debugger - const val PROJECT_DEBUGGER_OUTPUT = "project.debugger.output" - const val DEBUG_THREAD_SELECTOR = "debug.thread.selector" - const val DEBUG_OUTPUT_VARIABLES = "debug.output.variables" - const val DEBUG_OUTPUT_CALLSTACK = "debug.output.callstack" - const val DEBUG_NOT_CONNECTED = "debug.not.connected" - const val DEBUG_THREAD_SELECT_DIALOG = "debug.thread.select.dialog" - const val EDITOR_CHARACTER_TOOLBAR = "editor.character.toolbar" - - const val DIALOG_FIND_IN_PROJECT = "project.find.dialog" - const val DIALOG_FIND_IN_FILE = "file.find.dialog" - const val DIALOG_FIND_IN_FILE_OPTIONS = "file.find.options" - const val DIALOG_REPLACE_IN_FILE = "file.find.replace.dialog" - const val PROJECT_FILENAME = "project.filename" - const val EDITOR_FILE_CLOSE_OPTIONS = "editor.file.close.options" - const val PROJECT_FILE_HELP = "project.file.help" - - // Debugger Actions - const val DEBUGGER_ACTION_MOVE = "debug.toolbar.move" - const val DEBUGGER_ACTION_PAUSE_RESUME = "debug.toolbar.pauseresume" - const val DEBUGGER_ACTION_STEP_OVER = "debug.toolbar.stepover" - const val DEBUGGER_ACTION_STEP_INTO = "debug.toolbar.stepinto" - const val DEBUGGER_ACTION_STEP_OUT = "debug.toolbar.stepout" - const val DEBUGGER_ACTION_KILL = "debug.toolbar.kill" - const val DEBUGGER_ACTION_RESTART = "debug.toolbar.restart" - - const val PROJECT_PLUGIN_TAB = "project.plugin.tab" - - // Git - const val GIT_DOWNLOAD_SCREEN = "git.download" - const val GIT_PREFS = "git.prefs" - const val PROJECT_GIT = "project.git" - const val PROJECT_GIT_SUMMARY = "project.git.summary" - const val PROJECT_GIT_DESCRIPTION = "project.git.description" - const val PROJECT_GIT_FILES = "project.git.files" - const val PROJECT_GIT_ID = "project.git.id" - const val PROJECT_GIT_COMMIT = "project.git.commit" - const val PROJECT_GIT_COMMIT_HISTORY = "project.git.commit.history" - const val PROJECT_GIT_ABORT = "project.git.abort" - const val GIT_COMMIT_HISTORY = "git.commit.history" - const val GIT_DIALOG_SAVE = "git.dialog.save" - const val GIT_DIALOG_MERGE_CONFLICTS = "git.dialog.mergeconflicts" - const val GIT_DIALOG_PULL_FAIL = "git.dialog.pullfail" - const val GIT_DIALOG_PUSH_FAIL = "git.dialog.pushfail" - const val GIT_DIALOG_ABORT_MERGE = "git.dialog.abortmerge" - const val GIT_PUSH = "git.action.push" - const val GIT_PULL = "git.action.pull" + // Debugger + const val PROJECT_DEBUGGER_OUTPUT = "project.debugger.output" + const val DEBUG_THREAD_SELECTOR = "debug.thread.selector" + const val DEBUG_OUTPUT_VARIABLES = "debug.output.variables" + const val DEBUG_OUTPUT_CALLSTACK = "debug.output.callstack" + const val DEBUG_NOT_CONNECTED = "debug.not.connected" + const val DEBUG_THREAD_SELECT_DIALOG = "debug.thread.select.dialog" + const val EDITOR_CHARACTER_TOOLBAR = "editor.character.toolbar" + + const val DIALOG_FIND_IN_PROJECT = "project.find.dialog" + const val DIALOG_FIND_IN_FILE = "file.find.dialog" + const val DIALOG_FIND_IN_FILE_OPTIONS = "file.find.options" + const val DIALOG_REPLACE_IN_FILE = "file.find.replace.dialog" + const val PROJECT_FILENAME = "project.filename" + const val EDITOR_FILE_CLOSE_OPTIONS = "editor.file.close.options" + const val PROJECT_FILE_HELP = "project.file.help" + + // Debugger Actions + const val DEBUGGER_ACTION_MOVE = "debug.toolbar.move" + const val DEBUGGER_ACTION_PAUSE_RESUME = "debug.toolbar.pauseresume" + const val DEBUGGER_ACTION_STEP_OVER = "debug.toolbar.stepover" + const val DEBUGGER_ACTION_STEP_INTO = "debug.toolbar.stepinto" + const val DEBUGGER_ACTION_STEP_OUT = "debug.toolbar.stepout" + const val DEBUGGER_ACTION_KILL = "debug.toolbar.kill" + const val DEBUGGER_ACTION_RESTART = "debug.toolbar.restart" + + const val PROJECT_PLUGIN_TAB = "project.plugin.tab" + + // Git + const val GIT_DOWNLOAD_SCREEN = "git.download" + const val GIT_PREFS = "git.prefs" + const val PROJECT_GIT = "project.git" + const val PROJECT_GIT_SUMMARY = "project.git.summary" + const val PROJECT_GIT_DESCRIPTION = "project.git.description" + const val PROJECT_GIT_FILES = "project.git.files" + const val PROJECT_GIT_ID = "project.git.id" + const val PROJECT_GIT_COMMIT = "project.git.commit" + const val PROJECT_GIT_COMMIT_HISTORY = "project.git.commit.history" + const val PROJECT_GIT_ABORT = "project.git.abort" + const val GIT_COMMIT_HISTORY = "git.commit.history" + const val GIT_DIALOG_SAVE = "git.dialog.save" + const val GIT_DIALOG_MERGE_CONFLICTS = "git.dialog.mergeconflicts" + const val GIT_DIALOG_PULL_FAIL = "git.dialog.pullfail" + const val GIT_DIALOG_PUSH_FAIL = "git.dialog.pushfail" + const val GIT_DIALOG_ABORT_MERGE = "git.dialog.abortmerge" + const val GIT_PUSH = "git.action.push" + const val GIT_PULL = "git.action.pull" } diff --git a/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt index 90c9653032..0979a8a4fe 100644 --- a/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt +++ b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt @@ -33,13 +33,19 @@ import java.util.concurrent.atomic.AtomicInteger * @author Akash Yadav */ class GlobalBufferAppender : AppenderBase() { - interface Consumer { val logLevel: Level - fun consume(level: Level, message: String) + + fun consume( + level: Level, + message: String, + ) } - private data class LogEvent(val level: Level, val message: String) + private data class LogEvent( + val level: Level, + val message: String, + ) private val logLayout = IDELogFormatLayout(false) @@ -59,7 +65,7 @@ class GlobalBufferAppender : AppenderBase() { dispatchTo( consumer = consumer, level = message.level, - message = message.message + message = message.message, ) } } @@ -71,7 +77,10 @@ class GlobalBufferAppender : AppenderBase() { consumers.remove(consumer) } - private fun dispatch(level: Level, message: String) { + private fun dispatch( + level: Level, + message: String, + ) { consumers.forEach { consumer -> dispatchTo(consumer, level, message) } @@ -80,7 +89,7 @@ class GlobalBufferAppender : AppenderBase() { private fun dispatchTo( consumer: Consumer, level: Level, - message: String + message: String, ) { if (level.levelInt < consumer.logLevel.levelInt) return runCatching { consumer.consume(level, message) } From 1611d0b143f2404bea553d195e677fd0fd0bf064 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 14:10:25 +0100 Subject: [PATCH 07/14] ADFA-936: Rename level-chip strings to avoid Termux duplicates log_level_verbose/log_level_debug already exist in termux_shared_strings.xml in the same module; resource merging fails on the duplicates. Prefix ours with log_filter_. --- app/src/main/res/layout/layout_log_filter_bar.xml | 10 +++++----- resources/src/main/res/values/strings.xml | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/layout/layout_log_filter_bar.xml b/app/src/main/res/layout/layout_log_filter_bar.xml index fa634ddad0..9c885ce941 100644 --- a/app/src/main/res/layout/layout_log_filter_bar.xml +++ b/app/src/main/res/layout/layout_log_filter_bar.xml @@ -70,7 +70,7 @@ android:layout_height="wrap_content" android:checkable="true" android:checked="true" - android:text="@string/log_level_verbose" /> + android:text="@string/log_filter_level_verbose" /> + android:text="@string/log_filter_level_debug" /> + android:text="@string/log_filter_level_info" /> + android:text="@string/log_filter_level_warning" /> + android:text="@string/log_filter_level_error" /> diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 104748b98e..0a7ca418d9 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -680,11 +680,11 @@ Open a file to show its diagnostic results Filter lines Hide filter bar - Verbose - Debug - Info - Warning - Error + Verbose + Debug + Info + Warning + Error Search in output Filter output "Build the application or run a task to see its build output here. " From c78e51c6272cb7c894f13a11844768c4f1c69b2a Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 14:16:42 +0100 Subject: [PATCH 08/14] ADFA-936: Drop stale Nothing return type on observeLogs uiEvents is now a cold flow whose collect() completes, so the Nothing return type no longer compiles. --- .../com/itsaky/androidide/fragments/output/LogViewFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index ce879ca6f3..d104e0e0a6 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -143,7 +143,7 @@ abstract class LogViewFragment : } } - private suspend fun observeLogs(): Nothing { + private suspend fun observeLogs() { // Wait for the editor's first layout pass. The sora-editor's // LineBreakLayout populates its line-width tracker asynchronously after // layout; appending before that races BlockIntList.set on an empty list. From f561cf170c49dbce9afccd6ff686c55289024b7f Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 15 Jul 2026 14:41:17 +0100 Subject: [PATCH 09/14] ADFA-936: Consume IDE logs in the ViewModel to avoid replay duplicates GlobalBufferAppender replays its buffer on every registerConsumer(). With the fragment as consumer, each tab switch (view recreation) re-registered and re-submitted the replayed lines into the retained LogBuffer, duplicating history. The activity-scoped IDELogsViewModel is now the consumer, so the replay happens once per ViewModel lifetime. Found during on-device verification (line duplicated after switching tabs away and back). --- .../fragments/output/IDELogFragment.kt | 36 ++--------------- .../androidide/viewmodel/IDELogsViewModel.kt | 40 ++++++++++++++++++- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt index c710a7ad6c..c21d4023fa 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/IDELogFragment.kt @@ -20,21 +20,16 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels -import ch.qos.logback.classic.Level import com.itsaky.androidide.R import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.logging.GlobalBufferAppender -import com.itsaky.androidide.utils.FeatureFlags -import com.itsaky.androidide.utils.ILogger import com.itsaky.androidide.viewmodel.IDELogsViewModel /** - * Fragment to show IDE logs. + * Fragment to show IDE logs. Log consumption happens in [IDELogsViewModel]. + * * @author Akash Yadav */ -class IDELogFragment : - LogViewFragment(), - GlobalBufferAppender.Consumer { +class IDELogFragment : LogViewFragment() { override fun isSimpleFormattingEnabled() = true override fun getShareableFilename() = "ide_logs" @@ -43,36 +38,11 @@ class IDELogFragment : override val viewModel by activityViewModels() - override val logLevel: Level - get() = if (FeatureFlags.isDebugLoggingEnabled) Level.DEBUG else Level.INFO - override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_idelogs)) - - // Register with GlobalBufferAppender to receive all logs (including buffered ones) - GlobalBufferAppender.registerConsumer(this) - } - - override fun consume( - level: Level, - message: String, - ) = viewModel.submit(level.toILoggerLevel(), message) - - override fun onDestroyView() { - GlobalBufferAppender.unregisterConsumer(this) - super.onDestroyView() } } - -private fun Level.toILoggerLevel(): ILogger.Level = - when { - levelInt <= Level.TRACE_INT -> ILogger.Level.VERBOSE - levelInt <= Level.DEBUG_INT -> ILogger.Level.DEBUG - levelInt <= Level.INFO_INT -> ILogger.Level.INFO - levelInt <= Level.WARN_INT -> ILogger.Level.WARNING - else -> ILogger.Level.ERROR - } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt index 8f31788fc7..0a6e0575e3 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/IDELogsViewModel.kt @@ -1,6 +1,44 @@ package com.itsaky.androidide.viewmodel +import ch.qos.logback.classic.Level +import com.itsaky.androidide.logging.GlobalBufferAppender +import com.itsaky.androidide.utils.FeatureFlags +import com.itsaky.androidide.utils.ILogger + /** + * Consumes IDE logs from [GlobalBufferAppender]. The ViewModel (not the fragment) is the + * consumer so that the appender's buffer replay happens exactly once per ViewModel lifetime -- + * registering on every fragment view recreation would re-submit the replayed lines into the + * retained log history and duplicate them. + * * @author Akash Yadav */ -class IDELogsViewModel : LogViewModel() +class IDELogsViewModel : + LogViewModel(), + GlobalBufferAppender.Consumer { + override val logLevel: Level + get() = if (FeatureFlags.isDebugLoggingEnabled) Level.DEBUG else Level.INFO + + init { + GlobalBufferAppender.registerConsumer(this) + } + + override fun consume( + level: Level, + message: String, + ) = submit(level.toILoggerLevel(), message) + + override fun onCleared() { + GlobalBufferAppender.unregisterConsumer(this) + super.onCleared() + } +} + +private fun Level.toILoggerLevel(): ILogger.Level = + when { + levelInt <= Level.TRACE_INT -> ILogger.Level.VERBOSE + levelInt <= Level.DEBUG_INT -> ILogger.Level.DEBUG + levelInt <= Level.INFO_INT -> ILogger.Level.INFO + levelInt <= Level.WARN_INT -> ILogger.Level.WARNING + else -> ILogger.Level.ERROR + } From 7dafb60e43db84851c7272ae38f419a3d42170ad Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 17 Jul 2026 10:24:21 +0100 Subject: [PATCH 10/14] feat(ADFA-936): Replace log FABs with buttons --- .../itsaky/androidide/ui/EditorBottomSheet.kt | 62 +++++++------ .../res/layout/layout_editor_bottom_sheet.xml | 87 +++++++------------ resources/src/main/res/values/strings.xml | 4 + resources/src/main/res/values/styles.xml | 13 +++ 4 files changed, 78 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index b19c65a88a..8358a11aa8 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -204,7 +204,7 @@ class EditorBottomSheet }, ) - binding.shareOutputFab.setOnClickListener { + binding.shareOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) if (fragment !is ShareableOutputFragment) { log.error("Unknown fragment: {}", fragment) @@ -212,8 +212,8 @@ class EditorBottomSheet } if (shareJob?.isActive == true) return@setOnClickListener - binding.shareOutputFab.isEnabled = false - binding.clearFab.isEnabled = false + binding.shareOutputAction.isEnabled = false + binding.clearOutputAction.isEnabled = false shareJob = context.lifecycleScope.launch { @@ -232,15 +232,15 @@ class EditorBottomSheet } } finally { if (isAttachedToWindow) { - binding.shareOutputFab.isEnabled = true - binding.clearFab.isEnabled = true + binding.shareOutputAction.isEnabled = true + binding.clearOutputAction.isEnabled = true } } } } - binding.shareOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) + binding.shareOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SHARE_EXTERNAL)) - binding.clearFab.setOnClickListener { + binding.clearOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) if (fragment !is ShareableOutputFragment) { @@ -249,13 +249,13 @@ class EditorBottomSheet } (fragment as ShareableOutputFragment).clearOutput() } - binding.clearFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) + binding.clearOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_CLEAR)) binding.copyDiagnosticsFab.setOnClickListener { copyDiagnosticsToClipboard() } - binding.searchOutputFab.setOnClickListener { + binding.searchOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) if (fragment !is SearchableOutputFragment) { log.error("Unknown fragment: {}", fragment) @@ -265,9 +265,9 @@ class EditorBottomSheet viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) fragment.beginSearch() } - binding.searchOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) + binding.searchOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_SEARCH)) - binding.filterOutputFab.setOnClickListener { + binding.filterOutputAction.setOnClickListener { val fragment = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) if (fragment !is SearchableOutputFragment) { log.error("Unknown fragment: {}", fragment) @@ -276,7 +276,7 @@ class EditorBottomSheet viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) fragment.toggleFilterBar() } - binding.filterOutputFab.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) + binding.filterOutputAction.setOnLongClickListener(generateTooltipListener(TooltipTag.OUTPUT_FILTER)) binding.headerContainer.setOnClickListener { viewModel.setSheetState(sheetState = BottomSheetBehavior.STATE_EXPANDED) @@ -299,14 +299,14 @@ class EditorBottomSheet } binding.tabs.clearOnTabSelectedListeners() - binding.shareOutputFab.setOnClickListener(null) - binding.shareOutputFab.setOnLongClickListener(null) - binding.clearFab.setOnClickListener(null) - binding.clearFab.setOnLongClickListener(null) - binding.searchOutputFab.setOnClickListener(null) - binding.searchOutputFab.setOnLongClickListener(null) - binding.filterOutputFab.setOnClickListener(null) - binding.filterOutputFab.setOnLongClickListener(null) + binding.shareOutputAction.setOnClickListener(null) + binding.shareOutputAction.setOnLongClickListener(null) + binding.clearOutputAction.setOnClickListener(null) + binding.clearOutputAction.setOnLongClickListener(null) + binding.searchOutputAction.setOnClickListener(null) + binding.searchOutputAction.setOnLongClickListener(null) + binding.filterOutputAction.setOnClickListener(null) + binding.filterOutputAction.setOnLongClickListener(null) binding.copyDiagnosticsFab.setOnClickListener(null) binding.headerContainer.setOnClickListener(null) removeOnLayoutChangeListener(fabLayoutChangeListener) @@ -641,23 +641,19 @@ class EditorBottomSheet currentFragment != null && currentFragment === pagerAdapter.diagnosticsFragment - binding.clearFab.isVisible = showShareAndClear - binding.shareOutputFab.isVisible = showShareAndClear - binding.searchOutputFab.isVisible = showSearchAndFilter - binding.filterOutputFab.isVisible = showSearchAndFilter + binding.clearOutputAction.isVisible = showShareAndClear + binding.shareOutputAction.isVisible = showShareAndClear + binding.searchOutputAction.isVisible = showSearchAndFilter + binding.filterOutputAction.isVisible = showSearchAndFilter + binding.outputActions.isVisible = showShareAndClear || showSearchAndFilter binding.copyDiagnosticsFab.isVisible = showCopy } - // The bottom-anchored FABs go off-screen when the bottom sheet is collapsed. - // Shift them up by the offset from the expanded position (zero when fully expanded). + // The bottom-anchored FAB goes off-screen when the bottom sheet is collapsed. + // Shift it up by the offset from the expanded position (zero when fully expanded). + // The output actions row sits in normal layout flow, so it needs no translation. private fun updateFabTranslation() { val translationY = -(top - anchorOffset).coerceAtLeast(0).toFloat() - binding.apply { - clearFab.translationY = translationY - shareOutputFab.translationY = translationY - searchOutputFab.translationY = translationY - filterOutputFab.translationY = translationY - copyDiagnosticsFab.translationY = translationY - } + binding.copyDiagnosticsFab.translationY = translationY } } diff --git a/app/src/main/res/layout/layout_editor_bottom_sheet.xml b/app/src/main/res/layout/layout_editor_bottom_sheet.xml index 05fe7709d7..c310aca82c 100644 --- a/app/src/main/res/layout/layout_editor_bottom_sheet.xml +++ b/app/src/main/res/layout/layout_editor_bottom_sheet.xml @@ -9,7 +9,6 @@ + + + + + + + + + + + - - - - - - - - diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index f82438e217..f5effe586c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -679,6 +679,10 @@ Logs from the IDE are shown here. Open a file to show its diagnostic results Filter lines + Filter + Search + Share + Clear Hide filter bar Verbose Debug diff --git a/resources/src/main/res/values/styles.xml b/resources/src/main/res/values/styles.xml index a818961633..ae558eaa5c 100755 --- a/resources/src/main/res/values/styles.xml +++ b/resources/src/main/res/values/styles.xml @@ -192,4 +192,17 @@ rounded 50% + + + From ad92b5918b80261b8820af4437b99849904465c5 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 17 Jul 2026 10:37:54 +0100 Subject: [PATCH 11/14] fix(ADFA-936): Fix firing issue with filter action --- .../itsaky/androidide/fragments/output/BuildOutputFragment.kt | 3 ++- .../com/itsaky/androidide/fragments/output/LogViewFragment.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index d39c539ea5..c86d956715 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -115,7 +115,8 @@ class BuildOutputFragment : } override fun toggleFilterBar() { - (filterBar ?: createFilterBar())?.toggle() + val existing = filterBar + existing?.toggle() ?: createFilterBar() } private fun setupSearchLayout() { diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index d0e2eeace0..47f1964b85 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -94,7 +94,8 @@ abstract class LogViewFragment : } override fun toggleFilterBar() { - (filterBar ?: createFilterBar())?.toggle() + val existing = filterBar + existing?.toggle() ?: createFilterBar() } private fun createFilterBar(): LogFilterBarController? { From 9a3b091b52e08493977e174a84e24de38617d996 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 17 Jul 2026 12:30:28 +0100 Subject: [PATCH 12/14] fix(ADFA-936): Fix UI issues --- .../fragments/output/BuildOutputFragment.kt | 2 +- .../fragments/output/LogViewFragment.kt | 2 +- .../res/layout/layout_editor_bottom_sheet.xml | 11 +++-- .../main/res/layout/layout_log_filter_bar.xml | 8 ++-- .../res/drawable/ic_keyboard_arrow_up.xml | 11 +++++ resources/src/main/res/values/styles.xml | 43 ++++++++++++------- 6 files changed, 49 insertions(+), 28 deletions(-) create mode 100644 resources/src/main/res/drawable/ic_keyboard_arrow_up.xml diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index c86d956715..638074fafb 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -116,7 +116,7 @@ class BuildOutputFragment : override fun toggleFilterBar() { val existing = filterBar - existing?.toggle() ?: createFilterBar() + existing?.toggle() ?: createFilterBar() } private fun setupSearchLayout() { diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index 47f1964b85..796a096a42 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -95,7 +95,7 @@ abstract class LogViewFragment : override fun toggleFilterBar() { val existing = filterBar - existing?.toggle() ?: createFilterBar() + existing?.toggle() ?: createFilterBar() } private fun createFilterBar(): LogFilterBarController? { diff --git a/app/src/main/res/layout/layout_editor_bottom_sheet.xml b/app/src/main/res/layout/layout_editor_bottom_sheet.xml index c310aca82c..cb37bf19ba 100644 --- a/app/src/main/res/layout/layout_editor_bottom_sheet.xml +++ b/app/src/main/res/layout/layout_editor_bottom_sheet.xml @@ -68,19 +68,18 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/tabs" - android:gravity="end|center_vertical" android:orientation="horizontal" - android:padding="8dp"> + android:layout_margin="8dp"> + android:text="@string/log_action_search" /> + android:text="@string/log_action_filter" /> + android:paddingBottom="8dp"> @@ -44,10 +44,10 @@ style="@style/Widget.Material3.Button.IconButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="4dp" android:contentDescription="@string/log_filter_hide" + android:padding="4dp" android:tooltipText="@string/log_filter_hide" - app:icon="@drawable/ic_close" /> + app:icon="@drawable/ic_keyboard_arrow_up" /> + + diff --git a/resources/src/main/res/values/styles.xml b/resources/src/main/res/values/styles.xml index ae558eaa5c..b54f0f1dc8 100755 --- a/resources/src/main/res/values/styles.xml +++ b/resources/src/main/res/values/styles.xml @@ -170,17 +170,23 @@ ?attr/colorOnSurface - - - - - - + + From f853882ff366c5ce4d4ed5efef7dfaa751ada73d Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Mon, 20 Jul 2026 16:11:31 +0100 Subject: [PATCH 13/14] fix(ADFA-936): Address PR comments --- .../fragments/output/BuildOutputFragment.kt | 12 ++++++++++-- .../itsaky/androidide/viewmodel/LogViewModel.kt | 15 +++++++++++---- .../androidide/editor/ui/EditorSearchLayout.kt | 10 ++++++++-- .../androidide/logging/GlobalBufferAppender.kt | 7 ++++++- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 638074fafb..c14dda88ca 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -60,6 +60,8 @@ class BuildOutputFragment : // so a re-render never misses or duplicates a concurrently flushed batch. private val editorContentMutex = Mutex() + private var editorContentGeneration = 0 + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -87,6 +89,7 @@ class BuildOutputFragment : /** Re-renders the editor window from the session file, filtered by [query]. */ private suspend fun renderFiltered(query: String) { editorContentMutex.withLock { + editorContentGeneration++ val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() } val filtered = withContext(Dispatchers.Default) { @@ -285,11 +288,16 @@ class BuildOutputFragment : emptyStateViewModel.setEmpty(false) } else { // Timeout: defer append until layout is ready (same as restoreWindowFromViewModel) + val generationAtFlush = editorContentGeneration viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { editor?.run { awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) }) - appendBatch(visibleText) - emptyStateViewModel.setEmpty(false) + editorContentMutex.withLock { + if (editorContentGeneration == generationAtFlush) { + appendBatch(visibleText) + emptyStateViewModel.setEmpty(false) + } + } } } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt index 1db2cf7b36..5de3403f68 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt @@ -151,6 +151,9 @@ abstract class LogViewModel : ViewModel() { submit(level = null, line = line) } + // Keeps buffer mutation and live emission atomic + private val eventLock = Any() + /** * Submit a log line. * @@ -162,8 +165,10 @@ abstract class LogViewModel : ViewModel() { line: String, ) { val text = if (line.endsWith("\n")) line else "$line\n" - val entry = buffer.append(level, text) - liveEntries.tryEmit(entry) + synchronized(eventLock) { + val entry = buffer.append(level, text) + liveEntries.tryEmit(entry) + } } fun setFilter(filter: LogFilter) { @@ -172,8 +177,10 @@ abstract class LogViewModel : ViewModel() { /** Clear the retained log history and re-render the (now empty) view. */ fun clear() { - buffer.clear() - generation.update { it + 1 } + synchronized(eventLock) { + buffer.clear() + generation.update { it + 1 } + } } /** The full retained history, ignoring the active filter. */ diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt index 86a622829b..59885284c9 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt @@ -42,6 +42,7 @@ import com.itsaky.androidide.utils.SingleTextWatcher import com.itsaky.androidide.utils.applyLongPressRecursively import io.github.rosemoe.sora.widget.EditorSearcher.SearchOptions import java.util.regex.Pattern +import java.util.regex.PatternSyntaxException /** * The search layout in [IDEEditor]. @@ -261,7 +262,12 @@ class EditorSearchLayout( query.isNotBlank() && ( searchOptions.type != SearchOptions.TYPE_REGULAR_EXPRESSION || - runCatching { Pattern.compile(query) }.isSuccess + try { + Pattern.compile(query) + true + } catch (_: PatternSyntaxException) { + false + } ) if (isValidQuery) { @@ -323,7 +329,7 @@ class EditorSearchLayout( try { Pattern.compile(it) it - } catch (_: Throwable) { + } catch (_: PatternSyntaxException) { "" } } else { diff --git a/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt index 0979a8a4fe..fc107a47c8 100644 --- a/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt +++ b/logger/src/main/java/com/itsaky/androidide/logging/GlobalBufferAppender.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.logging +import android.util.Log import ch.qos.logback.classic.Level import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.AppenderBase @@ -92,7 +93,11 @@ class GlobalBufferAppender : AppenderBase() { message: String, ) { if (level.levelInt < consumer.logLevel.levelInt) return - runCatching { consumer.consume(level, message) } + try { + consumer.consume(level, message) + } catch (e: Exception) { + Log.e("GlobalBufferAppender", "Log consumer failed", e) + } } } From d8aadd25e62b03925d6748054a5c23bc9059d9b6 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 21 Jul 2026 00:01:40 +0100 Subject: [PATCH 14/14] fix(ADFA-936): Recover live entries --- .../main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt index 5de3403f68..b3a68857d4 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt @@ -82,7 +82,7 @@ abstract class LogViewModel : ViewModel() { private val liveEntries = MutableSharedFlow( - replay = 0, + replay = EVENT_BUFFER_COUNT, extraBufferCapacity = EVENT_BUFFER_COUNT, onBufferOverflow = BufferOverflow.DROP_OLDEST, )