diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt new file mode 100644 index 0000000000..d85eff49c4 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/PeerPresenceOverlayManager.kt @@ -0,0 +1,129 @@ +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 PeerPresenceOverlayManager( + 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 93cb2edf9e..95e7a3cb68 100644 --- a/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.app import android.os.Handler import android.os.Looper import androidx.lifecycle.lifecycleScope +import com.itsaky.androidide.activities.editor.PeerPresenceOverlayManager import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range @@ -36,6 +37,9 @@ class EditorProviderImpl( private val activityRef = WeakReference(activity) private val mainHandler = Handler(Looper.getMainLooper()) private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>() + private val peerPresenceOverlay = PeerPresenceOverlayManager { file -> + activity()?.getEditorForFile(file)?.editor + } private val internalListener: (File?) -> Unit = { file -> fileCallbacks.forEach { cb -> @@ -57,6 +61,7 @@ class EditorProviderImpl( fun dispose() { EditorEvents.removeFileChangeListener(internalListener) fileCallbacks.clear() + onMain { peerPresenceOverlay.clearAll(); true } activityRef.clear() } @@ -257,6 +262,25 @@ class EditorProviderImpl( 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 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..74b5e54724 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -145,6 +145,16 @@ class PluginRepositoryImpl( } manager.loadPlugins() + + if (manager.getPlugin(pluginId) == null) { + // The new package replaced the previous one but failed to load. Remove the broken + // artifact so subsequent loadPlugins() calls don't keep retrying it. + finalFile.delete() + throw IllegalStateException( + manager.getLoadError(pluginId) + ?: "Plugin \"$pluginId\" was installed but failed to load." + ) + } }.onFailure { exception -> Log.e(TAG, "Failed to install plugin from file: ${pluginFile.absolutePath}", exception) } 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 f7c02716c7..3f1d6416b4 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/services/IdeServices.kt @@ -30,6 +30,24 @@ interface IdeProjectService { * @return The project at the given path, or null if not found */ fun getProjectByPath(path: File): IProject? + + /** + * Requests the IDE to open the project rooted at [projectDir], replacing the project + * that is currently open. Dispatches asynchronously: a `true` return means the open was + * requested and the editor is being launched, not that the project has finished loading. + * Poll [getCurrentProject] to observe completion. + * + * Requires the FILESYSTEM_READ permission. + * + * Default-implemented (no-op, returns false) so adding it is a backward-compatible + * interface extension: existing implementers and any prebuilt plugin-api lib keep + * compiling; the host overrides it. + * + * @param projectDir The root directory of the project to open + * @return true if the open request was dispatched, false if it was rejected or the IDE + * has no foreground activity available to host the editor + */ + fun openProject(projectDir: File): Boolean = false } /** @@ -128,6 +146,31 @@ interface IdeEditorService { 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) 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 f0a8301c3b..fd3f079192 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -62,6 +62,7 @@ import com.itsaky.androidide.plugins.extensions.BuildActionExtension import com.itsaky.androidide.plugins.manager.build.PluginBuildActionManager import com.itsaky.androidide.actions.SidebarSlotManager import com.itsaky.androidide.actions.SidebarSlotExceededException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -151,6 +152,19 @@ class PluginManager private constructor( range: com.itsaky.androidide.plugins.services.SelectionRange, newText: String, ): Boolean = current()?.replaceRange(file, range, newText) ?: false + override fun showPeerCursor( + file: File, + line: Int, + column: Int, + peerId: String, + peerName: String, + peerColor: Int, + ): Boolean = current()?.showPeerCursor(file, line, column, peerId, peerName, peerColor) ?: false + override fun hidePeerCursor(file: File, peerId: String): Boolean = + current()?.hidePeerCursor(file, peerId) ?: false + override fun clearPeerCursors(file: File) { + current()?.clearPeerCursors(file) + } override fun addFileChangeCallback(callback: (File?) -> Unit) { pendingFileChangeCallbacks.add(callback) current()?.addFileChangeCallback(callback) @@ -184,6 +198,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 = ServiceRegistryImpl() @@ -256,15 +271,20 @@ 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}") + logger.debug("Loading plugin: ${pluginFile.name}") + val result = try { loadPlugin(pluginFile) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - logger.error("Failed to load plugin from ${pluginFile.name}", e) + Result.failure(e) } + result.onFailure { error -> recordLoadFailure(pluginFile, error) } } } @@ -754,6 +774,14 @@ class PluginManager private constructor( fun getPlugin(pluginId: String): IPlugin? { return loadedPlugins[pluginId]?.plugin } + + fun getLoadError(pluginId: String): String? = loadFailures[pluginId] + + private fun recordLoadFailure(pluginFile: File, error: Throwable) { + logger.error("Failed to load plugin from ${pluginFile.name}", error) + val id = loadAndValidate(pluginFile).getOrNull()?.first?.id ?: pluginFile.nameWithoutExtension + loadFailures[id] = error.message ?: error.toString() + } fun getAllPlugins(): List { return loadedPlugins.values.map { loadedPlugin -> @@ -1117,7 +1145,8 @@ class PluginManager private constructor( override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) override fun getAllowedPaths(): List = validator.getAllowedPaths() } - } + }, + activityProvider = activityProvider ) } @@ -1335,7 +1364,8 @@ class PluginManager private constructor( override fun isPathAllowed(path: File): Boolean = validator.isPathAllowed(path) override fun getAllowedPaths(): List = validator.getAllowedPaths() } - } + }, + activityProvider = activityProvider ) } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt index 29b1d5555f..0f462ce28b 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeEditorServiceImpl.kt @@ -26,7 +26,20 @@ class IdeEditorServiceImpl( fun getAllowedPaths(): List } - interface EditorProvider { + /** + * Remote-collaborator presence: draw, move and clear named peer cursors in open editors. + * Split out of [EditorProvider] so peer presence is a focused, separately-named contract + * rather than three more methods on the broad editor-access surface (interface segregation). + * The host bridge implements both through one object. Visual overlay only — never mutates + * file content. Each method defaults to a no-op so an implementer can opt in. + */ + interface PeerPresenceProvider { + fun showPeerCursor(file: File, line: Int, column: Int, peerId: String, peerName: String, peerColor: Int): Boolean = false + fun hidePeerCursor(file: File, peerId: String): Boolean = false + fun clearPeerCursors(file: File) {} + } + + interface EditorProvider : PeerPresenceProvider { fun getCurrentFile(): File? fun getOpenFiles(): List fun isFileOpen(file: File): Boolean @@ -254,6 +267,29 @@ class IdeEditorServiceImpl( 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) 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..b3c351b68a 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeProjectServiceImpl.kt @@ -2,9 +2,14 @@ package com.itsaky.androidide.plugins.manager.services +import android.util.Log import com.itsaky.androidide.plugins.PluginPermission import com.itsaky.androidide.plugins.extensions.IProject +import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.plugins.services.IdeProjectService +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.utils.Environment import java.io.File /** @@ -16,7 +21,8 @@ class IdeProjectServiceImpl( private val permissions: Set, private val projectProvider: ProjectProvider, private val requiredPermissions: Set = setOf(PluginPermission.FILESYSTEM_READ), - private val pathValidator: PathValidator? = null + private val pathValidator: PathValidator? = null, + private val activityProvider: PluginManager.ActivityProvider? = null ) : IdeProjectService { /** @@ -80,6 +86,60 @@ class IdeProjectServiceImpl( } } + override fun openProject(projectDir: File): Boolean { + if (!hasRequiredPermissions()) { + Log.w(TAG, "openProject denied: missing permissions ${getRequiredPermissionsString()}") + throw SecurityException("Plugin $pluginId does not have required permissions: ${getRequiredPermissionsString()}") + } + + // Validate against the canonical, containment-checked target and reuse it everywhere below, + // so a symlink/relative path can't pass the check as one path yet be switched to as another. + val resolvedProjectDir = resolveProjectDirUnderProjectsDir(projectDir) + if (resolvedProjectDir == null) { + Log.w(TAG, "openProject denied: ${projectDir.absolutePath} is not under projects dir ${Environment.PROJECTS_DIR?.absolutePath}") + throw SecurityException("Plugin $pluginId may only open projects under ${Environment.PROJECTS_DIR?.absolutePath}") + } + + // Apply the same path-access policy used by getProjectByPath. + if (!isPathAllowed(resolvedProjectDir)) { + throw SecurityException("Plugin $pluginId does not have access to path: ${resolvedProjectDir.absolutePath}") + } + + if (!resolvedProjectDir.exists() || !resolvedProjectDir.isDirectory) { + Log.w(TAG, "openProject aborted: not a directory (exists=${resolvedProjectDir.exists()}, isDir=${resolvedProjectDir.isDirectory})") + return false + } + + val activity = activityProvider?.getCurrentActivity() + if (activity == null) { + Log.w(TAG, "openProject aborted: no foreground activity available") + return false + } + + return try { + ProjectManagerImpl.getInstance().projectPath = resolvedProjectDir.absolutePath + GeneralPreferences.lastOpenedProject = resolvedProjectDir.absolutePath + + // The editor activity is launchMode=singleTask, so re-launching it only delivers + // onNewIntent (no reload). Recreating it re-runs onCreate, which loads the project + // from the projectPath we just set — the same effect as the IDE's own project switch. + activity.runOnUiThread { activity.recreate() } + true + } catch (e: Exception) { + Log.e(TAG, "openProject failed: ${e.javaClass.simpleName}: ${e.message}", e) + false + } + } + + private fun resolveProjectDirUnderProjectsDir(path: File): File? { + val projectsDir = runCatching { Environment.PROJECTS_DIR }.getOrNull() ?: return null + return runCatching { + val base = projectsDir.canonicalFile + val target = path.canonicalFile + target.takeIf { it.path == base.path || it.path.startsWith(base.path + File.separator) } + }.getOrNull() + } + private fun hasRequiredPermissions(): Boolean { return requiredPermissions.all { permission -> permissions.contains(permission) @@ -123,4 +183,8 @@ class IdeProjectServiceImpl( "/tmp/AndroidIDEProject" // Allow temporary project for demo purposes ) } + + private companion object { + const val TAG = "PairTrace" + } } \ No newline at end of file