From bb820af952b95153b20e7d5aad649f160e0a36e7 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Mon, 15 Jun 2026 21:40:49 -0400 Subject: [PATCH 1/3] ADFA-4326: restore EditorActivity project path after process death Sentry APPDEVFORALL-QZ. Restore ProjectManagerImpl.projectPath from savedInstanceState -> intent PROJECT_PATH extra -> lastOpenedProject; guard projectDirPath getter; route back to MainActivity if still unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../activities/editor/BaseEditorActivity.kt | 25 +++++++++++++++++-- .../androidide/viewmodel/EditorViewModel.kt | 4 +++ .../androidide/projects/ProjectManagerImpl.kt | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 5205da894e..a3494637fe 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -82,6 +82,7 @@ import com.google.android.material.tabs.TabLayout.Tab import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R import com.itsaky.androidide.R.string +import com.itsaky.androidide.activities.MainActivity import com.itsaky.androidide.actions.build.DebugAction import com.itsaky.androidide.adapters.DiagnosticsAdapter import com.itsaky.androidide.adapters.SearchListAdapter @@ -111,6 +112,7 @@ import com.itsaky.androidide.models.Range import com.itsaky.androidide.models.SearchResult import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager import com.itsaky.androidide.preferences.internal.BuildPreferences +import com.itsaky.androidide.preferences.internal.GeneralPreferences import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.services.debug.DebuggerService @@ -632,10 +634,29 @@ abstract class BaseEditorActivity : } override fun onCreate(savedInstanceState: Bundle?) { - savedInstanceState?.getString(KEY_PROJECT_PATH) - ?.let(ProjectManagerImpl.getInstance()::projectPath::set) + // The OS can recreate EditorActivity after process death without routing through + // MainActivity, leaving the ProjectManagerImpl singleton's lateinit projectPath unset. + // Restore it from the saved state, the launch intent, or the last opened project. + val restoredProjectPath = + savedInstanceState?.getString(KEY_PROJECT_PATH)?.takeIf { it.isNotBlank() } + ?: intent?.getStringExtra("PROJECT_PATH")?.takeIf { it.isNotBlank() } + ?: GeneralPreferences.lastOpenedProject + .takeIf { it.isNotBlank() && it != GeneralPreferences.NO_OPENED_PROJECT } + if (restoredProjectPath != null) { + ProjectManagerImpl.getInstance().projectPath = restoredProjectPath + } super.onCreate(savedInstanceState) + // If we still have no project path after every fallback, we cannot safely build the + // editor UI (setupToolbar -> getProjectName dereferences the project path). Route the + // user back to MainActivity instead of crashing. + if (ProjectManagerImpl.getInstance().projectDirPath.isBlank()) { + log.warn("No project path available in EditorActivity.onCreate(); returning to MainActivity") + startActivity(Intent(this, MainActivity::class.java)) + finish() + return + } + editorViewModel.isBuildInProgress = false editorViewModel.isInitializing = false diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt index 8e5a2c2a4a..9cc20f4ad9 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt @@ -346,6 +346,10 @@ class EditorViewModel : ViewModel() { fun getProjectName(): String { val manager = ProjectManagerImpl.getInstance() + val path = manager.projectDirPath + if (path.isBlank()) { + return "" + } return manager.projectDir.name } } diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt index 69be62d785..36a945a87d 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt @@ -99,7 +99,7 @@ class ProjectManagerImpl : private set override val projectDirPath: String - get() = projectPath + get() = if (this::projectPath.isInitialized) projectPath else "" override val projectSyncIssues: List get() = gradleBuild?.syncIssueList ?: emptyList() From 0b79226558fb64585dd517049cf07bef0c53e91b Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 17 Jun 2026 05:51:33 -0700 Subject: [PATCH 2/3] ADFA-4326: add repro test for uninitialized projectPath on recreation Adds ProjectManagerImplUninitializedPathTest covering the root cause of the EditorActivity crash after process-death recreation: reading ProjectManagerImpl.projectDirPath while the lateinit projectPath is unset. Pre-fix the getter delegated directly to the lateinit (get() = projectPath), throwing UninitializedPropertyAccessException; the fix guards with isInitialized and returns "" so the caller can route back to MainActivity. The test fails (UninitializedPropertyAccessException) on the pre-fix baseline and passes on the fix branch. A third case asserts the getter still reflects an assigned path, guarding against an always-blank regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ProjectManagerImplUninitializedPathTest.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 subprojects/projects/src/test/java/com/itsaky/androidide/projects/ProjectManagerImplUninitializedPathTest.kt diff --git a/subprojects/projects/src/test/java/com/itsaky/androidide/projects/ProjectManagerImplUninitializedPathTest.kt b/subprojects/projects/src/test/java/com/itsaky/androidide/projects/ProjectManagerImplUninitializedPathTest.kt new file mode 100644 index 0000000000..fef173bc04 --- /dev/null +++ b/subprojects/projects/src/test/java/com/itsaky/androidide/projects/ProjectManagerImplUninitializedPathTest.kt @@ -0,0 +1,88 @@ +/* + * 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.projects + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Reproduction test for ADFA-4326. + * + * The OS can recreate EditorActivity after process death without ever routing through + * MainActivity, leaving the [ProjectManagerImpl] singleton's `lateinit projectPath` unset. + * EditorActivity (via EditorViewModel.getProjectName -> projectDir -> projectDirPath) reads + * [ProjectManagerImpl.projectDirPath] during onCreate. Before the fix, the getter delegated + * directly to the uninitialized `lateinit` (`get() = projectPath`), which throws + * [UninitializedPropertyAccessException] and crashes the editor on recreation. + * + * The fix guards the getter with `isInitialized`, returning a blank path so the caller can + * detect the missing project and route back to MainActivity instead of crashing. + * + * Mutation check: if the guard is removed (raw `get() = projectPath`), [readingPathWhenUninitialized_returnsBlankAndDoesNotThrow] + * throws instead of returning "" -> RED. With the fix it returns "" -> GREEN. + * + * @author ADFA verification + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ProjectManagerImplUninitializedPathTest { + + /** + * Directly exercises the root-cause line of the bug on a fresh instance whose `projectPath` + * lateinit has never been assigned (the process-death recreation state). + */ + @Test + fun readingPathWhenUninitialized_returnsBlankAndDoesNotThrow() { + val manager = ProjectManagerImpl() + + // Pre-fix this access throws UninitializedPropertyAccessException; post-fix it returns "". + val path = manager.projectDirPath + + assertThat(path).isEmpty() + } + + /** + * projectDir is derived from projectDirPath (File(projectDirPath)). With the uninitialized + * path it must not propagate the lateinit access exception either. + */ + @Test + fun readingProjectDirWhenUninitialized_doesNotThrow() { + val manager = ProjectManagerImpl() + + val dir = manager.projectDir + + // File("") -> empty path; the contract here is simply "no UninitializedPropertyAccessException". + assertThat(dir.path).isEmpty() + } + + /** + * Once a path is assigned (the normal MainActivity-routed launch), the getter still reflects it. + * Guards against a fix that always returns "" regardless of state. + */ + @Test + fun readingPathAfterInitialization_reflectsAssignedValue() { + val manager = ProjectManagerImpl() + manager.projectPath = "/storage/emulated/0/CodeOnTheGoProjects/Sample" + + assertThat(manager.projectDirPath).isEqualTo("/storage/emulated/0/CodeOnTheGoProjects/Sample") + assertThat(manager.projectDir.name).isEqualTo("Sample") + } +} From ea4b254f1cb2b997cdd6fb10b71ace102c9b0785 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 19 Jun 2026 04:48:44 -0700 Subject: [PATCH 3/3] ADFA-4326: add KDoc for docstring coverage (CodeRabbit) Co-Authored-By: Claude Opus 4.8 --- .../androidide/activities/editor/BaseEditorActivity.kt | 5 +++++ .../java/com/itsaky/androidide/viewmodel/EditorViewModel.kt | 4 ++++ .../com/itsaky/androidide/projects/ProjectManagerImpl.kt | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index a3494637fe..dcfe1e67bc 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -633,6 +633,11 @@ abstract class BaseEditorActivity : builder.show() } + /** + * Restores the project path on recreation (saved state, launch intent, or last opened + * project) and routes back to MainActivity if none is available, rather than crashing while + * building the editor UI. + */ override fun onCreate(savedInstanceState: Bundle?) { // The OS can recreate EditorActivity after process death without routing through // MainActivity, leaving the ProjectManagerImpl singleton's lateinit projectPath unset. diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt index 9cc20f4ad9..62cb4f5e25 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EditorViewModel.kt @@ -344,6 +344,10 @@ class EditorViewModel : ViewModel() { return file } + /** + * Returns the open project's directory name, or an empty string when no project path is + * available (the process-death recreation state where the project path is uninitialized). + */ fun getProjectName(): String { val manager = ProjectManagerImpl.getInstance() val path = manager.projectDirPath diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt index 36a945a87d..2a142d7bbc 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt @@ -98,6 +98,11 @@ class ProjectManagerImpl : override var androidBuildVariants: Map = emptyMap() private set + /** + * The project directory path, or an empty string when [projectPath] has not yet been + * initialized (e.g. the OS recreated EditorActivity after process death without routing + * through MainActivity). Guarding the lateinit access avoids crashing the caller. + */ override val projectDirPath: String get() = if (this::projectPath.isInitialized) projectPath else ""