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 c8aa83ab93..255a18bc08 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,7 @@ package com.itsaky.androidide.activities.editor +import android.app.Dialog import android.content.ClipData import android.content.ClipboardManager import android.content.Intent @@ -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 @@ -275,6 +278,8 @@ open class EditorHandlerActivity : pluginEditorProvider = null } + private val crashDialogsAboveFloatingWindows = mutableListOf() + private val floatingTabController by lazy { com.itsaky.androidide.editor.floating .IdeFloatingTabController(this) @@ -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 -> @@ -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. @@ -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(R.id.plugin_crash_message).text = @@ -1854,7 +1894,7 @@ open class EditorHandlerActivity : } } - builder.show() + showAboveFloatingWindows(builder.create()) dialogView.findViewById(R.id.plugin_crash_view_logs).setOnClickListener { showPluginCrashLogDialog(event) @@ -1862,20 +1902,22 @@ open class EditorHandlerActivity : } 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) { diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt index 33046e68c7..76e92e9e16 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/PluginTabDockableContent.kt @@ -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 @@ -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() @@ -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 } diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 744ea89ed8..c43b60357d 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -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 diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt index ef805c8daf..36f4bf23fc 100644 --- a/floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/fragment/OverlayFragmentHost.kt @@ -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 @@ -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 /** @@ -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() } @@ -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 @@ -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 `