From c6937567a3312511e22cf03aee8b2390a432cfeb Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 28 May 2026 23:42:58 +0100 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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 { From 13864e204a8b0e148146f6aa938cbb170a2c4bae Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 7 Aug 2026 13:01:20 +0100 Subject: [PATCH 7/7] ADFA-4419: Fix spotless/ktlint violations in branch-touched files spotlessApply reformatting across the seven files this branch touched, plus the three lints ktlint cannot auto-fix: expand the wildcard import in PluginManager, move the orphaned KDoc onto delegatingEditorProvider, and rename INSTANCE/Loader to instance/loader per property-naming. --- .../editor/PeerPresenceOverlayManager.kt | 196 +- .../androidide/app/EditorProviderImpl.kt | 869 +++-- .../repositories/PluginRepositoryImpl.kt | 339 +- .../plugins/services/IdeServices.kt | 892 +++-- .../plugins/manager/core/PluginManager.kt | 3375 +++++++++-------- .../manager/services/IdeEditorServiceImpl.kt | 893 +++-- .../manager/services/IdeProjectServiceImpl.kt | 355 +- 7 files changed, 3644 insertions(+), 3275 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 d85eff49c4..836afef3e7 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 @@ -18,112 +18,122 @@ import java.io.File * All methods must be called on the main thread (the editor view is touched directly). */ class PeerPresenceOverlayManager( - private val editorForFile: (File) -> CodeEditor?, + private val editorForFile: (File) -> CodeEditor?, ) { + private val markers: HashMap> = HashMap() - 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 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) { + existing + } else { + existing?.dismiss() + PeerCursorWindow(editor).also { byPeer[peerId] = it } + } + window.update(peerName, peerColor, line, safeColumn) + return true + } - fun addMarker( - file: File, - line: Int, - column: Int, - peerId: String, - peerName: String, - peerColor: Int, - ): Boolean { - 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)) - val byPeer = markers.getOrPut(file.absolutePath) { HashMap() } - val existing = byPeer[peerId] - val window = if (existing != null && existing.boundEditor === editor) { - existing - } else { - existing?.dismiss() - PeerCursorWindow(editor).also { byPeer[peerId] = it } - } - window.update(peerName, peerColor, line, safeColumn) - return true - } + fun removeMarker( + file: File, + peerId: String, + ): Boolean { + val removed = markers[file.absolutePath]?.remove(peerId) ?: return false + removed.dismiss() + 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 clear(file: File) { - markers.remove(file.absolutePath)?.values?.forEach { it.dismiss() } - } - - fun clearAll() { - markers.values.forEach { byPeer -> byPeer.values.forEach { it.dismiss() } } - markers.clear() - } + fun clearAll() { + markers.values.forEach { byPeer -> byPeer.values.forEach { it.dismiss() } } + markers.clear() + } } class PeerCursorWindow( - val boundEditor: CodeEditor, + val boundEditor: CodeEditor, ) : EditorPopupWindow( - boundEditor, - FEATURE_SCROLL_AS_CONTENT or FEATURE_SHOW_OUTSIDE_VIEW_ALLOWED, -) { - - private val density = boundEditor.context.resources.displayMetrics.density + 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) - } + 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) - } + init { + popup.isClippingEnabled = false + setContentView(label) + } - fun update(peerName: String, peerColor: Int, line: Int, column: Int) { - // 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) - cornerRadius = 4 * density - } + fun update( + peerName: String, + peerColor: Int, + line: Int, + column: Int, + ) { + // 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) + 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) + 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) - // 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 - setLocationAbsolutely(x, y) - if (!isShowing) show() - } + // 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 + 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 - } + 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 5784244c08..54b1a39b30 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -3,13 +3,13 @@ package com.itsaky.androidide.app import android.os.Handler import android.os.Looper import androidx.lifecycle.lifecycleScope -import com.itsaky.androidide.activities.editor.PeerPresenceOverlayManager import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.activities.editor.PeerPresenceOverlayManager +import com.itsaky.androidide.editor.ui.IDEEditor +import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SaveResult -import com.itsaky.androidide.editor.ui.IDEEditor -import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.plugins.manager.services.IdeEditorServiceImpl import com.itsaky.androidide.plugins.services.CursorPosition import com.itsaky.androidide.plugins.services.SelectionRange @@ -36,412 +36,461 @@ import java.util.concurrent.atomic.AtomicReference * Activity reference is held weakly so a leaked provider can never keep the activity alive. */ class EditorProviderImpl( - activity: EditorHandlerActivity, + activity: EditorHandlerActivity, ) : IdeEditorServiceImpl.EditorProvider { - - private val activityRef = WeakReference(activity) - private val mainHandler = Handler(Looper.getMainLooper()) - private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>() - private val contentCallbacks = - java.util.concurrent.CopyOnWriteArrayList<(String, Int, Int, String) -> Unit>() - private val peerPresenceOverlay = PeerPresenceOverlayManager { file -> - activity()?.getEditorForFile(file)?.editor - } - - private val internalListener: (File?) -> Unit = { file -> - fileCallbacks.forEach { cb -> - try { - cb(file) - } catch (_: Exception) { - } - } - } - - init { - EditorEvents.addFileChangeListener(internalListener) - // Content changes reach us via the editor's existing DocumentChangeEvent (posted to the - // global EventBus on every edit); we fan them out to plugin-registered callbacks. - EventBus.getDefault().register(this) - } - - /** - * Detaches from EditorEvents / EventBus and clears any plugin-registered callbacks. Called - * by the activity in `onDestroy`. - */ - fun dispose() { - EditorEvents.removeFileChangeListener(internalListener) - EventBus.getDefault().unregister(this) - fileCallbacks.clear() - contentCallbacks.clear() - onMain { peerPresenceOverlay.clearAll(); true } - activityRef.clear() - } - - /** - * Bridges the editor's per-keystroke [DocumentChangeEvent] to the plugin content-change - * contract `(fileContent, cursorLine, cursorColumn, language)`. Line/column are 0-indexed, - * matching what plugins expect. Runs on the main thread so the editor cursor is current. - */ - @Subscribe(threadMode = ThreadMode.MAIN) - fun onDocumentChange(event: DocumentChangeEvent) { - if (contentCallbacks.isEmpty()) return - val file = event.file.toFile() - // Only fan out changes for the focused file; ghost text can only target the visible editor. - if (file.absolutePath != getCurrentFile()?.absolutePath) return - val editor = activity()?.getEditorForFile(file)?.editor - val content = event.newText ?: editor?.text?.toString() ?: return - // Prefer the live cursor; fall back to the change's end position (also 0-indexed). - val cursor = editor?.cursor - val line = cursor?.leftLine ?: event.changeRange.end.line - val column = cursor?.leftColumn ?: event.changeRange.end.column - val language = languageIdForFile(file) ?: file.extension.lowercase() - contentCallbacks.forEach { cb -> - try { - cb(content, line, column, language) - } catch (_: Exception) { - } - } - } - - private fun activity(): EditorHandlerActivity? = activityRef.get()?.takeIf { !it.isDestroyed } - - // --- File state --------------------------------------------------------- - - override fun getCurrentFile(): File? { - val activity = activity() ?: return null - val direct = activity.editorViewModel.getCurrentFile() - if (direct != null) return direct - - // Active tab may be a plugin tab; fall back to the last real file we saw, - // but only if it's still actually open. - val fallback = EditorEvents.lastActiveFile ?: return null - val opened = activity.editorViewModel.getOpenedFiles() - val target = fallback.absolutePath - return if (opened.any { it.absolutePath == target }) fallback else null - } - - override fun getOpenFiles(): List = - activity()?.editorViewModel?.getOpenedFiles() ?: emptyList() - - override fun isFileOpen(file: File): Boolean { - val opened = activity()?.editorViewModel?.getOpenedFiles() ?: return false - val target = file.absolutePath - return opened.any { it.absolutePath == target } - } - - override fun isFileModified(file: File): Boolean = - activity()?.getEditorForFile(file)?.isModified == true - - override fun getModifiedFiles(): List { - val activity = activity() ?: return emptyList() - return activity.editorViewModel.getOpenedFiles() - .filter { activity.getEditorForFile(it)?.isModified == true } - } - - // --- Cursor / selection / line text ------------------------------------ - - // When a plugin tab is on top, `getCurrentEditor()` is null; fall back to the editor - // for the last real file so plugins can still inspect cursor/selection/content. - private fun inspectableEditor(): CodeEditor? { - val activity = activity() ?: return null - activity.getCurrentEditor()?.editor?.let { return it } - val file = getCurrentFile() ?: return null - return activity.getEditorForFile(file)?.editor - } - - override fun getCurrentSelection(): String? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - if (!cursor.isSelected) return null - return editor.text.subSequence(cursor.left, cursor.right).toString() - } - - override fun getCurrentFileContent(): String? = - inspectableEditor()?.text?.toString() - - override fun getFileContent(file: File): String? = - activity()?.getEditorForFile(file)?.editor?.text?.toString() - - override fun getCurrentCursorPosition(): CursorPosition? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - return CursorPosition(cursor.leftLine, cursor.leftColumn, cursor.left) - } - - override fun getCurrentSelectionRange(): SelectionRange? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - if (!cursor.isSelected) return null - return SelectionRange(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn) - } - - override fun getCurrentLineText(): String? { - val editor = inspectableEditor() ?: return null - val line = editor.cursor.leftLine - val text = editor.text - if (line !in 0 until text.lineCount) return null - return text.getLine(line).toString() - } - - override fun getLineText(file: File, lineNumber: Int): String? { - val editor = activity()?.getEditorForFile(file)?.editor ?: return null - val text = editor.text - if (lineNumber !in 0 until text.lineCount) return null - return text.getLine(lineNumber).toString() - } - - override fun getLineCount(file: File): Int = - activity()?.getEditorForFile(file)?.editor?.text?.lineCount ?: 0 - - override fun getWordAtCursor(): String? { - val editor = inspectableEditor() ?: return null - val cursor = editor.cursor - val text = editor.text - val line = cursor.leftLine - if (line !in 0 until text.lineCount) return null - val lineText = text.getLine(line).toString() - val column = cursor.leftColumn.coerceIn(0, lineText.length) - var start = column - while (start > 0 && lineText[start - 1].isWordChar()) start-- - var end = column - while (end < lineText.length && lineText[end].isWordChar()) end++ - if (start == end) return null - return lineText.substring(start, end) - } - - override fun getCurrentLanguageId(): String? = - getCurrentFile()?.let { languageIdForFile(it) } - - override fun getFileLanguageId(file: File): String? = languageIdForFile(file) - - // --- Tab control -------------------------------------------------------- - - override fun openFile(file: File): Boolean { - val activity = activity() ?: return false - activity.openFileAsync(file) {} - return true - } - - override fun openFileAt(file: File, line: Int, column: Int): Boolean { - val activity = activity() ?: return false - val pos = Position(line.coerceAtLeast(0), column.coerceAtLeast(0)) - activity.openFileAndSelect(file, Range(pos, pos)) - return true - } - - override fun saveCurrentFile(): Boolean { - val activity = activity() ?: return false - val index = activity.editorViewModel.getCurrentFileIndex() - if (index < 0) return false - activity.lifecycleScope.launch { - activity.saveResult(index, SaveResult()) - } - return true - } - - // --- Buffer edits ------------------------------------------------------- - - override fun insertTextAtCursor(text: String): Boolean = onMain { - val editor = inspectableEditor() ?: return@onMain false - val cursor = editor.cursor - editor.text.runEdit { - if (cursor.isSelected) { - replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) - } else { - insert(cursor.leftLine, cursor.leftColumn, text) - } - } - true - } - - override fun replaceSelection(text: String): Boolean = onMain { - val editor = inspectableEditor() ?: return@onMain false - val cursor = editor.cursor - if (!cursor.isSelected) return@onMain false - editor.text.runEdit { - replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) - } - true - } - - override fun appendToLine(file: File, line: Int, text: String): Boolean = - lineEdit(file, line, existing = true) { insert(line, getColumnCount(line), text) } - - override fun prependToLine(file: File, line: Int, text: String): Boolean = - lineEdit(file, line, existing = true) { insert(line, 0, text) } - - override fun replaceLine(file: File, line: Int, newText: String): Boolean = - lineEdit(file, line, existing = true) { replace(line, 0, line, getColumnCount(line), newText) } - - override fun insertLineBefore(file: File, line: Int, text: String): Boolean { - val payload = if (text.endsWith("\n")) text else "$text\n" - return lineEdit(file, line, existing = false) { insert(line, 0, payload) } - } - - override fun deleteLine(file: File, line: Int): Boolean = - lineEdit(file, line, existing = true) { - if (line < lineCount - 1) { - delete(line, 0, line + 1, 0) - } else if (line > 0) { - delete(line - 1, getColumnCount(line - 1), line, getColumnCount(line)) - } else { - delete(line, 0, line, getColumnCount(line)) - } - } - - override fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean = onMain { - val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false - val content = editor.text - val maxLine = content.lineCount - 1 - if (range.startLine !in 0..maxLine || range.endLine !in 0..maxLine) return@onMain false - content.runEdit { - replace(range.startLine, range.startColumn, range.endLine, range.endColumn, newText) - } - true - } - - override fun showPeerCursor( - file: File, - line: Int, - column: Int, - peerId: String, - peerName: String, - peerColor: Int, - ): Boolean = onMain { - peerPresenceOverlay.addMarker(file, line, column, peerId, peerName, peerColor) - } - - override fun hidePeerCursor(file: File, peerId: String): Boolean = onMain { - peerPresenceOverlay.removeMarker(file, peerId) - } - - override fun clearPeerCursors(file: File) { - onMain { peerPresenceOverlay.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 - * single batched edit on the main thread. `existing = true` requires 0 ≤ line < lineCount; - * `existing = false` allows line == lineCount for "insert at end". - */ - private inline fun lineEdit( - file: File, - line: Int, - existing: Boolean, - crossinline block: Content.() -> Unit, - ): Boolean = onMain { - val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false - val content = editor.text - val valid = if (existing) line in 0 until content.lineCount else line in 0..content.lineCount - if (!valid) return@onMain false - content.runEdit(block) - true - } - - - override fun addFileChangeCallback(callback: (File?) -> Unit) { - fileCallbacks.addIfAbsent(callback) - } - - override fun removeFileChangeCallback(callback: (File?) -> Unit) { - fileCallbacks.remove(callback) - } - - override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { - contentCallbacks.addIfAbsent(callback) - } - - override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { - contentCallbacks.remove(callback) - } - - // --- Inline suggestions ------------------------------------------------- - - override fun showInlineSuggestion(pluginId: String, text: String) { - mainHandler.post { - (inspectableEditor() as? IDEEditor)?.showInlineSuggestion(pluginId, text) - } - } - - override fun dismissInlineSuggestion(pluginId: String) { - mainHandler.post { - (inspectableEditor() as? IDEEditor)?.dismissInlineSuggestion(pluginId) - } - } - - // --- Helpers ------------------------------------------------------------ - - private inline fun Content.runEdit(block: Content.() -> T): T { - beginBatchEdit() - try { - return block() - } finally { - endBatchEdit() - } - } - - /** - * Posts [block] to the main thread and blocks the caller until it finishes. If the main - * thread doesn't process the edit within [MAIN_EDIT_TIMEOUT_SECONDS] the call logs a - * warning and returns `false` rather than hanging the plugin's thread or throwing - * through to an uncaught-exception handler — a deadlocked UI should not be able to take - * the IDE down with it. - */ - private inline fun onMain(crossinline block: () -> Boolean): Boolean { - if (Looper.myLooper() === mainHandler.looper) return block() - val latch = CountDownLatch(1) - val resultRef = AtomicReference(false) - val errorRef = AtomicReference(null) - mainHandler.post { - try { - resultRef.set(block()) - } catch (t: Throwable) { - errorRef.set(t) - } finally { - latch.countDown() - } - } - if (!latch.await(MAIN_EDIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - log.warn( - "Main thread did not process plugin edit within {}s; aborting", - MAIN_EDIT_TIMEOUT_SECONDS, - ) - return false - } - errorRef.get()?.let { throw it } - return resultRef.get() - } - - private fun Char.isWordChar(): Boolean = isLetterOrDigit() || this == '_' - - private fun languageIdForFile(file: File): String? { - val ext = file.extension.lowercase() - return when (ext) { - "" -> null - "kt", "kts" -> "kotlin" - "java" -> "java" - "xml" -> "xml" - "json" -> "json" - "gradle" -> "groovy" - "groovy" -> "groovy" - "md", "markdown" -> "markdown" - "yml", "yaml" -> "yaml" - "properties" -> "properties" - "sh", "bash" -> "shell" - "c" -> "c" - "cpp", "cc", "cxx", "h", "hpp" -> "cpp" - "py" -> "python" - "js" -> "javascript" - "ts" -> "typescript" - "html", "htm" -> "html" - "css" -> "css" - else -> ext - } - } - - companion object { - private const val MAIN_EDIT_TIMEOUT_SECONDS = 5L - private val log = LoggerFactory.getLogger(EditorProviderImpl::class.java) - } + private val activityRef = WeakReference(activity) + private val mainHandler = Handler(Looper.getMainLooper()) + private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>() + private val contentCallbacks = + java.util.concurrent.CopyOnWriteArrayList<(String, Int, Int, String) -> Unit>() + private val peerPresenceOverlay = + PeerPresenceOverlayManager { file -> + activity()?.getEditorForFile(file)?.editor + } + + private val internalListener: (File?) -> Unit = { file -> + fileCallbacks.forEach { cb -> + try { + cb(file) + } catch (_: Exception) { + } + } + } + + init { + EditorEvents.addFileChangeListener(internalListener) + // Content changes reach us via the editor's existing DocumentChangeEvent (posted to the + // global EventBus on every edit); we fan them out to plugin-registered callbacks. + EventBus.getDefault().register(this) + } + + /** + * Detaches from EditorEvents / EventBus and clears any plugin-registered callbacks. Called + * by the activity in `onDestroy`. + */ + fun dispose() { + EditorEvents.removeFileChangeListener(internalListener) + EventBus.getDefault().unregister(this) + fileCallbacks.clear() + contentCallbacks.clear() + onMain { + peerPresenceOverlay.clearAll() + true + } + activityRef.clear() + } + + /** + * Bridges the editor's per-keystroke [DocumentChangeEvent] to the plugin content-change + * contract `(fileContent, cursorLine, cursorColumn, language)`. Line/column are 0-indexed, + * matching what plugins expect. Runs on the main thread so the editor cursor is current. + */ + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDocumentChange(event: DocumentChangeEvent) { + if (contentCallbacks.isEmpty()) return + val file = event.file.toFile() + // Only fan out changes for the focused file; ghost text can only target the visible editor. + if (file.absolutePath != getCurrentFile()?.absolutePath) return + val editor = activity()?.getEditorForFile(file)?.editor + val content = event.newText ?: editor?.text?.toString() ?: return + // Prefer the live cursor; fall back to the change's end position (also 0-indexed). + val cursor = editor?.cursor + val line = cursor?.leftLine ?: event.changeRange.end.line + val column = cursor?.leftColumn ?: event.changeRange.end.column + val language = languageIdForFile(file) ?: file.extension.lowercase() + contentCallbacks.forEach { cb -> + try { + cb(content, line, column, language) + } catch (_: Exception) { + } + } + } + + private fun activity(): EditorHandlerActivity? = activityRef.get()?.takeIf { !it.isDestroyed } + + // --- File state --------------------------------------------------------- + + override fun getCurrentFile(): File? { + val activity = activity() ?: return null + val direct = activity.editorViewModel.getCurrentFile() + if (direct != null) return direct + + // Active tab may be a plugin tab; fall back to the last real file we saw, + // but only if it's still actually open. + val fallback = EditorEvents.lastActiveFile ?: return null + val opened = activity.editorViewModel.getOpenedFiles() + val target = fallback.absolutePath + return if (opened.any { it.absolutePath == target }) fallback else null + } + + override fun getOpenFiles(): List = activity()?.editorViewModel?.getOpenedFiles() ?: emptyList() + + override fun isFileOpen(file: File): Boolean { + val opened = activity()?.editorViewModel?.getOpenedFiles() ?: return false + val target = file.absolutePath + return opened.any { it.absolutePath == target } + } + + override fun isFileModified(file: File): Boolean = activity()?.getEditorForFile(file)?.isModified == true + + override fun getModifiedFiles(): List { + val activity = activity() ?: return emptyList() + return activity.editorViewModel + .getOpenedFiles() + .filter { activity.getEditorForFile(it)?.isModified == true } + } + + // --- Cursor / selection / line text ------------------------------------ + + // When a plugin tab is on top, `getCurrentEditor()` is null; fall back to the editor + // for the last real file so plugins can still inspect cursor/selection/content. + private fun inspectableEditor(): CodeEditor? { + val activity = activity() ?: return null + activity.getCurrentEditor()?.editor?.let { return it } + val file = getCurrentFile() ?: return null + return activity.getEditorForFile(file)?.editor + } + + override fun getCurrentSelection(): String? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + if (!cursor.isSelected) return null + return editor.text.subSequence(cursor.left, cursor.right).toString() + } + + override fun getCurrentFileContent(): String? = inspectableEditor()?.text?.toString() + + override fun getFileContent(file: File): String? = + activity() + ?.getEditorForFile(file) + ?.editor + ?.text + ?.toString() + + override fun getCurrentCursorPosition(): CursorPosition? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + return CursorPosition(cursor.leftLine, cursor.leftColumn, cursor.left) + } + + override fun getCurrentSelectionRange(): SelectionRange? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + if (!cursor.isSelected) return null + return SelectionRange(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn) + } + + override fun getCurrentLineText(): String? { + val editor = inspectableEditor() ?: return null + val line = editor.cursor.leftLine + val text = editor.text + if (line !in 0 until text.lineCount) return null + return text.getLine(line).toString() + } + + override fun getLineText( + file: File, + lineNumber: Int, + ): String? { + val editor = activity()?.getEditorForFile(file)?.editor ?: return null + val text = editor.text + if (lineNumber !in 0 until text.lineCount) return null + return text.getLine(lineNumber).toString() + } + + override fun getLineCount(file: File): Int = + activity() + ?.getEditorForFile(file) + ?.editor + ?.text + ?.lineCount ?: 0 + + override fun getWordAtCursor(): String? { + val editor = inspectableEditor() ?: return null + val cursor = editor.cursor + val text = editor.text + val line = cursor.leftLine + if (line !in 0 until text.lineCount) return null + val lineText = text.getLine(line).toString() + val column = cursor.leftColumn.coerceIn(0, lineText.length) + var start = column + while (start > 0 && lineText[start - 1].isWordChar()) start-- + var end = column + while (end < lineText.length && lineText[end].isWordChar()) end++ + if (start == end) return null + return lineText.substring(start, end) + } + + override fun getCurrentLanguageId(): String? = getCurrentFile()?.let { languageIdForFile(it) } + + override fun getFileLanguageId(file: File): String? = languageIdForFile(file) + + // --- Tab control -------------------------------------------------------- + + override fun openFile(file: File): Boolean { + val activity = activity() ?: return false + activity.openFileAsync(file) {} + return true + } + + override fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean { + val activity = activity() ?: return false + val pos = Position(line.coerceAtLeast(0), column.coerceAtLeast(0)) + activity.openFileAndSelect(file, Range(pos, pos)) + return true + } + + override fun saveCurrentFile(): Boolean { + val activity = activity() ?: return false + val index = activity.editorViewModel.getCurrentFileIndex() + if (index < 0) return false + activity.lifecycleScope.launch { + activity.saveResult(index, SaveResult()) + } + return true + } + + // --- Buffer edits ------------------------------------------------------- + + override fun insertTextAtCursor(text: String): Boolean = + onMain { + val editor = inspectableEditor() ?: return@onMain false + val cursor = editor.cursor + editor.text.runEdit { + if (cursor.isSelected) { + replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) + } else { + insert(cursor.leftLine, cursor.leftColumn, text) + } + } + true + } + + override fun replaceSelection(text: String): Boolean = + onMain { + val editor = inspectableEditor() ?: return@onMain false + val cursor = editor.cursor + if (!cursor.isSelected) return@onMain false + editor.text.runEdit { + replace(cursor.leftLine, cursor.leftColumn, cursor.rightLine, cursor.rightColumn, text) + } + true + } + + override fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean = lineEdit(file, line, existing = true) { insert(line, getColumnCount(line), text) } + + override fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean = lineEdit(file, line, existing = true) { insert(line, 0, text) } + + override fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean = lineEdit(file, line, existing = true) { replace(line, 0, line, getColumnCount(line), newText) } + + override fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean { + val payload = if (text.endsWith("\n")) text else "$text\n" + return lineEdit(file, line, existing = false) { insert(line, 0, payload) } + } + + override fun deleteLine( + file: File, + line: Int, + ): Boolean = + lineEdit(file, line, existing = true) { + if (line < lineCount - 1) { + delete(line, 0, line + 1, 0) + } else if (line > 0) { + delete(line - 1, getColumnCount(line - 1), line, getColumnCount(line)) + } else { + delete(line, 0, line, getColumnCount(line)) + } + } + + override fun replaceRange( + file: File, + range: SelectionRange, + newText: String, + ): Boolean = + onMain { + val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false + val content = editor.text + val maxLine = content.lineCount - 1 + if (range.startLine !in 0..maxLine || range.endLine !in 0..maxLine) return@onMain false + content.runEdit { + replace(range.startLine, range.startColumn, range.endLine, range.endColumn, newText) + } + true + } + + override fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = + onMain { + peerPresenceOverlay.addMarker(file, line, column, peerId, peerName, peerColor) + } + + override fun hidePeerCursor( + file: File, + peerId: String, + ): Boolean = + onMain { + peerPresenceOverlay.removeMarker(file, peerId) + } + + override fun clearPeerCursors(file: File) { + onMain { + peerPresenceOverlay.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 + * single batched edit on the main thread. `existing = true` requires 0 ≤ line < lineCount; + * `existing = false` allows line == lineCount for "insert at end". + */ + private inline fun lineEdit( + file: File, + line: Int, + existing: Boolean, + crossinline block: Content.() -> Unit, + ): Boolean = + onMain { + val editor = activity()?.getEditorForFile(file)?.editor ?: return@onMain false + val content = editor.text + val valid = if (existing) line in 0 until content.lineCount else line in 0..content.lineCount + if (!valid) return@onMain false + content.runEdit(block) + true + } + + override fun addFileChangeCallback(callback: (File?) -> Unit) { + fileCallbacks.addIfAbsent(callback) + } + + override fun removeFileChangeCallback(callback: (File?) -> Unit) { + fileCallbacks.remove(callback) + } + + override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { + contentCallbacks.addIfAbsent(callback) + } + + override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { + contentCallbacks.remove(callback) + } + + // --- Inline suggestions ------------------------------------------------- + + override fun showInlineSuggestion( + pluginId: String, + text: String, + ) { + mainHandler.post { + (inspectableEditor() as? IDEEditor)?.showInlineSuggestion(pluginId, text) + } + } + + override fun dismissInlineSuggestion(pluginId: String) { + mainHandler.post { + (inspectableEditor() as? IDEEditor)?.dismissInlineSuggestion(pluginId) + } + } + + // --- Helpers ------------------------------------------------------------ + + private inline fun Content.runEdit(block: Content.() -> T): T { + beginBatchEdit() + try { + return block() + } finally { + endBatchEdit() + } + } + + /** + * Posts [block] to the main thread and blocks the caller until it finishes. If the main + * thread doesn't process the edit within [MAIN_EDIT_TIMEOUT_SECONDS] the call logs a + * warning and returns `false` rather than hanging the plugin's thread or throwing + * through to an uncaught-exception handler — a deadlocked UI should not be able to take + * the IDE down with it. + */ + private inline fun onMain(crossinline block: () -> Boolean): Boolean { + if (Looper.myLooper() === mainHandler.looper) return block() + val latch = CountDownLatch(1) + val resultRef = AtomicReference(false) + val errorRef = AtomicReference(null) + mainHandler.post { + try { + resultRef.set(block()) + } catch (t: Throwable) { + errorRef.set(t) + } finally { + latch.countDown() + } + } + if (!latch.await(MAIN_EDIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + log.warn( + "Main thread did not process plugin edit within {}s; aborting", + MAIN_EDIT_TIMEOUT_SECONDS, + ) + return false + } + errorRef.get()?.let { throw it } + return resultRef.get() + } + + private fun Char.isWordChar(): Boolean = isLetterOrDigit() || this == '_' + + private fun languageIdForFile(file: File): String? { + val ext = file.extension.lowercase() + return when (ext) { + "" -> null + "kt", "kts" -> "kotlin" + "java" -> "java" + "xml" -> "xml" + "json" -> "json" + "gradle" -> "groovy" + "groovy" -> "groovy" + "md", "markdown" -> "markdown" + "yml", "yaml" -> "yaml" + "properties" -> "properties" + "sh", "bash" -> "shell" + "c" -> "c" + "cpp", "cc", "cxx", "h", "hpp" -> "cpp" + "py" -> "python" + "js" -> "javascript" + "ts" -> "typescript" + "html", "htm" -> "html" + "css" -> "css" + else -> ext + } + } + + companion object { + private const val MAIN_EDIT_TIMEOUT_SECONDS = 5L + private val log = LoggerFactory.getLogger(EditorProviderImpl::class.java) + } } 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 74b5e54724..565d989c2f 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -14,165 +14,182 @@ import java.io.File * Handles all plugin-related data operations */ class PluginRepositoryImpl( - private val pluginManagerProvider: () -> PluginManager?, - private val pluginsDir: File + private val pluginManagerProvider: () -> PluginManager?, + private val pluginsDir: File, ) : PluginRepository { - - private companion object { - private const val TAG = "PluginRepository" - } - - private val pluginManager: PluginManager? - get() = pluginManagerProvider() - - override suspend fun getAllPlugins(): Result> = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - manager.getAllPlugins() - }.onFailure { exception -> - Log.e(TAG, "Failed to get all plugins", exception) - } - } - - override suspend fun enablePlugin(pluginId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - val result = manager.enablePlugin(pluginId) - result - }.onFailure { exception -> - Log.e(TAG, "Failed to enable plugin: $pluginId", exception) - } - } - - override suspend fun disablePlugin(pluginId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - val result = manager.disablePlugin(pluginId) - result - }.onFailure { exception -> - Log.e(TAG, "Failed to disable plugin: $pluginId", exception) - } - } - - override suspend fun uninstallPlugin(pluginId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - - Log.d(TAG, "Uninstalling plugin: $pluginId") - val result = manager.uninstallPlugin(pluginId) - result - }.onFailure { exception -> - Log.e(TAG, "Failed to uninstall plugin: $pluginId", exception) - } - } - - override suspend fun getPluginMetadataFromFile(pluginFile: File): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - manager.getPluginMetadataOnly(pluginFile).getOrThrow().toPluginMetadata() - } - } - - override suspend fun haveMatchingSignatures(incomingFile: File, existingPluginId: String): Result = - withContext(Dispatchers.IO) { - runCatching { - pluginManager?.haveMatchingSignatures(incomingFile, existingPluginId) - ?: throw IllegalStateException("Plugin system not available") - } - } - - override suspend fun installPluginFromFile(pluginFile: File): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - - val validationResult = manager.getPluginValidation(pluginFile) - if (validationResult.isFailure) { - pluginFile.delete() - throw validationResult.exceptionOrNull() - ?: Exception("Failed to read plugin metadata") - } - - val validation = validationResult.getOrNull()!! - val metadata = validation.manifest - val pluginId = metadata.id - - if (validation.isDebug) { - val missing = listOfNotNull( - "icon_day".takeIf { - metadata.iconDay == null || !validation.iconDayEntryExists - }, - "icon_night".takeIf { - metadata.iconNight == null || !validation.iconNightEntryExists - } - ).joinToString(" and ") { "\"$it\"" } - if (missing.isNotEmpty()) { - pluginFile.delete() - throw IllegalArgumentException( - "[$pluginId] Missing $missing for debug plugin. Debug plugins must declare and ship both icon_day and icon_night assets." - ) - } - } - - try { - manager.uninstallPlugin(pluginId) - Log.d(TAG, "Uninstalled existing version of plugin: $pluginId") - } catch (e: Exception) { - Log.w(TAG, "Error uninstalling existing plugin: ${e.message}") - } - - val fileExtension = if (pluginFile.name.endsWith(".cgp")) ".cgp" else ".apk" - val finalFileName = "${pluginId}$fileExtension" - - if (!pluginsDir.exists()) { - pluginsDir.mkdirs() - } - - val finalFile = File(pluginsDir, finalFileName) - - try { - pluginFile.copyTo(finalFile, overwrite = true) - Log.d(TAG, "Plugin file copied to: ${finalFile.absolutePath}") - pluginFile.delete() - } catch (e: Exception) { - Log.e(TAG, "Failed to copy plugin file to plugins directory", e) - throw e - } - - 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." - ) - } - }.onFailure { exception -> - Log.e(TAG, "Failed to install plugin from file: ${pluginFile.absolutePath}", exception) - } - } - - override suspend fun reloadPlugins(): Result = withContext(Dispatchers.IO) { - runCatching { - val manager = pluginManager - ?: throw IllegalStateException("Plugin system not available") - - manager.loadPlugins() - }.onFailure { exception -> - Log.e(TAG, "Failed to reload plugins", exception) - } - } - - override fun isPluginManagerAvailable(): Boolean { - val available = pluginManager != null - return available - } -} \ No newline at end of file + private companion object { + private const val TAG = "PluginRepository" + } + + private val pluginManager: PluginManager? + get() = pluginManagerProvider() + + override suspend fun getAllPlugins(): Result> = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + manager.getAllPlugins() + }.onFailure { exception -> + Log.e(TAG, "Failed to get all plugins", exception) + } + } + + override suspend fun enablePlugin(pluginId: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + val result = manager.enablePlugin(pluginId) + result + }.onFailure { exception -> + Log.e(TAG, "Failed to enable plugin: $pluginId", exception) + } + } + + override suspend fun disablePlugin(pluginId: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + val result = manager.disablePlugin(pluginId) + result + }.onFailure { exception -> + Log.e(TAG, "Failed to disable plugin: $pluginId", exception) + } + } + + override suspend fun uninstallPlugin(pluginId: String): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + + Log.d(TAG, "Uninstalling plugin: $pluginId") + val result = manager.uninstallPlugin(pluginId) + result + }.onFailure { exception -> + Log.e(TAG, "Failed to uninstall plugin: $pluginId", exception) + } + } + + override suspend fun getPluginMetadataFromFile(pluginFile: File): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + manager.getPluginMetadataOnly(pluginFile).getOrThrow().toPluginMetadata() + } + } + + override suspend fun haveMatchingSignatures( + incomingFile: File, + existingPluginId: String, + ): Result = + withContext(Dispatchers.IO) { + runCatching { + pluginManager?.haveMatchingSignatures(incomingFile, existingPluginId) + ?: throw IllegalStateException("Plugin system not available") + } + } + + override suspend fun installPluginFromFile(pluginFile: File): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + + val validationResult = manager.getPluginValidation(pluginFile) + if (validationResult.isFailure) { + pluginFile.delete() + throw validationResult.exceptionOrNull() + ?: Exception("Failed to read plugin metadata") + } + + val validation = validationResult.getOrNull()!! + val metadata = validation.manifest + val pluginId = metadata.id + + if (validation.isDebug) { + val missing = + listOfNotNull( + "icon_day".takeIf { + metadata.iconDay == null || !validation.iconDayEntryExists + }, + "icon_night".takeIf { + metadata.iconNight == null || !validation.iconNightEntryExists + }, + ).joinToString(" and ") { "\"$it\"" } + if (missing.isNotEmpty()) { + pluginFile.delete() + throw IllegalArgumentException( + "[$pluginId] Missing $missing for debug plugin. Debug plugins must declare and ship both icon_day and icon_night assets.", + ) + } + } + + try { + manager.uninstallPlugin(pluginId) + Log.d(TAG, "Uninstalled existing version of plugin: $pluginId") + } catch (e: Exception) { + Log.w(TAG, "Error uninstalling existing plugin: ${e.message}") + } + + val fileExtension = if (pluginFile.name.endsWith(".cgp")) ".cgp" else ".apk" + val finalFileName = "${pluginId}$fileExtension" + + if (!pluginsDir.exists()) { + pluginsDir.mkdirs() + } + + val finalFile = File(pluginsDir, finalFileName) + + try { + pluginFile.copyTo(finalFile, overwrite = true) + Log.d(TAG, "Plugin file copied to: ${finalFile.absolutePath}") + pluginFile.delete() + } catch (e: Exception) { + Log.e(TAG, "Failed to copy plugin file to plugins directory", e) + throw e + } + + 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.", + ) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to install plugin from file: ${pluginFile.absolutePath}", exception) + } + } + + override suspend fun reloadPlugins(): Result = + withContext(Dispatchers.IO) { + runCatching { + val manager = + pluginManager + ?: throw IllegalStateException("Plugin system not available") + + manager.loadPlugins() + }.onFailure { exception -> + Log.e(TAG, "Failed to reload plugins", exception) + } + } + + override fun isPluginManagerAvailable(): Boolean { + val available = pluginManager != null + return available + } +} 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 99ef4bc2de..ef8f9ef281 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 @@ -13,71 +13,75 @@ import java.util.concurrent.CompletableFuture * that have the FILESYSTEM_READ permission. */ interface IdeProjectService { - /** - * Gets the currently active/open project. - * @return The current project, or null if no project is open - */ - fun getCurrentProject(): IProject? - - /** - * Gets all projects currently loaded in the IDE. - * @return List of all loaded projects - */ - fun getAllProjects(): List - - /** - * Finds a project by its root directory path. - * @param path The root directory path of the project - * @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 - - /** - * Resolves the build context (compile/intermediate classpaths, runtime dex files, - * selected variant, resource APK, and whether a build is needed) for the module that - * owns the given file. - * - * Defaults to returning null so the method is binary-compatible: hosts that predate it, - * and implementors that do not override it, report "unavailable" (mirrors the default on - * [IdeUIService.openPluginScreen]). - * - * @param filePath The absolute path of a source file owned by the module - * @return The module context, or null if no module can be resolved - */ - fun getModuleContext(filePath: String): ModuleContext? = null + /** + * Gets the currently active/open project. + * @return The current project, or null if no project is open + */ + fun getCurrentProject(): IProject? + + /** + * Gets all projects currently loaded in the IDE. + * @return List of all loaded projects + */ + fun getAllProjects(): List + + /** + * Finds a project by its root directory path. + * @param path The root directory path of the project + * @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 + + /** + * Resolves the build context (compile/intermediate classpaths, runtime dex files, + * selected variant, resource APK, and whether a build is needed) for the module that + * owns the given file. + * + * Defaults to returning null so the method is binary-compatible: hosts that predate it, + * and implementors that do not override it, report "unavailable" (mirrors the default on + * [IdeUIService.openPluginScreen]). + * + * @param filePath The absolute path of a source file owned by the module + * @return The module context, or null if no module can be resolved + */ + fun getModuleContext(filePath: String): ModuleContext? = null } /** * 0-based cursor position inside an editor buffer. */ -data class CursorPosition(val line: Int, val column: Int, val index: Int) +data class CursorPosition( + val line: Int, + val column: Int, + val index: Int, +) /** * 0-based selection range. Inclusive of start, exclusive of end (matches the underlying editor). */ data class SelectionRange( - val startLine: Int, - val startColumn: Int, - val endLine: Int, - val endColumn: Int, + val startLine: Int, + val startColumn: Int, + val endLine: Int, + val endColumn: Int, ) /** @@ -85,7 +89,7 @@ data class SelectionRange( * or null if all files are closed. */ fun interface FileChangeListener { - fun onFileChanged(file: File?) + fun onFileChanged(file: File?) } /** @@ -93,14 +97,19 @@ fun interface FileChangeListener { * modifies editor content. Used for features like inline code suggestions. */ fun interface EditorContentChangeListener { - /** - * Called when editor content changes. - * @param fileContent The full file content after the change - * @param cursorLine The 0-based line number of the cursor - * @param cursorColumn The 0-based column number of the cursor - * @param language The language ID of the file (e.g., "kotlin", "java", "xml") - */ - fun onContentChanged(fileContent: String, cursorLine: Int, cursorColumn: Int, language: String) + /** + * Called when editor content changes. + * @param fileContent The full file content after the change + * @param cursorLine The 0-based line number of the cursor + * @param cursorColumn The 0-based column number of the cursor + * @param language The language ID of the file (e.g., "kotlin", "java", "xml") + */ + fun onContentChanged( + fileContent: String, + cursorLine: Int, + cursorColumn: Int, + language: String, + ) } /** @@ -109,125 +118,158 @@ fun interface EditorContentChangeListener { * FILESYSTEM_WRITE. */ interface IdeEditorService { - fun getCurrentFile(): File? - - fun getOpenFiles(): List - - fun isFileOpen(file: File): Boolean - - fun getCurrentSelection(): String? - - fun getCurrentFileContent(): String? - - fun getFileContent(file: File): String? - - fun getCurrentCursorPosition(): CursorPosition? - - fun getCurrentSelectionRange(): SelectionRange? - - fun getCurrentLineText(): String? - - fun getLineText(file: File, lineNumber: Int): String? - - fun getLineCount(file: File): Int - - fun getWordAtCursor(): String? - - fun getCurrentLanguageId(): String? - - fun getFileLanguageId(file: File): String? - - fun isFileModified(file: File): Boolean - - fun getModifiedFiles(): List - - /** - * Schedules the given file to be opened in the editor. The open itself runs asynchronously - * on the IDE's editor thread — a `true` return means the request was dispatched, not that - * the file is already open or that it exists, is readable, or was handled by this IDE - * rather than delegated (image viewer, another plugin, etc.). Poll [isFileOpen] if you - * need to confirm completion. - */ - fun openFile(file: File): Boolean - - /** See [openFile]. The caret is moved to the given 0-based position once the open completes. */ - fun openFileAt(file: File, line: Int, column: Int): Boolean - - /** - * Schedules a save of the active editor tab. Runs asynchronously; a `true` return means - * the save was dispatched, not that the buffer has been flushed to disk. Poll - * [isFileModified] on the current file to confirm completion. - */ - fun saveCurrentFile(): Boolean - - fun insertTextAtCursor(text: String): Boolean - - fun replaceSelection(text: String): Boolean - - fun appendToLine(file: File, line: Int, text: String): Boolean - - fun prependToLine(file: File, line: Int, text: String): Boolean - - fun replaceLine(file: File, line: Int, newText: String): Boolean - - fun insertLineBefore(file: File, line: Int, text: String): Boolean - - fun deleteLine(file: File, line: Int): Boolean - - fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean + fun getCurrentFile(): File? - /** - * 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 showPeerCursor( - file: File, - line: Int, - column: Int, - peerId: String, - peerName: String, - peerColor: Int, - ): Boolean = false + fun getOpenFiles(): List - /** Hides the cursor for [peerId] in [file], if present. Default-implemented no-op. */ - fun hidePeerCursor(file: File, peerId: String): Boolean = false + fun isFileOpen(file: File): Boolean - /** Removes all remote peer cursors in [file]. Default-implemented no-op. */ - fun clearPeerCursors(file: File) {} + fun getCurrentSelection(): String? - fun addFileChangeListener(listener: FileChangeListener) + fun getCurrentFileContent(): String? - fun removeFileChangeListener(listener: FileChangeListener) + fun getFileContent(file: File): String? + + fun getCurrentCursorPosition(): CursorPosition? + + fun getCurrentSelectionRange(): SelectionRange? + + fun getCurrentLineText(): String? + + fun getLineText( + file: File, + lineNumber: Int, + ): String? + + fun getLineCount(file: File): Int + + fun getWordAtCursor(): String? - /** - * Registers a listener to be notified when editor content changes. - * @param listener The listener to register - */ - fun addContentChangeListener(listener: EditorContentChangeListener) {} + fun getCurrentLanguageId(): String? - /** - * Unregisters an editor content change listener. - * @param listener The listener to unregister - */ - fun removeContentChangeListener(listener: EditorContentChangeListener) {} + fun getFileLanguageId(file: File): String? - /** - * Shows an inline suggestion (ghost text) at the cursor position. - * The suggestion is displayed semi-transparently and can be dismissed. - * @param text The suggestion text to display - */ - fun showInlineSuggestion(text: String) {} + fun isFileModified(file: File): Boolean + + fun getModifiedFiles(): List - /** - * Dismisses any currently displayed inline suggestion. - */ - fun dismissInlineSuggestion() {} + /** + * Schedules the given file to be opened in the editor. The open itself runs asynchronously + * on the IDE's editor thread — a `true` return means the request was dispatched, not that + * the file is already open or that it exists, is readable, or was handled by this IDE + * rather than delegated (image viewer, another plugin, etc.). Poll [isFileOpen] if you + * need to confirm completion. + */ + fun openFile(file: File): Boolean + + /** See [openFile]. The caret is moved to the given 0-based position once the open completes. */ + fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean + + /** + * Schedules a save of the active editor tab. Runs asynchronously; a `true` return means + * the save was dispatched, not that the buffer has been flushed to disk. Poll + * [isFileModified] on the current file to confirm completion. + */ + fun saveCurrentFile(): Boolean + + fun insertTextAtCursor(text: String): Boolean + + fun replaceSelection(text: String): Boolean + + fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean + + fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean + + fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean + + fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean + + fun deleteLine( + file: File, + line: Int, + ): Boolean + + fun replaceRange( + file: File, + range: SelectionRange, + newText: String, + ): Boolean + + /** + * 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 showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): 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 cursors in [file]. Default-implemented no-op. */ + fun clearPeerCursors(file: File) {} + + fun addFileChangeListener(listener: FileChangeListener) + + fun removeFileChangeListener(listener: FileChangeListener) + + /** + * Registers a listener to be notified when editor content changes. + * @param listener The listener to register + */ + fun addContentChangeListener(listener: EditorContentChangeListener) {} + + /** + * Unregisters an editor content change listener. + * @param listener The listener to unregister + */ + fun removeContentChangeListener(listener: EditorContentChangeListener) {} + + /** + * Shows an inline suggestion (ghost text) at the cursor position. + * The suggestion is displayed semi-transparently and can be dismissed. + * @param text The suggestion text to display + */ + fun showInlineSuggestion(text: String) {} + + /** + * Dismisses any currently displayed inline suggestion. + */ + fun dismissInlineSuggestion() {} } /** @@ -236,50 +278,50 @@ interface IdeEditorService { * that need to show dialogs or perform UI operations. */ interface IdeUIService { - /** - * Gets the current Activity context that can be used for showing dialogs. - * @return The current Activity, or null if no activity is available - */ - fun getCurrentActivity(): Activity? - - /** - * Checks if UI operations are currently possible. - * @return true if UI operations can be performed, false otherwise - */ - fun isUIAvailable(): Boolean - - /** - * Opens a fullscreen host surface for a plugin-owned Fragment. - * - * The host app owns only the generic container. The plugin owns the Fragment class and all - * feature-specific behavior. - */ - fun openPluginScreen( - pluginId: String, - fragmentClassName: String, - title: String? = null - ): Boolean = false - - /** - * Asks the IDE to rebuild the editor toolbar, re-evaluating each plugin - * [com.itsaky.androidide.plugins.extensions.ToolbarAction]'s dynamic providers - * ([com.itsaky.androidide.plugins.extensions.ToolbarAction.iconProvider], - * `isEnabledProvider`, `isVisibleProvider`). Call this after changing plugin state - * that those providers depend on — e.g. to swap a toolbar icon between - * idle/active/processing states. - * - * Safe to call from any thread; the rebuild is marshalled to the UI thread. A no-op - * when no editor is in the foreground. Default implementation does nothing so older - * hosts remain source/binary compatible. - */ - fun refreshToolbarActions() {} - - companion object { - const val ACTION_OPEN_PLUGIN_SCREEN = "com.itsaky.androidide.plugins.OPEN_PLUGIN_SCREEN" - const val EXTRA_PLUGIN_ID = "com.itsaky.androidide.plugins.extra.PLUGIN_ID" - const val EXTRA_FRAGMENT_CLASS_NAME = "com.itsaky.androidide.plugins.extra.FRAGMENT_CLASS_NAME" - const val EXTRA_TITLE = "com.itsaky.androidide.plugins.extra.TITLE" - } + /** + * Gets the current Activity context that can be used for showing dialogs. + * @return The current Activity, or null if no activity is available + */ + fun getCurrentActivity(): Activity? + + /** + * Checks if UI operations are currently possible. + * @return true if UI operations can be performed, false otherwise + */ + fun isUIAvailable(): Boolean + + /** + * Opens a fullscreen host surface for a plugin-owned Fragment. + * + * The host app owns only the generic container. The plugin owns the Fragment class and all + * feature-specific behavior. + */ + fun openPluginScreen( + pluginId: String, + fragmentClassName: String, + title: String? = null, + ): Boolean = false + + /** + * Asks the IDE to rebuild the editor toolbar, re-evaluating each plugin + * [com.itsaky.androidide.plugins.extensions.ToolbarAction]'s dynamic providers + * ([com.itsaky.androidide.plugins.extensions.ToolbarAction.iconProvider], + * `isEnabledProvider`, `isVisibleProvider`). Call this after changing plugin state + * that those providers depend on — e.g. to swap a toolbar icon between + * idle/active/processing states. + * + * Safe to call from any thread; the rebuild is marshalled to the UI thread. A no-op + * when no editor is in the foreground. Default implementation does nothing so older + * hosts remain source/binary compatible. + */ + fun refreshToolbarActions() {} + + companion object { + const val ACTION_OPEN_PLUGIN_SCREEN = "com.itsaky.androidide.plugins.OPEN_PLUGIN_SCREEN" + const val EXTRA_PLUGIN_ID = "com.itsaky.androidide.plugins.extra.PLUGIN_ID" + const val EXTRA_FRAGMENT_CLASS_NAME = "com.itsaky.androidide.plugins.extra.FRAGMENT_CLASS_NAME" + const val EXTRA_TITLE = "com.itsaky.androidide.plugins.extra.TITLE" + } } /** @@ -288,85 +330,90 @@ interface IdeUIService { * that need to monitor build status or trigger builds. */ interface IdeBuildService { - /** - * Checks if a build/sync operation is currently in progress. - * @return true if a build is running, false otherwise - */ - fun isBuildInProgress(): Boolean - - /** - * Checks if the Gradle tooling server is started and ready. - * @return true if the tooling server is available, false otherwise - */ - fun isToolingServerStarted(): Boolean - - /** - * Registers a callback to be notified when build status changes. - * @param callback The callback to register - */ - fun addBuildStatusListener(callback: BuildStatusListener) - - /** - * Unregisters a build status callback. - * @param callback The callback to unregister - */ - fun removeBuildStatusListener(callback: BuildStatusListener) - - /** - * Executes the given Gradle task paths (e.g. ":app:assembleDebug") and completes with - * true on success, false on failure/cancellation. - * - * Default completes with false so this addition is binary-compatible: hosts that predate - * the method, and any implementor that does not override it, report "not executed". - */ - fun executeTasks(vararg tasks: String): CompletableFuture = - CompletableFuture.completedFuture(false) - - /** - * Builds and runs the app on the connected device. - * @param callback The callback to be invoked when the operation completes - */ - fun runApp(callback: BuildAndLaunchCallback) { - callback.onComplete(false, "Not implemented") - } - - /** - * Triggers a Gradle sync operation. - * @param callback The callback to be invoked when the sync completes - */ - fun triggerGradleSync(callback: GradleSyncCallback) { - callback.onComplete(false, "") - } - - /** - * Gets the latest build output logs. - * @return The build output as a string, or null if no build output is available - */ - fun getBuildOutput(): String? = null + /** + * Checks if a build/sync operation is currently in progress. + * @return true if a build is running, false otherwise + */ + fun isBuildInProgress(): Boolean + + /** + * Checks if the Gradle tooling server is started and ready. + * @return true if the tooling server is available, false otherwise + */ + fun isToolingServerStarted(): Boolean + + /** + * Registers a callback to be notified when build status changes. + * @param callback The callback to register + */ + fun addBuildStatusListener(callback: BuildStatusListener) + + /** + * Unregisters a build status callback. + * @param callback The callback to unregister + */ + fun removeBuildStatusListener(callback: BuildStatusListener) + + /** + * Executes the given Gradle task paths (e.g. ":app:assembleDebug") and completes with + * true on success, false on failure/cancellation. + * + * Default completes with false so this addition is binary-compatible: hosts that predate + * the method, and any implementor that does not override it, report "not executed". + */ + fun executeTasks(vararg tasks: String): CompletableFuture = CompletableFuture.completedFuture(false) + + /** + * Builds and runs the app on the connected device. + * @param callback The callback to be invoked when the operation completes + */ + fun runApp(callback: BuildAndLaunchCallback) { + callback.onComplete(false, "Not implemented") + } + + /** + * Triggers a Gradle sync operation. + * @param callback The callback to be invoked when the sync completes + */ + fun triggerGradleSync(callback: GradleSyncCallback) { + callback.onComplete(false, "") + } + + /** + * Gets the latest build output logs. + * @return The build output as a string, or null if no build output is available + */ + fun getBuildOutput(): String? = null } /** * Callback interface for build and launch operations. */ fun interface BuildAndLaunchCallback { - /** - * Called when the build and launch operation completes. - * @param success true if the operation succeeded, false otherwise - * @param message A message describing the result - */ - fun onComplete(success: Boolean, message: String) + /** + * Called when the build and launch operation completes. + * @param success true if the operation succeeded, false otherwise + * @param message A message describing the result + */ + fun onComplete( + success: Boolean, + message: String, + ) } /** * Callback interface for Gradle sync operations. */ fun interface GradleSyncCallback { - /** - * Called when the Gradle sync operation completes. - * @param success true if the sync succeeded, false otherwise - * @param output The sync output - */ - fun onComplete(success: Boolean, output: String) + /** + * Called when the Gradle sync operation completes. + * @param success true if the sync succeeded, false otherwise + * @param output The sync output + */ + fun onComplete( + success: Boolean, + output: String, + ) } /** @@ -375,106 +422,129 @@ fun interface GradleSyncCallback { * that have the FILESYSTEM_WRITE permission. */ interface IdeFileService { - /** - * Reads the entire content of a file. - * @param file The file to read - * @return The file content as a string, or null if the file cannot be read - */ - fun readFile(file: File): String? - - /** - * Writes content to a file, replacing any existing content. - * @param file The file to write to - * @param content The content to write - * @return true if the write operation was successful, false otherwise - */ - fun writeFile(file: File, content: String): Boolean - - /** - * Appends content to the end of a file. - * @param file The file to append to - * @param content The content to append - * @return true if the append operation was successful, false otherwise - */ - fun appendToFile(file: File, content: String): Boolean - - /** - * Inserts content after the first occurrence of a pattern in a file. - * @param file The file to modify - * @param pattern The pattern to search for - * @param content The content to insert after the pattern - * @return true if the insertion was successful, false otherwise - */ - fun insertAfterPattern(file: File, pattern: String, content: String): Boolean - - /** - * Replaces all occurrences of old text with new text in a file. - * @param file The file to modify - * @param oldText The text to replace - * @param newText The replacement text - * @return true if the replacement was successful, false otherwise - */ - fun replaceInFile(file: File, oldText: String, newText: String): Boolean - - /** - * Writes binary content to a file, replacing any existing content. - * Use this instead of [writeFile] for non-text data: UTF-8 transcoding in - * [writeFile] corrupts arbitrary bytes. - * @param file The file to write to - * @param data The bytes to write - * @return true if the write operation was successful, false otherwise - */ - fun writeBinary(file: File, data: ByteArray): Boolean - - /** - * Writes content from an input stream to a file, replacing any existing - * content. Preferred for large payloads (archives, toolchain assets) since - * no intermediate buffer of the full payload is held in memory. - * - * The caller owns [input] and is responsible for closing it. - * @param file The file to write to - * @param input The stream to read from - * @return The number of bytes written, or -1 if the operation failed - */ - fun writeStream(file: File, input: InputStream): Long - - /** - * Deletes a file or directory. Directories are removed recursively. - * Required for plugins that need to clean up installed assets in - * [com.itsaky.androidide.plugins.IPlugin.deactivate]. - * @param file The file or directory to delete - * @return true if the deletion was successful, false otherwise - */ - fun delete(file: File): Boolean - - /** - * Lists files in a directory. - * @param dir The directory to list (or null for project root) - * @param recursive Whether to list recursively - * @return List of files, or empty list if the directory cannot be read - */ - fun listFiles(dir: File?, recursive: Boolean = false): List + /** + * Reads the entire content of a file. + * @param file The file to read + * @return The file content as a string, or null if the file cannot be read + */ + fun readFile(file: File): String? + + /** + * Writes content to a file, replacing any existing content. + * @param file The file to write to + * @param content The content to write + * @return true if the write operation was successful, false otherwise + */ + fun writeFile( + file: File, + content: String, + ): Boolean + + /** + * Appends content to the end of a file. + * @param file The file to append to + * @param content The content to append + * @return true if the append operation was successful, false otherwise + */ + fun appendToFile( + file: File, + content: String, + ): Boolean + + /** + * Inserts content after the first occurrence of a pattern in a file. + * @param file The file to modify + * @param pattern The pattern to search for + * @param content The content to insert after the pattern + * @return true if the insertion was successful, false otherwise + */ + fun insertAfterPattern( + file: File, + pattern: String, + content: String, + ): Boolean + + /** + * Replaces all occurrences of old text with new text in a file. + * @param file The file to modify + * @param oldText The text to replace + * @param newText The replacement text + * @return true if the replacement was successful, false otherwise + */ + fun replaceInFile( + file: File, + oldText: String, + newText: String, + ): Boolean + + /** + * Writes binary content to a file, replacing any existing content. + * Use this instead of [writeFile] for non-text data: UTF-8 transcoding in + * [writeFile] corrupts arbitrary bytes. + * @param file The file to write to + * @param data The bytes to write + * @return true if the write operation was successful, false otherwise + */ + fun writeBinary( + file: File, + data: ByteArray, + ): Boolean + + /** + * Writes content from an input stream to a file, replacing any existing + * content. Preferred for large payloads (archives, toolchain assets) since + * no intermediate buffer of the full payload is held in memory. + * + * The caller owns [input] and is responsible for closing it. + * @param file The file to write to + * @param input The stream to read from + * @return The number of bytes written, or -1 if the operation failed + */ + fun writeStream( + file: File, + input: InputStream, + ): Long + + /** + * Deletes a file or directory. Directories are removed recursively. + * Required for plugins that need to clean up installed assets in + * [com.itsaky.androidide.plugins.IPlugin.deactivate]. + * @param file The file or directory to delete + * @return true if the deletion was successful, false otherwise + */ + fun delete(file: File): Boolean + + /** + * Lists files in a directory. + * @param dir The directory to list (or null for project root) + * @param recursive Whether to list recursively + * @return List of files, or empty list if the directory cannot be read + */ + fun listFiles( + dir: File?, + recursive: Boolean = false, + ): List } /** * Callback interface for build status changes. */ interface BuildStatusListener { - /** - * Called when a build starts. - */ - fun onBuildStarted() - - /** - * Called when a build finishes successfully. - */ - fun onBuildFinished() - - /** - * Called when a build fails or is cancelled. - * @param error The error message, or null if cancelled - */ - fun onBuildFailed(error: String?) + /** + * Called when a build starts. + */ + fun onBuildStarted() + + /** + * Called when a build finishes successfully. + */ + fun onBuildFinished() + + /** + * Called when a build fails or is cancelled. + * @param error The error message, or null if cancelled + */ + fun onBuildFailed(error: String?) } /** @@ -489,28 +559,34 @@ interface BuildStatusListener { * (build files, resources, etc.). */ interface IdeProjectManipulationService { - /** - * Adds a dependency to a Gradle build file. - * @param dependencyString The dependency line including configuration, e.g., 'implementation("io.coil-kt:coil:2.6.0")' - * @param buildFilePath Relative path to build file, e.g., 'app/build.gradle.kts' - * @return true if the dependency was added successfully, false otherwise - */ - fun addDependency(dependencyString: String, buildFilePath: String): Boolean = false - - /** - * Adds a string resource to the strings.xml file. - * @param name The resource name, e.g., 'welcome_message' - * @param value The string content, e.g., 'Hello, World!' - * @return true if the string resource was added successfully, false otherwise - */ - fun addStringResource(name: String, value: String): Boolean = false - - /** - * Deletes a file from the project. - * @param path The path to the file to delete - * @return true if the file was deleted successfully, false otherwise - */ - fun deleteFile(path: String): Boolean = false + /** + * Adds a dependency to a Gradle build file. + * @param dependencyString The dependency line including configuration, e.g., 'implementation("io.coil-kt:coil:2.6.0")' + * @param buildFilePath Relative path to build file, e.g., 'app/build.gradle.kts' + * @return true if the dependency was added successfully, false otherwise + */ + fun addDependency( + dependencyString: String, + buildFilePath: String, + ): Boolean = false + + /** + * Adds a string resource to the strings.xml file. + * @param name The resource name, e.g., 'welcome_message' + * @param value The string content, e.g., 'Hello, World!' + * @return true if the string resource was added successfully, false otherwise + */ + fun addStringResource( + name: String, + value: String, + ): Boolean = false + + /** + * Deletes a file from the project. + * @param path The path to the file to delete + * @return true if the file was deleted successfully, false otherwise + */ + fun deleteFile(path: String): Boolean = false } /** @@ -521,11 +597,11 @@ interface IdeProjectManipulationService { * host-internal project types to the plugin. */ data class ModuleContext( - val modulePath: String?, - val variantName: String, - val compileClasspaths: List, - val intermediateClasspaths: List, - val runtimeDexFiles: List, - val resourceApk: File?, - val needsBuild: Boolean + val modulePath: String?, + val variantName: String, + val compileClasspaths: List, + val intermediateClasspaths: List, + val runtimeDexFiles: List, + val resourceApk: File?, + val needsBuild: Boolean, ) 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 a710a742df..37c8f18d57 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 @@ -4,35 +4,24 @@ package com.itsaky.androidide.plugins.manager.core import android.app.Activity import android.content.Context -import com.itsaky.androidide.plugins.* +import com.itsaky.androidide.actions.SidebarSlotExceededException +import com.itsaky.androidide.actions.SidebarSlotManager +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.PluginMetadata +import com.itsaky.androidide.plugins.PluginPermission +import com.itsaky.androidide.plugins.ServiceRegistry import com.itsaky.androidide.plugins.base.PluginFragmentHelper -import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory -import com.itsaky.androidide.plugins.services.IdeProjectService -import com.itsaky.androidide.plugins.services.IdeUIService -import com.itsaky.androidide.plugins.services.IdeBuildService -import com.itsaky.androidide.plugins.services.IdeProjectManipulationService -import com.itsaky.androidide.plugins.manager.services.IdeUIServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeProjectManipulationServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeProjectServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeFileServiceImpl -import com.itsaky.androidide.plugins.manager.services.CogoProjectProvider -import com.itsaky.androidide.plugins.manager.services.IdeTooltipServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeEditorTabServiceImpl +import com.itsaky.androidide.plugins.extensions.BuildActionExtension import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.EditorDecorationProvider import com.itsaky.androidide.plugins.extensions.FileOpenExtension -import com.itsaky.androidide.plugins.extensions.SnippetExtension -import com.itsaky.androidide.plugins.manager.services.IdeSnippetServiceImpl -import com.itsaky.androidide.plugins.manager.snippets.PluginSnippetManager -import com.itsaky.androidide.plugins.services.IdeSnippetService import com.itsaky.androidide.plugins.extensions.FileTabMenuItem +import com.itsaky.androidide.plugins.extensions.SnippetExtension import com.itsaky.androidide.plugins.extensions.UIExtension -import com.itsaky.androidide.plugins.manager.loaders.PluginManifest -import com.itsaky.androidide.plugins.manager.loaders.PluginLoader -import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata -import com.itsaky.androidide.plugins.manager.loaders.PluginResourceContext -import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager +import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.plugins.manager.context.PluginContextImpl import com.itsaky.androidide.plugins.manager.context.PluginLifecycleDispatcher import com.itsaky.androidide.plugins.manager.context.PluginLoggerImpl @@ -41,31 +30,48 @@ import com.itsaky.androidide.plugins.manager.context.ResourceManagerImpl import com.itsaky.androidide.plugins.manager.context.ServiceRegistryImpl import com.itsaky.androidide.plugins.manager.context.SharedServiceRegistry import com.itsaky.androidide.plugins.manager.documentation.PluginDocumentationManager +import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory +import com.itsaky.androidide.plugins.manager.loaders.PluginLoader +import com.itsaky.androidide.plugins.manager.loaders.PluginManifest +import com.itsaky.androidide.plugins.manager.loaders.PluginResourceContext +import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata import com.itsaky.androidide.plugins.manager.project.PluginProjectManager +import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager +import com.itsaky.androidide.plugins.manager.services.CogoProjectProvider +import com.itsaky.androidide.plugins.manager.services.IdeArchiveServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeBuildServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeCommandServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeEditorServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeEditorTabServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeEnvironmentServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeFeatureFlagServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeFileServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeProjectManipulationServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeProjectServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeSidebarServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeSnippetServiceImpl import com.itsaky.androidide.plugins.manager.services.IdeTemplateServiceImpl -import com.itsaky.androidide.plugins.services.IdeTemplateService -import com.itsaky.androidide.plugins.services.IdeTooltipService +import com.itsaky.androidide.plugins.manager.services.IdeThemeServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeTooltipServiceImpl +import com.itsaky.androidide.plugins.manager.services.IdeUIServiceImpl +import com.itsaky.androidide.plugins.manager.snippets.PluginSnippetManager +import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager +import com.itsaky.androidide.plugins.services.IdeArchiveService +import com.itsaky.androidide.plugins.services.IdeBuildService +import com.itsaky.androidide.plugins.services.IdeCommandService +import com.itsaky.androidide.plugins.services.IdeEditorService import com.itsaky.androidide.plugins.services.IdeEditorTabService +import com.itsaky.androidide.plugins.services.IdeEnvironmentService +import com.itsaky.androidide.plugins.services.IdeFeatureFlagService import com.itsaky.androidide.plugins.services.IdeFileService +import com.itsaky.androidide.plugins.services.IdeProjectManipulationService +import com.itsaky.androidide.plugins.services.IdeProjectService import com.itsaky.androidide.plugins.services.IdeSidebarService -import com.itsaky.androidide.plugins.services.IdeEditorService -import com.itsaky.androidide.plugins.services.IdeEnvironmentService -import com.itsaky.androidide.plugins.services.IdeArchiveService -import com.itsaky.androidide.plugins.manager.services.IdeEnvironmentServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeArchiveServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeSidebarServiceImpl -import com.itsaky.androidide.plugins.manager.services.IdeEditorServiceImpl -import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager -import com.itsaky.androidide.plugins.manager.services.IdeThemeServiceImpl +import com.itsaky.androidide.plugins.services.IdeSnippetService +import com.itsaky.androidide.plugins.services.IdeTemplateService import com.itsaky.androidide.plugins.services.IdeThemeService -import com.itsaky.androidide.plugins.services.IdeFeatureFlagService -import com.itsaky.androidide.plugins.manager.services.IdeFeatureFlagServiceImpl -import com.itsaky.androidide.plugins.services.IdeCommandService -import com.itsaky.androidide.plugins.manager.services.IdeCommandServiceImpl -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 com.itsaky.androidide.plugins.services.IdeTooltipService +import com.itsaky.androidide.plugins.services.IdeUIService import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -80,1610 +86,1703 @@ import java.io.File import java.util.concurrent.ConcurrentHashMap class PluginManager private constructor( - private val context: Context, - private val eventBus: Any, // EventBus reference to avoid direct dependency - private val logger: PluginLogger + private val context: Context, + private val eventBus: Any, // EventBus reference to avoid direct dependency + private val logger: PluginLogger, ) { - - /** - * Interface for providing the current Activity context for UI operations - */ - fun interface ActivityProvider { - fun getCurrentActivity(): Activity? - } - - /** - * Interface for validating file/path access for plugins - */ - interface PluginPathValidator { - fun isPathAllowed(path: File): Boolean - fun getAllowedPaths(): List - } - - private var activityProvider: ActivityProvider? = null - private var pathValidator: PluginPathValidator? = null - private var editorProvider: IdeEditorServiceImpl.EditorProvider? = null - - /** - * Stable provider handed to every plugin's [IdeEditorServiceImpl]. Each call delegates to - * the currently-set [editorProvider] (or returns a no-op default when none is wired). This - * lets the editor activity register/unregister a real provider over its lifecycle without - * having to rebuild already-loaded plugin services. - */ - /** - * Callbacks added by plugins before any real provider was set. We hold them here and - * replay onto a real provider when one is registered, so listener registration doesn't - * silently drop on the floor during the early-boot window. - */ - private val pendingFileChangeCallbacks = java.util.concurrent.CopyOnWriteArraySet<(File?) -> Unit>() - private val pendingContentChangeCallbacks = - java.util.concurrent.CopyOnWriteArraySet<(String, Int, Int, String) -> Unit>() - - private val delegatingEditorProvider = object : IdeEditorServiceImpl.EditorProvider { - private fun current(): IdeEditorServiceImpl.EditorProvider? = editorProvider - - override fun getCurrentFile(): File? = current()?.getCurrentFile() - override fun getOpenFiles(): List = current()?.getOpenFiles() ?: emptyList() - override fun isFileOpen(file: File): Boolean = current()?.isFileOpen(file) ?: false - override fun getCurrentSelection(): String? = current()?.getCurrentSelection() - override fun getCurrentFileContent(): String? = current()?.getCurrentFileContent() - override fun getFileContent(file: File): String? = current()?.getFileContent(file) - override fun getCurrentCursorPosition() = current()?.getCurrentCursorPosition() - override fun getCurrentSelectionRange() = current()?.getCurrentSelectionRange() - override fun getCurrentLineText(): String? = current()?.getCurrentLineText() - override fun getLineText(file: File, lineNumber: Int): String? = current()?.getLineText(file, lineNumber) - override fun getLineCount(file: File): Int = current()?.getLineCount(file) ?: 0 - override fun getWordAtCursor(): String? = current()?.getWordAtCursor() - override fun getCurrentLanguageId(): String? = current()?.getCurrentLanguageId() - override fun getFileLanguageId(file: File): String? = current()?.getFileLanguageId(file) - override fun isFileModified(file: File): Boolean = current()?.isFileModified(file) ?: false - override fun getModifiedFiles(): List = current()?.getModifiedFiles() ?: emptyList() - override fun openFile(file: File): Boolean = current()?.openFile(file) ?: false - override fun openFileAt(file: File, line: Int, column: Int): Boolean = - current()?.openFileAt(file, line, column) ?: false - override fun saveCurrentFile(): Boolean = current()?.saveCurrentFile() ?: false - override fun insertTextAtCursor(text: String): Boolean = current()?.insertTextAtCursor(text) ?: false - override fun replaceSelection(text: String): Boolean = current()?.replaceSelection(text) ?: false - override fun appendToLine(file: File, line: Int, text: String): Boolean = - current()?.appendToLine(file, line, text) ?: false - override fun prependToLine(file: File, line: Int, text: String): Boolean = - current()?.prependToLine(file, line, text) ?: false - override fun replaceLine(file: File, line: Int, newText: String): Boolean = - current()?.replaceLine(file, line, newText) ?: false - override fun insertLineBefore(file: File, line: Int, text: String): Boolean = - current()?.insertLineBefore(file, line, text) ?: false - override fun deleteLine(file: File, line: Int): Boolean = current()?.deleteLine(file, line) ?: false - override fun replaceRange( - file: File, - range: com.itsaky.androidide.plugins.services.SelectionRange, - newText: String, - ): Boolean = current()?.replaceRange(file, range, newText) ?: false - 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) - } - override fun removeFileChangeCallback(callback: (File?) -> Unit) { - pendingFileChangeCallbacks.remove(callback) - current()?.removeFileChangeCallback(callback) - } - override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { - pendingContentChangeCallbacks.add(callback) - current()?.addContentChangeCallback(callback) - } - override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { - pendingContentChangeCallbacks.remove(callback) - current()?.removeContentChangeCallback(callback) - } - override fun showInlineSuggestion(pluginId: String, text: String) { current()?.showInlineSuggestion(pluginId, text) } - override fun dismissInlineSuggestion(pluginId: String) { current()?.dismissInlineSuggestion(pluginId) } - } - - // Configurable permissions for different services - private var projectServicePermissions: Set = setOf(PluginPermission.FILESYSTEM_READ) - - companion object { - @Volatile - private var INSTANCE: PluginManager? = null - - fun getInstance(context: Context, eventBus: Any, logger: PluginLogger): PluginManager { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: PluginManager(context, eventBus, logger).also { INSTANCE = it } - } - } - - /** - * Get the already initialized instance, or null if not yet initialized - */ - fun getInstance(): PluginManager? { - return INSTANCE - } - } - - private val loadedPlugins = ConcurrentHashMap() - private val pluginStates = ConcurrentHashMap() - private val loadFailures = ConcurrentHashMap() - private val pluginRegistry = PluginRegistry(context) - private val securityManager = PluginSecurityManager() - private val serviceRegistry = SharedServiceRegistry() - private val lifecycleDispatcher = PluginLifecycleDispatcher() - - private val pluginsDir = File(context.filesDir, "plugins") - private val documentationManager = PluginDocumentationManager(context) - private var templateReloadListener: (() -> Unit)? = null - private var snippetRefreshListener: ((String) -> Unit)? = null - val crashTracker = PluginCrashTracker(context, logger) - - private val statePersistScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val statePersistMutex = Mutex() - - fun setTemplateReloadListener(listener: (() -> Unit)?) { - this.templateReloadListener = listener - PluginProjectManager.getInstance().setTemplateReloadListener(listener) - } - - fun setSnippetRefreshListener(listener: ((String) -> Unit)?) { - this.snippetRefreshListener = listener - } - - // Helper methods for cleaner error handling - private fun executeWithErrorHandling( - operationDescription: String, - pluginId: String? = null, - operation: () -> T - ): Result { - return runCatching(operation).onFailure { exception -> - val contextInfo = if (pluginId != null) " for plugin $pluginId" else "" - logger.error("Failed to $operationDescription$contextInfo", exception) - } - } - - private fun registerServiceWithErrorHandling( - serviceRegistry: ServiceRegistryImpl, - serviceClass: Class, - pluginId: String, - serviceName: String, - serviceFactory: () -> T - ) { - executeWithErrorHandling("create $serviceName service", pluginId) { - val serviceInstance = serviceFactory() - serviceRegistry.register(serviceClass, serviceInstance) - logger.debug("Registered $serviceName service for plugin: $pluginId") - } - } - - // IDE service providers - private val projectProvider = CogoProjectProvider() - - init { - if (!pluginsDir.exists()) { - pluginsDir.mkdirs() - } - } - - suspend fun loadPlugins() = withContext(Dispatchers.IO) { - logger.info("Loading plugins from directory: ${pluginsDir.absolutePath}") - - // Load plugin states first - loadPluginStates() - - // After loading all plugins, verify documentation for already loaded plugins - verifyDocumentationForLoadedPlugins() - - val pluginFiles = pluginsDir.listFiles { file -> - file.isFile && file.name.endsWith(".cgp", ignoreCase = true) - } ?: return@withContext - - logger.info("Found ${pluginFiles.size} plugin files") - - loadFailures.clear() - - // Load plugins in parallel - val loadJobs = pluginFiles.map { pluginFile -> - async { - logger.debug("Loading plugin: ${pluginFile.name}") - val result = try { - loadPlugin(pluginFile) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Result.failure(e) - } - result.onFailure { error -> recordLoadFailure(pluginFile, error) } - } - } - - // Wait for all plugins to load - loadJobs.awaitAll() - - logger.info("Successfully loaded ${loadedPlugins.size} plugins") - - // Verify documentation after all plugins are loaded - verifyDocumentationForLoadedPlugins() - } - - /** - * Verify and recreate documentation for all loaded plugins that support it. - * This ensures documentation is present even after database updates. - */ - private suspend fun verifyDocumentationForLoadedPlugins() = withContext(Dispatchers.IO) { - val pluginsWithDocs = loadedPlugins.values - .filter { it.plugin is DocumentationExtension } - .associate { it.manifest.id to it.plugin as DocumentationExtension } - - if (pluginsWithDocs.isNotEmpty()) { - logger.info("Verifying documentation for ${pluginsWithDocs.size} plugins") - val recreatedCount = documentationManager.verifyAllPluginDocumentation(pluginsWithDocs) - if (recreatedCount > 0) { - logger.info("Recreated missing documentation for $recreatedCount plugins") - } - } - } - - /** - * Public method to manually trigger documentation verification. - * Can be called when database changes are detected. - */ - suspend fun verifyAllPluginDocumentation() = withContext(Dispatchers.IO) { - verifyDocumentationForLoadedPlugins() - } - - private fun loadAndValidate(pluginFile: File): Result> { - if (!pluginFile.exists()) return Result.failure(IllegalArgumentException("Plugin file does not exist: ${pluginFile.absolutePath}")) - if (!pluginFile.canRead()) return Result.failure(IllegalArgumentException("Cannot read plugin file: ${pluginFile.absolutePath}")) - val loader = PluginLoader(context, pluginFile) - val manifest = loader.getPluginMetadata() - ?: return Result.failure(IllegalArgumentException("Plugin manifest not found in: ${pluginFile.name}")) - return Result.success(manifest to loader) - } - - fun getPluginMetadataOnly(pluginFile: File): Result = - loadAndValidate(pluginFile).map { it.first } - - fun getPluginValidation(pluginFile: File): Result = - loadAndValidate(pluginFile).map { (manifest, loader) -> - PluginValidation( - manifest = manifest, - isDebug = loader.isDebuggable(), - iconDayEntryExists = manifest.iconDay?.let(loader::hasEntry) ?: false, - iconNightEntryExists = manifest.iconNight?.let(loader::hasEntry) ?: false - ) - } - - /** - * Load plugin and return both the plugin instance and its metadata - */ - fun loadPluginWithMetadata(pluginFile: File): Result> { - logger.info("Attempting to load plugin with metadata: ${pluginFile.name}") - - // Pre-flight checks - if (!pluginFile.exists()) { - val error = "Plugin file does not exist: ${pluginFile.absolutePath}" - logger.error(error) - return Result.failure(IllegalArgumentException(error)) - } - - if (!pluginFile.canRead()) { - val error = "Cannot read plugin file: ${pluginFile.absolutePath}" - logger.error(error) - return Result.failure(IllegalArgumentException(error)) - } - - if (!pluginFile.name.endsWith(".cgp", ignoreCase = true)) { - val error = "Only CGP plugins are supported. File: ${pluginFile.name}" - logger.error(error) - return Result.failure(IllegalArgumentException(error)) - } - - // Create plugin loader - val pluginLoader = PluginLoader(context, pluginFile) - - // Get plugin manifest from - val manifest = pluginLoader.getPluginMetadata() - if (manifest == null) { - return Result.failure(IllegalArgumentException("Plugin manifest not found in CGP: ${pluginFile.name}")) - } - - // Load the plugin - val pluginResult = loadPlugin(pluginFile) - return if (pluginResult.isSuccess) { - val plugin = pluginResult.getOrNull()!! - Result.success(plugin to manifest) - } else { - Result.failure(pluginResult.exceptionOrNull() ?: RuntimeException("Failed to load plugin")) - } - } - - /** - * Load plugin with full resource support - */ - fun loadPlugin(file: File): Result { - var reservedSlotsPluginId: String? = null - return try { - logger.debug("Loading plugin from: ${file.absolutePath}") - - // Validate prerequisites - if (!file.exists() || !file.canRead()) { - return Result.failure(IllegalArgumentException("Plugin does not exist or is not readable: ${file.absolutePath}")) - } - - // Create plugin loader - val pluginLoader = PluginLoader(context, file) - - // Validate signature - if (!pluginLoader.validateSignature()) { - logger.warn("signature validation failed for: ${file.name}") - // Continue anyway for development - } - - // Get plugin manifest from - val manifest = pluginLoader.getPluginMetadata() - if (manifest == null) { - return Result.failure(IllegalArgumentException("Plugin manifest not found in: ${file.name}")) - } - - logger.debug("Parsed manifest for plugin: ${manifest.name} (${manifest.id})") - - if (!securityManager.validatePlugin(file, manifest)) { - return Result.failure(SecurityException("plugin failed security validation: ${manifest.id}")) - } - - // Validate sidebar slots BEFORE loading plugin code - if (manifest.sidebarItems > 0) { - val available = SidebarSlotManager.getAvailableSlotsForPlugins() - if (manifest.sidebarItems > available) { - return Result.failure( - SidebarSlotExceededException(manifest.sidebarItems, available, manifest.id) - ) - } - SidebarSlotManager.reservePluginSlots(manifest.id, manifest.sidebarItems) - reservedSlotsPluginId = manifest.id - } - - // Parse permissions - val permissions = executeWithErrorHandling("parse permissions", manifest.id) { - manifest.permissions.mapNotNull { permissionStr -> - PluginPermission.values().find { it.key == permissionStr } ?: run { - logger.warn("Invalid permission in plugin manifest: $permissionStr") - null - } - }.toSet() - }.getOrElse { emptySet() } - - var nativeLibPath: String? = try { - pluginLoader.extractNativeLibs(manifest.id)?.absolutePath - } catch (e: Exception) { - logger.warn("Failed to extract native libs for plugin: ${manifest.id}", e) - null - } - - val (iconDayPath, iconNightPath) = try { - pluginLoader.extractPluginIcons(manifest.id, manifest) - } catch (e: Exception) { - logger.warn("Failed to extract icons for plugin: ${manifest.id}", e) - null to null - } - - if (nativeLibPath != null && !permissions.contains(PluginPermission.NATIVE_CODE)) { - File(nativeLibPath).deleteRecursively() - if (manifest.sidebarItems > 0) { - SidebarSlotManager.releasePluginSlots(manifest.id) - } - return Result.failure(SecurityException( - "Plugin '${manifest.name}' bundles native libraries but does not declare " + - "'native.code' permission. Add 'native.code' to plugin.permissions in the manifest." - )) - } - - val classLoader = pluginLoader.loadPluginClasses(this::class.java.classLoader!!, nativeLibPath) - - logger.debug("Loading main class: ${manifest.mainClass}") - val pluginClass = executeWithErrorHandling("load main class ${manifest.mainClass}", manifest.id) { - classLoader.loadClass(manifest.mainClass) - }.getOrElse { - if (manifest.sidebarItems > 0) { - SidebarSlotManager.releasePluginSlots(manifest.id) - } - return Result.failure(it) - } - - logger.debug("Creating plugin instance for: ${manifest.id}") - val plugin = executeWithErrorHandling("create plugin instance", manifest.id) { - pluginClass.getDeclaredConstructor().newInstance() as IPlugin - }.getOrElse { - if (manifest.sidebarItems > 0) { - SidebarSlotManager.releasePluginSlots(manifest.id) - } - return Result.failure(it) - } - - // Create plugin context with resources - val ctx = pluginLoader.createPluginContext(manifest.id) - if (ctx == null) { - if (manifest.sidebarItems > 0) { - SidebarSlotManager.releasePluginSlots(manifest.id) - } - return Result.failure(RuntimeException("Failed to create plugin context for: ${manifest.id}")) - } - - val pluginContext = createPluginContextWithResources(manifest.id, classLoader, permissions, ctx) - - logger.debug("Initializing plugin: ${manifest.id}") - val initResult = try { - plugin.initialize(pluginContext) - } catch (e: Exception) { - logger.error("Plugin initialization threw exception for: ${manifest.id}", e) - if (manifest.sidebarItems > 0) { - SidebarSlotManager.releasePluginSlots(manifest.id) - } - return Result.failure(e) - } - - if (!initResult) { - logger.warn(" plugin initialization returned false for: ${manifest.id}") - if (manifest.sidebarItems > 0) { - SidebarSlotManager.releasePluginSlots(manifest.id) - } - return Result.failure(RuntimeException(" plugin initialization failed for: ${manifest.id}")) - } - - val isLegacy = (ctx as? PluginResourceContext)?.let { !it.usesCustomPackageId() } ?: true - PluginFragmentHelper.registerPluginContext(manifest.id, ctx, isLegacy) - logger.debug("Registered resource context for plugin: ${manifest.id} (legacy=$isLegacy)") - PluginFragmentHelper.registerServiceRegistry(manifest.id, pluginContext.services) - logger.debug("Registered service registry for plugin: ${manifest.id}") - - val isEnabled = getPluginState(manifest.id) - val loadedPlugin = LoadedPlugin( - plugin, manifest, classLoader, pluginContext, file.absolutePath, isEnabled, - iconDayPath = iconDayPath, - iconNightPath = iconNightPath - ) - loadedPlugins[manifest.id] = loadedPlugin - - if (!isEnabled) { - logger.info("Successfully loaded plugin (disabled): ${manifest.name} (${manifest.id})") - return Result.success(plugin) - } - - activateLoadedPlugin(loadedPlugin) - Result.success(plugin) - } catch (e: Exception) { - reservedSlotsPluginId?.let { pluginId -> - SidebarSlotManager.releasePluginSlots(pluginId) - } - logger.error("Failed to load plugin from ${file.name}: ${e.javaClass.simpleName}: ${e.message}", e) - val prefix = if (e is RuntimeException) "" else "[${e.javaClass.simpleName}] " - Result.failure(RuntimeException("Error loading plugin: $prefix${e.message}", e)) - } - } - - private fun activateLoadedPlugin(loadedPlugin: LoadedPlugin) { - val plugin = loadedPlugin.plugin - val manifest = loadedPlugin.manifest - runCatching { - if (plugin is SnippetExtension) { - PluginSnippetManager.getInstance().registerPlugin(manifest.id, plugin) - } - - plugin.activate() - logger.info("Successfully loaded and activated plugin: ${manifest.name} (${manifest.id})") - - if (plugin is DocumentationExtension) { - installPluginDocumentationAsync(manifest.id, plugin, loadedPlugin.apkPath) - } - - val buildActionManager = PluginBuildActionManager.getInstance() - if (plugin is BuildActionExtension) { - buildActionManager.registerPlugin(manifest.id, manifest.name, plugin) - logger.info("Registered build actions for plugin: ${manifest.id}") - } - buildActionManager.registerManifestActions(manifest.id, manifest.name, manifest) - lifecycleDispatcher.notifyActivated(manifest.id) - }.onFailure { e -> - logger.error("Failed to activate plugin: ${manifest.id}", e) - loadedPlugin.isEnabled = false - savePluginState(manifest.id, false) - } - } - - private fun installPluginDocumentationAsync( - pluginId: String, - plugin: DocumentationExtension, - apkPath: String - ) { - CoroutineScope(Dispatchers.IO).launch { - runDocStep("documentation", pluginId) { - documentationManager.verifyAndRecreateDocumentation(pluginId, plugin) - } - runDocStep("Tier 3 docs", pluginId) { - documentationManager.verifyAndRecreateTier3Documentation(pluginId, plugin, apkPath) - } - } - } - - private suspend fun runDocStep( - label: String, - pluginId: String, - block: suspend () -> Boolean - ) { - runCatching { block() } - .onSuccess { result -> - if (result) { - logger.info("$label verified/installed for plugin: $pluginId") - } else { - logger.warn("Failed to verify/install $label for plugin: $pluginId") - } - } - .onFailure { e -> - logger.error("Error verifying/installing $label for plugin: $pluginId", e) - } - } - - fun unloadPlugin(pluginId: String): Boolean { - val loadedPlugin = loadedPlugins.remove(pluginId) ?: return false - - try { - // Remove documentation if plugin implements DocumentationExtension - if (loadedPlugin.plugin is DocumentationExtension) { - CoroutineScope(Dispatchers.IO).launch { - try { - val docResult = documentationManager.removePluginDocumentation(pluginId, loadedPlugin.plugin) - if (docResult) { - logger.info("Removed documentation for plugin: $pluginId") - } else { - logger.warn("Failed to remove documentation for plugin: $pluginId") - } - } catch (e: Exception) { - logger.error("Error removing documentation for plugin: $pluginId", e) - } - - try { - val tier3Result = documentationManager.removePluginTier3Documentation(pluginId) - if (tier3Result) { - logger.info("Removed Tier 3 docs for plugin: $pluginId") - } else { - logger.warn("Failed to remove Tier 3 docs for plugin: $pluginId") - } - } catch (e: Exception) { - logger.error("Error removing Tier 3 docs for plugin: $pluginId", e) - } - } - } - - PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) - PluginSnippetManager.getInstance().cleanupPlugin(pluginId) - snippetRefreshListener?.invoke(pluginId) - - PluginBuildActionManager.getInstance().cleanupPlugin(pluginId) - val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) - if (commandService is IdeCommandServiceImpl) { - commandService.cancelAllCommands() - } - - val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) - if (templateService is IdeTemplateServiceImpl) { - templateService.cleanupAllTemplates() - } - - runCatching { loadedPlugin.plugin.deactivate() }.onFailure { e -> - logger.error("Plugin deactivate threw during unload: $pluginId", e) - } - runCatching { loadedPlugin.plugin.dispose() }.onFailure { e -> - logger.error("Plugin dispose threw during unload: $pluginId", e) - } - - val themeService = loadedPlugin.context.services.get(IdeThemeService::class.java) - if (themeService is IdeThemeServiceImpl) { - themeService.dispose() - } - - val editorService = loadedPlugin.context.services.get(IdeEditorService::class.java) - if (editorService is IdeEditorServiceImpl) { - editorService.dispose() - } - - // Unregister the plugin's resource context - PluginFragmentHelper.unregisterPluginContext(pluginId) - - PluginFragmentFactory.unregisterAllClassLoadersForPlugin(pluginId) - - // Drop lifecycle listeners this plugin registered; its classloader is going away. - lifecycleDispatcher.removeAllFrom(pluginId) - - File(context.getDir("plugin_native_libs", Context.MODE_PRIVATE), pluginId).let { dir -> - if (dir.exists()) dir.deleteRecursively() - } - - logger.info("Unloaded plugin: $pluginId") - return true - } catch (e: Exception) { - logger.error("Failed to unload plugin: $pluginId", e) - return false - } - } - - - fun haveMatchingSignatures(incomingFile: File, existingPluginId: String): Boolean { - val existingFile = File(pluginsDir, "$existingPluginId.cgp") - val incomingSig = PluginLoader(context, incomingFile).getSignatureHash() - val existingSig = PluginLoader(context, existingFile).getSignatureHash() - if (incomingSig == null || existingSig == null) { - logger.warn("Could not extract signatures for $existingPluginId; treating as mismatch") - return false - } - return incomingSig.contentEquals(existingSig) - } - - fun uninstallPlugin(pluginId: String): Boolean { - logger.info("=== Starting uninstall for plugin: $pluginId ===") - - // Release sidebar slots reserved by this plugin - SidebarSlotManager.releasePluginSlots(pluginId) - - // Clean up sidebar actions BEFORE unloading the plugin - cleanupSidebarActions(pluginId) - - // Then unload the plugin from memory - val unloaded = unloadPlugin(pluginId) - if (!unloaded) { - logger.warn("Could not unload plugin from memory: $pluginId (may already be unloaded)") - } else { - logger.info("Successfully unloaded plugin from memory: $pluginId") - } - - // Find and delete the plugin file (CGP) - val pluginFiles = pluginsDir.listFiles { file -> - file.isFile && file.name.endsWith(".cgp", ignoreCase = true) - } - - if (pluginFiles == null || pluginFiles.isEmpty()) { - logger.error("No plugin files found in plugins directory") - return false - } - - logger.info("Found ${pluginFiles.size} plugin files to check") - var deleted = false - for (pluginFile in pluginFiles) { - try { - // Check if this contains the plugin we want to delete - val Loader = PluginLoader(context, pluginFile) - val manifest = Loader.getPluginMetadata() - - if (manifest != null) { - if (manifest.id == pluginId) { - if (pluginFile.delete()) { - deleted = true - break // Found and deleted the right file - } else { - logger.error("File exists: ${pluginFile.exists()}, Can write: ${pluginFile.canWrite()}") - } - } - } else { - logger.warn("Could not read manifest from ${pluginFile.name}") - } - } catch (e: Exception) { - logger.error("Error checking plugin file ${pluginFile.name}: ${e.message}", e) - } - } - - // Remove plugin state and cleanup contributions - if (deleted) { - removePluginState(pluginId) - crashTracker.removeCrashCount(pluginId) - cleanupPluginCacheFiles(pluginId) - lifecycleDispatcher.notifyUninstalled(pluginId) - logger.info("Plugin uninstall completed successfully: $pluginId") - } else { - logger.error("Failed to uninstall plugin: $pluginId - file not found or could not be deleted") - } - logger.info("Uninstall process ended for plugin: $pluginId (success: $deleted) ===") - return deleted - } - - fun getPlugin(pluginId: String): IPlugin? { - return loadedPlugins[pluginId]?.plugin - } - - fun getLoadError(pluginId: String): String? = loadFailures[pluginId] - - private fun recordLoadFailure(pluginFile: File, error: Throwable) { - logger.error("Failed to load plugin from ${pluginFile.name}", error) - val id = loadAndValidate(pluginFile).getOrNull()?.first?.id ?: pluginFile.nameWithoutExtension - loadFailures[id] = error.message ?: error.toString() - } - - fun getAllPlugins(): List { - return loadedPlugins.values.map { loadedPlugin -> - PluginInfo( - metadata = loadedPlugin.toPluginMetadata(), - isEnabled = loadedPlugin.isEnabled, - isLoaded = true - ) - } - } - - /** - * Get all enabled plugin instances for UI integration - */ - fun getAllPluginInstances(): List { - return loadedPlugins.values - .filter { it.isEnabled } - .map { it.plugin } - } - - fun getLoadedPlugin(pluginId: String): LoadedPlugin? { - return loadedPlugins[pluginId]?.takeIf { it.isEnabled } - } - - /** - * Get all enabled plugins that implement UI extensions - */ - fun getEnabledUIExtensions(): List { - return loadedPlugins.values - .filter { it.isEnabled } - .map { it.plugin } - .filterIsInstance() - } - - fun getEnabledFileOpenExtensions(): List { - return loadedPlugins.values - .filter { it.isEnabled } - .map { it.plugin } - .filterIsInstance() - } - - /** - * Get all enabled plugins that provide editor decorations (additive coloring of editor text). - */ - fun getEnabledEditorDecorationProviders(): List { - return loadedPlugins.values - .filter { it.isEnabled } - .map { it.plugin } - .filterIsInstance() - } - - fun notifyFileOpened(file: File) { - getEnabledFileOpenExtensions().forEach { extension -> - executeWithErrorHandling("notify file opened") { - extension.onFileOpened(file) - } - } - } - - fun notifyFileClosed(file: File) { - getEnabledFileOpenExtensions().forEach { extension -> - executeWithErrorHandling("notify file closed") { - extension.onFileClosed(file) - } - } - } - - fun getFileTabMenuItems(file: File): List { - return getEnabledFileOpenExtensions().flatMap { extension -> - executeWithErrorHandling("get file tab menu items") { - extension.getFileTabMenuItems(file) - }.getOrDefault(emptyList()) - }.sortedBy { it.order } - } - - fun delegateFileOpen(file: File): Boolean { - val handler = getEnabledFileOpenExtensions().firstOrNull { extension -> - executeWithErrorHandling("check canHandleFileOpen") { - extension.canHandleFileOpen(file) - }.getOrDefault(false) - } ?: return false - return executeWithErrorHandling("handle file open") { - handler.handleFileOpen(file) - }.getOrDefault(false) - } - - fun getPluginIdForInstance(plugin: IPlugin): String? { - return loadedPlugins.entries - .find { it.value.plugin === plugin } - ?.key - } - - fun getClassLoaderForPlugin(plugin: IPlugin): ClassLoader? { - return loadedPlugins.values - .find { it.plugin === plugin } - ?.classLoader - } - - fun getClassLoaderForPluginId(pluginId: String): ClassLoader? { - return loadedPlugins[pluginId]?.classLoader - } - - fun enablePlugin(pluginId: String): Boolean { - val loadedPlugin = loadedPlugins[pluginId] ?: return false - - if (loadedPlugin.isEnabled) { - logger.info("Plugin $pluginId is already enabled") - return true - } - - loadedPlugin.isEnabled = true - activateLoadedPlugin(loadedPlugin) - return if (loadedPlugin.isEnabled) { - savePluginState(pluginId, true) - crashTracker.resetCrashCount(pluginId) - logger.info("Enabled plugin: $pluginId") - true - } else { - logger.error("Failed to enable plugin: $pluginId (activation failed)") - false - } - } - - fun disablePlugin(pluginId: String): Boolean { - val loadedPlugin = loadedPlugins[pluginId] ?: return false - - if (!loadedPlugin.isEnabled) { - logger.info("Plugin $pluginId is already disabled") - return true - } - - return try { - cleanupPluginContributions(loadedPlugin) - loadedPlugin.plugin.deactivate() - loadedPlugin.isEnabled = false - savePluginState(pluginId, false) - lifecycleDispatcher.notifyDeactivated(pluginId) - - logger.info("Disabled plugin: $pluginId") - true - } catch (e: Exception) { - logger.error("Failed to disable plugin: $pluginId", e) - false - } - } - - fun forceDisablePlugin(pluginId: String) { - val loadedPlugin = loadedPlugins[pluginId] ?: return - cleanupSidebarActions(pluginId) - runCatching { PluginEditorTabManager.getInstance().removePluginTabs(pluginId) }.onFailure { e -> - logger.error("Failed to remove plugin tabs during force-disable: $pluginId", e) - } - cleanupPluginContributions(loadedPlugin) - runCatching { loadedPlugin.plugin.deactivate() }.onFailure { e -> - logger.error("Plugin deactivate threw during force-disable: $pluginId", e) - } - loadedPlugin.isEnabled = false - savePluginState(pluginId, false) - lifecycleDispatcher.notifyDeactivated(pluginId) - logger.warn("Force-disabled plugin due to crashes: $pluginId") - } - - private fun cleanupPluginContributions(loadedPlugin: LoadedPlugin) { - val pluginId = loadedPlugin.manifest.id - runCatching { PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) } - .onFailure { logger.error("Failed to clean project templates for: $pluginId", it) } - runCatching { - PluginSnippetManager.getInstance().cleanupPlugin(pluginId) - snippetRefreshListener?.invoke(pluginId) - }.onFailure { logger.error("Failed to clean snippets for: $pluginId", it) } - runCatching { PluginBuildActionManager.getInstance().cleanupPlugin(pluginId) } - .onFailure { logger.error("Failed to clean build actions for: $pluginId", it) } - runCatching { - val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) - if (commandService is IdeCommandServiceImpl) commandService.cancelAllCommands() - }.onFailure { logger.error("Failed to cancel commands for: $pluginId", it) } - runCatching { - val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) - if (templateService is IdeTemplateServiceImpl) templateService.cleanupAllTemplates() - }.onFailure { logger.error("Failed to cleanup templates for: $pluginId", it) } - } - - sealed class CrashResult { - abstract val pluginId: String - abstract val pluginName: String - - data class Recorded( - override val pluginId: String, - override val pluginName: String, - val crashCount: Int, - ) : CrashResult() - - data class Disabled( - override val pluginId: String, - override val pluginName: String, - ) : CrashResult() - } - - fun recordPluginCrash(pluginId: String): CrashResult { - val count = crashTracker.recordCrash(pluginId) - val name = loadedPlugins[pluginId]?.manifest?.name ?: pluginId - return if (crashTracker.shouldDisable(pluginId)) { - forceDisablePlugin(pluginId) - CrashResult.Disabled(pluginId, name) - } else { - CrashResult.Recorded(pluginId, name, count) - } - } - - fun getLoadedPluginIds(): Set = loadedPlugins.keys.toSet() - - fun getServiceRegistry(): ServiceRegistry = - serviceRegistry.asRegistry(SharedServiceRegistry.HOST_PROVIDER_ID) - - /** - * Set the activity provider to enable UI operations in plugins - */ - fun setActivityProvider(provider: ActivityProvider?) { - this.activityProvider = provider - } - - fun getCurrentActivity(): Activity? = activityProvider?.getCurrentActivity() - - /** - * Set the path validator for validating plugin file access - */ - fun setPathValidator(validator: PluginPathValidator?) { - this.pathValidator = validator - } - - /** - * Set the editor provider to enable plugin access to editor state. Safe to call after - * plugins have already loaded — buffered file-change callbacks are replayed on the new - * provider and detached from the previous one. - */ - fun setEditorProvider(provider: IdeEditorServiceImpl.EditorProvider?) { - val previous = this.editorProvider - if (previous === provider) return - if (previous != null) { - pendingFileChangeCallbacks.forEach { cb -> - runCatching { previous.removeFileChangeCallback(cb) } - } - pendingContentChangeCallbacks.forEach { cb -> - runCatching { previous.removeContentChangeCallback(cb) } - } - } - this.editorProvider = provider - if (provider != null) { - pendingFileChangeCallbacks.forEach { cb -> - runCatching { provider.addFileChangeCallback(cb) } - } - pendingContentChangeCallbacks.forEach { cb -> - runCatching { provider.addContentChangeCallback(cb) } - } - } - } - - /** - * Save plugin enabled state to persistent storage - */ - private fun savePluginState(pluginId: String, enabled: Boolean) { - pluginStates[pluginId] = enabled - statePersistScope.launch { - statePersistMutex.withLock { - executeWithErrorHandling("save plugin state", pluginId) { - val prefsFile = File(context.filesDir, "plugin_states.properties") - val properties = java.util.Properties() - - if (prefsFile.exists()) { - prefsFile.inputStream().use { input -> - properties.load(input) - } - } - - properties.setProperty(pluginId, enabled.toString()) - - prefsFile.outputStream().use { output -> - properties.store(output, "Plugin enabled/disabled states") - } - - logger.debug("Saved plugin state: $pluginId = $enabled") - } - } - } - } - - /** - * Load plugin enabled states from persistent storage - */ - private fun loadPluginStates() { - executeWithErrorHandling("load plugin states") { - val prefsFile = File(context.filesDir, "plugin_states.properties") - if (prefsFile.exists()) { - val properties = java.util.Properties() - prefsFile.inputStream().use { input -> - properties.load(input) - } - - properties.forEach { key, value -> - val pluginId = key as String - val enabled = (value as String).toBoolean() - pluginStates[pluginId] = enabled - logger.debug("Loaded plugin state: $pluginId = $enabled") - } - } - } - } - - /** - * Get the saved enabled state for a plugin (defaults to true for new plugins) - */ - private fun getPluginState(pluginId: String): Boolean { - return pluginStates[pluginId] ?: true - } - - private fun removePluginState(pluginId: String) { - pluginStates.remove(pluginId) - statePersistScope.launch { - statePersistMutex.withLock { - executeWithErrorHandling("remove plugin state", pluginId) { - val prefsFile = File(context.filesDir, "plugin_states.properties") - if (prefsFile.exists()) { - val properties = java.util.Properties() - properties.load(prefsFile.inputStream()) - properties.remove(pluginId) - properties.store(prefsFile.outputStream(), "Plugin states") - logger.debug("Removed plugin state: $pluginId") - } - } - } - } - } - - /** - * Configure required permissions for project service access - */ - fun setProjectServicePermissions(permissions: Set) { - this.projectServicePermissions = permissions - } - - - /** - * Create plugin context with resources - */ - private fun createPluginContextWithResources( - pluginId: String, - classLoader: ClassLoader, - permissions: Set, - resourceContext: Context - ): PluginContext { - // Create a plugin-specific service registry with permission-validated services - val pluginServiceRegistry = ServiceRegistryImpl() - - logger.debug("Creating IDE services with resources for plugin: $pluginId") - - // Create services with resource context - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeProjectService::class.java, - pluginId, - "project" - ) { - IdeProjectServiceImpl( - pluginId = pluginId, - permissions = permissions, - projectProvider = projectProvider, - requiredPermissions = projectServicePermissions, - pathValidator = pathValidator?.let { validator -> - object : IdeProjectServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - }, - activityProvider = activityProvider - ) - } - - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeUIService::class.java, - pluginId, - "UI" - ) { - IdeUIServiceImpl(activityProvider) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeBuildService::class.java, - pluginId, - "build" - ) { - IdeBuildServiceImpl.getInstance() - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeProjectManipulationService::class.java, - pluginId, - "project_manipulation" - ) { - IdeProjectManipulationServiceImpl.getInstance() - } - - // Tooltip service for showing help documentation - // Use main app context for tooltip service to access app's tooltip layouts - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeTooltipService::class.java, - pluginId, - "tooltip" - ) { - IdeTooltipServiceImpl(context, pluginId, activityProvider) - } - - // Editor tab service for plugin editor tab integration - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeEditorTabService::class.java, - pluginId, - "editor_tab" - ) { - IdeEditorTabServiceImpl(activityProvider) - } - - // File service for editing project files - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeFileService::class.java, - pluginId, - "file" - ) { - IdeFileServiceImpl( - pluginId = pluginId, - permissions = permissions, - pathValidator = pathValidator?.let { validator -> - object : IdeFileServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - } - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeEnvironmentService::class.java, - pluginId, - "environment" - ) { - IdeEnvironmentServiceImpl(pluginId) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeArchiveService::class.java, - pluginId, - "archive" - ) { - IdeArchiveServiceImpl( - pluginId = pluginId, - permissions = permissions, - pathValidator = pathValidator?.let { validator -> - object : IdeFileServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - } - ) - } - - // Sidebar service for plugin sidebar slot management - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeSidebarService::class.java, - pluginId, - "sidebar" - ) { - IdeSidebarServiceImpl(pluginId) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeThemeService::class.java, - pluginId, - "theme" - ) { - IdeThemeServiceImpl(context) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeFeatureFlagService::class.java, - pluginId, - "feature_flag" - ) { - IdeFeatureFlagServiceImpl() - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeTemplateService::class.java, - pluginId, - "template" - ) { - IdeTemplateServiceImpl( - pluginId = pluginId, - permissions = permissions, - onTemplatesChanged = { templateReloadListener?.invoke() } - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeSnippetService::class.java, - pluginId, - "snippet" - ) { - IdeSnippetServiceImpl().apply { - setRefreshCallback { pid -> - snippetRefreshListener?.invoke(pid) - } - } - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeEditorService::class.java, - pluginId, - "editor" - ) { - IdeEditorServiceImpl( - pluginId = pluginId, - permissions = permissions, - editorProvider = delegatingEditorProvider, - pathValidator = pathValidator?.let { validator -> - object : IdeEditorServiceImpl.PathValidator { - override fun isPathAllowed(file: File): Boolean = validator.isPathAllowed(file) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - } - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeCommandService::class.java, - pluginId, - "command" - ) { - IdeCommandServiceImpl( - pluginId = pluginId, - permissions = permissions, - projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, - appFilesDir = context.filesDir - ) - } - - // Note: Phase 2 services (IdeFileService, IdeProjectService, IdeResourceService) - // have been removed. Use existing services instead: - // - IdeFileService for file operations (now includes listFiles) - // - IdeProjectManipulationService for dependency management - // - IdeBuildService for build operations - // This maintains binary compatibility with existing plugins. - - // Create PluginContext with resource context - return PluginContextImpl( - androidContext = resourceContext, // Use the resource context instead of app context - services = pluginServiceRegistry, - eventBus = eventBus, - logger = PluginLoggerImpl(pluginId, logger), - resources = ResourceManagerImpl( - pluginId = pluginId, - pluginsDir = pluginsDir, - classLoader = classLoader, - assetManager = resourceContext.assets - ), - pluginId = pluginId, - sharedServices = serviceRegistry, - lifecycleDispatcher = lifecycleDispatcher, - pluginInfoProvider = { id -> - loadedPlugins[id]?.let { - PluginInfo(it.toPluginMetadata(), it.isEnabled, isLoaded = true) - } - } - ) - } - - private fun createPluginContext( - pluginId: String, - classLoader: ClassLoader, - permissions: Set - ): PluginContext { - val pluginServiceRegistry = ServiceRegistryImpl() - - logger.debug("Creating IDE services for plugin: $pluginId") - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeProjectService::class.java, - pluginId, - "project" - ) { - IdeProjectServiceImpl( - pluginId = pluginId, - permissions = permissions, - projectProvider = projectProvider, - requiredPermissions = projectServicePermissions, - pathValidator = pathValidator?.let { validator -> - object : IdeProjectServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - }, - activityProvider = activityProvider - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeUIService::class.java, - pluginId, - "UI" - ) { - IdeUIServiceImpl(activityProvider) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeBuildService::class.java, - pluginId, - "build" - ) { - IdeBuildServiceImpl.getInstance() - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeProjectManipulationService::class.java, - pluginId, - "project_manipulation" - ) { - IdeProjectManipulationServiceImpl.getInstance() - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeTooltipService::class.java, - pluginId, - "tooltip" - ) { - IdeTooltipServiceImpl(context, pluginId, activityProvider) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeEditorTabService::class.java, - pluginId, - "editor_tab" - ) { - IdeEditorTabServiceImpl(activityProvider) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeFileService::class.java, - pluginId, - "file" - ) { - IdeFileServiceImpl( - pluginId = pluginId, - permissions = permissions, - pathValidator = pathValidator?.let { validator -> - object : IdeFileServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - } - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeEnvironmentService::class.java, - pluginId, - "environment" - ) { - IdeEnvironmentServiceImpl(pluginId) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeArchiveService::class.java, - pluginId, - "archive" - ) { - IdeArchiveServiceImpl( - pluginId = pluginId, - permissions = permissions, - pathValidator = pathValidator?.let { validator -> - object : IdeFileServiceImpl.PathValidator { - override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) - override fun getAllowedPaths(): List = validator.getAllowedPaths() - } - } - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeSidebarService::class.java, - pluginId, - "sidebar" - ) { - IdeSidebarServiceImpl(pluginId) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeThemeService::class.java, - pluginId, - "theme" - ) { - IdeThemeServiceImpl(context) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeFeatureFlagService::class.java, - pluginId, - "feature_flag" - ) { - IdeFeatureFlagServiceImpl() - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeTemplateService::class.java, - pluginId, - "template" - ) { - IdeTemplateServiceImpl( - pluginId = pluginId, - permissions = permissions, - onTemplatesChanged = { templateReloadListener?.invoke() } - ) - } - - registerServiceWithErrorHandling( - pluginServiceRegistry, - IdeSnippetService::class.java, - pluginId, - "snippet" - ) { - IdeSnippetServiceImpl().apply { - setRefreshCallback { pid -> - snippetRefreshListener?.invoke(pid) - } - } - } - - return PluginContextImpl( - androidContext = context, - services = pluginServiceRegistry, - eventBus = eventBus, - logger = PluginLoggerImpl(pluginId, logger), - resources = ResourceManagerImpl(pluginId, pluginsDir, classLoader), - pluginId = pluginId, - sharedServices = serviceRegistry, - lifecycleDispatcher = lifecycleDispatcher, - pluginInfoProvider = { id -> - loadedPlugins[id]?.let { - PluginInfo(it.toPluginMetadata(), it.isEnabled, isLoaded = true) - } - } - ) - } - - /** - * Clean up ALL plugin files and cache directories - */ - private fun cleanupPluginCacheFiles(pluginId: String) { - executeWithErrorHandling("cleanup plugin cache files", pluginId) { - logger.debug("Cleaning up ALL files and cache for plugin: $pluginId") - - val pluginDir = File(pluginsDir, pluginId) - if (pluginDir.exists()) { - val deleted = pluginDir.deleteRecursively() - logger.debug("Deleted plugin directory: ${pluginDir.absolutePath} (success: $deleted)") - } - - File(context.getDir("plugin_native_libs", Context.MODE_PRIVATE), pluginId).let { dir -> - if (dir.exists()) dir.deleteRecursively() - } - - File(context.getDir("plugin_icons", Context.MODE_PRIVATE), pluginId).let { dir -> - if (dir.exists()) dir.deleteRecursively() - } - - // Clean up ART cache files in oat directory - try { - val oatDir = File(pluginsDir, "oat") - if (oatDir.exists() && oatDir.isDirectory) { - oatDir.walkTopDown().forEach { file -> - if (file.name.contains(pluginId)) { - val deleted = file.deleteRecursively() - logger.debug("Deleted ART cache: ${file.absolutePath} (success: $deleted)") - } - } - } - } catch (e: Exception) { - logger.warn("Failed to cleanup ART cache files for: $pluginId", e) - } - - // Clean up any other directories or files that contain the plugin ID - try { - pluginsDir.walkTopDown().forEach { file -> - if (file != pluginsDir && file.name.contains(pluginId)) { - val deleted = file.deleteRecursively() - logger.debug("Deleted plugin-related item: ${file.absolutePath} (success: $deleted)") - } - } - } catch (e: Exception) { - logger.warn("Failed to cleanup plugin-related files for: $pluginId", e) - } - - logger.debug("Complete plugin cleanup finished for: $pluginId") - } - } - - /** - * Clean up sidebar actions from the current session's action registry - */ - private fun cleanupSidebarActions(pluginId: String) { - executeWithErrorHandling("cleanup sidebar actions", pluginId) { - logger.debug("Cleaning up sidebar actions for plugin: $pluginId") - - val plugin = loadedPlugins[pluginId]?.plugin - if (plugin is UIExtension) { - val registryClass = Class.forName("com.itsaky.androidide.actions.ActionsRegistry") - val getInstanceMethod = registryClass.getMethod("getInstance") - val registry = getInstanceMethod.invoke(null) - - plugin.getSideMenuItems().forEach { navItem -> - val actionId = "plugin_sidebar_${navItem.id}" - val unregisterMethod = registryClass.getMethod("unregisterAction", String::class.java) - val success = unregisterMethod.invoke(registry, actionId) as Boolean - logger.debug("Unregistered sidebar action: $actionId (success: $success)") - } - - logger.debug("Sidebar actions cleanup completed for: $pluginId") - } else { - logger.debug("Plugin $pluginId does not implement UIExtension, no sidebar actions to cleanup") - } - } - } + /** + * Interface for providing the current Activity context for UI operations + */ + fun interface ActivityProvider { + fun getCurrentActivity(): Activity? + } + + /** + * Interface for validating file/path access for plugins + */ + interface PluginPathValidator { + fun isPathAllowed(path: File): Boolean + + fun getAllowedPaths(): List + } + + private var activityProvider: ActivityProvider? = null + private var pathValidator: PluginPathValidator? = null + private var editorProvider: IdeEditorServiceImpl.EditorProvider? = null + + /** + * Callbacks added by plugins before any real provider was set. We hold them here and + * replay onto a real provider when one is registered, so listener registration doesn't + * silently drop on the floor during the early-boot window. + */ + private val pendingFileChangeCallbacks = java.util.concurrent.CopyOnWriteArraySet<(File?) -> Unit>() + private val pendingContentChangeCallbacks = + java.util.concurrent.CopyOnWriteArraySet<(String, Int, Int, String) -> Unit>() + + /** + * Stable provider handed to every plugin's [IdeEditorServiceImpl]. Each call delegates to + * the currently-set [editorProvider] (or returns a no-op default when none is wired). This + * lets the editor activity register/unregister a real provider over its lifecycle without + * having to rebuild already-loaded plugin services. + */ + private val delegatingEditorProvider = + object : IdeEditorServiceImpl.EditorProvider { + private fun current(): IdeEditorServiceImpl.EditorProvider? = editorProvider + + override fun getCurrentFile(): File? = current()?.getCurrentFile() + + override fun getOpenFiles(): List = current()?.getOpenFiles() ?: emptyList() + + override fun isFileOpen(file: File): Boolean = current()?.isFileOpen(file) ?: false + + override fun getCurrentSelection(): String? = current()?.getCurrentSelection() + + override fun getCurrentFileContent(): String? = current()?.getCurrentFileContent() + + override fun getFileContent(file: File): String? = current()?.getFileContent(file) + + override fun getCurrentCursorPosition() = current()?.getCurrentCursorPosition() + + override fun getCurrentSelectionRange() = current()?.getCurrentSelectionRange() + + override fun getCurrentLineText(): String? = current()?.getCurrentLineText() + + override fun getLineText( + file: File, + lineNumber: Int, + ): String? = current()?.getLineText(file, lineNumber) + override fun getLineCount(file: File): Int = current()?.getLineCount(file) ?: 0 + + override fun getWordAtCursor(): String? = current()?.getWordAtCursor() + + override fun getCurrentLanguageId(): String? = current()?.getCurrentLanguageId() + + override fun getFileLanguageId(file: File): String? = current()?.getFileLanguageId(file) + + override fun isFileModified(file: File): Boolean = current()?.isFileModified(file) ?: false + + override fun getModifiedFiles(): List = current()?.getModifiedFiles() ?: emptyList() + + override fun openFile(file: File): Boolean = current()?.openFile(file) ?: false + + override fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean = current()?.openFileAt(file, line, column) ?: false + + override fun saveCurrentFile(): Boolean = current()?.saveCurrentFile() ?: false + + override fun insertTextAtCursor(text: String): Boolean = current()?.insertTextAtCursor(text) ?: false + + override fun replaceSelection(text: String): Boolean = current()?.replaceSelection(text) ?: false + + override fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean = current()?.appendToLine(file, line, text) ?: false + + override fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean = current()?.prependToLine(file, line, text) ?: false + + override fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean = current()?.replaceLine(file, line, newText) ?: false + + override fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean = current()?.insertLineBefore(file, line, text) ?: false + + override fun deleteLine( + file: File, + line: Int, + ): Boolean = current()?.deleteLine(file, line) ?: false + + override fun replaceRange( + file: File, + range: com.itsaky.androidide.plugins.services.SelectionRange, + newText: String, + ): Boolean = current()?.replaceRange(file, range, newText) ?: false + + 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) + } + + override fun removeFileChangeCallback(callback: (File?) -> Unit) { + pendingFileChangeCallbacks.remove(callback) + current()?.removeFileChangeCallback(callback) + } + + override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { + pendingContentChangeCallbacks.add(callback) + current()?.addContentChangeCallback(callback) + } + + override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) { + pendingContentChangeCallbacks.remove(callback) + current()?.removeContentChangeCallback(callback) + } + + override fun showInlineSuggestion( + pluginId: String, + text: String, + ) { + current()?.showInlineSuggestion(pluginId, text) + } + + override fun dismissInlineSuggestion(pluginId: String) { + current()?.dismissInlineSuggestion(pluginId) + } + } + + // Configurable permissions for different services + private var projectServicePermissions: Set = setOf(PluginPermission.FILESYSTEM_READ) + + companion object { + @Volatile + private var instance: PluginManager? = null + + fun getInstance( + context: Context, + eventBus: Any, + logger: PluginLogger, + ): PluginManager = + instance ?: synchronized(this) { + instance ?: PluginManager(context, eventBus, logger).also { instance = it } + } + + /** + * Get the already initialized instance, or null if not yet initialized + */ + fun getInstance(): PluginManager? = instance + } + + private val loadedPlugins = ConcurrentHashMap() + private val pluginStates = ConcurrentHashMap() + private val loadFailures = ConcurrentHashMap() + private val pluginRegistry = PluginRegistry(context) + private val securityManager = PluginSecurityManager() + private val serviceRegistry = SharedServiceRegistry() + private val lifecycleDispatcher = PluginLifecycleDispatcher() + + private val pluginsDir = File(context.filesDir, "plugins") + private val documentationManager = PluginDocumentationManager(context) + private var templateReloadListener: (() -> Unit)? = null + private var snippetRefreshListener: ((String) -> Unit)? = null + val crashTracker = PluginCrashTracker(context, logger) + + private val statePersistScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val statePersistMutex = Mutex() + + fun setTemplateReloadListener(listener: (() -> Unit)?) { + this.templateReloadListener = listener + PluginProjectManager.getInstance().setTemplateReloadListener(listener) + } + + fun setSnippetRefreshListener(listener: ((String) -> Unit)?) { + this.snippetRefreshListener = listener + } + + // Helper methods for cleaner error handling + private fun executeWithErrorHandling( + operationDescription: String, + pluginId: String? = null, + operation: () -> T, + ): Result = + runCatching(operation).onFailure { exception -> + val contextInfo = if (pluginId != null) " for plugin $pluginId" else "" + logger.error("Failed to $operationDescription$contextInfo", exception) + } + + private fun registerServiceWithErrorHandling( + serviceRegistry: ServiceRegistryImpl, + serviceClass: Class, + pluginId: String, + serviceName: String, + serviceFactory: () -> T, + ) { + executeWithErrorHandling("create $serviceName service", pluginId) { + val serviceInstance = serviceFactory() + serviceRegistry.register(serviceClass, serviceInstance) + logger.debug("Registered $serviceName service for plugin: $pluginId") + } + } + + // IDE service providers + private val projectProvider = CogoProjectProvider() + + init { + if (!pluginsDir.exists()) { + pluginsDir.mkdirs() + } + } + + suspend fun loadPlugins() = + withContext(Dispatchers.IO) { + logger.info("Loading plugins from directory: ${pluginsDir.absolutePath}") + + // Load plugin states first + loadPluginStates() + + // After loading all plugins, verify documentation for already loaded plugins + verifyDocumentationForLoadedPlugins() + + val pluginFiles = + pluginsDir.listFiles { file -> + file.isFile && file.name.endsWith(".cgp", ignoreCase = true) + } ?: return@withContext + + logger.info("Found ${pluginFiles.size} plugin files") + + loadFailures.clear() + + // Load plugins in parallel + val loadJobs = + pluginFiles.map { pluginFile -> + async { + logger.debug("Loading plugin: ${pluginFile.name}") + val result = + try { + loadPlugin(pluginFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + result.onFailure { error -> recordLoadFailure(pluginFile, error) } + } + } + + // Wait for all plugins to load + loadJobs.awaitAll() + + logger.info("Successfully loaded ${loadedPlugins.size} plugins") + + // Verify documentation after all plugins are loaded + verifyDocumentationForLoadedPlugins() + } + + /** + * Verify and recreate documentation for all loaded plugins that support it. + * This ensures documentation is present even after database updates. + */ + private suspend fun verifyDocumentationForLoadedPlugins() = + withContext(Dispatchers.IO) { + val pluginsWithDocs = + loadedPlugins.values + .filter { it.plugin is DocumentationExtension } + .associate { it.manifest.id to it.plugin as DocumentationExtension } + + if (pluginsWithDocs.isNotEmpty()) { + logger.info("Verifying documentation for ${pluginsWithDocs.size} plugins") + val recreatedCount = documentationManager.verifyAllPluginDocumentation(pluginsWithDocs) + if (recreatedCount > 0) { + logger.info("Recreated missing documentation for $recreatedCount plugins") + } + } + } + + /** + * Public method to manually trigger documentation verification. + * Can be called when database changes are detected. + */ + suspend fun verifyAllPluginDocumentation() = + withContext(Dispatchers.IO) { + verifyDocumentationForLoadedPlugins() + } + + private fun loadAndValidate(pluginFile: File): Result> { + if (!pluginFile.exists()) return Result.failure(IllegalArgumentException("Plugin file does not exist: ${pluginFile.absolutePath}")) + if (!pluginFile.canRead()) return Result.failure(IllegalArgumentException("Cannot read plugin file: ${pluginFile.absolutePath}")) + val loader = PluginLoader(context, pluginFile) + val manifest = + loader.getPluginMetadata() + ?: return Result.failure(IllegalArgumentException("Plugin manifest not found in: ${pluginFile.name}")) + return Result.success(manifest to loader) + } + + fun getPluginMetadataOnly(pluginFile: File): Result = loadAndValidate(pluginFile).map { it.first } + + fun getPluginValidation(pluginFile: File): Result = + loadAndValidate(pluginFile).map { (manifest, loader) -> + PluginValidation( + manifest = manifest, + isDebug = loader.isDebuggable(), + iconDayEntryExists = manifest.iconDay?.let(loader::hasEntry) ?: false, + iconNightEntryExists = manifest.iconNight?.let(loader::hasEntry) ?: false, + ) + } + + /** + * Load plugin and return both the plugin instance and its metadata + */ + fun loadPluginWithMetadata(pluginFile: File): Result> { + logger.info("Attempting to load plugin with metadata: ${pluginFile.name}") + + // Pre-flight checks + if (!pluginFile.exists()) { + val error = "Plugin file does not exist: ${pluginFile.absolutePath}" + logger.error(error) + return Result.failure(IllegalArgumentException(error)) + } + + if (!pluginFile.canRead()) { + val error = "Cannot read plugin file: ${pluginFile.absolutePath}" + logger.error(error) + return Result.failure(IllegalArgumentException(error)) + } + + if (!pluginFile.name.endsWith(".cgp", ignoreCase = true)) { + val error = "Only CGP plugins are supported. File: ${pluginFile.name}" + logger.error(error) + return Result.failure(IllegalArgumentException(error)) + } + + // Create plugin loader + val pluginLoader = PluginLoader(context, pluginFile) + + // Get plugin manifest from + val manifest = pluginLoader.getPluginMetadata() + if (manifest == null) { + return Result.failure(IllegalArgumentException("Plugin manifest not found in CGP: ${pluginFile.name}")) + } + + // Load the plugin + val pluginResult = loadPlugin(pluginFile) + return if (pluginResult.isSuccess) { + val plugin = pluginResult.getOrNull()!! + Result.success(plugin to manifest) + } else { + Result.failure(pluginResult.exceptionOrNull() ?: RuntimeException("Failed to load plugin")) + } + } + + /** + * Load plugin with full resource support + */ + fun loadPlugin(file: File): Result { + var reservedSlotsPluginId: String? = null + return try { + logger.debug("Loading plugin from: ${file.absolutePath}") + + // Validate prerequisites + if (!file.exists() || !file.canRead()) { + return Result.failure(IllegalArgumentException("Plugin does not exist or is not readable: ${file.absolutePath}")) + } + + // Create plugin loader + val pluginLoader = PluginLoader(context, file) + + // Validate signature + if (!pluginLoader.validateSignature()) { + logger.warn("signature validation failed for: ${file.name}") + // Continue anyway for development + } + + // Get plugin manifest from + val manifest = pluginLoader.getPluginMetadata() + if (manifest == null) { + return Result.failure(IllegalArgumentException("Plugin manifest not found in: ${file.name}")) + } + + logger.debug("Parsed manifest for plugin: ${manifest.name} (${manifest.id})") + + if (!securityManager.validatePlugin(file, manifest)) { + return Result.failure(SecurityException("plugin failed security validation: ${manifest.id}")) + } + + // Validate sidebar slots BEFORE loading plugin code + if (manifest.sidebarItems > 0) { + val available = SidebarSlotManager.getAvailableSlotsForPlugins() + if (manifest.sidebarItems > available) { + return Result.failure( + SidebarSlotExceededException(manifest.sidebarItems, available, manifest.id), + ) + } + SidebarSlotManager.reservePluginSlots(manifest.id, manifest.sidebarItems) + reservedSlotsPluginId = manifest.id + } + + // Parse permissions + val permissions = + executeWithErrorHandling("parse permissions", manifest.id) { + manifest.permissions + .mapNotNull { permissionStr -> + PluginPermission.values().find { it.key == permissionStr } ?: run { + logger.warn("Invalid permission in plugin manifest: $permissionStr") + null + } + }.toSet() + }.getOrElse { emptySet() } + + var nativeLibPath: String? = + try { + pluginLoader.extractNativeLibs(manifest.id)?.absolutePath + } catch (e: Exception) { + logger.warn("Failed to extract native libs for plugin: ${manifest.id}", e) + null + } + + val (iconDayPath, iconNightPath) = + try { + pluginLoader.extractPluginIcons(manifest.id, manifest) + } catch (e: Exception) { + logger.warn("Failed to extract icons for plugin: ${manifest.id}", e) + null to null + } + + if (nativeLibPath != null && !permissions.contains(PluginPermission.NATIVE_CODE)) { + File(nativeLibPath).deleteRecursively() + if (manifest.sidebarItems > 0) { + SidebarSlotManager.releasePluginSlots(manifest.id) + } + return Result.failure( + SecurityException( + "Plugin '${manifest.name}' bundles native libraries but does not declare " + + "'native.code' permission. Add 'native.code' to plugin.permissions in the manifest.", + ), + ) + } + + val classLoader = pluginLoader.loadPluginClasses(this::class.java.classLoader!!, nativeLibPath) + + logger.debug("Loading main class: ${manifest.mainClass}") + val pluginClass = + executeWithErrorHandling("load main class ${manifest.mainClass}", manifest.id) { + classLoader.loadClass(manifest.mainClass) + }.getOrElse { + if (manifest.sidebarItems > 0) { + SidebarSlotManager.releasePluginSlots(manifest.id) + } + return Result.failure(it) + } + + logger.debug("Creating plugin instance for: ${manifest.id}") + val plugin = + executeWithErrorHandling("create plugin instance", manifest.id) { + pluginClass.getDeclaredConstructor().newInstance() as IPlugin + }.getOrElse { + if (manifest.sidebarItems > 0) { + SidebarSlotManager.releasePluginSlots(manifest.id) + } + return Result.failure(it) + } + + // Create plugin context with resources + val ctx = pluginLoader.createPluginContext(manifest.id) + if (ctx == null) { + if (manifest.sidebarItems > 0) { + SidebarSlotManager.releasePluginSlots(manifest.id) + } + return Result.failure(RuntimeException("Failed to create plugin context for: ${manifest.id}")) + } + + val pluginContext = createPluginContextWithResources(manifest.id, classLoader, permissions, ctx) + + logger.debug("Initializing plugin: ${manifest.id}") + val initResult = + try { + plugin.initialize(pluginContext) + } catch (e: Exception) { + logger.error("Plugin initialization threw exception for: ${manifest.id}", e) + if (manifest.sidebarItems > 0) { + SidebarSlotManager.releasePluginSlots(manifest.id) + } + return Result.failure(e) + } + + if (!initResult) { + logger.warn(" plugin initialization returned false for: ${manifest.id}") + if (manifest.sidebarItems > 0) { + SidebarSlotManager.releasePluginSlots(manifest.id) + } + return Result.failure(RuntimeException(" plugin initialization failed for: ${manifest.id}")) + } + + val isLegacy = (ctx as? PluginResourceContext)?.let { !it.usesCustomPackageId() } ?: true + PluginFragmentHelper.registerPluginContext(manifest.id, ctx, isLegacy) + logger.debug("Registered resource context for plugin: ${manifest.id} (legacy=$isLegacy)") + PluginFragmentHelper.registerServiceRegistry(manifest.id, pluginContext.services) + logger.debug("Registered service registry for plugin: ${manifest.id}") + + val isEnabled = getPluginState(manifest.id) + val loadedPlugin = + LoadedPlugin( + plugin, + manifest, + classLoader, + pluginContext, + file.absolutePath, + isEnabled, + iconDayPath = iconDayPath, + iconNightPath = iconNightPath, + ) + loadedPlugins[manifest.id] = loadedPlugin + + if (!isEnabled) { + logger.info("Successfully loaded plugin (disabled): ${manifest.name} (${manifest.id})") + return Result.success(plugin) + } + + activateLoadedPlugin(loadedPlugin) + Result.success(plugin) + } catch (e: Exception) { + reservedSlotsPluginId?.let { pluginId -> + SidebarSlotManager.releasePluginSlots(pluginId) + } + logger.error("Failed to load plugin from ${file.name}: ${e.javaClass.simpleName}: ${e.message}", e) + val prefix = if (e is RuntimeException) "" else "[${e.javaClass.simpleName}] " + Result.failure(RuntimeException("Error loading plugin: $prefix${e.message}", e)) + } + } + + private fun activateLoadedPlugin(loadedPlugin: LoadedPlugin) { + val plugin = loadedPlugin.plugin + val manifest = loadedPlugin.manifest + runCatching { + if (plugin is SnippetExtension) { + PluginSnippetManager.getInstance().registerPlugin(manifest.id, plugin) + } + + plugin.activate() + logger.info("Successfully loaded and activated plugin: ${manifest.name} (${manifest.id})") + + if (plugin is DocumentationExtension) { + installPluginDocumentationAsync(manifest.id, plugin, loadedPlugin.apkPath) + } + + val buildActionManager = PluginBuildActionManager.getInstance() + if (plugin is BuildActionExtension) { + buildActionManager.registerPlugin(manifest.id, manifest.name, plugin) + logger.info("Registered build actions for plugin: ${manifest.id}") + } + buildActionManager.registerManifestActions(manifest.id, manifest.name, manifest) + lifecycleDispatcher.notifyActivated(manifest.id) + }.onFailure { e -> + logger.error("Failed to activate plugin: ${manifest.id}", e) + loadedPlugin.isEnabled = false + savePluginState(manifest.id, false) + } + } + + private fun installPluginDocumentationAsync( + pluginId: String, + plugin: DocumentationExtension, + apkPath: String, + ) { + CoroutineScope(Dispatchers.IO).launch { + runDocStep("documentation", pluginId) { + documentationManager.verifyAndRecreateDocumentation(pluginId, plugin) + } + runDocStep("Tier 3 docs", pluginId) { + documentationManager.verifyAndRecreateTier3Documentation(pluginId, plugin, apkPath) + } + } + } + + private suspend fun runDocStep( + label: String, + pluginId: String, + block: suspend () -> Boolean, + ) { + runCatching { block() } + .onSuccess { result -> + if (result) { + logger.info("$label verified/installed for plugin: $pluginId") + } else { + logger.warn("Failed to verify/install $label for plugin: $pluginId") + } + }.onFailure { e -> + logger.error("Error verifying/installing $label for plugin: $pluginId", e) + } + } + + fun unloadPlugin(pluginId: String): Boolean { + val loadedPlugin = loadedPlugins.remove(pluginId) ?: return false + + try { + // Remove documentation if plugin implements DocumentationExtension + if (loadedPlugin.plugin is DocumentationExtension) { + CoroutineScope(Dispatchers.IO).launch { + try { + val docResult = documentationManager.removePluginDocumentation(pluginId, loadedPlugin.plugin) + if (docResult) { + logger.info("Removed documentation for plugin: $pluginId") + } else { + logger.warn("Failed to remove documentation for plugin: $pluginId") + } + } catch (e: Exception) { + logger.error("Error removing documentation for plugin: $pluginId", e) + } + + try { + val tier3Result = documentationManager.removePluginTier3Documentation(pluginId) + if (tier3Result) { + logger.info("Removed Tier 3 docs for plugin: $pluginId") + } else { + logger.warn("Failed to remove Tier 3 docs for plugin: $pluginId") + } + } catch (e: Exception) { + logger.error("Error removing Tier 3 docs for plugin: $pluginId", e) + } + } + } + + PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) + PluginSnippetManager.getInstance().cleanupPlugin(pluginId) + snippetRefreshListener?.invoke(pluginId) + + PluginBuildActionManager.getInstance().cleanupPlugin(pluginId) + val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) + if (commandService is IdeCommandServiceImpl) { + commandService.cancelAllCommands() + } + + val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) + if (templateService is IdeTemplateServiceImpl) { + templateService.cleanupAllTemplates() + } + + runCatching { loadedPlugin.plugin.deactivate() }.onFailure { e -> + logger.error("Plugin deactivate threw during unload: $pluginId", e) + } + runCatching { loadedPlugin.plugin.dispose() }.onFailure { e -> + logger.error("Plugin dispose threw during unload: $pluginId", e) + } + + val themeService = loadedPlugin.context.services.get(IdeThemeService::class.java) + if (themeService is IdeThemeServiceImpl) { + themeService.dispose() + } + + val editorService = loadedPlugin.context.services.get(IdeEditorService::class.java) + if (editorService is IdeEditorServiceImpl) { + editorService.dispose() + } + + // Unregister the plugin's resource context + PluginFragmentHelper.unregisterPluginContext(pluginId) + + PluginFragmentFactory.unregisterAllClassLoadersForPlugin(pluginId) + + // Drop lifecycle listeners this plugin registered; its classloader is going away. + lifecycleDispatcher.removeAllFrom(pluginId) + + File(context.getDir("plugin_native_libs", Context.MODE_PRIVATE), pluginId).let { dir -> + if (dir.exists()) dir.deleteRecursively() + } + + logger.info("Unloaded plugin: $pluginId") + return true + } catch (e: Exception) { + logger.error("Failed to unload plugin: $pluginId", e) + return false + } + } + + fun haveMatchingSignatures( + incomingFile: File, + existingPluginId: String, + ): Boolean { + val existingFile = File(pluginsDir, "$existingPluginId.cgp") + val incomingSig = PluginLoader(context, incomingFile).getSignatureHash() + val existingSig = PluginLoader(context, existingFile).getSignatureHash() + if (incomingSig == null || existingSig == null) { + logger.warn("Could not extract signatures for $existingPluginId; treating as mismatch") + return false + } + return incomingSig.contentEquals(existingSig) + } + + fun uninstallPlugin(pluginId: String): Boolean { + logger.info("=== Starting uninstall for plugin: $pluginId ===") + + // Release sidebar slots reserved by this plugin + SidebarSlotManager.releasePluginSlots(pluginId) + + // Clean up sidebar actions BEFORE unloading the plugin + cleanupSidebarActions(pluginId) + + // Then unload the plugin from memory + val unloaded = unloadPlugin(pluginId) + if (!unloaded) { + logger.warn("Could not unload plugin from memory: $pluginId (may already be unloaded)") + } else { + logger.info("Successfully unloaded plugin from memory: $pluginId") + } + + // Find and delete the plugin file (CGP) + val pluginFiles = + pluginsDir.listFiles { file -> + file.isFile && file.name.endsWith(".cgp", ignoreCase = true) + } + + if (pluginFiles == null || pluginFiles.isEmpty()) { + logger.error("No plugin files found in plugins directory") + return false + } + + logger.info("Found ${pluginFiles.size} plugin files to check") + var deleted = false + for (pluginFile in pluginFiles) { + try { + // Check if this contains the plugin we want to delete + val loader = PluginLoader(context, pluginFile) + val manifest = loader.getPluginMetadata() + + if (manifest != null) { + if (manifest.id == pluginId) { + if (pluginFile.delete()) { + deleted = true + break // Found and deleted the right file + } else { + logger.error("File exists: ${pluginFile.exists()}, Can write: ${pluginFile.canWrite()}") + } + } + } else { + logger.warn("Could not read manifest from ${pluginFile.name}") + } + } catch (e: Exception) { + logger.error("Error checking plugin file ${pluginFile.name}: ${e.message}", e) + } + } + + // Remove plugin state and cleanup contributions + if (deleted) { + removePluginState(pluginId) + crashTracker.removeCrashCount(pluginId) + cleanupPluginCacheFiles(pluginId) + lifecycleDispatcher.notifyUninstalled(pluginId) + logger.info("Plugin uninstall completed successfully: $pluginId") + } else { + logger.error("Failed to uninstall plugin: $pluginId - file not found or could not be deleted") + } + logger.info("Uninstall process ended for plugin: $pluginId (success: $deleted) ===") + return deleted + } + + fun getPlugin(pluginId: String): IPlugin? = loadedPlugins[pluginId]?.plugin + + fun getLoadError(pluginId: String): String? = loadFailures[pluginId] + + private fun recordLoadFailure( + pluginFile: File, + error: Throwable, + ) { + logger.error("Failed to load plugin from ${pluginFile.name}", error) + val id = loadAndValidate(pluginFile).getOrNull()?.first?.id ?: pluginFile.nameWithoutExtension + loadFailures[id] = error.message ?: error.toString() + } + + fun getAllPlugins(): List = + loadedPlugins.values.map { loadedPlugin -> + PluginInfo( + metadata = loadedPlugin.toPluginMetadata(), + isEnabled = loadedPlugin.isEnabled, + isLoaded = true, + ) + } + + /** + * Get all enabled plugin instances for UI integration + */ + fun getAllPluginInstances(): List = + loadedPlugins.values + .filter { it.isEnabled } + .map { it.plugin } + + fun getLoadedPlugin(pluginId: String): LoadedPlugin? = loadedPlugins[pluginId]?.takeIf { it.isEnabled } + + /** + * Get all enabled plugins that implement UI extensions + */ + fun getEnabledUIExtensions(): List = + loadedPlugins.values + .filter { it.isEnabled } + .map { it.plugin } + .filterIsInstance() + + fun getEnabledFileOpenExtensions(): List = + loadedPlugins.values + .filter { it.isEnabled } + .map { it.plugin } + .filterIsInstance() + + /** + * Get all enabled plugins that provide editor decorations (additive coloring of editor text). + */ + fun getEnabledEditorDecorationProviders(): List = + loadedPlugins.values + .filter { it.isEnabled } + .map { it.plugin } + .filterIsInstance() + + fun notifyFileOpened(file: File) { + getEnabledFileOpenExtensions().forEach { extension -> + executeWithErrorHandling("notify file opened") { + extension.onFileOpened(file) + } + } + } + + fun notifyFileClosed(file: File) { + getEnabledFileOpenExtensions().forEach { extension -> + executeWithErrorHandling("notify file closed") { + extension.onFileClosed(file) + } + } + } + + fun getFileTabMenuItems(file: File): List = + getEnabledFileOpenExtensions() + .flatMap { extension -> + executeWithErrorHandling("get file tab menu items") { + extension.getFileTabMenuItems(file) + }.getOrDefault(emptyList()) + }.sortedBy { it.order } + + fun delegateFileOpen(file: File): Boolean { + val handler = + getEnabledFileOpenExtensions().firstOrNull { extension -> + executeWithErrorHandling("check canHandleFileOpen") { + extension.canHandleFileOpen(file) + }.getOrDefault(false) + } ?: return false + return executeWithErrorHandling("handle file open") { + handler.handleFileOpen(file) + }.getOrDefault(false) + } + + fun getPluginIdForInstance(plugin: IPlugin): String? = + loadedPlugins.entries + .find { it.value.plugin === plugin } + ?.key + + fun getClassLoaderForPlugin(plugin: IPlugin): ClassLoader? = + loadedPlugins.values + .find { it.plugin === plugin } + ?.classLoader + + fun getClassLoaderForPluginId(pluginId: String): ClassLoader? = loadedPlugins[pluginId]?.classLoader + + fun enablePlugin(pluginId: String): Boolean { + val loadedPlugin = loadedPlugins[pluginId] ?: return false + + if (loadedPlugin.isEnabled) { + logger.info("Plugin $pluginId is already enabled") + return true + } + + loadedPlugin.isEnabled = true + activateLoadedPlugin(loadedPlugin) + return if (loadedPlugin.isEnabled) { + savePluginState(pluginId, true) + crashTracker.resetCrashCount(pluginId) + logger.info("Enabled plugin: $pluginId") + true + } else { + logger.error("Failed to enable plugin: $pluginId (activation failed)") + false + } + } + + fun disablePlugin(pluginId: String): Boolean { + val loadedPlugin = loadedPlugins[pluginId] ?: return false + + if (!loadedPlugin.isEnabled) { + logger.info("Plugin $pluginId is already disabled") + return true + } + + return try { + cleanupPluginContributions(loadedPlugin) + loadedPlugin.plugin.deactivate() + loadedPlugin.isEnabled = false + savePluginState(pluginId, false) + lifecycleDispatcher.notifyDeactivated(pluginId) + + logger.info("Disabled plugin: $pluginId") + true + } catch (e: Exception) { + logger.error("Failed to disable plugin: $pluginId", e) + false + } + } + + fun forceDisablePlugin(pluginId: String) { + val loadedPlugin = loadedPlugins[pluginId] ?: return + cleanupSidebarActions(pluginId) + runCatching { PluginEditorTabManager.getInstance().removePluginTabs(pluginId) }.onFailure { e -> + logger.error("Failed to remove plugin tabs during force-disable: $pluginId", e) + } + cleanupPluginContributions(loadedPlugin) + runCatching { loadedPlugin.plugin.deactivate() }.onFailure { e -> + logger.error("Plugin deactivate threw during force-disable: $pluginId", e) + } + loadedPlugin.isEnabled = false + savePluginState(pluginId, false) + lifecycleDispatcher.notifyDeactivated(pluginId) + logger.warn("Force-disabled plugin due to crashes: $pluginId") + } + + private fun cleanupPluginContributions(loadedPlugin: LoadedPlugin) { + val pluginId = loadedPlugin.manifest.id + runCatching { PluginProjectManager.getInstance().cleanupPluginTemplates(pluginId) } + .onFailure { logger.error("Failed to clean project templates for: $pluginId", it) } + runCatching { + PluginSnippetManager.getInstance().cleanupPlugin(pluginId) + snippetRefreshListener?.invoke(pluginId) + }.onFailure { logger.error("Failed to clean snippets for: $pluginId", it) } + runCatching { PluginBuildActionManager.getInstance().cleanupPlugin(pluginId) } + .onFailure { logger.error("Failed to clean build actions for: $pluginId", it) } + runCatching { + val commandService = loadedPlugin.context.services.get(IdeCommandService::class.java) + if (commandService is IdeCommandServiceImpl) commandService.cancelAllCommands() + }.onFailure { logger.error("Failed to cancel commands for: $pluginId", it) } + runCatching { + val templateService = loadedPlugin.context.services.get(IdeTemplateService::class.java) + if (templateService is IdeTemplateServiceImpl) templateService.cleanupAllTemplates() + }.onFailure { logger.error("Failed to cleanup templates for: $pluginId", it) } + } + + sealed class CrashResult { + abstract val pluginId: String + abstract val pluginName: String + + data class Recorded( + override val pluginId: String, + override val pluginName: String, + val crashCount: Int, + ) : CrashResult() + + data class Disabled( + override val pluginId: String, + override val pluginName: String, + ) : CrashResult() + } + + fun recordPluginCrash(pluginId: String): CrashResult { + val count = crashTracker.recordCrash(pluginId) + val name = loadedPlugins[pluginId]?.manifest?.name ?: pluginId + return if (crashTracker.shouldDisable(pluginId)) { + forceDisablePlugin(pluginId) + CrashResult.Disabled(pluginId, name) + } else { + CrashResult.Recorded(pluginId, name, count) + } + } + + fun getLoadedPluginIds(): Set = loadedPlugins.keys.toSet() + + fun getServiceRegistry(): ServiceRegistry = serviceRegistry.asRegistry(SharedServiceRegistry.HOST_PROVIDER_ID) + + /** + * Set the activity provider to enable UI operations in plugins + */ + fun setActivityProvider(provider: ActivityProvider?) { + this.activityProvider = provider + } + + fun getCurrentActivity(): Activity? = activityProvider?.getCurrentActivity() + + /** + * Set the path validator for validating plugin file access + */ + fun setPathValidator(validator: PluginPathValidator?) { + this.pathValidator = validator + } + + /** + * Set the editor provider to enable plugin access to editor state. Safe to call after + * plugins have already loaded — buffered file-change callbacks are replayed on the new + * provider and detached from the previous one. + */ + fun setEditorProvider(provider: IdeEditorServiceImpl.EditorProvider?) { + val previous = this.editorProvider + if (previous === provider) return + if (previous != null) { + pendingFileChangeCallbacks.forEach { cb -> + runCatching { previous.removeFileChangeCallback(cb) } + } + pendingContentChangeCallbacks.forEach { cb -> + runCatching { previous.removeContentChangeCallback(cb) } + } + } + this.editorProvider = provider + if (provider != null) { + pendingFileChangeCallbacks.forEach { cb -> + runCatching { provider.addFileChangeCallback(cb) } + } + pendingContentChangeCallbacks.forEach { cb -> + runCatching { provider.addContentChangeCallback(cb) } + } + } + } + + /** + * Save plugin enabled state to persistent storage + */ + private fun savePluginState( + pluginId: String, + enabled: Boolean, + ) { + pluginStates[pluginId] = enabled + statePersistScope.launch { + statePersistMutex.withLock { + executeWithErrorHandling("save plugin state", pluginId) { + val prefsFile = File(context.filesDir, "plugin_states.properties") + val properties = java.util.Properties() + + if (prefsFile.exists()) { + prefsFile.inputStream().use { input -> + properties.load(input) + } + } + + properties.setProperty(pluginId, enabled.toString()) + + prefsFile.outputStream().use { output -> + properties.store(output, "Plugin enabled/disabled states") + } + + logger.debug("Saved plugin state: $pluginId = $enabled") + } + } + } + } + + /** + * Load plugin enabled states from persistent storage + */ + private fun loadPluginStates() { + executeWithErrorHandling("load plugin states") { + val prefsFile = File(context.filesDir, "plugin_states.properties") + if (prefsFile.exists()) { + val properties = java.util.Properties() + prefsFile.inputStream().use { input -> + properties.load(input) + } + + properties.forEach { key, value -> + val pluginId = key as String + val enabled = (value as String).toBoolean() + pluginStates[pluginId] = enabled + logger.debug("Loaded plugin state: $pluginId = $enabled") + } + } + } + } + + /** + * Get the saved enabled state for a plugin (defaults to true for new plugins) + */ + private fun getPluginState(pluginId: String): Boolean = pluginStates[pluginId] ?: true + + private fun removePluginState(pluginId: String) { + pluginStates.remove(pluginId) + statePersistScope.launch { + statePersistMutex.withLock { + executeWithErrorHandling("remove plugin state", pluginId) { + val prefsFile = File(context.filesDir, "plugin_states.properties") + if (prefsFile.exists()) { + val properties = java.util.Properties() + properties.load(prefsFile.inputStream()) + properties.remove(pluginId) + properties.store(prefsFile.outputStream(), "Plugin states") + logger.debug("Removed plugin state: $pluginId") + } + } + } + } + } + + /** + * Configure required permissions for project service access + */ + fun setProjectServicePermissions(permissions: Set) { + this.projectServicePermissions = permissions + } + + /** + * Create plugin context with resources + */ + private fun createPluginContextWithResources( + pluginId: String, + classLoader: ClassLoader, + permissions: Set, + resourceContext: Context, + ): PluginContext { + // Create a plugin-specific service registry with permission-validated services + val pluginServiceRegistry = ServiceRegistryImpl() + + logger.debug("Creating IDE services with resources for plugin: $pluginId") + + // Create services with resource context + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeProjectService::class.java, + pluginId, + "project", + ) { + IdeProjectServiceImpl( + pluginId = pluginId, + permissions = permissions, + projectProvider = projectProvider, + requiredPermissions = projectServicePermissions, + pathValidator = + pathValidator?.let { validator -> + object : IdeProjectServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + }, + activityProvider = activityProvider, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeUIService::class.java, + pluginId, + "UI", + ) { + IdeUIServiceImpl(activityProvider) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeBuildService::class.java, + pluginId, + "build", + ) { + IdeBuildServiceImpl.getInstance() + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeProjectManipulationService::class.java, + pluginId, + "project_manipulation", + ) { + IdeProjectManipulationServiceImpl.getInstance() + } + + // Tooltip service for showing help documentation + // Use main app context for tooltip service to access app's tooltip layouts + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeTooltipService::class.java, + pluginId, + "tooltip", + ) { + IdeTooltipServiceImpl(context, pluginId, activityProvider) + } + + // Editor tab service for plugin editor tab integration + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeEditorTabService::class.java, + pluginId, + "editor_tab", + ) { + IdeEditorTabServiceImpl(activityProvider) + } + + // File service for editing project files + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeFileService::class.java, + pluginId, + "file", + ) { + IdeFileServiceImpl( + pluginId = pluginId, + permissions = permissions, + pathValidator = + pathValidator?.let { validator -> + object : IdeFileServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + }, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeEnvironmentService::class.java, + pluginId, + "environment", + ) { + IdeEnvironmentServiceImpl(pluginId) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeArchiveService::class.java, + pluginId, + "archive", + ) { + IdeArchiveServiceImpl( + pluginId = pluginId, + permissions = permissions, + pathValidator = + pathValidator?.let { validator -> + object : IdeFileServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + }, + ) + } + + // Sidebar service for plugin sidebar slot management + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeSidebarService::class.java, + pluginId, + "sidebar", + ) { + IdeSidebarServiceImpl(pluginId) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeThemeService::class.java, + pluginId, + "theme", + ) { + IdeThemeServiceImpl(context) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeFeatureFlagService::class.java, + pluginId, + "feature_flag", + ) { + IdeFeatureFlagServiceImpl() + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeTemplateService::class.java, + pluginId, + "template", + ) { + IdeTemplateServiceImpl( + pluginId = pluginId, + permissions = permissions, + onTemplatesChanged = { templateReloadListener?.invoke() }, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeSnippetService::class.java, + pluginId, + "snippet", + ) { + IdeSnippetServiceImpl().apply { + setRefreshCallback { pid -> + snippetRefreshListener?.invoke(pid) + } + } + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeEditorService::class.java, + pluginId, + "editor", + ) { + IdeEditorServiceImpl( + pluginId = pluginId, + permissions = permissions, + editorProvider = delegatingEditorProvider, + pathValidator = + pathValidator?.let { validator -> + object : IdeEditorServiceImpl.PathValidator { + override fun isPathAllowed(file: File): Boolean = validator.isPathAllowed(file) + + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + }, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeCommandService::class.java, + pluginId, + "command", + ) { + IdeCommandServiceImpl( + pluginId = pluginId, + permissions = permissions, + projectRootProvider = { projectProvider.getCurrentProject()?.rootDir }, + appFilesDir = context.filesDir, + ) + } + + // Note: Phase 2 services (IdeFileService, IdeProjectService, IdeResourceService) + // have been removed. Use existing services instead: + // - IdeFileService for file operations (now includes listFiles) + // - IdeProjectManipulationService for dependency management + // - IdeBuildService for build operations + // This maintains binary compatibility with existing plugins. + + // Create PluginContext with resource context + return PluginContextImpl( + androidContext = resourceContext, // Use the resource context instead of app context + services = pluginServiceRegistry, + eventBus = eventBus, + logger = PluginLoggerImpl(pluginId, logger), + resources = + ResourceManagerImpl( + pluginId = pluginId, + pluginsDir = pluginsDir, + classLoader = classLoader, + assetManager = resourceContext.assets, + ), + pluginId = pluginId, + sharedServices = serviceRegistry, + lifecycleDispatcher = lifecycleDispatcher, + pluginInfoProvider = { id -> + loadedPlugins[id]?.let { + PluginInfo(it.toPluginMetadata(), it.isEnabled, isLoaded = true) + } + }, + ) + } + + private fun createPluginContext( + pluginId: String, + classLoader: ClassLoader, + permissions: Set, + ): PluginContext { + val pluginServiceRegistry = ServiceRegistryImpl() + + logger.debug("Creating IDE services for plugin: $pluginId") + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeProjectService::class.java, + pluginId, + "project", + ) { + IdeProjectServiceImpl( + pluginId = pluginId, + permissions = permissions, + projectProvider = projectProvider, + requiredPermissions = projectServicePermissions, + pathValidator = + pathValidator?.let { validator -> + object : IdeProjectServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + }, + activityProvider = activityProvider, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeUIService::class.java, + pluginId, + "UI", + ) { + IdeUIServiceImpl(activityProvider) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeBuildService::class.java, + pluginId, + "build", + ) { + IdeBuildServiceImpl.getInstance() + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeProjectManipulationService::class.java, + pluginId, + "project_manipulation", + ) { + IdeProjectManipulationServiceImpl.getInstance() + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeTooltipService::class.java, + pluginId, + "tooltip", + ) { + IdeTooltipServiceImpl(context, pluginId, activityProvider) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeEditorTabService::class.java, + pluginId, + "editor_tab", + ) { + IdeEditorTabServiceImpl(activityProvider) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeFileService::class.java, + pluginId, + "file", + ) { + IdeFileServiceImpl( + pluginId = pluginId, + permissions = permissions, + pathValidator = + pathValidator?.let { validator -> + object : IdeFileServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + }, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeEnvironmentService::class.java, + pluginId, + "environment", + ) { + IdeEnvironmentServiceImpl(pluginId) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeArchiveService::class.java, + pluginId, + "archive", + ) { + IdeArchiveServiceImpl( + pluginId = pluginId, + permissions = permissions, + pathValidator = + pathValidator?.let { validator -> + object : IdeFileServiceImpl.PathValidator { + override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) + + override fun getAllowedPaths(): List = validator.getAllowedPaths() + } + }, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeSidebarService::class.java, + pluginId, + "sidebar", + ) { + IdeSidebarServiceImpl(pluginId) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeThemeService::class.java, + pluginId, + "theme", + ) { + IdeThemeServiceImpl(context) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeFeatureFlagService::class.java, + pluginId, + "feature_flag", + ) { + IdeFeatureFlagServiceImpl() + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeTemplateService::class.java, + pluginId, + "template", + ) { + IdeTemplateServiceImpl( + pluginId = pluginId, + permissions = permissions, + onTemplatesChanged = { templateReloadListener?.invoke() }, + ) + } + + registerServiceWithErrorHandling( + pluginServiceRegistry, + IdeSnippetService::class.java, + pluginId, + "snippet", + ) { + IdeSnippetServiceImpl().apply { + setRefreshCallback { pid -> + snippetRefreshListener?.invoke(pid) + } + } + } + + return PluginContextImpl( + androidContext = context, + services = pluginServiceRegistry, + eventBus = eventBus, + logger = PluginLoggerImpl(pluginId, logger), + resources = ResourceManagerImpl(pluginId, pluginsDir, classLoader), + pluginId = pluginId, + sharedServices = serviceRegistry, + lifecycleDispatcher = lifecycleDispatcher, + pluginInfoProvider = { id -> + loadedPlugins[id]?.let { + PluginInfo(it.toPluginMetadata(), it.isEnabled, isLoaded = true) + } + }, + ) + } + + /** + * Clean up ALL plugin files and cache directories + */ + private fun cleanupPluginCacheFiles(pluginId: String) { + executeWithErrorHandling("cleanup plugin cache files", pluginId) { + logger.debug("Cleaning up ALL files and cache for plugin: $pluginId") + + val pluginDir = File(pluginsDir, pluginId) + if (pluginDir.exists()) { + val deleted = pluginDir.deleteRecursively() + logger.debug("Deleted plugin directory: ${pluginDir.absolutePath} (success: $deleted)") + } + + File(context.getDir("plugin_native_libs", Context.MODE_PRIVATE), pluginId).let { dir -> + if (dir.exists()) dir.deleteRecursively() + } + + File(context.getDir("plugin_icons", Context.MODE_PRIVATE), pluginId).let { dir -> + if (dir.exists()) dir.deleteRecursively() + } + + // Clean up ART cache files in oat directory + try { + val oatDir = File(pluginsDir, "oat") + if (oatDir.exists() && oatDir.isDirectory) { + oatDir.walkTopDown().forEach { file -> + if (file.name.contains(pluginId)) { + val deleted = file.deleteRecursively() + logger.debug("Deleted ART cache: ${file.absolutePath} (success: $deleted)") + } + } + } + } catch (e: Exception) { + logger.warn("Failed to cleanup ART cache files for: $pluginId", e) + } + + // Clean up any other directories or files that contain the plugin ID + try { + pluginsDir.walkTopDown().forEach { file -> + if (file != pluginsDir && file.name.contains(pluginId)) { + val deleted = file.deleteRecursively() + logger.debug("Deleted plugin-related item: ${file.absolutePath} (success: $deleted)") + } + } + } catch (e: Exception) { + logger.warn("Failed to cleanup plugin-related files for: $pluginId", e) + } + + logger.debug("Complete plugin cleanup finished for: $pluginId") + } + } + + /** + * Clean up sidebar actions from the current session's action registry + */ + private fun cleanupSidebarActions(pluginId: String) { + executeWithErrorHandling("cleanup sidebar actions", pluginId) { + logger.debug("Cleaning up sidebar actions for plugin: $pluginId") + + val plugin = loadedPlugins[pluginId]?.plugin + if (plugin is UIExtension) { + val registryClass = Class.forName("com.itsaky.androidide.actions.ActionsRegistry") + val getInstanceMethod = registryClass.getMethod("getInstance") + val registry = getInstanceMethod.invoke(null) + + plugin.getSideMenuItems().forEach { navItem -> + val actionId = "plugin_sidebar_${navItem.id}" + val unregisterMethod = registryClass.getMethod("unregisterAction", String::class.java) + val success = unregisterMethod.invoke(registry, actionId) as Boolean + logger.debug("Unregistered sidebar action: $actionId (success: $success)") + } + + logger.debug("Sidebar actions cleanup completed for: $pluginId") + } else { + logger.debug("Plugin $pluginId does not implement UIExtension, no sidebar actions to cleanup") + } + } + } } data class PluginValidation( - val manifest: PluginManifest, - val isDebug: Boolean, - val iconDayEntryExists: Boolean, - val iconNightEntryExists: Boolean + val manifest: PluginManifest, + val isDebug: Boolean, + val iconDayEntryExists: Boolean, + val iconNightEntryExists: Boolean, ) data class LoadedPlugin( - val plugin: IPlugin, - val manifest: PluginManifest, - val classLoader: ClassLoader, - val context: PluginContext, - val apkPath: String, - var isEnabled: Boolean = true, - val iconDayPath: String? = null, - val iconNightPath: String? = null + val plugin: IPlugin, + val manifest: PluginManifest, + val classLoader: ClassLoader, + val context: PluginContext, + val apkPath: String, + var isEnabled: Boolean = true, + val iconDayPath: String? = null, + val iconNightPath: String? = null, ) fun LoadedPlugin.toPluginMetadata(): PluginMetadata = - manifest.toPluginMetadata().copy( - iconDayPath = iconDayPath, - iconNightPath = iconNightPath, - ) \ No newline at end of file + manifest.toPluginMetadata().copy( + iconDayPath = iconDayPath, + iconNightPath = iconNightPath, + ) 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 8b2ff93df0..29650cc3f9 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 @@ -14,397 +14,508 @@ import java.io.File import java.util.concurrent.CopyOnWriteArrayList class IdeEditorServiceImpl( - private val pluginId: String, - private val permissions: Set, - private val editorProvider: EditorProvider, - private val readPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), - private val writePermissions: Set = setOf(PluginPermission.FILESYSTEM_WRITE), - private val pathValidator: PathValidator? = null, + private val pluginId: String, + private val permissions: Set, + private val editorProvider: EditorProvider, + private val readPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), + private val writePermissions: Set = setOf(PluginPermission.FILESYSTEM_WRITE), + private val pathValidator: PathValidator? = null, ) : IdeEditorService { + interface PathValidator { + fun isPathAllowed(file: File): Boolean - interface PathValidator { - fun isPathAllowed(file: File): Boolean - fun getAllowedPaths(): List - } - - /** - * 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 - fun getCurrentSelection(): String? - fun getCurrentFileContent(): String? = null - fun getFileContent(file: File): String? = null - fun getCurrentCursorPosition(): CursorPosition? = null - fun getCurrentSelectionRange(): SelectionRange? = null - fun getCurrentLineText(): String? = null - fun getLineText(file: File, lineNumber: Int): String? = null - fun getLineCount(file: File): Int = 0 - fun getWordAtCursor(): String? = null - fun getCurrentLanguageId(): String? = null - fun getFileLanguageId(file: File): String? = null - fun isFileModified(file: File): Boolean = false - fun getModifiedFiles(): List = emptyList() - fun openFile(file: File): Boolean = false - fun openFileAt(file: File, line: Int, column: Int): Boolean = false - fun saveCurrentFile(): Boolean = false - fun insertTextAtCursor(text: String): Boolean = false - fun replaceSelection(text: String): Boolean = false - fun appendToLine(file: File, line: Int, text: String): Boolean = false - fun prependToLine(file: File, line: Int, text: String): Boolean = false - fun replaceLine(file: File, line: Int, newText: String): Boolean = false - 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 addFileChangeCallback(callback: (File?) -> Unit) {} - fun removeFileChangeCallback(callback: (File?) -> Unit) {} - fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} - fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} - fun showInlineSuggestion(pluginId: String, text: String) {} - fun dismissInlineSuggestion(pluginId: String) {} - } - - private val fileChangeListeners = CopyOnWriteArrayList() - private val contentChangeListeners = CopyOnWriteArrayList() - - private val internalFileChangeCallback: (File?) -> Unit = { file -> - fileChangeListeners.forEach { listener -> - try { - listener.onFileChanged(file) - } catch (_: Exception) { - } - } - } - - private val internalContentChangeCallback: (String, Int, Int, String) -> Unit = { content, line, col, lang -> - contentChangeListeners.forEach { listener -> - try { - listener.onContentChanged(content, line, col, lang) - } catch (_: Exception) { - } - } - } - - init { - editorProvider.addFileChangeCallback(internalFileChangeCallback) - editorProvider.addContentChangeCallback(internalContentChangeCallback) - } - - fun dispose() { - editorProvider.removeFileChangeCallback(internalFileChangeCallback) - editorProvider.removeContentChangeCallback(internalContentChangeCallback) - fileChangeListeners.clear() - contentChangeListeners.clear() - } - - override fun getCurrentFile(): File? { - requireRead() - val file = editorProvider.getCurrentFile() ?: return null - ensureFileAccessible(file) - return file - } - - override fun getOpenFiles(): List { - requireRead() - return editorProvider.getOpenFiles().filter { isFileAccessAllowed(it) } - } - - /** - * Null-safe "what file is the user looking at, and am I allowed to see it?" used by every - * read method that short-circuits when there's no current file. Assumes the caller already - * ran [requireRead]. Doesn't log and never throws — that's the whole point: these methods - * fire constantly and can't afford to run the full public [getCurrentFile] pipeline on - * each call. - */ - private fun resolveCurrentFile(): File? { - val file = editorProvider.getCurrentFile() ?: return null - return if (isFileAccessAllowed(file)) file else null - } - - override fun isFileOpen(file: File): Boolean { - requireRead() - ensureFileAccessible(file) - return editorProvider.isFileOpen(file) - } - - override fun getCurrentSelection(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentSelection() - } - - override fun getCurrentFileContent(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentFileContent() - } - - override fun getFileContent(file: File): String? { - requireRead() - ensureFileAccessible(file) - return editorProvider.getFileContent(file) - } - - override fun getCurrentCursorPosition(): CursorPosition? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentCursorPosition() - } - - override fun getCurrentSelectionRange(): SelectionRange? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentSelectionRange() - } - - override fun getCurrentLineText(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentLineText() - } - - override fun getLineText(file: File, lineNumber: Int): String? { - requireRead() - ensureFileAccessible(file) - return editorProvider.getLineText(file, lineNumber) - } - - override fun getLineCount(file: File): Int { - requireRead() - ensureFileAccessible(file) - return editorProvider.getLineCount(file) - } - - override fun getWordAtCursor(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getWordAtCursor() - } - - override fun getCurrentLanguageId(): String? { - requireRead() - if (resolveCurrentFile() == null) return null - return editorProvider.getCurrentLanguageId() - } - - override fun getFileLanguageId(file: File): String? { - requireRead() - ensureFileAccessible(file) - return editorProvider.getFileLanguageId(file) - } - - override fun isFileModified(file: File): Boolean { - requireRead() - ensureFileAccessible(file) - return editorProvider.isFileModified(file) - } - - override fun getModifiedFiles(): List { - requireRead() - return editorProvider.getModifiedFiles().filter { isFileAccessAllowed(it) } - } - - override fun openFile(file: File): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.openFile(file) - } - - override fun openFileAt(file: File, line: Int, column: Int): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.openFileAt(file, line, column) - } - - override fun saveCurrentFile(): Boolean { - if (!writableCurrentFile()) return false - return editorProvider.saveCurrentFile() - } - - override fun insertTextAtCursor(text: String): Boolean { - if (!writableCurrentFile()) return false - return editorProvider.insertTextAtCursor(text) - } - - override fun replaceSelection(text: String): Boolean { - if (!writableCurrentFile()) return false - return editorProvider.replaceSelection(text) - } - - private fun writableCurrentFile(): Boolean { - requireWrite() - val file = editorProvider.getCurrentFile() ?: return false - return isFileAccessAllowed(file) - } - - override fun appendToLine(file: File, line: Int, text: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.appendToLine(file, line, text) - } - - override fun prependToLine(file: File, line: Int, text: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.prependToLine(file, line, text) - } - - override fun replaceLine(file: File, line: Int, newText: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.replaceLine(file, line, newText) - } - - override fun insertLineBefore(file: File, line: Int, text: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.insertLineBefore(file, line, text) - } - - override fun deleteLine(file: File, line: Int): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.deleteLine(file, line) - } - - override fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean { - requireWrite() - ensureFileAccessible(file) - return editorProvider.replaceRange(file, range, newText) - } - - override fun showPeerCursor( - file: File, - line: Int, - column: Int, - peerId: String, - peerName: String, - peerColor: Int, - ): Boolean { - requireRead() - ensureFileAccessible(file) - return editorProvider.showPeerCursor(file, line, column, peerId, peerName, peerColor) - } - - override fun hidePeerCursor(file: File, peerId: String): Boolean { - requireRead() - return editorProvider.hidePeerCursor(file, peerId) - } - - override fun clearPeerCursors(file: File) { - requireRead() - editorProvider.clearPeerCursors(file) - } - - override fun addFileChangeListener(listener: FileChangeListener) { - requireRead() - fileChangeListeners.addIfAbsent(listener) - } - - override fun removeFileChangeListener(listener: FileChangeListener) { - fileChangeListeners.remove(listener) - } - - override fun addContentChangeListener(listener: EditorContentChangeListener) { - contentChangeListeners.addIfAbsent(listener) - } - - override fun removeContentChangeListener(listener: EditorContentChangeListener) { - contentChangeListeners.remove(listener) - } - - override fun showInlineSuggestion(text: String) { - // Tag the suggestion with this plugin's id so concurrent plugins don't clobber or dismiss - // each other's ghost text. The public IdeEditorService signature is unchanged. - editorProvider.showInlineSuggestion(pluginId, text) - } - - override fun dismissInlineSuggestion() { - editorProvider.dismissInlineSuggestion(pluginId) - } - - private fun requireRead() { - if (!hasAll(readPermissions)) { - throw SecurityException( - "Plugin $pluginId is missing required permissions: ${readPermissions.joinToString(",") { it.name }}" - ) - } - } - - private fun requireWrite() { - if (!hasAll(writePermissions)) { - throw SecurityException( - "Plugin $pluginId is missing required permissions: ${writePermissions.joinToString(",") { it.name }}" - ) - } - } - - private fun hasAll(required: Set) = required.all { permissions.contains(it) } - - private fun ensureFileAccessible(file: File) { - if (!isFileAccessAllowed(file)) { - throw SecurityException("Plugin $pluginId does not have access to file: ${file.absolutePath}") - } - } - - private fun isFileAccessAllowed(file: File): Boolean { - pathValidator?.let { validator -> - val ok = runCatching { validator.isPathAllowed(file) }.getOrDefault(false) - if (!ok) { - Log.d(TAG, "[$pluginId] pathValidator rejected ${file.absolutePath}") - } - return ok - } - - // No validator wired by the host: if the editor itself has this file open, - // the user is already exposed to it — trust that and allow the read. - val openInEditor = runCatching { editorProvider.isFileOpen(file) }.getOrDefault(false) - if (openInEditor) return true - - val allowed = isFileAccessAllowedDefault(file) - if (!allowed) { - Log.d(TAG, "[$pluginId] static allowlist rejected ${file.absolutePath}; allowed roots=$defaultAllowedPaths") - } - return allowed - } - - private fun isFileAccessAllowedDefault(file: File): Boolean { - val canonicalPath = try { - file.canonicalPath - } catch (_: Exception) { - return false - } - return defaultAllowedPaths.any { root -> - canonicalPath == root || canonicalPath.startsWith(root + File.separator) - } - } - - // Canonicalised so symlinked roots don't bypass the check; anchored on File.separator at - // the match site so e.g. "/…/CodeOnTheGoProjects" doesn't also admit - // "/…/CodeOnTheGoProjectsBackup/". - private val defaultAllowedPaths: List by lazy { - val projects = Environment.PROJECTS_FOLDER - listOf( - "/storage/emulated/0/$projects", - "/sdcard/$projects", - (System.getProperty("user.home") ?: "/") + "/$projects", - "/tmp/CodeOnTheGoProject", - ).map { runCatching { File(it).canonicalPath }.getOrDefault(it) } - } - - companion object { - private const val TAG = "IdeEditorService" - } + fun getAllowedPaths(): List + } + + /** + * 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 + + fun getCurrentSelection(): String? + + fun getCurrentFileContent(): String? = null + + fun getFileContent(file: File): String? = null + + fun getCurrentCursorPosition(): CursorPosition? = null + + fun getCurrentSelectionRange(): SelectionRange? = null + + fun getCurrentLineText(): String? = null + + fun getLineText( + file: File, + lineNumber: Int, + ): String? = null + + fun getLineCount(file: File): Int = 0 + + fun getWordAtCursor(): String? = null + + fun getCurrentLanguageId(): String? = null + + fun getFileLanguageId(file: File): String? = null + + fun isFileModified(file: File): Boolean = false + + fun getModifiedFiles(): List = emptyList() + + fun openFile(file: File): Boolean = false + + fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean = false + + fun saveCurrentFile(): Boolean = false + + fun insertTextAtCursor(text: String): Boolean = false + + fun replaceSelection(text: String): Boolean = false + + fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean = false + + fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean = false + + fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean = false + + 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 addFileChangeCallback(callback: (File?) -> Unit) {} + + fun removeFileChangeCallback(callback: (File?) -> Unit) {} + + fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} + + fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {} + + fun showInlineSuggestion( + pluginId: String, + text: String, + ) {} + + fun dismissInlineSuggestion(pluginId: String) {} + } + + private val fileChangeListeners = CopyOnWriteArrayList() + private val contentChangeListeners = CopyOnWriteArrayList() + + private val internalFileChangeCallback: (File?) -> Unit = { file -> + fileChangeListeners.forEach { listener -> + try { + listener.onFileChanged(file) + } catch (_: Exception) { + } + } + } + + private val internalContentChangeCallback: (String, Int, Int, String) -> Unit = { content, line, col, lang -> + contentChangeListeners.forEach { listener -> + try { + listener.onContentChanged(content, line, col, lang) + } catch (_: Exception) { + } + } + } + + init { + editorProvider.addFileChangeCallback(internalFileChangeCallback) + editorProvider.addContentChangeCallback(internalContentChangeCallback) + } + + fun dispose() { + editorProvider.removeFileChangeCallback(internalFileChangeCallback) + editorProvider.removeContentChangeCallback(internalContentChangeCallback) + fileChangeListeners.clear() + contentChangeListeners.clear() + } + + override fun getCurrentFile(): File? { + requireRead() + val file = editorProvider.getCurrentFile() ?: return null + ensureFileAccessible(file) + return file + } + + override fun getOpenFiles(): List { + requireRead() + return editorProvider.getOpenFiles().filter { isFileAccessAllowed(it) } + } + + /** + * Null-safe "what file is the user looking at, and am I allowed to see it?" used by every + * read method that short-circuits when there's no current file. Assumes the caller already + * ran [requireRead]. Doesn't log and never throws — that's the whole point: these methods + * fire constantly and can't afford to run the full public [getCurrentFile] pipeline on + * each call. + */ + private fun resolveCurrentFile(): File? { + val file = editorProvider.getCurrentFile() ?: return null + return if (isFileAccessAllowed(file)) file else null + } + + override fun isFileOpen(file: File): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.isFileOpen(file) + } + + override fun getCurrentSelection(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentSelection() + } + + override fun getCurrentFileContent(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentFileContent() + } + + override fun getFileContent(file: File): String? { + requireRead() + ensureFileAccessible(file) + return editorProvider.getFileContent(file) + } + + override fun getCurrentCursorPosition(): CursorPosition? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentCursorPosition() + } + + override fun getCurrentSelectionRange(): SelectionRange? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentSelectionRange() + } + + override fun getCurrentLineText(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentLineText() + } + + override fun getLineText( + file: File, + lineNumber: Int, + ): String? { + requireRead() + ensureFileAccessible(file) + return editorProvider.getLineText(file, lineNumber) + } + + override fun getLineCount(file: File): Int { + requireRead() + ensureFileAccessible(file) + return editorProvider.getLineCount(file) + } + + override fun getWordAtCursor(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getWordAtCursor() + } + + override fun getCurrentLanguageId(): String? { + requireRead() + if (resolveCurrentFile() == null) return null + return editorProvider.getCurrentLanguageId() + } + + override fun getFileLanguageId(file: File): String? { + requireRead() + ensureFileAccessible(file) + return editorProvider.getFileLanguageId(file) + } + + override fun isFileModified(file: File): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.isFileModified(file) + } + + override fun getModifiedFiles(): List { + requireRead() + return editorProvider.getModifiedFiles().filter { isFileAccessAllowed(it) } + } + + override fun openFile(file: File): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.openFile(file) + } + + override fun openFileAt( + file: File, + line: Int, + column: Int, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.openFileAt(file, line, column) + } + + override fun saveCurrentFile(): Boolean { + if (!writableCurrentFile()) return false + return editorProvider.saveCurrentFile() + } + + override fun insertTextAtCursor(text: String): Boolean { + if (!writableCurrentFile()) return false + return editorProvider.insertTextAtCursor(text) + } + + override fun replaceSelection(text: String): Boolean { + if (!writableCurrentFile()) return false + return editorProvider.replaceSelection(text) + } + + private fun writableCurrentFile(): Boolean { + requireWrite() + val file = editorProvider.getCurrentFile() ?: return false + return isFileAccessAllowed(file) + } + + override fun appendToLine( + file: File, + line: Int, + text: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.appendToLine(file, line, text) + } + + override fun prependToLine( + file: File, + line: Int, + text: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.prependToLine(file, line, text) + } + + override fun replaceLine( + file: File, + line: Int, + newText: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.replaceLine(file, line, newText) + } + + override fun insertLineBefore( + file: File, + line: Int, + text: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.insertLineBefore(file, line, text) + } + + override fun deleteLine( + file: File, + line: Int, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.deleteLine(file, line) + } + + override fun replaceRange( + file: File, + range: SelectionRange, + newText: String, + ): Boolean { + requireWrite() + ensureFileAccessible(file) + return editorProvider.replaceRange(file, range, newText) + } + + override fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean { + requireRead() + ensureFileAccessible(file) + return editorProvider.showPeerCursor(file, line, column, peerId, peerName, peerColor) + } + + override fun hidePeerCursor( + file: File, + peerId: String, + ): Boolean { + requireRead() + return editorProvider.hidePeerCursor(file, peerId) + } + + override fun clearPeerCursors(file: File) { + requireRead() + editorProvider.clearPeerCursors(file) + } + + override fun addFileChangeListener(listener: FileChangeListener) { + requireRead() + fileChangeListeners.addIfAbsent(listener) + } + + override fun removeFileChangeListener(listener: FileChangeListener) { + fileChangeListeners.remove(listener) + } + + override fun addContentChangeListener(listener: EditorContentChangeListener) { + contentChangeListeners.addIfAbsent(listener) + } + + override fun removeContentChangeListener(listener: EditorContentChangeListener) { + contentChangeListeners.remove(listener) + } + + override fun showInlineSuggestion(text: String) { + // Tag the suggestion with this plugin's id so concurrent plugins don't clobber or dismiss + // each other's ghost text. The public IdeEditorService signature is unchanged. + editorProvider.showInlineSuggestion(pluginId, text) + } + + override fun dismissInlineSuggestion() { + editorProvider.dismissInlineSuggestion(pluginId) + } + + private fun requireRead() { + if (!hasAll(readPermissions)) { + throw SecurityException( + "Plugin $pluginId is missing required permissions: ${readPermissions.joinToString(",") { it.name }}", + ) + } + } + + private fun requireWrite() { + if (!hasAll(writePermissions)) { + throw SecurityException( + "Plugin $pluginId is missing required permissions: ${writePermissions.joinToString(",") { it.name }}", + ) + } + } + + private fun hasAll(required: Set) = required.all { permissions.contains(it) } + + private fun ensureFileAccessible(file: File) { + if (!isFileAccessAllowed(file)) { + throw SecurityException("Plugin $pluginId does not have access to file: ${file.absolutePath}") + } + } + + private fun isFileAccessAllowed(file: File): Boolean { + pathValidator?.let { validator -> + val ok = runCatching { validator.isPathAllowed(file) }.getOrDefault(false) + if (!ok) { + Log.d(TAG, "[$pluginId] pathValidator rejected ${file.absolutePath}") + } + return ok + } + + // No validator wired by the host: if the editor itself has this file open, + // the user is already exposed to it — trust that and allow the read. + val openInEditor = runCatching { editorProvider.isFileOpen(file) }.getOrDefault(false) + if (openInEditor) return true + + val allowed = isFileAccessAllowedDefault(file) + if (!allowed) { + Log.d(TAG, "[$pluginId] static allowlist rejected ${file.absolutePath}; allowed roots=$defaultAllowedPaths") + } + return allowed + } + + private fun isFileAccessAllowedDefault(file: File): Boolean { + val canonicalPath = + try { + file.canonicalPath + } catch (_: Exception) { + return false + } + return defaultAllowedPaths.any { root -> + canonicalPath == root || canonicalPath.startsWith(root + File.separator) + } + } + + // Canonicalised so symlinked roots don't bypass the check; anchored on File.separator at + // the match site so e.g. "/…/CodeOnTheGoProjects" doesn't also admit + // "/…/CodeOnTheGoProjectsBackup/". + private val defaultAllowedPaths: List by lazy { + val projects = Environment.PROJECTS_FOLDER + listOf( + "/storage/emulated/0/$projects", + "/sdcard/$projects", + (System.getProperty("user.home") ?: "/") + "/$projects", + "/tmp/CodeOnTheGoProject", + ).map { runCatching { File(it).canonicalPath }.getOrDefault(it) } + } + + companion object { + private const val TAG = "IdeEditorService" + } } 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 a620ceaa38..dad54896ff 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 @@ -17,178 +17,185 @@ import java.io.File * with proper permission validation. */ class IdeProjectServiceImpl( - private val pluginId: String, - private val permissions: Set, - private val projectProvider: ProjectProvider, - private val requiredPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), - private val pathValidator: PathValidator? = null, - private val activityProvider: PluginManager.ActivityProvider? = null + private val pluginId: String, + private val permissions: Set, + private val projectProvider: ProjectProvider, + private val requiredPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), + private val pathValidator: PathValidator? = null, + private val activityProvider: PluginManager.ActivityProvider? = null, ) : IdeProjectService { - - /** - * Interface for validating project path access - */ - interface PathValidator { - fun isPathAllowed(path: File): Boolean - fun getAllowedPaths(): List - } - - /** - * Interface for providing actual project data from AndroidIDE - */ - interface ProjectProvider { - fun getCurrentProject(): IProject? - fun getAllProjects(): List - fun getProjectByPath(path: File): IProject? - } - - override fun getCurrentProject(): IProject? { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - return try { - projectProvider.getCurrentProject() - } catch (e: Exception) { - // Log error but don't expose internal details - null - } - } - - override fun getAllProjects(): List { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - return try { - projectProvider.getAllProjects() - } catch (e: Exception) { - // Log error but don't expose internal details - emptyList() - } - } - - override fun getProjectByPath(path: File): IProject? { - if (!hasRequiredPermissions()) { - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - // Additional security check: ensure the path is not outside allowed directories - if (!isPathAllowed(path)) { - throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}") - } - - return try { - projectProvider.getProjectByPath(path) - } catch (e: Exception) { - // Log error but don't expose internal details - null - } - } - - override fun openProject(projectDir: File): Boolean { - if (!hasRequiredPermissions()) { - Log.w(TAG, "openProject denied: missing permissions ${getRequiredPermissionsString()}") - throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") - } - - // 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}") - } - - // 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, "openProject aborted: no foreground activity available") - return false - } - - return try { - 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() } - true - } catch (e: Exception) { - Log.e(TAG, "openProject failed: ${e.javaClass.simpleName}: ${e.message}", e) - 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.takeIf { it.path == base.path || it.path.startsWith(base.path + File.separator) } - }.getOrNull() - } - - private fun hasRequiredPermissions(): Boolean { - return requiredPermissions.all { permission -> - permissions.contains(permission) - } - } - - private fun getRequiredPermissionsString(): String { - return requiredPermissions.joinToString(", ") { it.name } - } - - private fun isPathAllowed(path: File): Boolean { - // Use custom path validator if provided - pathValidator?.let { validator -> - return validator.isPathAllowed(path) - } - - // Fallback to default validation for backward compatibility - return isPathAllowedDefault(path) - } - - private fun isPathAllowedDefault(path: File): Boolean { - // Default allowed paths - this should be replaced by AndroidIDE with actual project paths - val allowedPaths = getDefaultAllowedPaths() - - val canonicalPath = try { - path.canonicalPath - } catch (e: Exception) { - return false - } - - return allowedPaths.any { allowedPath -> - canonicalPath.startsWith(allowedPath) - } - } - - private fun getDefaultAllowedPaths(): List { - val projectsDirPaths = runCatching { Environment.PROJECTS_DIR }.getOrNull()?.let { dir -> - listOfNotNull(dir.absolutePath, runCatching { dir.canonicalPath }.getOrNull()) - }.orEmpty() - - return projectsDirPaths + listOf( - "/storage/emulated/0/CodeOnTheGoProjects", - "/sdcard/CodeOnTheGoProjects", - System.getProperty("user.home", "/") + "/CodeOnTheGoProjects", - "/tmp/AndroidIDEProject" // Allow temporary project for demo purposes - ) - } - - private companion object { - const val TAG = "PairTrace" - } -} \ No newline at end of file + /** + * Interface for validating project path access + */ + interface PathValidator { + fun isPathAllowed(path: File): Boolean + + fun getAllowedPaths(): List + } + + /** + * Interface for providing actual project data from AndroidIDE + */ + interface ProjectProvider { + fun getCurrentProject(): IProject? + + fun getAllProjects(): List + + fun getProjectByPath(path: File): IProject? + } + + override fun getCurrentProject(): IProject? { + if (!hasRequiredPermissions()) { + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + return try { + projectProvider.getCurrentProject() + } catch (e: Exception) { + // Log error but don't expose internal details + null + } + } + + override fun getAllProjects(): List { + if (!hasRequiredPermissions()) { + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + return try { + projectProvider.getAllProjects() + } catch (e: Exception) { + // Log error but don't expose internal details + emptyList() + } + } + + override fun getProjectByPath(path: File): IProject? { + if (!hasRequiredPermissions()) { + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + // Additional security check: ensure the path is not outside allowed directories + if (!isPathAllowed(path)) { + throw SecurityException("Plugin $pluginId does not have access to path: ${path.absolutePath}") + } + + return try { + projectProvider.getProjectByPath(path) + } catch (e: Exception) { + // Log error but don't expose internal details + null + } + } + + override fun openProject(projectDir: File): Boolean { + if (!hasRequiredPermissions()) { + Log.w(TAG, "openProject denied: missing permissions ${getRequiredPermissionsString()}") + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + // 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}") + } + + // 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, "openProject aborted: no foreground activity available") + return false + } + + return try { + 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() } + true + } catch (e: Exception) { + Log.e(TAG, "openProject failed: ${e.javaClass.simpleName}: ${e.message}", e) + 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.takeIf { it.path == base.path || it.path.startsWith(base.path + File.separator) } + }.getOrNull() + } + + private fun hasRequiredPermissions(): Boolean = + requiredPermissions.all { permission -> + permissions.contains(permission) + } + + private fun getRequiredPermissionsString(): String = requiredPermissions.joinToString(", ") { it.name } + + private fun isPathAllowed(path: File): Boolean { + // Use custom path validator if provided + pathValidator?.let { validator -> + return validator.isPathAllowed(path) + } + + // Fallback to default validation for backward compatibility + return isPathAllowedDefault(path) + } + + private fun isPathAllowedDefault(path: File): Boolean { + // Default allowed paths - this should be replaced by AndroidIDE with actual project paths + val allowedPaths = getDefaultAllowedPaths() + + val canonicalPath = + try { + path.canonicalPath + } catch (e: Exception) { + return false + } + + return allowedPaths.any { allowedPath -> + canonicalPath.startsWith(allowedPath) + } + } + + private fun getDefaultAllowedPaths(): List { + val projectsDirPaths = + runCatching { Environment.PROJECTS_DIR } + .getOrNull() + ?.let { dir -> + listOfNotNull(dir.absolutePath, runCatching { dir.canonicalPath }.getOrNull()) + }.orEmpty() + + return projectsDirPaths + + listOf( + "/storage/emulated/0/CodeOnTheGoProjects", + "/sdcard/CodeOnTheGoProjects", + System.getProperty("user.home", "/") + "/CodeOnTheGoProjects", + "/tmp/AndroidIDEProject", // Allow temporary project for demo purposes + ) + } + + private companion object { + const val TAG = "PairTrace" + } +}