Skip to content

Commit 110d759

Browse files
Merge branch 'stage' into ADFA-4436-editor-coloring-hook
2 parents 7c1b683 + c1fdf95 commit 110d759

18 files changed

Lines changed: 586 additions & 29 deletions

File tree

‎app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import com.google.android.material.tabs.TabLayout.Tab
8282
import com.itsaky.androidide.FeedbackButtonManager
8383
import com.itsaky.androidide.R
8484
import com.itsaky.androidide.R.string
85+
import com.itsaky.androidide.activities.MainActivity
8586
import com.itsaky.androidide.actions.build.DebugAction
8687
import com.itsaky.androidide.adapters.DiagnosticsAdapter
8788
import com.itsaky.androidide.adapters.SearchListAdapter
@@ -111,6 +112,7 @@ import com.itsaky.androidide.models.Range
111112
import com.itsaky.androidide.models.SearchResult
112113
import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager
113114
import com.itsaky.androidide.preferences.internal.BuildPreferences
115+
import com.itsaky.androidide.preferences.internal.GeneralPreferences
114116
import com.itsaky.androidide.projects.IProjectManager
115117
import com.itsaky.androidide.projects.ProjectManagerImpl
116118
import com.itsaky.androidide.services.debug.DebuggerService
@@ -631,11 +633,35 @@ abstract class BaseEditorActivity :
631633
builder.show()
632634
}
633635

636+
/**
637+
* Restores the project path on recreation (saved state, launch intent, or last opened
638+
* project) and routes back to MainActivity if none is available, rather than crashing while
639+
* building the editor UI.
640+
*/
634641
override fun onCreate(savedInstanceState: Bundle?) {
635-
savedInstanceState?.getString(KEY_PROJECT_PATH)
636-
?.let(ProjectManagerImpl.getInstance()::projectPath::set)
642+
// The OS can recreate EditorActivity after process death without routing through
643+
// MainActivity, leaving the ProjectManagerImpl singleton's lateinit projectPath unset.
644+
// Restore it from the saved state, the launch intent, or the last opened project.
645+
val restoredProjectPath =
646+
savedInstanceState?.getString(KEY_PROJECT_PATH)?.takeIf { it.isNotBlank() }
647+
?: intent?.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() }
648+
?: GeneralPreferences.lastOpenedProject
649+
.takeIf { it.isNotBlank() && it != GeneralPreferences.NO_OPENED_PROJECT }
650+
if (restoredProjectPath != null) {
651+
ProjectManagerImpl.getInstance().projectPath = restoredProjectPath
652+
}
637653
super.onCreate(savedInstanceState)
638654

655+
// If we still have no project path after every fallback, we cannot safely build the
656+
// editor UI (setupToolbar -> getProjectName dereferences the project path). Route the
657+
// user back to MainActivity instead of crashing.
658+
if (ProjectManagerImpl.getInstance().projectDirPath.isBlank()) {
659+
log.warn("No project path available in EditorActivity.onCreate(); returning to MainActivity")
660+
startActivity(Intent(this, MainActivity::class.java))
661+
finish()
662+
return
663+
}
664+
639665
editorViewModel.isBuildInProgress = false
640666
editorViewModel.isInitializing = false
641667

‎app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -420,23 +420,35 @@ open class EditorHandlerActivity :
420420
syncPluginUiFontSize()
421421
}
422422

