Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package com.itsaky.androidide.activities.editor

import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
Expand Down Expand Up @@ -77,6 +78,8 @@ import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent
import com.itsaky.androidide.eventbus.events.file.FileRenameEvent
import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent
import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent
import com.itsaky.androidide.floating.model.DockingManager
import com.itsaky.androidide.floating.window.OverlayDialogs
import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment
import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.idetooltips.TooltipTag
Expand Down Expand Up @@ -275,6 +278,8 @@ open class EditorHandlerActivity :
pluginEditorProvider = null
}

private val crashDialogsAboveFloatingWindows = mutableListOf<Dialog>()

private val floatingTabController by lazy {
com.itsaky.androidide.editor.floating
.IdeFloatingTabController(this)
Expand Down Expand Up @@ -311,6 +316,14 @@ open class EditorHandlerActivity :
supportFragmentManager.registerFragmentLifecycleCallbacks(pluginFontScalingListener, true)
floatingTabController.start()

lifecycleScope.launch {
DockingManager.windows.collect { windows ->
if (windows.isEmpty()) {
dismissCrashDialogsAboveFloatingWindows()
}
}
}

editorViewModel._displayedFile.observe(
this,
) { fileIndex ->
Expand Down Expand Up @@ -456,6 +469,7 @@ open class EditorHandlerActivity :

override fun onDestroy() {
super.onDestroy()
dismissCrashDialogsAboveFloatingWindows()
ActionContextProvider.clearActivity(this)
// Not dismissing this would leak the dialog's window (WindowLeaked) past this activity's
// death -- e.g. a rotation while the confirm-close dialog is showing.
Expand Down Expand Up @@ -1833,6 +1847,32 @@ open class EditorHandlerActivity :
}
}

/**
* Shows [dialog] above any floating windows, tracking it when it was actually raised.
*
* Only a raised dialog is tracked: it carries no app token, so nothing else will dismiss it.
* An untouched one is an ordinary activity dialog the platform tears down. Dismissed entries
* are pruned here rather than through [Dialog.setOnDismissListener], which has no additive form
* and would silently drop a listener the caller had set.
*/
private fun showAboveFloatingWindows(dialog: Dialog) {
crashDialogsAboveFloatingWindows.removeAll { !it.isShowing }
if (OverlayDialogs.show(dialog)) {
crashDialogsAboveFloatingWindows.add(dialog)
}
}

private fun dismissCrashDialogsAboveFloatingWindows() {
crashDialogsAboveFloatingWindows
.toList()
.forEach { dialog ->
if (dialog.isShowing) {
dialog.dismiss()
}
}
crashDialogsAboveFloatingWindows.clear()
}

private fun showPluginCrashDialog(event: PluginCrashedEvent) {
val dialogView = layoutInflater.inflate(R.layout.dialog_plugin_crash, null)
dialogView.findViewById<TextView>(R.id.plugin_crash_message).text =
Expand All @@ -1854,28 +1894,30 @@ open class EditorHandlerActivity :
}
}

builder.show()
showAboveFloatingWindows(builder.create())
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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()
showAboveFloatingWindows(
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)
}.create(),
)
}

