diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/PeerCursorOverlayManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerCursorOverlayManager.kt new file mode 100644 index 0000000000..fdd9f4c4b9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerCursorOverlayManager.kt @@ -0,0 +1,139 @@ +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 PeerCursorOverlayManager( + 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 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 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 PeerCursorWindow( + 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, + ) { + // 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) + + // 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 + } +} 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 78b3fae7e9..4fc16ff0fc 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -4,11 +4,12 @@ import android.os.Handler import android.os.Looper import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.activities.editor.PeerCursorOverlayManager +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 @@ -35,389 +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 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() - 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 - } - - /** - * 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 = + PeerCursorOverlayManager { 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 e2c2d1d024..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,155 +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() - }.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/api/plugin-api.api b/plugin-api/api/plugin-api.api index 399036d52e..b1ee917a06 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1337,6 +1337,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor public abstract fun addContentChangeListener (Lcom/itsaky/androidide/plugins/services/EditorContentChangeListener;)V public abstract fun addFileChangeListener (Lcom/itsaky/androidide/plugins/services/FileChangeListener;)V public abstract fun appendToLine (Ljava/io/File;ILjava/lang/String;)Z + public abstract fun clearPeerCursors (Ljava/io/File;)V public abstract fun deleteLine (Ljava/io/File;I)Z public abstract fun dismissInlineSuggestion ()V public abstract fun getCurrentCursorPosition ()Lcom/itsaky/androidide/plugins/services/CursorPosition; @@ -1353,6 +1354,7 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor public abstract fun getModifiedFiles ()Ljava/util/List; public abstract fun getOpenFiles ()Ljava/util/List; public abstract fun getWordAtCursor ()Ljava/lang/String; + public abstract fun hidePeerCursor (Ljava/io/File;Ljava/lang/String;)Z public abstract fun insertLineBefore (Ljava/io/File;ILjava/lang/String;)Z public abstract fun insertTextAtCursor (Ljava/lang/String;)Z public abstract fun isFileModified (Ljava/io/File;)Z @@ -1367,13 +1369,17 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeEditor public abstract fun replaceSelection (Ljava/lang/String;)Z public abstract fun saveCurrentFile ()Z public abstract fun showInlineSuggestion (Ljava/lang/String;)V + public abstract fun showPeerCursor (Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z } public final class com/itsaky/androidide/plugins/services/IdeEditorService$DefaultImpls { public static fun addContentChangeListener (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Lcom/itsaky/androidide/plugins/services/EditorContentChangeListener;)V + public static fun clearPeerCursors (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;)V public static fun dismissInlineSuggestion (Lcom/itsaky/androidide/plugins/services/IdeEditorService;)V + public static fun hidePeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;Ljava/lang/String;)Z public static fun removeContentChangeListener (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Lcom/itsaky/androidide/plugins/services/EditorContentChangeListener;)V public static fun showInlineSuggestion (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/lang/String;)V + public static fun showPeerCursor (Lcom/itsaky/androidide/plugins/services/IdeEditorService;Ljava/io/File;IILjava/lang/String;Ljava/lang/String;I)Z } public abstract interface class com/itsaky/androidide/plugins/services/IdeEditorTabService { @@ -1428,10 +1434,12 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeProjec public abstract fun getCurrentProject ()Lcom/itsaky/androidide/plugins/extensions/IProject; public abstract fun getModuleContext (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/ModuleContext; public abstract fun getProjectByPath (Ljava/io/File;)Lcom/itsaky/androidide/plugins/extensions/IProject; + public abstract fun openProject (Ljava/io/File;)Z } public final class com/itsaky/androidide/plugins/services/IdeProjectService$DefaultImpls { public static fun getModuleContext (Lcom/itsaky/androidide/plugins/services/IdeProjectService;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/ModuleContext; + public static fun openProject (Lcom/itsaky/androidide/plugins/services/IdeProjectService;Ljava/io/File;)Z } public abstract interface class com/itsaky/androidide/plugins/services/IdeSidebarService { 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 617b8a0e05..b0595c3f2b 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,53 +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? - - /** - * 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, ) /** @@ -67,7 +89,7 @@ data class SelectionRange( * or null if all files are closed. */ fun interface FileChangeListener { - fun onFileChanged(file: File?) + fun onFileChanged(file: File?) } /** @@ -75,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, + ) } /** @@ -91,100 +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 getCurrentFile(): File? - fun insertLineBefore(file: File, line: Int, text: String): Boolean + fun getOpenFiles(): List - fun deleteLine(file: File, line: Int): Boolean + fun isFileOpen(file: File): Boolean - fun replaceRange(file: File, range: SelectionRange, newText: String): Boolean + 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() {} } /** @@ -193,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" + } } /** @@ -245,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, + ) } /** @@ -332,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?) } /** @@ -446,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 } /** @@ -478,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 de79b82ad5..ba895ebd7c 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 @@ -228,6 +228,24 @@ class PluginManager private constructor( 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) { synchronized(editorCallbackLock) { if (fileChangeCallbacks.add(callback)) { @@ -297,6 +315,7 @@ class PluginManager private constructor( 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() @@ -371,21 +390,22 @@ class PluginManager private constructor( logger.info("Found ${pluginFiles.size} plugin files") + loadFailures.clear() + // Load plugins in parallel val loadJobs = pluginFiles.map { pluginFile -> async { - try { - logger.debug("Loading plugin: ${pluginFile.name}") - val result = loadPlugin(pluginFile) - result.onFailure { error -> - logger.error("Failed to load plugin from ${pluginFile.name}: ${error.message}", error) + logger.debug("Loading plugin: ${pluginFile.name}") + val result = + try { + loadPlugin(pluginFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.error("Failed to load plugin from ${pluginFile.name}", e) - } + result.onFailure { error -> recordLoadFailure(pluginFile, error) } } } @@ -899,6 +919,17 @@ class PluginManager private constructor( 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( @@ -1324,6 +1355,7 @@ class PluginManager private constructor( override fun getAllowedPaths(): List = validator.getAllowedPaths() } }, + activityProvider = delegatingActivityProvider, ) } @@ -1573,6 +1605,7 @@ class PluginManager private constructor( override fun getAllowedPaths(): List = validator.getAllowedPaths() } }, + activityProvider = delegatingActivityProvider, ) } 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 aa801a5a61..388bc692e4 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,361 +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 - } - - interface EditorProvider { - 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 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 f9b83f019f..6327969664 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 @@ -4,7 +4,12 @@ package com.itsaky.androidide.plugins.manager.services 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 org.slf4j.LoggerFactory import java.io.File /** @@ -12,115 +17,205 @@ 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 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 - /** - * 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 - } - } - - 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 { - return listOf( - "/storage/emulated/0/AndroidIDEProjects", - "/sdcard/AndroidIDEProjects", - System.getProperty("user.home", "/") + "/AndroidIDEProjects", - "/tmp/AndroidIDEProject" // Allow temporary project for demo purposes - ) - } -} \ No newline at end of file + 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.warn("getCurrentProject failed for plugin {}; reporting no current project", pluginId, e) + 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.warn("getAllProjects failed for plugin {}; reporting an empty project list", pluginId, e) + 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.warn("getProjectByPath failed for plugin {}; reporting no project at the requested path", pluginId, e) + null + } + } + + override fun openProject(projectDir: File): Boolean { + if (!hasRequiredPermissions()) { + log.warn("openProject denied for plugin {}: missing required permissions", pluginId) + 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.warn("openProject denied for plugin {}: target is not under the IDE projects directory", pluginId) + throw SecurityException("Plugin $pluginId may only open projects under the IDE projects directory") + } + + // Apply the same path-access policy used by getProjectByPath. + if (!isPathAllowed(resolvedProjectDir)) { + log.warn("openProject denied for plugin {}: path-access policy rejected the target", pluginId) + throw SecurityException("Plugin $pluginId does not have access to the requested path") + } + + if (!resolvedProjectDir.exists() || !resolvedProjectDir.isDirectory) { + log.warn("openProject aborted for plugin {}: target does not resolve to an existing directory", pluginId) + return false + } + + val activity = activityProvider?.getCurrentActivity() + if (activity == null) { + log.warn("openProject aborted for plugin {}: no foreground activity available", pluginId) + return false + } + if (activity.isFinishing || activity.isDestroyed) { + log.warn("openProject aborted for plugin {}: host activity is finishing or destroyed", pluginId) + return false + } + + return try { + // Switch project state on the UI thread, immediately before recreate(), so the write and + // the reload are atomic with respect to the activity lifecycle: recreate() is a no-op on + // an activity that is finishing or destroyed, and mutating the path first would leave the + // IDE pointing at a project nothing ever loaded. + activity.runOnUiThread { + if (activity.isFinishing || activity.isDestroyed) { + log.warn("openProject aborted for plugin {}: host activity died before recreate", pluginId) + return@runOnUiThread + } + runCatching { + 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.recreate() + }.onFailure { log.error("openProject failed for plugin {} while switching projects", pluginId, it) } + } + true + } catch (e: Exception) { + log.error("openProject failed for plugin {}", pluginId, 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 + } + + // Anchored on File.separator so an allowed root like ".../CodeOnTheGoProjects" does not + // also admit a sibling such as ".../CodeOnTheGoProjects_evil". + return allowedPaths.any { root -> + canonicalPath == root || canonicalPath.startsWith(root + File.separator) + } + } + + // Canonicalised so a symlinked root cannot bypass the containment check by presenting a + // different textual prefix than the path being tested. + 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 + ) + ).map { runCatching { File(it).canonicalPath }.getOrDefault(it) } + } + + private companion object { + private val log = LoggerFactory.getLogger(IdeProjectServiceImpl::class.java) + } +}