423+
/**
424+
* Restores the plugin tabs cached from the previous session, running the
425+
* SharedPreferences IO and Gson decode off the main thread to avoid a startup UI stall.
426+
*/
423427
private fun restoreOpenedPluginTabs() {
424-
try {
425-
val prefs = (application as BaseApplication).prefManager
426-
val json = prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null) ?: return
428+
lifecycleScope.launch {
429+
try {
430+
val prefs = (application as BaseApplication).prefManager
431+
val json = withContext(Dispatchers.IO) {
432+
prefs.getString(PREF_KEY_OPEN_PLUGIN_TABS, null)
433+
} ?: return@launch
427434

428-
val tabIds = Gson().fromJson(json, Array<String>::class.java)?.toList() ?: return
429-
Log.d("EditorHandlerActivity", "Restoring plugin tabs: $tabIds")
435+
// Decoding the cached JSON off the main thread avoids a UI stall on startup.
436+
val tabIds = withContext(Dispatchers.Default) {
437+
Gson().fromJson(json, Array<String>::class.java)?.toList()
438+
} ?: return@launch
439+
Log.d("EditorHandlerActivity", "Restoring plugin tabs: $tabIds")
430440

431-
tabIds.forEach { tabId ->
432-
if (!pluginTabIndices.containsKey(tabId)) {
433-
selectPluginTabById(tabId)
441+
// Tab selection touches UI state, so keep it on the main thread.
442+
tabIds.forEach { tabId ->
443+
if (!pluginTabIndices.containsKey(tabId)) {
444+
selectPluginTabById(tabId)
445+
}
434446
}
435-
}
436447

437-
prefs.putString(PREF_KEY_OPEN_PLUGIN_TABS, null)
438-
} catch (e: Exception) {
439-
Log.e("EditorHandlerActivity", "Failed to restore plugin tabs", e)
448+
withContext(Dispatchers.IO) { prefs.putString(PREF_KEY_OPEN_PLUGIN_TABS, null) }
449+
} catch (e: Exception) {
450+
Log.e("EditorHandlerActivity", "Failed to restore plugin tabs", e)
451+
}
440452
}
441453
}
442454

‎app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.core.net.toUri
3232
import androidx.fragment.app.viewModels
3333
import androidx.lifecycle.Lifecycle
3434
import androidx.lifecycle.repeatOnLifecycle
35+
import androidx.lifecycle.withResumed
3536
import androidx.recyclerview.widget.RecyclerView
3637
import com.github.appintro.SlidePolicy
3738
import com.github.appintro.SlideSelectionListener
@@ -45,6 +46,7 @@ import com.itsaky.androidide.databinding.LayoutOnboardingPermissionsBinding
4546
import com.itsaky.androidide.events.InstallationEvent
4647
import com.itsaky.androidide.preferences.internal.prefManager
4748
import com.itsaky.androidide.tasks.doAsyncWithProgress
49+
import com.itsaky.androidide.utils.OverlayPermissionGuide
4850
import com.itsaky.androidide.utils.PermissionsHelper
4951
import com.itsaky.androidide.utils.flashError
5052
import com.itsaky.androidide.utils.isTestMode
@@ -73,7 +75,6 @@ class PermissionsFragment :
7375
private var recyclerView: RecyclerView? = null
7476
private var finishButton: MaterialButton? = null
7577
private lateinit var pulseAnimation: Animation
76-
private var awaitingOverlayGrantResult = false
7778

7879
private val storagePermissionRequestLauncher =
7980
registerForActivityResult(
@@ -97,6 +98,8 @@ class PermissionsFragment :
9798
private val logger = LoggerFactory.getLogger(PermissionsFragment::class.java)
9899
private const val KEY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown"
99100

101+
private var awaitingOverlayGrantResult = false
102+
100103
@JvmStatic
101104
fun newInstance(context: Context): PermissionsFragment =
102105
PermissionsFragment().apply {
@@ -231,12 +234,13 @@ class PermissionsFragment :
231234
}
232235
awaitingOverlayGrantResult = false
233236

234-
if (PermissionsHelper.canDrawOverlays(requireContext())) {
235-
return
237+
viewLifecycleScope.launch {
238+
viewLifecycleOwner.withResumed {
239+
if (!PermissionsHelper.canDrawOverlays(requireContext())) {
240+
OverlayPermissionGuide.showRestrictedSettingsDialog(requireContext())
241+
}
242+
}
236243
}
237-
238-
flashError(getString(R.string.permission_overlay_restricted_settings_hint))
239-
requestSettingsTogglePermission(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
240244
}
241245

242246
private fun startIdeSetup() {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.itsaky.androidide.utils
2+
3+
import android.app.Activity
4+
import android.content.ActivityNotFoundException
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.net.Uri
8+
import android.provider.Settings
9+
import android.text.SpannableStringBuilder
10+
import com.itsaky.androidide.resources.R
11+
import org.slf4j.LoggerFactory
12+
13+
object OverlayPermissionGuide {
14+
15+
private val log = LoggerFactory.getLogger(OverlayPermissionGuide::class.java)
16+
17+
fun showRestrictedSettingsDialog(context: Context) {
18+
val message = SpannableStringBuilder()
19+
message.append(context.getString(R.string.permission_overlay_restricted_dialog_message))
20+
message.append("\n\n")
21+
message.appendOrderedList(*context.resources.getStringArray(R.array.overlay_restricted_settings_steps))
22+
23+
DialogUtils
24+
.newMaterialDialogBuilder(context)
25+
.setTitle(R.string.permission_overlay_restricted_dialog_title)
26+
.setMessage(message)
27+
.setPositiveButton(R.string.permission_overlay_open_app_info) { dialog, _ ->
28+
dialog.dismiss()
29+
openAppInfo(context)
30+
}
31+
.setNegativeButton(android.R.string.cancel, null)
32+
.show()
33+
}
34+
35+
private fun openAppInfo(context: Context) {
36+
val intent = Intent(
37+
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
38+
Uri.fromParts("package", context.packageName, null),
39+
)
40+
if (context !is Activity) {
41+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
42+
}
43+
try {
44+
context.startActivity(intent)
45+
} catch (e: ActivityNotFoundException) {
46+
log.error("Failed to open App info settings", e)
47+
flashError(
48+
context.getString(
49+
R.string.err_no_activity_to_handle_action,
50+
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
51+
),
52+
)
53+
}
54+
}
55+
}

‎app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,16 @@ class EditorViewModel : ViewModel() {
344344
return file
345345
}
346346

347+
/**
348+
* Returns the open project's directory name, or an empty string when no project path is
349+
* available (the process-death recreation state where the project path is uninitialized).
350+
*/
347351
fun getProjectName(): String {
348352
val manager = ProjectManagerImpl.getInstance()
353+
val path = manager.projectDirPath
354+
if (path.isBlank()) {
355+
return ""
356+
}
349357
return manager.projectDir.name
350358
}
351359
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* This file is part of AndroidIDE.
3+
*
4+
* AndroidIDE is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* AndroidIDE is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.itsaky.androidide.activities.editor
19+
20+
import com.google.common.truth.Truth.assertThat
21+
import com.itsaky.androidide.app.BaseApplication
22+
import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent
23+
import java.util.concurrent.CountDownLatch
24+
import java.util.concurrent.TimeUnit
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.test.StandardTestDispatcher
28+
import kotlinx.coroutines.test.resetMain
29+
import kotlinx.coroutines.test.setMain
30+
import org.greenrobot.eventbus.EventBus
31+
import org.greenrobot.eventbus.Subscribe
32+
import org.greenrobot.eventbus.ThreadMode
33+
import org.junit.After
34+
import org.junit.Before
35+
import org.junit.Test
36+
import org.junit.runner.RunWith
37+
import org.robolectric.Robolectric
38+
import org.robolectric.RobolectricTestRunner
39+
import org.robolectric.annotation.Config
40+
41+
/**
42+
* ADFA-4331 repro: [EditorHandlerActivity.restoreOpenedPluginTabs] must do its
43+
* SharedPreferences IO + Gson decode OFF the main thread (it stalls startup otherwise).
44+
*
45+
* Detector (deterministic, no timing): we run the real, private production method with
46+
* the Main dispatcher pinned to a single-threaded [StandardTestDispatcher] (so "main" ==
47+
* the test thread). The method, when it finishes restoring, clears the cached-tabs
48+
* preference via `prefManager.putString(KEY, null)`, which posts a [PreferenceChangeEvent]
49+
* on EventBus on the *thread that executed the write*. We capture that thread.
50+
*
51+
* - BUGGED (stage): the whole body — pref read, Gson decode, final pref write — runs
52+
* directly inside `lifecycleScope.launch { }` on the Main dispatcher, i.e. the test
53+
* thread. The captured write thread == the test/main thread -> test FAILS.
54+
* - FIXED (branch): read/decode are wrapped in `withContext(Dispatchers.IO/Default)` and
55+
* the final write in `withContext(Dispatchers.IO)`, so the write runs on a real
56+
* background thread != the test/main thread -> test PASSES.
57+
*
58+
* UI tab-selection is skipped: we pre-seed the private `pluginTabIndices` map with the
59+
* decoded id so `restoreOpenedPluginTabs` short-circuits the `selectPluginTabById(...)`
60+
* UI call (`if (!pluginTabIndices.containsKey(tabId))`).
61+
*/
62+
@OptIn(ExperimentalCoroutinesApi::class)
63+
@RunWith(RobolectricTestRunner::class)
64+
@Config(application = RestorePluginTabsThreadTest.TestApp::class)
65+
class RestorePluginTabsThreadTest {
66+
67+
open class TestApp : BaseApplication()
68+
69+
/** Captures the thread on which the cached-tabs preference write executed. */
70+
class WriteThreadCapture {
71+
@Volatile var writeThreadName: String? = null
72+
val latch = CountDownLatch(1)
73+
74+
@Subscribe(threadMode = ThreadMode.POSTING)
75+
fun onPrefChange(event: PreferenceChangeEvent) {
76+
if (event.key == EditorHandlerActivity.PREF_KEY_OPEN_PLUGIN_TABS && event.value == null) {
77+
writeThreadName = Thread.currentThread().name
78+
latch.countDown()
79+
}
80+
}
81+
}
82+
83+
private val testDispatcher = StandardTestDispatcher()
84+
85+
@Before
86+
fun setUp() {
87+
Dispatchers.setMain(testDispatcher)
88+
}
89+
90+
@After
91+
fun tearDown() {
92+
Dispatchers.resetMain()
93+
}
94+
95+
/** Asserts the cached-tabs clearing write runs on a background thread, not the main thread. */
96+
@Test
97+
fun `restore decodes and writes off the main thread`() {
98+
val mainThreadName = Thread.currentThread().name
99+
100+
val controller = Robolectric.buildActivity(EditorHandlerActivity::class.java)
101+
val activity = controller.get()
102+
val app = activity.application as BaseApplication
103+
104+
// Seed the cached plugin-tabs JSON that the method will read + decode.
105+
app.prefManager.putString(
106+
EditorHandlerActivity.PREF_KEY_OPEN_PLUGIN_TABS,
107+
"""["tabA"]""",
108+
)
109+
110+
// Pre-seed the private pluginTabIndices map so restoreOpenedPluginTabs() skips the
111+
// UI-touching selectPluginTabById("tabA") call.
112+
val mapField = EditorHandlerActivity::class.java.getDeclaredField("pluginTabIndices")
113+
mapField.isAccessible = true
114+
@Suppress("UNCHECKED_CAST")
115+
val pluginTabIndices = mapField.get(activity) as MutableMap<String, Int>
116+
pluginTabIndices["tabA"] = 0
117+
118+
// Subscribe AFTER seeding so we only capture the method's own clearing write.
119+
val capture = WriteThreadCapture()
120+
EventBus.getDefault().register(capture)
121+
try {
122+
// Invoke the real private production method.
123+
val method = EditorHandlerActivity::class.java.getDeclaredMethod("restoreOpenedPluginTabs")
124+
method.isAccessible = true
125+
method.invoke(activity)
126+
127+
// The launched coroutine hops between the (test) Main dispatcher and the real
128+
// Dispatchers.IO/Default background pools. Pump the test scheduler repeatedly while
129+
// giving the background hops time to complete, until the clearing write fires.
130+
val deadline = System.currentTimeMillis() + 10_000
131+
while (capture.latch.count > 0 && System.currentTimeMillis() < deadline) {
132+
testDispatcher.scheduler.advanceUntilIdle()
133+
capture.latch.await(50, TimeUnit.MILLISECONDS)
134+
}
135+
136+
val writeThread = capture.writeThreadName
137+
assertThat(writeThread).isNotNull()
138+
// The fix requires the IO/decode work to run OFF the main thread.
139+
assertThat(writeThread).isNotEqualTo(mainThreadName)
140+
} finally {
141+
EventBus.getDefault().unregister(capture)
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)