Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<TextView>(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<View>(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) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -93,6 +97,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
}

initializePluginSystem()
installPluginCrashLooperGuard()

app.coroutineScope.launch(Dispatchers.IO) {
// color schemes are stored in files
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -209,6 +288,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {

// Set up plugin service providers
setupPluginServices()
setupPluginInflationErrorHandler()

// Load plugins asynchronously
GlobalScope.launch {
Expand All @@ -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) {
Expand Down
Loading
Loading