From 6514cf4981d35e84184c46841621582842ae663e Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 15 Jun 2026 21:40:50 -0400 Subject: [PATCH 1/3] ADFA-4331: decode opened plugin tabs off the main thread Sentry APPDEVFORALL-1J. Move Gson decode in restoreOpenedPluginTabs to Dispatchers.Default; keep UI updates on main. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/EditorHandlerActivity.kt | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) 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 da9527798e..a131d08ed7 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 @@ -395,22 +395,30 @@ open class EditorHandlerActivity : } private fun restoreOpenedPluginTabs() { - try { - val prefs = (application as BaseApplication).prefManager - val json = prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null) ?: return + lifecycleScope.launch { + try { + val prefs = (application as BaseApplication).prefManager + val json = withContext(Dispatchers.IO) { + prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null) + } ?: return@launch - val tabIds = Gson().fromJson(json, Array::class.java)?.toList() ?: return - Log.d("EditorHandlerActivity", "Restoring plugin tabs: $tabIds") + // Decoding the cached JSON off the main thread avoids a UI stall on startup. + val tabIds = withContext(Dispatchers.Default) { + Gson().fromJson(json, Array::class.java)?.toList() + } ?: return@launch + Log.d("EditorHandlerActivity", "Restoring plugin tabs: $tabIds") - tabIds.forEach { tabId -> - if (!pluginTabIndices.containsKey(tabId)) { - selectPluginTabById(tabId) + // Tab selection touches UI state, so keep it on the main thread. + tabIds.forEach { tabId -> + if (!pluginTabIndices.containsKey(tabId)) { + selectPluginTabById(tabId) + } } - } - prefs.putString(PREF_KEY_OPEN_PLUGIN_TABS, null) - } catch (e: Exception) { - Log.e("EditorHandlerActivity", "Failed to restore plugin tabs", e) + withContext(Dispatchers.IO) { prefs.putString(PREF_KEY_OPEN_PLUGIN_TABS, null) } + } catch (e: Exception) { + Log.e("EditorHandlerActivity", "Failed to restore plugin tabs", e) + } } } From cf63fcb06d7bf0cb8ceacd569c6fe3b5440378bc Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 17 Jun 2026 06:31:21 -0700 Subject: [PATCH 2/3] =?UTF-8?q?ADFA-4331:=20repro=20test=20=E2=80=94=20res?= =?UTF-8?q?toreOpenedPluginTabs=20decodes/writes=20off=20main=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing-first repro that invokes the real private restoreOpenedPluginTabs() under Robolectric and captures the thread on which the cached-tabs preference write executes (via the EventBus PreferenceChangeEvent posted by PreferenceManager.putString). UI tab-selection is skipped by pre-seeding the private pluginTabIndices map. Pre-fix: read + Gson decode + clearing write run synchronously on the main thread -> write thread == main thread -> test FAILS. Fixed: read/decode/write are wrapped in withContext(Dispatchers.IO/Default) -> write runs on a background worker -> test PASSES. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/RestorePluginTabsThreadTest.kt | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt new file mode 100644 index 0000000000..87086d6bfc --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt @@ -0,0 +1,144 @@ +/* + * 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.activities.editor + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +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 + +/** + * ADFA-4331 repro: [EditorHandlerActivity.restoreOpenedPluginTabs] must do its + * SharedPreferences IO + Gson decode OFF the main thread (it stalls startup otherwise). + * + * Detector (deterministic, no timing): we run the real, private production method with + * the Main dispatcher pinned to a single-threaded [StandardTestDispatcher] (so "main" == + * the test thread). The method, when it finishes restoring, clears the cached-tabs + * preference via `prefManager.putString(KEY, null)`, which posts a [PreferenceChangeEvent] + * on EventBus on the *thread that executed the write*. We capture that thread. + * + * - BUGGED (stage): the whole body — pref read, Gson decode, final pref write — runs + * directly inside `lifecycleScope.launch { }` on the Main dispatcher, i.e. the test + * thread. The captured write thread == the test/main thread -> test FAILS. + * - FIXED (branch): read/decode are wrapped in `withContext(Dispatchers.IO/Default)` and + * the final write in `withContext(Dispatchers.IO)`, so the write runs on a real + * background thread != the test/main thread -> test PASSES. + * + * UI tab-selection is skipped: we pre-seed the private `pluginTabIndices` map with the + * decoded id so `restoreOpenedPluginTabs` short-circuits the `selectPluginTabById(...)` + * UI call (`if (!pluginTabIndices.containsKey(tabId))`). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = RestorePluginTabsThreadTest.TestApp::class) +class RestorePluginTabsThreadTest { + + open class TestApp : BaseApplication() + + /** Captures the thread on which the cached-tabs preference write executed. */ + class WriteThreadCapture { + @Volatile var writeThreadName: String? = null + val latch = CountDownLatch(1) + + @Subscribe(threadMode = ThreadMode.POSTING) + fun onPrefChange(event: PreferenceChangeEvent) { + if (event.key == EditorHandlerActivity.PREF_KEY_OPEN_PLUGIN_TABS && event.value == null) { + writeThreadName = Thread.currentThread().name + latch.countDown() + } + } + } + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `restore decodes and writes off the main thread`() { + val mainThreadName = Thread.currentThread().name + + val controller = Robolectric.buildActivity(EditorHandlerActivity::class.java) + val activity = controller.get() + val app = activity.application as BaseApplication + + // Seed the cached plugin-tabs JSON that the method will read + decode. + app.prefManager.putString( + EditorHandlerActivity.PREF_KEY_OPEN_PLUGIN_TABS, + """["tabA"]""", + ) + + // Pre-seed the private pluginTabIndices map so restoreOpenedPluginTabs() skips the + // UI-touching selectPluginTabById("tabA") call. + val mapField = EditorHandlerActivity::class.java.getDeclaredField("pluginTabIndices") + mapField.isAccessible = true + @Suppress("UNCHECKED_CAST") + val pluginTabIndices = mapField.get(activity) as MutableMap + pluginTabIndices["tabA"] = 0 + + // Subscribe AFTER seeding so we only capture the method's own clearing write. + val capture = WriteThreadCapture() + EventBus.getDefault().register(capture) + try { + // Invoke the real private production method. + val method = EditorHandlerActivity::class.java.getDeclaredMethod("restoreOpenedPluginTabs") + method.isAccessible = true + method.invoke(activity) + + // The launched coroutine hops between the (test) Main dispatcher and the real + // Dispatchers.IO/Default background pools. Pump the test scheduler repeatedly while + // giving the background hops time to complete, until the clearing write fires. + val deadline = System.currentTimeMillis() + 10_000 + while (capture.latch.count > 0 && System.currentTimeMillis() < deadline) { + testDispatcher.scheduler.advanceUntilIdle() + capture.latch.await(50, TimeUnit.MILLISECONDS) + } + + val writeThread = capture.writeThreadName + assertThat(writeThread).isNotNull() + println("PROBE: mainThread='$mainThreadName' writeThread='$writeThread'") + // The fix requires the IO/decode work to run OFF the main thread. + assertThat(writeThread).isNotEqualTo(mainThreadName) + } finally { + EventBus.getDefault().unregister(capture) + } + } +} From 99467d13912ea9cdc94ea88791ab710b1f15d910 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 19 Jun 2026 04:48:49 -0700 Subject: [PATCH 3/3] ADFA-4331: add KDoc for docstring coverage (CodeRabbit) Add KDoc to restoreOpenedPluginTabs() and the repro @Test; remove a leftover println("PROBE: ...") debug line in RestorePluginTabsThreadTest. Co-Authored-By: Claude Opus 4.8 --- .../androidide/activities/editor/EditorHandlerActivity.kt | 4 ++++ .../activities/editor/RestorePluginTabsThreadTest.kt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) 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 a131d08ed7..f5379b494a 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 @@ -394,6 +394,10 @@ open class EditorHandlerActivity : restoreOpenedPluginTabs() } + /** + * Restores the plugin tabs cached from the previous session, running the + * SharedPreferences IO and Gson decode off the main thread to avoid a startup UI stall. + */ private fun restoreOpenedPluginTabs() { lifecycleScope.launch { try { diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt index 87086d6bfc..e008bddaed 100644 --- a/app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/RestorePluginTabsThreadTest.kt @@ -92,6 +92,7 @@ class RestorePluginTabsThreadTest { Dispatchers.resetMain() } + /** Asserts the cached-tabs clearing write runs on a background thread, not the main thread. */ @Test fun `restore decodes and writes off the main thread`() { val mainThreadName = Thread.currentThread().name @@ -134,7 +135,6 @@ class RestorePluginTabsThreadTest { val writeThread = capture.writeThreadName assertThat(writeThread).isNotNull() - println("PROBE: mainThread='$mainThreadName' writeThread='$writeThread'") // The fix requires the IO/decode work to run OFF the main thread. assertThat(writeThread).isNotEqualTo(mainThreadName) } finally {