From c6937567a3312511e22cf03aee8b2390a432cfeb Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 28 May 2026 23:42:58 +0100 Subject: [PATCH 1/6] feat(plugin-api): add remote-peer editor decoration API Add IdeEditorService.addRemotePeerMarker/removeRemotePeerMarker/clearRemotePeerMarkers as default-implemented (backward-compatible) methods so a plugin can draw a remote collaborator's caret/badge inside the editor. Backed by a new EditorDecorationManager + RemotePeerMarkerWindow (an EditorPopupWindow overlay that tracks scroll via FEATURE_SCROLL_AS_CONTENT) in the app module; EditorProviderImpl resolves the live editor via EditorHandlerActivity.getEditorForFile and marshals onto the main thread, clearing markers on dispose. IdeEditorServiceImpl exposes read-gated overrides and the parallel EditorProvider contract methods. The new interface methods are default-implemented so this is an additive, non-breaking change for the generated plugin-api lib and existing implementers. Consumed by the Pair pair-programming plugin. --- .../editor/EditorDecorationManager.kt | 116 ++++++++++++++++++ .../androidide/app/EditorProviderImpl.kt | 24 ++++ .../plugins/services/IdeServices.kt | 25 ++++ .../manager/services/IdeEditorServiceImpl.kt | 28 +++++ 4 files changed, 193 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt new file mode 100644 index 0000000000..19dad33976 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt @@ -0,0 +1,116 @@ +package com.itsaky.androidide.activities.editor + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.widget.TextView +import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.base.EditorPopupWindow +import java.io.File + +/** + * Renders remote-collaborator presence as small named caret badges floating in the editor, + * one per (file, peerId). Markers are positioned in content coordinates and track scrolling + * via [EditorPopupWindow.FEATURE_SCROLL_AS_CONTENT]; the plugin repositions a marker by + * calling [addMarker] again with a new line/column on each cursor move. + * + * All methods must be called on the main thread (the editor view is touched directly). + */ +class EditorDecorationManager( + private val editorForFile: (File) -> CodeEditor?, +) { + + private val markers: HashMap> = HashMap() + + fun addMarker( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean { + val editor = editorForFile(file) ?: return false + val byPeer = markers.getOrPut(file.absolutePath) { HashMap() } + val existing = byPeer[peerId] + val window = if (existing != null && existing.boundEditor === editor) { + existing + } else { + existing?.dismiss() + RemotePeerMarkerWindow(editor).also { byPeer[peerId] = it } + } + window.update(peerName, peerColor, line, column) + return true + } + + fun removeMarker(file: File, peerId: String): Boolean { + val removed = markers[file.absolutePath]?.remove(peerId) ?: return false + removed.dismiss() + return true + } + + fun clear(file: File) { + markers.remove(file.absolutePath)?.values?.forEach { it.dismiss() } + } + + fun clearAll() { + markers.values.forEach { byPeer -> byPeer.values.forEach { it.dismiss() } } + markers.clear() + } +} + +class RemotePeerMarkerWindow( + val boundEditor: CodeEditor, +) : EditorPopupWindow( + boundEditor, + FEATURE_SCROLL_AS_CONTENT or FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED, +) { + + private val density = boundEditor.context.resources.displayMetrics.density + + private val label = TextView(boundEditor.context).apply { + textSize = 11f + gravity = Gravity.CENTER + maxLines = 1 + includeFontPadding = false + val padH = (8 * density).toInt() + val padV = (3 * density).toInt() + setPadding(padH, padV, padH, padV) + } + + init { + popup.isClippingEnabled = false + setContentView(label) + } + + fun update(peerName: String, peerColor: Int, line: Int, column: Int) { + label.text = peerName + label.setTextColor(contrastingTextColor(peerColor)) + label.background = GradientDrawable().apply { + setColor(peerColor) + cornerRadius = 4 * density + } + + label.measure( + View.MeasureSpec.makeMeasureSpec(boundEditor.width, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(boundEditor.height, View.MeasureSpec.AT_MOST), + ) + val width = label.measuredWidth + val height = label.measuredHeight + setSize(width, height) + + val x = boundEditor.getOffset(line, column).toInt() + val y = (boundEditor.rowHeight * line) - boundEditor.offsetY - height + setLocationAbsolutely(x, y) + if (!isShowing) show() + } + + private fun contrastingTextColor(background: Int): Int { + val r = Color.red(background) / 255.0 + val g = Color.green(background) / 255.0 + val b = Color.blue(background) / 255.0 + val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + return if (luminance > 0.6) Color.parseColor("#0A0A0A") else Color.WHITE + } +} diff --git a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt index 93cb2edf9e..4b85200022 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.app import android.os.Handler import android.os.Looper import androidx.lifecycle.lifecycleScope +import com.itsaky.androidide.activities.editor.EditorDecorationManager import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range @@ -36,6 +37,9 @@ class EditorProviderImpl( private val activityRef = WeakReference(activity) private val mainHandler = Handler(Looper.getMainLooper()) private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>() + private val decorationManager = EditorDecorationManager { file -> + activity()?.getEditorForFile(file)?.editor + } private val internalListener: (File?) -> Unit = { file -> fileCallbacks.forEach { cb -> @@ -57,6 +61,7 @@ class EditorProviderImpl( fun dispose() { EditorEvents.removeFileChangeListener(internalListener) fileCallbacks.clear() + onMain { decorationManager.clearAll(); true } activityRef.clear() } @@ -257,6 +262,25 @@ class EditorProviderImpl( true } + override fun addRemotePeerMarker( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = onMain { + decorationManager.addMarker(file, line, column, peerId, peerName, peerColor) + } + + override fun removeRemotePeerMarker(file: File, peerId: String): Boolean = onMain { + decorationManager.removeMarker(file, peerId) + } + + override fun clearRemotePeerMarkers(file: File) { + onMain { decorationManager.clear(file); true } + } + /** * Resolves the editor for [file], validates [line] (bounds differ between edits that * mutate an existing line and those that insert a new one), and runs [block] inside a diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt index b614cef6ce..50be8f068b 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt @@ -128,6 +128,31 @@ interface IdeEditorService { fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean + /** + * Draws (or moves) a remote-collaborator marker — a small colored, named caret badge — + * inside the editor for [file] at the 0-based [line]/[column]. Markers are keyed by + * [peerId]: calling again for the same (file, peerId) repositions the existing marker. + * [peerColor] is an ARGB int. No-op (returns false) if the file isn't open in an editor. + * Visual overlay only — never mutates file content. Requires FILESYSTEM_READ. + * + * Default-implemented (no-op) so adding it is a backward-compatible interface extension: + * existing implementers and any prebuilt plugin-api lib keep compiling; the host overrides it. + */ + fun addRemotePeerMarker( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = false + + /** Removes the marker for [peerId] in [file], if present. Default-implemented no-op. */ + fun removeRemotePeerMarker(file: File, peerId: String): Boolean = false + + /** Removes all remote-peer markers in [file]. Default-implemented no-op. */ + fun clearRemotePeerMarkers(file: File) {} + fun addFileChangeListener(listener: FileChangeListener) fun removeFileChangeListener(listener: FileChangeListener) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt index 29b1d5555f..4a720e1e9f 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt @@ -54,6 +54,9 @@ class IdeEditorServiceImpl( fun insertLineBefore(file: File, line: Int, text: String): Boolean = false fun deleteLine(file: File, line: Int): Boolean = false fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean = false + fun addRemotePeerMarker(file: File, line: Int, column: Int, peerId: String, peerName: String, peerColor: Int): Boolean = false + fun removeRemotePeerMarker(file: File, peerId: String): Boolean = false + fun clearRemotePeerMarkers(file: File) {} fun addFileChangeCallback(callback: (File?) -> Unit) {} fun removeFileChangeCallback(callback: (File?) -> Unit) {} } @@ -254,6 +257,31 @@ class IdeEditorServiceImpl( return editorProvider.replaceRange(file, range, newText) } + override fun addRemotePeerMarker( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.addRemotePeerMarker(file, line, column, peerId, peerName, peerColor) + } + + override fun removeRemotePeerMarker(file: File, peerId: String): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.removeRemotePeerMarker(file, peerId) + } + + override fun clearRemotePeerMarkers(file: File) { + requireRead() + ensureFileAccessible(file) + editorProvider.clearRemotePeerMarkers(file) + } + override fun addFileChangeListener(listener: FileChangeListener) { requireRead() fileChangeListeners.addIfAbsent(listener) From 58fcf5ca615d92a80c527ef8df27553176157ba1 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 29 May 2026 23:41:44 +0100 Subject: [PATCH 2/6] WIP: Add cursor markers to plugin --- .../editor/EditorDecorationManager.kt | 6 +++--- .../androidide/app/EditorProviderImpl.kt | 6 +++--- .../androidide/plugins/services/IdeServices.kt | 16 ++++++++-------- .../manager/services/IdeEditorServiceImpl.kt | 18 +++++++++--------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt index 19dad33976..cfd88fb9a5 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt @@ -21,7 +21,7 @@ class EditorDecorationManager( private val editorForFile: (File) -> CodeEditor?, ) { - private val markers: HashMap> = HashMap() + private val markers: HashMap> = HashMap() fun addMarker( file: File, @@ -38,7 +38,7 @@ class EditorDecorationManager( existing } else { existing?.dismiss() - RemotePeerMarkerWindow(editor).also { byPeer[peerId] = it } + PeerCursorWindow(editor).also { byPeer[peerId] = it } } window.update(peerName, peerColor, line, column) return true @@ -60,7 +60,7 @@ class EditorDecorationManager( } } -class RemotePeerMarkerWindow( +class PeerCursorWindow( val boundEditor: CodeEditor, ) : EditorPopupWindow( boundEditor, diff --git a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt index 4b85200022..23dfd8a63c 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -262,7 +262,7 @@ class EditorProviderImpl( true } - override fun addRemotePeerMarker( + override fun showPeerCursor( file: File, line: Int, column: Int, @@ -273,11 +273,11 @@ class EditorProviderImpl( decorationManager.addMarker(file, line, column, peerId, peerName, peerColor) } - override fun removeRemotePeerMarker(file: File, peerId: String): Boolean = onMain { + override fun hidePeerCursor(file: File, peerId: String): Boolean = onMain { decorationManager.removeMarker(file, peerId) } - override fun clearRemotePeerMarkers(file: File) { + override fun clearPeerCursors(file: File) { onMain { decorationManager.clear(file); true } } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt index 50be8f068b..3ab6b07970 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt @@ -129,16 +129,16 @@ interface IdeEditorService { fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean /** - * Draws (or moves) a remote-collaborator marker — a small colored, named caret badge — - * inside the editor for [file] at the 0-based [line]/[column]. Markers are keyed by - * [peerId]: calling again for the same (file, peerId) repositions the existing marker. + * Draws (or moves) a remote peer's cursor — a small colored, named caret badge — + * inside the editor for [file] at the 0-based [line]/[column]. Cursors are keyed by + * [peerId]: calling again for the same (file, peerId) repositions the existing cursor. * [peerColor] is an ARGB int. No-op (returns false) if the file isn't open in an editor. * Visual overlay only — never mutates file content. Requires FILESYSTEM_READ. * * Default-implemented (no-op) so adding it is a backward-compatible interface extension: * existing implementers and any prebuilt plugin-api lib keep compiling; the host overrides it. */ - fun addRemotePeerMarker( + fun showPeerCursor( file: File, line: Int, column: Int, @@ -147,11 +147,11 @@ interface IdeEditorService { peerColor: Int, ): Boolean = false - /** Removes the marker for [peerId] in [file], if present. Default-implemented no-op. */ - fun removeRemotePeerMarker(file: File, peerId: String): Boolean = false + /** Hides the cursor for [peerId] in [file], if present. Default-implemented no-op. */ + fun hidePeerCursor(file: File, peerId: String): Boolean = false - /** Removes all remote-peer markers in [file]. Default-implemented no-op. */ - fun clearRemotePeerMarkers(file: File) {} + /** Removes all remote peer cursors in [file]. Default-implemented no-op. */ + fun clearPeerCursors(file: File) {} fun addFileChangeListener(listener: FileChangeListener) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt index 4a720e1e9f..db02fbb8c9 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt @@ -54,9 +54,9 @@ class IdeEditorServiceImpl( fun insertLineBefore(file: File, line: Int, text: String): Boolean = false fun deleteLine(file: File, line: Int): Boolean = false fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean = false - fun addRemotePeerMarker(file: File, line: Int, column: Int, peerId: String, peerName: String, peerColor: Int): Boolean = false - fun removeRemotePeerMarker(file: File, peerId: String): Boolean = false - fun clearRemotePeerMarkers(file: File) {} + fun showPeerCursor(file: File, line: Int, column: Int, peerId: String, peerName: String, peerColor: Int): Boolean = false + fun hidePeerCursor(file: File, peerId: String): Boolean = false + fun clearPeerCursors(file: File) {} fun addFileChangeCallback(callback: (File?) -> Unit) {} fun removeFileChangeCallback(callback: (File?) -> Unit) {} } @@ -257,7 +257,7 @@ class IdeEditorServiceImpl( return editorProvider.replaceRange(file, range, newText) } - override fun addRemotePeerMarker( + override fun showPeerCursor( file: File, line: Int, column: Int, @@ -267,19 +267,19 @@ class IdeEditorServiceImpl( ): Boolean { requireRead() ensureFileAccessible(file) - return editorProvider.addRemotePeerMarker(file, line, column, peerId, peerName, peerColor) + return editorProvider.showPeerCursor(file, line, column, peerId, peerName, peerColor) } - override fun removeRemotePeerMarker(file: File, peerId: String): Boolean { + override fun hidePeerCursor(file: File, peerId: String): Boolean { requireRead() ensureFileAccessible(file) - return editorProvider.removeRemotePeerMarker(file, peerId) + return editorProvider.hidePeerCursor(file, peerId) } - override fun clearRemotePeerMarkers(file: File) { + override fun clearPeerCursors(file: File) { requireRead() ensureFileAccessible(file) - editorProvider.clearRemotePeerMarkers(file) + editorProvider.clearPeerCursors(file) } override fun addFileChangeListener(listener: FileChangeListener) { From 2d887f0e84ff9e67f0ecdd92c1841d61a6f79a73 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sat, 20 Jun 2026 07:36:19 -0400 Subject: [PATCH 3/6] Implement pair programming plugin --- .../editor/EditorDecorationManager.kt | 35 +++++++++-- .../plugins/services/IdeServices.kt | 18 ++++++ .../plugins/manager/core/PluginManager.kt | 19 +++++- .../manager/services/IdeProjectServiceImpl.kt | 61 ++++++++++++++++++- 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt index cfd88fb9a5..b0ecebaac0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.activities.editor import android.graphics.Color import android.graphics.drawable.GradientDrawable +import android.util.Log import android.view.Gravity import android.view.View import android.widget.TextView @@ -31,7 +32,14 @@ class EditorDecorationManager( peerName: String, peerColor: Int, ): Boolean { - val editor = editorForFile(file) ?: return false + val editor = editorForFile(file) + if (editor == null) { + Log.d(TAG, "[MARKERS-HOST] no open editor for ${file.name} — cannot show $peerName") + return false + } + val content = editor.text + if (line !in 0 until content.lineCount) return false + val safeColumn = column.coerceIn(0, content.getColumnCount(line)) val byPeer = markers.getOrPut(file.absolutePath) { HashMap() } val existing = byPeer[peerId] val window = if (existing != null && existing.boundEditor === editor) { @@ -40,7 +48,7 @@ class EditorDecorationManager( existing?.dismiss() PeerCursorWindow(editor).also { byPeer[peerId] = it } } - window.update(peerName, peerColor, line, column) + window.update(peerName, peerColor, line, safeColumn) return true } @@ -58,6 +66,10 @@ class EditorDecorationManager( markers.values.forEach { byPeer -> byPeer.values.forEach { it.dismiss() } } markers.clear() } + + private companion object { + const val TAG = "PairTrace" + } } class PeerCursorWindow( @@ -85,7 +97,14 @@ class PeerCursorWindow( } fun update(peerName: String, peerColor: Int, line: Int, column: Int) { - label.text = peerName + // getOffset returns the on-screen x. If the caret is past the visible width, add a + // direction arrow so the badge (clamped to the edge below) signals where the peer is. + val rawX = boundEditor.getOffset(line, column).toInt() + label.text = when { + rawX > boundEditor.width -> "$peerName →" + rawX < 0 -> "← $peerName" + else -> peerName + } label.setTextColor(contrastingTextColor(peerColor)) label.background = GradientDrawable().apply { setColor(peerColor) @@ -100,8 +119,12 @@ class PeerCursorWindow( val height = label.measuredHeight setSize(width, height) - val x = boundEditor.getOffset(line, column).toInt() + // Clamp into the visible width so a caret scrolled off to the right pins at the edge + // instead of vanishing. Only clamp when the editor has a known width. + val maxX = boundEditor.width - width + val x = if (maxX > 0) rawX.coerceIn(0, maxX) else rawX val y = (boundEditor.rowHeight * line) - boundEditor.offsetY - height + Log.d(TAG, "[MARKERS-HOST] $peerName editor=${boundEditor.width}x${boundEditor.height} raw=$rawX -> ($x,$y) wasShowing=$isShowing") setLocationAbsolutely(x, y) if (!isShowing) show() } @@ -113,4 +136,8 @@ class PeerCursorWindow( val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b return if (luminance > 0.6) Color.parseColor("#0A0A0A") else Color.WHITE } + + private companion object { + const val TAG = "PairTrace" + } } diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt index 3ab6b07970..1b2072952b 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt @@ -30,6 +30,24 @@ interface IdeProjectService { * @return The project at the given path, or null if not found */ fun getProjectByPath(path: File): IProject? + + /** + * Requests the IDE to open the project rooted at [projectDir], replacing the project + * that is currently open. Dispatches asynchronously: a `true` return means the open was + * requested and the editor is being launched, not that the project has finished loading. + * Poll [getCurrentProject] to observe completion. + * + * Requires the FILESYSTEM_READ permission. + * + * Default-implemented (no-op, returns false) so adding it is a backward-compatible + * interface extension: existing implementers and any prebuilt plugin-api lib keep + * compiling; the host overrides it. + * + * @param projectDir The root directory of the project to open + * @return true if the open request was dispatched, false if it was rejected or the IDE + * has no foreground activity available to host the editor + */ + fun openProject(projectDir: File): Boolean = false } /** diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index aa810dc6bd..55009ac1ce 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -150,6 +150,19 @@ class PluginManager private constructor( range: com.itsaky.androidide.plugins.services.SelectionRange, newText: String, ): Boolean = current()?.replaceRange(file, range, newText) ?: false +\ASSA override fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = current()?.showPeerCursor(file, line, column, peerId, peerName, peerColor) ?: false + override fun hidePeerCursor(file: File, peerId: String): Boolean = + current()?.hidePeerCursor(file, peerId) ?: false + override fun clearPeerCursors(file: File) { + current()?.clearPeerCursors(file) + } override fun addFileChangeCallback(callback: (File?) -> Unit) { pendingFileChangeCallbacks.add(callback) current()?.addFileChangeCallback(callback) @@ -1118,7 +1131,8 @@ class PluginManager private constructor( override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) override fun getAllowedPaths(): List = validator.getAllowedPaths() } - } + }, + activityProvider = activityProvider ) } @@ -1336,7 +1350,8 @@ class PluginManager private constructor( override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) override fun getAllowedPaths(): List = validator.getAllowedPaths() } - } + }, + activityProvider = activityProvider ) } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt index f9b83f019f..57a4f4fbbd 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt @@ -2,9 +2,14 @@ package com.itsaky.androidide.plugins.manager.services +import android.util.Log import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.extensions.IProject +import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.plugins.services.IdeProjectService +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.utils.Environment import java.io.File /** @@ -16,7 +21,8 @@ class IdeProjectServiceImpl( private val permissions: Set, private val projectProvider: ProjectProvider, private val requiredPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), - private val pathValidator: PathValidator? = null + private val pathValidator: PathValidator? = null, + private val activityProvider: PluginManager.ActivityProvider? = null ) : IdeProjectService { /** @@ -80,6 +86,55 @@ class IdeProjectServiceImpl( } } + override fun openProject(projectDir: File): Boolean { + Log.d(TAG, "[HOST] openProject requested: ${projectDir.absolutePath}") + + if (!hasRequiredPermissions()) { + Log.w(TAG, "[HOST] openProject denied: missing permissions ${getRequiredPermissionsString()}") + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + if (!isUnderProjectsDir(projectDir)) { + Log.w(TAG, "[HOST] openProject denied: ${projectDir.absolutePath} is not under projects dir ${Environment.PROJECTS_DIR?.absolutePath}") + throw SecurityException("Plugin $pluginId may only open projects under ${Environment.PROJECTS_DIR?.absolutePath}") + } + + if (!projectDir.exists() || !projectDir.isDirectory) { + Log.w(TAG, "[HOST] openProject aborted: not a directory (exists=${projectDir.exists()}, isDir=${projectDir.isDirectory})") + return false + } + + val activity = activityProvider?.getCurrentActivity() + if (activity == null) { + Log.w(TAG, "[HOST] openProject aborted: no foreground activity available") + return false + } + + return try { + ProjectManagerImpl.getInstance().projectPath = projectDir.absolutePath + GeneralPreferences.lastOpenedProject = projectDir.absolutePath + + // The editor activity is launchMode=singleTask, so re-launching it only delivers + // onNewIntent (no reload). Recreating it re-runs onCreate, which loads the project + // from the projectPath we just set — the same effect as the IDE's own project switch. + activity.runOnUiThread { activity.recreate() } + Log.d(TAG, "[HOST] openProject: set projectPath and recreated ${activity.javaClass.simpleName} for ${projectDir.absolutePath}") + true + } catch (e: Exception) { + Log.e(TAG, "[HOST] openProject: failed ${e.javaClass.simpleName}: ${e.message}", e) + false + } + } + + private fun isUnderProjectsDir(path: File): Boolean { + val projectsDir = runCatching { Environment.PROJECTS_DIR }.getOrNull() ?: return false + return runCatching { + val base = projectsDir.canonicalFile + val target = path.canonicalFile + target.path == base.path || target.path.startsWith(base.path + File.separator) + }.getOrDefault(false) + } + private fun hasRequiredPermissions(): Boolean { return requiredPermissions.all { permission -> permissions.contains(permission) @@ -123,4 +178,8 @@ class IdeProjectServiceImpl( "/tmp/AndroidIDEProject" // Allow temporary project for demo purposes ) } + + private companion object { + const val TAG = "PairTrace" + } } \ No newline at end of file From 136ea3ae8c4d7f4c960785658e54437d391c7608 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 24 Jun 2026 13:15:16 +0100 Subject: [PATCH 4/6] fix(ADFA-4419): remove stray token breaking PluginManager compilation --- .../com/itsaky/androidide/plugins/manager/core/PluginManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index 55009ac1ce..20095db1dc 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -150,7 +150,7 @@ class PluginManager private constructor( range: com.itsaky.androidide.plugins.services.SelectionRange, newText: String, ): Boolean = current()?.replaceRange(file, range, newText) ?: false -\ASSA override fun showPeerCursor( + override fun showPeerCursor( file: File, line: Int, column: Int, From ea2293a6367734251c198c2a8be05bb514c25011 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 29 Jun 2026 00:46:50 +0100 Subject: [PATCH 5/6] refactor(ADFA-4419): distinguish peer-presence overlay from #1448 decorations Rename EditorDecorationManager -> PeerPresenceOverlayManager and extract a focused PeerPresenceProvider interface out of the broad EditorProvider, so the pair-programming peer-cursor overlay (floating named badges) reads as a distinct concern from the generic EditorDecorationProvider (additive color spans) added in #1448 (ADFA-4436). Host-internal only: no plugin-api contract changed and the merged rainbow- brackets plugin is unaffected. Verified with :app:compileV8DebugKotlin. --- ...anager.kt => PeerPresenceOverlayManager.kt} | 2 +- .../androidide/app/EditorProviderImpl.kt | 12 ++++++------ .../manager/services/IdeEditorServiceImpl.kt | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 11 deletions(-) rename app/src/main/java/com/itsaky/androidide/activities/editor/{EditorDecorationManager.kt => PeerPresenceOverlayManager.kt} (99%) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt similarity index 99% rename from app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt rename to app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt index b0ecebaac0..c69a3a407e 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorDecorationManager.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt @@ -18,7 +18,7 @@ import java.io.File * * All methods must be called on the main thread (the editor view is touched directly). */ -class EditorDecorationManager( +class PeerPresenceOverlayManager( private val editorForFile: (File) -> CodeEditor?, ) { diff --git a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt index 23dfd8a63c..95e7a3cb68 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -3,7 +3,7 @@ package com.itsaky.androidide.app import android.os.Handler import android.os.Looper import androidx.lifecycle.lifecycleScope -import com.itsaky.androidide.activities.editor.EditorDecorationManager +import com.itsaky.androidide.activities.editor.PeerPresenceOverlayManager import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range @@ -37,7 +37,7 @@ class EditorProviderImpl( private val activityRef = WeakReference(activity) private val mainHandler = Handler(Looper.getMainLooper()) private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>() - private val decorationManager = EditorDecorationManager { file -> + private val peerPresenceOverlay = PeerPresenceOverlayManager { file -> activity()?.getEditorForFile(file)?.editor } @@ -61,7 +61,7 @@ class EditorProviderImpl( fun dispose() { EditorEvents.removeFileChangeListener(internalListener) fileCallbacks.clear() - onMain { decorationManager.clearAll(); true } + onMain { peerPresenceOverlay.clearAll(); true } activityRef.clear() } @@ -270,15 +270,15 @@ class EditorProviderImpl( peerName: String, peerColor: Int, ): Boolean = onMain { - decorationManager.addMarker(file, line, column, peerId, peerName, peerColor) + peerPresenceOverlay.addMarker(file, line, column, peerId, peerName, peerColor) } override fun hidePeerCursor(file: File, peerId: String): Boolean = onMain { - decorationManager.removeMarker(file, peerId) + peerPresenceOverlay.removeMarker(file, peerId) } override fun clearPeerCursors(file: File) { - onMain { decorationManager.clear(file); true } + onMain { peerPresenceOverlay.clear(file); true } } /** diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt index db02fbb8c9..8719cfa627 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt @@ -26,7 +26,20 @@ class IdeEditorServiceImpl( fun getAllowedPaths(): List } - interface EditorProvider { + /** + * Remote-collaborator presence: draw, move and clear named peer cursors in open editors. + * Split out of [EditorProvider] so peer presence is a focused, separately-named contract + * rather than three more methods on the broad editor-access surface (interface segregation). + * The host bridge implements both through one object. Visual overlay only — never mutates + * file content. Each method defaults to a no-op so an implementer can opt in. + */ + interface PeerPresenceProvider { + fun showPeerCursor(file: File, line: Int, column: Int, peerId: String, peerName: String, peerColor: Int): Boolean = false + fun hidePeerCursor(file: File, peerId: String): Boolean = false + fun clearPeerCursors(file: File) {} + } + + interface EditorProvider : PeerPresenceProvider { fun getCurrentFile(): File? fun getOpenFiles(): List fun isFileOpen(file: File): Boolean @@ -54,9 +67,6 @@ class IdeEditorServiceImpl( fun insertLineBefore(file: File, line: Int, text: String): Boolean = false fun deleteLine(file: File, line: Int): Boolean = false fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean = false - fun showPeerCursor(file: File, line: Int, column: Int, peerId: String, peerName: String, peerColor: Int): Boolean = false - fun hidePeerCursor(file: File, peerId: String): Boolean = false - fun clearPeerCursors(file: File) {} fun addFileChangeCallback(callback: (File?) -> Unit) {} fun removeFileChangeCallback(callback: (File?) -> Unit) {} } From 90dbab1b0babb57fb88304fe8cb88840271f1da0 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 29 Jun 2026 11:23:31 +0100 Subject: [PATCH 6/6] fix(ADFA-4419): address CodeRabbit review + drop dev-trace logging - PeerPresenceOverlayManager: clamp peer badge on exact-fit width (maxX >= 0) - PluginManager.loadPlugins: rethrow CancellationException instead of recording cancellation as a plugin load failure - IdeEditorServiceImpl: don't gate hidePeerCursor/clearPeerCursors on file accessibility, so overlay cleanup still works after a tab closes - IdeProjectServiceImpl.openProject: use the validated canonical path and run it through PathValidator before switching projects - PluginRepositoryImpl: delete the broken artifact when an upgraded plugin fails to load, so loadPlugins() doesn't keep retrying it - Drop PairTrace / [HOST] dev-trace Log.d (kept warn/error diagnostics) --- .../editor/PeerPresenceOverlayManager.kt | 18 +-------- .../repositories/PluginRepositoryImpl.kt | 3 ++ .../plugins/manager/core/PluginManager.kt | 3 ++ .../manager/services/IdeEditorServiceImpl.kt | 2 - .../manager/services/IdeProjectServiceImpl.kt | 37 +++++++++++-------- 5 files changed, 29 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt index c69a3a407e..d85eff49c4 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt @@ -2,7 +2,6 @@ package com.itsaky.androidide.activities.editor import android.graphics.Color import android.graphics.drawable.GradientDrawable -import android.util.Log import android.view.Gravity import android.view.View import android.widget.TextView @@ -32,11 +31,7 @@ class PeerPresenceOverlayManager( peerName: String, peerColor: Int, ): Boolean { - val editor = editorForFile(file) - if (editor == null) { - Log.d(TAG, "[MARKERS-HOST] no open editor for ${file.name} — cannot show $peerName") - return false - } + val editor = editorForFile(file) ?: return false val content = editor.text if (line !in 0 until content.lineCount) return false val safeColumn = column.coerceIn(0, content.getColumnCount(line)) @@ -66,10 +61,6 @@ class PeerPresenceOverlayManager( markers.values.forEach { byPeer -> byPeer.values.forEach { it.dismiss() } } markers.clear() } - - private companion object { - const val TAG = "PairTrace" - } } class PeerCursorWindow( @@ -122,9 +113,8 @@ class PeerCursorWindow( // Clamp into the visible width so a caret scrolled off to the right pins at the edge // instead of vanishing. Only clamp when the editor has a known width. val maxX = boundEditor.width - width - val x = if (maxX > 0) rawX.coerceIn(0, maxX) else rawX + val x = if (maxX >= 0) rawX.coerceIn(0, maxX) else rawX val y = (boundEditor.rowHeight * line) - boundEditor.offsetY - height - Log.d(TAG, "[MARKERS-HOST] $peerName editor=${boundEditor.width}x${boundEditor.height} raw=$rawX -> ($x,$y) wasShowing=$isShowing") setLocationAbsolutely(x, y) if (!isShowing) show() } @@ -136,8 +126,4 @@ class PeerCursorWindow( val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b return if (luminance > 0.6) Color.parseColor("#0A0A0A") else Color.WHITE } - - private companion object { - const val TAG = "PairTrace" - } } diff --git a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt index 00d686e47b..74b5e54724 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -147,6 +147,9 @@ class PluginRepositoryImpl( manager.loadPlugins() if (manager.getPlugin(pluginId) == null) { + // The new package replaced the previous one but failed to load. Remove the broken + // artifact so subsequent loadPlugins() calls don't keep retrying it. + finalFile.delete() throw IllegalStateException( manager.getLoadError(pluginId) ?: "Plugin \"$pluginId\" was installed but failed to load." diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index 5f9c833e56..fd3f079192 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -62,6 +62,7 @@ import com.itsaky.androidide.plugins.extensions.BuildActionExtension import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.actions.SidebarSlotManager import com.itsaky.androidide.actions.SidebarSlotExceededException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -278,6 +279,8 @@ class PluginManager private constructor( logger.debug("Loading plugin: ${pluginFile.name}") val result = try { loadPlugin(pluginFile) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt index 8719cfa627..0f462ce28b 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt @@ -282,13 +282,11 @@ class IdeEditorServiceImpl( override fun hidePeerCursor(file: File, peerId: String): Boolean { requireRead() - ensureFileAccessible(file) return editorProvider.hidePeerCursor(file, peerId) } override fun clearPeerCursors(file: File) { requireRead() - ensureFileAccessible(file) editorProvider.clearPeerCursors(file) } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt index 57a4f4fbbd..b3c351b68a 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt @@ -87,52 +87,57 @@ class IdeProjectServiceImpl( } override fun openProject(projectDir: File): Boolean { - Log.d(TAG, "[HOST] openProject requested: ${projectDir.absolutePath}") - if (!hasRequiredPermissions()) { - Log.w(TAG, "[HOST] openProject denied: missing permissions ${getRequiredPermissionsString()}") + Log.w(TAG, "openProject denied: missing permissions ${getRequiredPermissionsString()}") throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") } - if (!isUnderProjectsDir(projectDir)) { - Log.w(TAG, "[HOST] openProject denied: ${projectDir.absolutePath} is not under projects dir ${Environment.PROJECTS_DIR?.absolutePath}") + // Validate against the canonical, containment-checked target and reuse it everywhere below, + // so a symlink/relative path can't pass the check as one path yet be switched to as another. + val resolvedProjectDir = resolveProjectDirUnderProjectsDir(projectDir) + if (resolvedProjectDir == null) { + Log.w(TAG, "openProject denied: ${projectDir.absolutePath} is not under projects dir ${Environment.PROJECTS_DIR?.absolutePath}") throw SecurityException("Plugin $pluginId may only open projects under ${Environment.PROJECTS_DIR?.absolutePath}") } - if (!projectDir.exists() || !projectDir.isDirectory) { - Log.w(TAG, "[HOST] openProject aborted: not a directory (exists=${projectDir.exists()}, isDir=${projectDir.isDirectory})") + // Apply the same path-access policy used by getProjectByPath. + if (!isPathAllowed(resolvedProjectDir)) { + throw SecurityException("Plugin $pluginId does not have access to path: ${resolvedProjectDir.absolutePath}") + } + + if (!resolvedProjectDir.exists() || !resolvedProjectDir.isDirectory) { + Log.w(TAG, "openProject aborted: not a directory (exists=${resolvedProjectDir.exists()}, isDir=${resolvedProjectDir.isDirectory})") return false } val activity = activityProvider?.getCurrentActivity() if (activity == null) { - Log.w(TAG, "[HOST] openProject aborted: no foreground activity available") + Log.w(TAG, "openProject aborted: no foreground activity available") return false } return try { - ProjectManagerImpl.getInstance().projectPath = projectDir.absolutePath - GeneralPreferences.lastOpenedProject = projectDir.absolutePath + ProjectManagerImpl.getInstance().projectPath = resolvedProjectDir.absolutePath + GeneralPreferences.lastOpenedProject = resolvedProjectDir.absolutePath // The editor activity is launchMode=singleTask, so re-launching it only delivers // onNewIntent (no reload). Recreating it re-runs onCreate, which loads the project // from the projectPath we just set — the same effect as the IDE's own project switch. activity.runOnUiThread { activity.recreate() } - Log.d(TAG, "[HOST] openProject: set projectPath and recreated ${activity.javaClass.simpleName} for ${projectDir.absolutePath}") true } catch (e: Exception) { - Log.e(TAG, "[HOST] openProject: failed ${e.javaClass.simpleName}: ${e.message}", e) + Log.e(TAG, "openProject failed: ${e.javaClass.simpleName}: ${e.message}", e) false } } - private fun isUnderProjectsDir(path: File): Boolean { - val projectsDir = runCatching { Environment.PROJECTS_DIR }.getOrNull() ?: return false + private fun resolveProjectDirUnderProjectsDir(path: File): File? { + val projectsDir = runCatching { Environment.PROJECTS_DIR }.getOrNull() ?: return null return runCatching { val base = projectsDir.canonicalFile val target = path.canonicalFile - target.path == base.path || target.path.startsWith(base.path + File.separator) - }.getOrDefault(false) + target.takeIf { it.path == base.path || it.path.startsWith(base.path + File.separator) } + }.getOrNull() } private fun hasRequiredPermissions(): Boolean {