private fun tearDownDisabledPluginContributions(pluginId: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import com.itsaky.androidide.floating.fragment.OverlayFragmentHost
import com.itsaky.androidide.floating.model.ChromeControl
import com.itsaky.androidide.floating.model.DockableContent
import com.itsaky.androidide.floating.window.FloatingWindowHost
import com.itsaky.androidide.plugins.base.InternalPluginApi
import com.itsaky.androidide.plugins.base.PluginWindows
import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager

Expand All @@ -32,11 +34,15 @@ class PluginTabDockableContent(

private var fragmentHost: OverlayFragmentHost? = null

/** The window context the plugin's dialogs were built against; see [onDestroyView]. */
private var windowContext: Context? = null

override fun onCreateView(
context: Context,
host: FloatingWindowHost,
): View =
try {
windowContext = context
val overlayHost = OverlayFragmentHost(context, host, PluginFragmentFactory(FragmentFactory()))
fragmentHost = overlayHost
overlayHost.start()
Expand All @@ -56,7 +62,17 @@ class PluginTabDockableContent(
errorView(context)
}

/**
* Tears the plugin's fragment down and closes any dialog it left open.
*
* A plugin dialog shown from a floating window is retyped as an overlay and so carries no app
* token: destroying the window does not remove it, and it would go on drawing over whatever the
* user does next. [PluginWindows] tracks the ones it raised so they can be dismissed here.
*/
@OptIn(InternalPluginApi::class)
override fun onDestroyView() {
windowContext?.let { runCatching { PluginWindows.dismissOverlayDialogsFor(it) } }
windowContext = null
runCatching { fragmentHost?.destroy() }
fragmentHost = null
}
Expand Down
17 changes: 17 additions & 0 deletions docs/PLUGIN_API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]
= diffed from `plugin-api/src` history (predates the dump; symbol-accurate).

### 26.36 — unreleased
- **added — Dialogs and toasts from a floating window** _(ADFA-4500)_
Show a dialog or a toast from a plugin tab the user has undocked into a floating
window. An undocked fragment runs against a window context created for
`TYPE_APPLICATION_OVERLAY`, and the platform requires every window added through it
to carry that same type. A `Dialog` builds a `TYPE_APPLICATION` window and a `Toast`
a `TYPE_TOAST` one, so both `AlertDialog.Builder(requireContext()).show()` and
`Toast.makeText(requireContext(), ...)` throw `IllegalArgumentException` once the tab
is floating — the toast after any work preceding it has already run.
`PluginWindows.showDialog(Dialog)` applies the window type the dialog's context
requires and shows it (`prepareDialog` does so without showing; build the dialog with
`create()` rather than showing it from its builder).
`PluginWindows.showToast(Context, CharSequence, Int)` posts the toast against the
application context, which imposes no window type. Both keep the ordinary
activity-backed behaviour while the plugin is docked, so one call site is correct in
either state. Neither can be applied by the IDE
on a plugin's behalf: `Window` has no theme attribute for its type, and a toast is
posted by the system against whatever context built it.
- **added — File-targeted editor save** _(ADFA-5259)_
Save a named file's open buffer and find out whether the bytes actually landed.
`saveCurrentFile` follows whichever tab the user has focused and returns as soon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ package com.itsaky.androidide.floating.fragment
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.core.view.LayoutInflaterCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentController
import androidx.fragment.app.FragmentFactory
Expand All @@ -20,6 +23,7 @@ import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import com.google.android.material.theme.MaterialComponentsViewInflater
import com.itsaky.androidide.floating.window.FloatingWindowHost

/**
Expand All @@ -41,6 +45,8 @@ class OverlayFragmentHost(
) {
private val handler = Handler(Looper.getMainLooper())

private val viewFactory = MaterialViewFactory()

private val container: FrameLayout =
FrameLayout(context).apply { id = View.generateViewId() }

Expand All @@ -57,6 +63,12 @@ class OverlayFragmentHost(

override fun onHasView(): Boolean = true

override fun onGetLayoutInflater(): LayoutInflater {
val themed = LayoutInflater.from(context).cloneInContext(context)
LayoutInflaterCompat.setFactory2(themed, viewFactory)
return themed.cloneInContext(context)
}

override val viewModelStore: ViewModelStore
get() = owner.viewModelStore

Expand Down Expand Up @@ -113,3 +125,32 @@ class OverlayFragmentHost(
started = false
}
}

/**
* Restores the AppCompat/Material widget substitution that a window without an activity loses.
*
* An activity installs AppCompat's view factory on its [LayoutInflater], and that is what turns an
* unqualified `<Button>` in a layout into a `MaterialButton`. [OverlayFragmentHost] drives a
* FragmentManager with no activity, so without this the same layout inflates plain framework
* widgets and a plugin's UI visibly changes the moment it is undocked.
*
* The inflater handed to a Fragment must still accept the child FragmentManager's own factory, and
* [LayoutInflater] refuses a second `setFactory2`. Cloning after installing this one carries the
* factory across with that flag cleared, so the two are merged instead of colliding.
*/
private class MaterialViewFactory : LayoutInflater.Factory2 {
private val inflater = MaterialComponentsViewInflater()

override fun onCreateView(
parent: View?,
name: String,
context: Context,
attrs: AttributeSet,
): View? = inflater.createView(parent, name, context, attrs, false, false, true, false)

override fun onCreateView(
name: String,
context: Context,
attrs: AttributeSet,
): View? = onCreateView(null, name, context, attrs)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.itsaky.androidide.floating.window

import android.app.Dialog
import com.itsaky.androidide.floating.model.DockingManager
import com.itsaky.androidide.floating.permission.OverlayPermission

/**
* Shows a host [Dialog] above any floating overlay windows.
*
* Floating windows are [android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY], which
* the platform always stacks above an activity's own windows. An activity dialog therefore renders
* *behind* them, and because an overlay is a separate window the dialog's modality does not extend
* to it: tapping the overlay never surfaces the dialog. Raising the dialog to the same window type
* puts it back on top, where its modality is visible.
*
* The platform attaches no app token to a system-type window, so a raised dialog outlives the
* activity that created it. Callers must dismiss it themselves when the activity goes away.
*/
object OverlayDialogs {
/**
* Shows [dialog], raising it above the floating windows when any are open. Leaves an ordinary
* activity dialog untouched when nothing is floating.
*
* @return `true` when the dialog was raised, and so carries no app token and outlives the
* activity. Only a raised dialog needs the caller to dismiss it; an untouched one is an
* ordinary activity dialog the platform tears down.
*/
fun show(dialog: Dialog): Boolean {
val raise =
DockingManager.windows.value.isNotEmpty() &&
OverlayPermission.canDrawOverlays(dialog.context)
if (raise) {
dialog.window?.setType(OverlayLayoutParams.overlayType)
}
dialog.show()
return raise
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,31 @@ import android.view.WindowManager
* focusable so the IME can attach.
*/
object OverlayLayoutParams {

private val overlayType: Int =
val overlayType: Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.TYPE_PHONE
}

fun create(state: FloatingWindowState, focusable: Boolean): WindowManager.LayoutParams =
WindowManager.LayoutParams(
state.bounds.width,
state.bounds.height,
overlayType,
flagsFor(focusable),
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.START
x = state.bounds.x
y = state.bounds.y
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
}
fun create(
state: FloatingWindowState,
focusable: Boolean,
): WindowManager.LayoutParams =
WindowManager
.LayoutParams(
state.bounds.width,
state.bounds.height,
overlayType,
flagsFor(focusable),
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.START
x = state.bounds.x
y = state.bounds.y
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
}

fun flagsFor(focusable: Boolean): Int {
val common =
Expand Down
9 changes: 9 additions & 0 deletions plugin-api/api/plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ public final class com/itsaky/androidide/plugins/base/PluginFragmentHelper {
public static final fun setOnPluginInflationError (Lkotlin/jvm/functions/Function2;)V
}

public final class com/itsaky/androidide/plugins/base/PluginWindows {
public static final field INSTANCE Lcom/itsaky/androidide/plugins/base/PluginWindows;
public static final fun prepareDialog (Landroid/app/Dialog;)Z
public static final fun showDialog (Landroid/app/Dialog;)Z
public static final fun showToast (Landroid/content/Context;Ljava/lang/CharSequence;)V
public static final fun showToast (Landroid/content/Context;Ljava/lang/CharSequence;I)V
public static synthetic fun showToast$default (Landroid/content/Context;Ljava/lang/CharSequence;IILjava/lang/Object;)V
}

public final class com/itsaky/androidide/plugins/base/SafePluginLayoutInflater : android/view/LayoutInflater {
public static final field Companion Lcom/itsaky/androidide/plugins/base/SafePluginLayoutInflater$Companion;
public synthetic fun <init> (Landroid/view/LayoutInflater;Landroid/content/Context;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
Loading
Loading