diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 785cda65e4..da9527798e 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -17,6 +17,8 @@ package com.itsaky.androidide.activities.editor +import android.content.ClipData +import android.content.ClipboardManager import android.content.Intent import android.content.res.Configuration import android.os.Bundle @@ -25,6 +27,7 @@ import android.util.Log import android.view.KeyEvent import android.view.View import android.view.ViewGroup.LayoutParams +import android.widget.TextView import androidx.collection.MutableIntObjectMap import androidx.core.content.res.ResourcesCompat import androidx.core.view.GravityCompat @@ -33,6 +36,7 @@ import androidx.lifecycle.lifecycleScope import com.blankj.utilcode.util.ImageUtils import com.google.android.material.tabs.TabLayout import com.google.gson.Gson +import com.itsaky.androidide.R import com.itsaky.androidide.R.string import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem @@ -57,6 +61,8 @@ import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.file.FileRenameEvent +import com.itsaky.androidide.activities.PluginManagerActivity +import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler @@ -77,8 +83,10 @@ import com.itsaky.androidide.shortcuts.ShortcutExecutionContext import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.tasks.executeAsync import com.itsaky.androidide.ui.CodeEditorView +import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.DialogUtils.showConfirmationDialog +import com.itsaky.androidide.utils.EditorSidebarActions import com.itsaky.androidide.utils.IntentUtils.openImage import com.itsaky.androidide.utils.UniqueNameBuilder import com.itsaky.androidide.utils.flashSuccess @@ -1097,6 +1105,88 @@ open class EditorHandlerActivity : tab.text = "*${tab.text}" } + @Subscribe(threadMode = ThreadMode.MAIN) + fun onPluginCrashed(event: PluginCrashedEvent) { + if (event.wasDisabled) { + tearDownDisabledPluginContributions(event.pluginId) + } + showPluginCrashDialog(event) + } + + private fun showPluginCrashDialog(event: PluginCrashedEvent) { + val dialogView = layoutInflater.inflate(R.layout.dialog_plugin_crash, null) + dialogView.findViewById(R.id.plugin_crash_message).text = + if (event.wasDisabled) { + getString(string.msg_plugin_crash_disabled, event.pluginName) + } else { + getString(string.msg_plugin_crash, event.pluginName, event.crashCount) + } + + val builder = newMaterialDialogBuilder(this) + .setTitle(string.title_plugin_crashed) + .setView(dialogView) + .setPositiveButton(string.dismiss, null) + + if (event.wasDisabled) { + builder.setNegativeButton(string.plugin_manager) { _, _ -> + startActivity(Intent(this, PluginManagerActivity::class.java)) + } + } + + builder.show() + + dialogView.findViewById(R.id.plugin_crash_view_logs).setOnClickListener { + showPluginCrashLogDialog(event) + } + } + + private fun showPluginCrashLogDialog(event: PluginCrashedEvent) { + newMaterialDialogBuilder(this) + .setTitle(getString(string.title_plugin_crash_log, event.pluginName)) + .setMessage(event.stackTrace) + .setPositiveButton(string.close, null) + .setNeutralButton(string.copy) { _, _ -> + val clipboard = getSystemService(ClipboardManager::class.java) + clipboard?.setPrimaryClip( + ClipData.newPlainText( + getString(string.title_plugin_crash_log, event.pluginName), + event.stackTrace + ) + ) + flashSuccess(string.msg_crash_log_copied) + } + .show() + } + + private fun tearDownDisabledPluginContributions(pluginId: String) { + runCatching { + val pluginManager = IDEApplication.getPluginManager() ?: return + val tabManager = PluginEditorTabManager.getInstance() + + val tabsToClose = pluginTabIndices.keys.toList().filter { tabId -> + tabManager.getPluginIdForTab(tabId) == pluginId + } + tabsToClose.forEach { tabId -> + val index = pluginTabIndices[tabId] ?: return@forEach + closePluginTab(index) + } + + tabManager.loadPluginTabs(pluginManager) + + val registry = getInstance() + registry.clearActions(ActionItem.Location.EDITOR_SIDEBAR) + EditorSidebarActions.registerActions(this) + (supportFragmentManager.findFragmentById(R.id.drawer_sidebar) as? EditorSidebarFragment) + ?.let { EditorSidebarActions.setup(it) } + + invalidateOptionsMenu() + + Log.i("EditorHandlerActivity", "Tore down contributions for disabled plugin: $pluginId") + }.onFailure { e -> + Log.e("EditorHandlerActivity", "Failed to tear down contributions for disabled plugin: $pluginId", e) + } + } + private fun updateTabs() { editorActivityScope.launch { val files = editorViewModel.getOpenedFiles() diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index efe83b7d9e..c499da7a40 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -11,6 +11,7 @@ import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent import com.itsaky.androidide.managers.ToolsManager import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.base.PluginFragmentHelper import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.preferences.internal.DevOpsPreferences import com.itsaky.androidide.preferences.internal.GeneralPreferences @@ -21,6 +22,7 @@ import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.FeatureFlags import com.itsaky.androidide.utils.FileUtil import com.itsaky.androidide.utils.VMUtils +import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import io.sentry.Sentry import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -31,6 +33,8 @@ import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory +import android.os.Handler +import android.os.Looper import java.io.File import java.util.concurrent.atomic.AtomicBoolean import kotlin.system.exitProcess @@ -93,6 +97,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { } initializePluginSystem() + installPluginCrashLooperGuard() app.coroutineScope.launch(Dispatchers.IO) { // color schemes are stored in files @@ -109,10 +114,24 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { thread: Thread, exception: Throwable, ) { + val pluginManager = PluginManager.getInstance() + val pluginId = runCatching { + pluginManager?.let { pm -> + pm.crashTracker.findPluginForStackTrace( + exception, + pm.getLoadedPluginIds() + ) { pm.getClassLoaderForPluginId(it) } + } + }.getOrNull() + + if (pluginId != null) { + handlePluginCrash(pluginId, exception) + return + } + writeException(exception) Sentry.captureException(exception) - // schedule crash handler activity to be shown runCatching { val intent = Intent() intent.action = CrashHandlerActivity.REPORT_ACTION @@ -127,13 +146,73 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { logger.error("Unable to start crash handler activity", error) } - // notify the original exception handler, if any IDEApplication.instance.uncaughtExceptionHandler?.uncaughtException(thread, exception) - // finally, exit process exitProcess(EXIT_CODE_CRASH) } + private fun handlePluginCrash(pluginId: String, exception: Throwable) { + runCatching { + writeException(exception) + + Sentry.withScope { scope -> + scope.setTag("plugin_crash", "true") + scope.setTag("plugin_id", pluginId) + Sentry.captureException(exception) + } + + val pluginManager = PluginManager.getInstance() ?: return + val result = pluginManager.recordPluginCrash(pluginId) + + val wasDisabled = result is PluginManager.CrashResult.Disabled + val crashCount = when (result) { + is PluginManager.CrashResult.Recorded -> result.crashCount + is PluginManager.CrashResult.Disabled -> pluginManager.crashTracker.getCrashCount(pluginId) + } + + EventBus.getDefault().post( + PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, ThrowableUtils.getFullStackTrace(exception)) + ) + logger.warn("Plugin crash handled without killing process: {} (disabled={})", pluginId, wasDisabled) + }.onFailure { e -> + logger.error("Failed to handle plugin crash gracefully for: {}", pluginId, e) + } + } + + private var lastPluginCrashTime = 0L + + private fun installPluginCrashLooperGuard() { + Handler(Looper.getMainLooper()).post { + while (true) { + try { + Looper.loop() + break + } catch (e: Throwable) { + val pluginId = runCatching { + PluginManager.getInstance()?.let { pm -> + pm.crashTracker.findPluginForStackTrace( + e, pm.getLoadedPluginIds() + ) { pm.getClassLoaderForPluginId(it) } + } + }.getOrNull() + + if (pluginId != null) { + lastPluginCrashTime = System.currentTimeMillis() + handlePluginCrash(pluginId, e) + } else if (System.currentTimeMillis() - lastPluginCrashTime < COLLATERAL_CRASH_WINDOW_MS) { + logger.warn("Suppressing collateral crash after recent plugin crash: {}", e.message) + } else { + handleUncaughtException(Thread.currentThread(), e) + break + } + } + } + } + logger.info("Plugin crash Looper guard installed on main thread") + } + + private const val COLLATERAL_CRASH_WINDOW_MS = 3000L + private fun writeException(throwable: Throwable?) = runCatching { // ignore errors @@ -209,6 +288,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { // Set up plugin service providers setupPluginServices() + setupPluginInflationErrorHandler() // Load plugins asynchronously GlobalScope.launch { @@ -235,6 +315,24 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { } } + private fun setupPluginInflationErrorHandler() { + PluginFragmentHelper.onPluginInflationError = { pluginId, error -> + logger.error("Plugin layout inflation failed for: {}", pluginId, error) + runCatching { + val pm = PluginManager.getInstance() ?: return@runCatching + val result = pm.recordPluginCrash(pluginId) + val wasDisabled = result is PluginManager.CrashResult.Disabled + val crashCount = when (result) { + is PluginManager.CrashResult.Recorded -> result.crashCount + is PluginManager.CrashResult.Disabled -> pm.crashTracker.getCrashCount(pluginId) + } + EventBus.getDefault().post( + PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, ThrowableUtils.getFullStackTrace(error)) + ) + } + } + } + @Suppress("unused") @Subscribe(threadMode = ThreadMode.MAIN) fun onPrefChanged(event: PreferenceChangeEvent) { diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt index 1a9540d29b..39395b33ce 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorSidebarActions.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.utils import android.content.Context import android.os.Bundle +import android.util.Log import android.view.View import androidx.annotation.IdRes import androidx.core.view.forEach @@ -51,6 +52,8 @@ import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.actions.PluginSidebarActionItem import com.itsaky.androidide.actions.SidebarSlotManager import com.itsaky.androidide.plugins.manager.core.PluginManager +import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent +import org.greenrobot.eventbus.EventBus import java.lang.ref.WeakReference /** @@ -65,6 +68,10 @@ object ContactDetails { } internal object EditorSidebarActions { + + private var previousDestinationListener: NavController.OnDestinationChangedListener? = null + private var previousNavController: WeakReference? = null + @JvmStatic fun registerActions(context: Context) { val registry = ActionsRegistry.getInstance() @@ -174,27 +181,33 @@ internal object EditorSidebarActions { } } + previousDestinationListener?.let { stale -> + previousNavController?.get()?.removeOnDestinationChangedListener(stale) + } + val railRef = WeakReference(rail) - controller.addOnDestinationChangedListener( - object : NavController.OnDestinationChangedListener { - override fun onDestinationChanged( - controller: NavController, - destination: NavDestination, - arguments: Bundle? - ) { - val railView = railRef.get() - if (railView == null) { - controller.removeOnDestinationChangedListener(this) - return - } - railView.menu.forEach { item -> - if (destination.matchDestination(item.itemId)) { - item.isChecked = true - titleRef.get()?.text = item.title - } + val destinationListener = object : NavController.OnDestinationChangedListener { + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + val railView = railRef.get() + if (railView == null) { + controller.removeOnDestinationChangedListener(this) + return + } + railView.menu.forEach { item -> + if (destination.matchDestination(item.itemId)) { + item.isChecked = true + titleRef.get()?.text = item.title } } - }) + } + } + controller.addOnDestinationChangedListener(destinationListener) + previousDestinationListener = destinationListener + previousNavController = WeakReference(controller) rail.menu.findItem(FileTreeSidebarAction.ID.hashCode())?.also { it.isChecked = true @@ -241,21 +254,37 @@ internal object EditorSidebarActions { .filterIsInstance() .forEach { plugin -> val pluginId = pluginManager.getPluginIdForInstance(plugin as com.itsaky.androidide.plugins.IPlugin) - val declaredSlots = SidebarSlotManager.getDeclaredSlots(pluginId ?: "") - val sideMenuItems = plugin.getSideMenuItems() + ?: return@forEach - if (sideMenuItems.isEmpty()) return@forEach + try { + val declaredSlots = SidebarSlotManager.getDeclaredSlots(pluginId) + val sideMenuItems = plugin.getSideMenuItems() - if (sideMenuItems.size > declaredSlots) { - throw IllegalStateException( - "Plugin '$pluginId' returned ${sideMenuItems.size} sidebar items " + - "but only declared $declaredSlots in manifest" - ) - } + if (sideMenuItems.isEmpty()) return@forEach + + if (sideMenuItems.size > declaredSlots) { + Log.w("EditorSidebarActions", + "Plugin '$pluginId' returned ${sideMenuItems.size} sidebar items " + + "but only declared $declaredSlots in manifest — skipping" + ) + return@forEach + } - sideMenuItems.forEach { navItem -> - val action = PluginSidebarActionItem(context, navItem, order++, pluginId ?: "") - registry.registerAction(action) + sideMenuItems.forEach { navItem -> + val action = PluginSidebarActionItem(context, navItem, order++, pluginId) + registry.registerAction(action) + } + } catch (e: Exception) { + Log.e("EditorSidebarActions", "Plugin '$pluginId' crashed in getSideMenuItems()", e) + val result = pluginManager.recordPluginCrash(pluginId) + val wasDisabled = result is PluginManager.CrashResult.Disabled + val crashCount = when (result) { + is PluginManager.CrashResult.Recorded -> result.crashCount + is PluginManager.CrashResult.Disabled -> pluginManager.crashTracker.getCrashCount(pluginId) + } + EventBus.getDefault().post( + PluginCrashedEvent(pluginId, result.pluginName, crashCount, wasDisabled, Log.getStackTraceString(e)) + ) } } } diff --git a/app/src/main/res/layout/dialog_plugin_crash.xml b/app/src/main/res/layout/dialog_plugin_crash.xml new file mode 100644 index 0000000000..39e6a1098d --- /dev/null +++ b/app/src/main/res/layout/dialog_plugin_crash.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/snackbar_custom.xml b/app/src/main/res/layout/snackbar_custom.xml index e8aa67f8d9..9505e69381 100644 --- a/app/src/main/res/layout/snackbar_custom.xml +++ b/app/src/main/res/layout/snackbar_custom.xml @@ -18,8 +18,7 @@ android:text="@string/msg_action_open_application" android:textColor="@android:color/white" android:textSize="14sp" - android:maxLines="1" - android:singleLine="true" + android:maxLines="3" android:ellipsize="end"/>