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 92ae21f419..c3f187e2dd 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
@@ -1307,7 +1307,7 @@ open class EditorHandlerActivity :
// If there are NO unsaved files, just perform the close action directly.
// The 'manualFinish' is false because this action doesn't exit the activity by itself.
- performCloseAllFiles(manualFinish = false)
+ performCloseAllFiles()
runAfter()
}
@@ -1907,7 +1907,7 @@ open class EditorHandlerActivity :
confirmProjectClose()
}
- private fun performCloseAllFiles(manualFinish: Boolean) {
+ private fun performCloseAllFiles() {
val pluginManager = IDEApplication.getPluginManager()
val fileCount = editorViewModel.getOpenedFileCount()
for (i in 0 until fileCount) {
@@ -1929,8 +1929,12 @@ open class EditorHandlerActivity :
tabs.removeAllTabs()
editorContainer.removeAllViews()
}
+ }
- if (manualFinish) {
+ private fun closeProject(saveFloatingFiles: Boolean) {
+ performCloseAllFiles()
+ lifecycleScope.launch {
+ floatingTabController.closeAll(save = saveFloatingFiles)
finish()
}
}
@@ -1951,7 +1955,7 @@ open class EditorHandlerActivity :
(content.editorContainer.getChildAt(i) as? CodeEditorView)?.editor?.markUnmodified()
}
- performCloseAllFiles(manualFinish = true)
+ closeProject(saveFloatingFiles = false)
}
// OPTION 2: Save and close
@@ -1961,7 +1965,7 @@ open class EditorHandlerActivity :
saveAllAsync(notify = false) {
runOnUiThread {
if (contentOrNull == null) return@runOnUiThread
- performCloseAllFiles(manualFinish = true)
+ closeProject(saveFloatingFiles = true)
}
recentProjectsViewModel.updateProjectModifiedDate(
editorViewModel.getProjectName(),
diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt
index fa1765a30c..cee0b2ad74 100644
--- a/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt
+++ b/app/src/main/java/com/itsaky/androidide/editor/floating/EditorPanelDockableContent.kt
@@ -19,11 +19,11 @@ import com.itsaky.androidide.models.Position
import com.itsaky.androidide.models.Range
import com.itsaky.androidide.projects.builder.BuildService
import com.itsaky.androidide.ui.CodeEditorView
-import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
+import java.io.File
import com.itsaky.androidide.resources.R as ResR
/**
@@ -118,6 +118,9 @@ class EditorPanelDockableContent(
}
}
+ val isModified: Boolean
+ get() = editorView?.isModified == true
+
suspend fun save(): Boolean = editorView?.save() ?: false
fun release() {
diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt
index 917b2139fb..69a6b95210 100644
--- a/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt
+++ b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt
@@ -12,7 +12,9 @@ import com.itsaky.androidide.floating.permission.OverlayPermission
import com.itsaky.androidide.floating.service.FloatingTabService
import com.itsaky.androidide.floating.window.InitialBounds
import com.itsaky.androidide.resources.R
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
+import org.slf4j.LoggerFactory
/**
* Bridges the editor activity to the floating-window system: turns an editor file tab into a
@@ -77,9 +79,44 @@ class IdeFloatingTabController(
FloatingTabService.ensureRunning(activity.applicationContext)
}
+ /**
+ * Tears down every floating window because the project is closing: a docked plugin tab or file
+ * panel is closed with the project, and an undocked one is the same tab in another window.
+ *
+ * File panels are saved (when [save] is set, i.e. the user chose "save and close") and released
+ * inline, not through [onEvent]: that coroutine dies with the finishing activity, and it saves
+ * unconditionally, which would defeat "close without saving". Hence [DockingManager.remove]
+ * rather than [DockingManager.close] - the teardown is done, no listener should redo it.
+ */
+ suspend fun closeAll(save: Boolean) {
+ for (tab in DockingManager.windows.value) {
+ val panel = tab.content as? EditorPanelDockableContent
+ if (save && panel != null && panel.isModified && !savePanel(panel)) {
+ Toast
+ .makeText(
+ activity,
+ activity.getString(R.string.msg_floating_close_save_failed, panel.title),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ DockingManager.remove(tab.id)
+ panel?.release()
+ }
+ }
+
+ private suspend fun savePanel(panel: EditorPanelDockableContent): Boolean =
+ try {
+ panel.save()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ log.error("Failed to save floating panel '{}' while closing the project", panel.title, e)
+ false
+ }
+
private fun onEvent(event: DockingEvent) {
when (val content = event.content) {
- is EditorPanelDockableContent ->
+ is EditorPanelDockableContent -> {
activity.lifecycleScope.launch {
content.save()
content.release()
@@ -88,12 +125,14 @@ class IdeFloatingTabController(
activity.openFile(content.file, null)
}
}
+ }
- is PluginTabDockableContent ->
+ is PluginTabDockableContent -> {
if (event is DockingEvent.Redock) {
bringIdeToFront()
activity.selectPluginTabById(content.tabId)
}
+ }
}
}
@@ -106,4 +145,8 @@ class IdeFloatingTabController(
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
)
}
+
+ private companion object {
+ private val log = LoggerFactory.getLogger(IdeFloatingTabController::class.java)
+ }
}
diff --git a/app/src/test/java/com/itsaky/androidide/editor/floating/FloatingWindowProjectCloseTest.kt b/app/src/test/java/com/itsaky/androidide/editor/floating/FloatingWindowProjectCloseTest.kt
new file mode 100644
index 0000000000..65f16d11ef
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/editor/floating/FloatingWindowProjectCloseTest.kt
@@ -0,0 +1,173 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.editor.floating
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.activities.editor.EditorHandlerActivity
+import com.itsaky.androidide.app.BaseApplication
+import com.itsaky.androidide.floating.model.DockingEvent
+import com.itsaky.androidide.floating.model.DockingManager
+import com.itsaky.androidide.floating.window.WindowBounds
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+/**
+ * ADFA-4501: closing the project must leave no floating window behind. A docked plugin tab or file
+ * panel is closed with the project; an undocked one is the same tab living in another window, and
+ * used to keep running over other apps against a project that no longer exists.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(application = FloatingWindowProjectCloseTest.TestApp::class)
+class FloatingWindowProjectCloseTest {
+ open class TestApp : BaseApplication()
+
+ private lateinit var controller: IdeFloatingTabController
+
+ @Before
+ fun setUp() {
+ val activity = Robolectric.buildActivity(EditorHandlerActivity::class.java).get()
+ controller = IdeFloatingTabController(activity)
+ }
+
+ @After
+ fun tearDown() {
+ DockingManager.windows.value.forEach { DockingManager.close(it.id) }
+ }
+
+ @Test
+ fun `closing the project tears down every floating window`() =
+ runTest {
+ DockingManager.undock(PluginTabDockableContent("keygen.tab", "Keystore Generator"), BOUNDS)
+ DockingManager.undock(EditorPanelDockableContent(File("/tmp/adfa4501/Main.kt")), BOUNDS)
+ assertThat(DockingManager.windows.value).hasSize(2)
+
+ controller.closeAll(save = false)
+
+ assertThat(DockingManager.windows.value).isEmpty()
+ }
+
+ /**
+ * The teardown saves and releases file panels itself, according to the choice the user made in
+ * the close dialog. A [DockingEvent.Close] would hand the same panel to a listener that saves
+ * unconditionally, overriding "close without saving".
+ */
+ @Test
+ fun `teardown emits no docking event`() =
+ runTest {
+ val events = mutableListOf()
+ backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+ DockingManager.events.collect(events::add)
+ }
+ DockingManager.undock(EditorPanelDockableContent(File("/tmp/adfa4501/Notes.md")), BOUNDS)
+
+ controller.closeAll(save = false)
+
+ assertThat(events).isEmpty()
+ assertThat(DockingManager.windows.value).isEmpty()
+ }
+
+ @Test
+ fun `tearing down with no floating windows is a no-op`() =
+ runTest {
+ controller.closeAll(save = true)
+
+ assertThat(DockingManager.windows.value).isEmpty()
+ }
+
+ @Test
+ fun `save and close writes a modified panel out before releasing it`() =
+ runTest {
+ val panel = floatingPanel("/tmp/adfa4501/Main.kt", modified = true)
+ coEvery { panel.save() } returns true
+ DockingManager.undock(panel, BOUNDS)
+
+ controller.closeAll(save = true)
+
+ coVerifyOrder {
+ panel.save()
+ panel.release()
+ }
+ assertThat(DockingManager.windows.value).isEmpty()
+ }
+
+ /** "Close without saving" must reach the panel too, not just the docked editors. */
+ @Test
+ fun `close without saving discards a modified panel`() =
+ runTest {
+ val panel = floatingPanel("/tmp/adfa4501/Draft.kt", modified = true)
+ DockingManager.undock(panel, BOUNDS)
+
+ controller.closeAll(save = false)
+
+ coVerify(exactly = 0) { panel.save() }
+ verify { panel.release() }
+ assertThat(DockingManager.windows.value).isEmpty()
+ }
+
+ /**
+ * A file that went away under its panel (deleted directory, revoked permission) makes
+ * `writeTo` throw. The project is closing: that must not crash the teardown, nor strand the
+ * windows queued behind the bad one - which is the very state this ticket exists to prevent.
+ */
+ @Test
+ fun `a panel whose save throws does not strand the windows behind it`() =
+ runTest {
+ val failing = floatingPanel("/tmp/adfa4501/Gone.kt", modified = true)
+ coEvery { failing.save() } throws RuntimeException("parent directory deleted")
+ val queued = floatingPanel("/tmp/adfa4501/Queued.kt", modified = false)
+ DockingManager.undock(failing, BOUNDS)
+ DockingManager.undock(queued, BOUNDS)
+
+ controller.closeAll(save = true)
+
+ assertThat(DockingManager.windows.value).isEmpty()
+ verify { failing.release() }
+ verify { queued.release() }
+ }
+
+ private fun floatingPanel(
+ path: String,
+ modified: Boolean,
+ ): EditorPanelDockableContent =
+ mockk(relaxed = true).also { panel ->
+ every { panel.id } returns path
+ every { panel.title } returns File(path).name
+ every { panel.isModified } returns modified
+ }
+
+ private companion object {
+ private val BOUNDS = WindowBounds(x = 0, y = 0, width = 600, height = 400)
+ }
+}
diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt
index 0099de19f2..c4699e05f6 100644
--- a/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt
+++ b/floating-window/src/main/java/com/itsaky/androidide/floating/model/DockingManager.kt
@@ -46,18 +46,18 @@ object DockingManager {
}
/** Remove the floating window for [id] and signal that the tab should return to the dock. */
- fun dock(id: String): DockableContent? {
- val tab = find(id) ?: return null
- _windows.update { current -> current.filterNot { it.id == id } }
- _events.tryEmit(DockingEvent.Redock(tab.content))
- return tab.content
- }
+ fun dock(id: String): DockableContent? = remove(id)?.also { _events.tryEmit(DockingEvent.Redock(it)) }
/** Remove the floating window for [id] and signal that the tab should be closed entirely. */
- fun close(id: String): DockableContent? {
+ fun close(id: String): DockableContent? = remove(id)?.also { _events.tryEmit(DockingEvent.Close(it)) }
+
+ /**
+ * Remove the floating window for [id] without emitting a [DockingEvent]. For a caller that has
+ * already torn the tab down itself and must not have a listener act on it a second time.
+ */
+ fun remove(id: String): DockableContent? {
val tab = find(id) ?: return null
_windows.update { current -> current.filterNot { it.id == id } }
- _events.tryEmit(DockingEvent.Close(tab.content))
return tab.content
}
@@ -74,15 +74,17 @@ object DockingManager {
) {
mutate(id) { state ->
when (mode) {
- WindowMode.MAXIMIZED, WindowMode.MINIMIZED ->
+ WindowMode.MAXIMIZED, WindowMode.MINIMIZED -> {
if (state.mode == WindowMode.NORMAL) {
state.copy(mode = mode, restoreBounds = state.bounds)
} else {
state.copy(mode = mode)
}
+ }
- WindowMode.NORMAL ->
+ WindowMode.NORMAL -> {
state.copy(mode = mode, bounds = state.restoreBounds)
+ }
}
}
}
diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml
index dbc02a3bb3..581cffcb83 100644
--- a/resources/src/main/res/values/strings.xml
+++ b/resources/src/main/res/values/strings.xml
@@ -808,6 +808,7 @@
Could not save the font file. Please try again.
A file save operation is already in progress!
Couldn\'t save ā%1$sā. Undock cancelled.
+ Couldn\'t save ā%1$sā. Its unsaved changes were discarded with the project.
Install Development Tools
\nTo install the tools needed to build Android projects, tap the button at the bottom right of the screen\n