Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -631,11 +633,35 @@ 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?) {
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,16 @@ 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
if (path.isBlank()) {
return ""
}
return manager.projectDir.name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ class ProjectManagerImpl :
override var androidBuildVariants: Map<String, BuildVariantInfo> = 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() = projectPath
get() = if (this::projectPath.isInitialized) projectPath else ""

override val projectSyncIssues: List<GradleModels.SyncIssue>
get() = gradleBuild?.syncIssueList ?: emptyList()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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")
}
}
Loading