From e985d2ac4db743255d5936e96b9425c64e70e215 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 6 Nov 2023 23:20:57 +0530 Subject: [PATCH] fix(tooling-api): handle failures related to project directory --- .../editor/ProjectHandlerActivity.kt | 28 +++++-- resources/src/main/res/values/strings.xml | 3 + .../androidide/projects/ProjectManagerImpl.kt | 4 + .../tooling/impl/ToolingApiServerImpl.kt | 82 ++++++++++++++++--- .../impl/MultiModuleAndroidProjectTest.kt | 32 ++++++++ .../api/messages/result/InitializeResult.kt | 2 +- .../messages/result/TaskExecutionResult.kt | 8 +- 7 files changed, 137 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index cd3361e4c..05db7993e 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -50,6 +50,10 @@ import com.itsaky.androidide.tasks.executeWithProgress import com.itsaky.androidide.tooling.api.messages.AndroidInitializationParams import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams import com.itsaky.androidide.tooling.api.messages.result.InitializeResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_DIRECTORY_INACCESSIBLE +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_FOUND import com.itsaky.androidide.tooling.api.models.BuildVariantInfo import com.itsaky.androidide.tooling.api.models.mapToSelectedVariants import com.itsaky.androidide.utils.DURATION_INDEFINITE @@ -394,7 +398,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { if (result == null || !result.isSuccessful || error != null) { log.error("An error occurred initializing the project with Tooling API", error) ThreadUtils.runOnUiThread { - postProjectInit(false) + postProjectInit(false, result?.failure) } return@whenCompleteAsync } @@ -464,7 +468,9 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { manager.notifyProjectUpdate() updateBuildVariants(manager.androidBuildVariants) - com.itsaky.androidide.tasks.runOnUiThread { postProjectInit(true) } + com.itsaky.androidide.tasks.runOnUiThread { + postProjectInit(true, null) + } } } @@ -473,11 +479,23 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { editorViewModel.isInitializing = true } - protected open fun postProjectInit(isSuccessful: Boolean) { + protected open fun postProjectInit(isSuccessful: Boolean, failure: TaskExecutionResult.Failure?) { val manager = ProjectManagerImpl.getInstance() if (!isSuccessful) { - setStatus(getString(string.msg_project_initialization_failed)) - flashError(string.msg_project_initialization_failed) + val initFailed = getString(string.msg_project_initialization_failed) + setStatus(initFailed) + + val msg = when(failure) { + PROJECT_DIRECTORY_INACCESSIBLE -> string.msg_project_dir_inaccessible + PROJECT_NOT_DIRECTORY -> string.msg_file_is_not_dir + PROJECT_NOT_FOUND -> string.msg_project_dir_doesnt_exist + else -> null + }?.let { + "$initFailed: ${getString(it)}" + } + + flashError(msg ?: initFailed) + editorViewModel.isInitializing = false manager.projectInitialized = false return diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 96e75f770..c6d800f72 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -622,4 +622,7 @@ Language Select a language for AndroidIDE. System Default + Project directory is not accessible + Selected file is not a directory + Project directory does not exist 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 361fe6535..6d0b6fd72 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 @@ -219,6 +219,10 @@ class ProjectManagerImpl : IProjectManager, EventReceiver { return } + if (builder.isBuildInProgress) { + return + } + val tasks = getAndroidModules().flatMap { module -> val variant = module.getSelectedVariant() if (variant == null) { diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt index fe8a77373..fdf4d9668 100644 --- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt +++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/ToolingApiServerImpl.kt @@ -35,6 +35,10 @@ import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Fai import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.BUILD_FAILED import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.CONNECTION_CLOSED import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.CONNECTION_ERROR +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_DIRECTORY_INACCESSIBLE +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_FOUND +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_INITIALIZED import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.UNKNOWN import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.UNSUPPORTED_BUILD_ARGUMENT import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.UNSUPPORTED_CONFIGURATION @@ -57,7 +61,6 @@ import org.gradle.tooling.internal.consumer.DefaultGradleConnector import java.io.File import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException -import java.util.concurrent.atomic.AtomicBoolean import kotlin.system.exitProcess /** @@ -68,7 +71,6 @@ import kotlin.system.exitProcess internal class ToolingApiServerImpl(private val project: ProjectImpl) : IToolingApiServer { - private val _isInitialized = AtomicBoolean(false) private var client: IToolingApiClient? = null private var connector: GradleConnector? = null private var connection: ProjectConnection? = null @@ -79,9 +81,13 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : /** * Whether the project has been initialized or not. */ - var isInitialized: Boolean - get() = _isInitialized.get() - private set(value) = _isInitialized.set(value) + var isInitialized: Boolean = false + private set + + /** + * Whether a build or project synchronization is in progress. + */ + private var isBuildInProgress: Boolean = false /** * Whether the server has a live connection to Gradle. @@ -102,7 +108,7 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : } override fun initialize(params: InitializeProjectParams): CompletableFuture { - return CompletableFuture.supplyAsync { + return runBuild { try { log.debug("Got initialize request", params) @@ -112,6 +118,14 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : cancelCurrentBuild().get() } + val projectDirectory = File(params.directory) + val failureReason = validateProjectDirectory(projectDirectory) + + if (failureReason != null) { + log.error("Cannot initialize project: $failureReason") + return@runBuild InitializeResult(false, failureReason) + } + val stopWatch = StopWatch("Connection to project") val isReinitializing = connector != null && connection != null && params == lastInitParams @@ -123,7 +137,7 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : // or the project is being initialized with different parameters connector?.disconnect() - connector = GradleConnector.newConnector().forProjectDirectory(File(params.directory)) + connector = GradleConnector.newConnector().forProjectDirectory(projectDirectory) setupConnectorForGradleInstallation(this.connector!!, params.gradleDistribution) stopWatch.lap("Connector created") } @@ -163,15 +177,24 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : this.isInitialized = true notifyBuildSuccess(emptyList()) - return@supplyAsync InitializeResult(true) + return@runBuild InitializeResult(true) } catch (err: Throwable) { log.error("Failed to initialize project", err) notifyBuildFailure(emptyList()) - return@supplyAsync InitializeResult(false) + return@runBuild InitializeResult(false, getTaskFailureType(err)) } } } + private fun validateProjectDirectory( + projectDirectory: File + ) = when { + !projectDirectory.exists() -> PROJECT_NOT_FOUND + !projectDirectory.isDirectory -> PROJECT_NOT_DIRECTORY + !projectDirectory.canRead() -> PROJECT_DIRECTORY_INACCESSIBLE + else -> null + } + override fun isServerInitialized(): CompletableFuture { return CompletableFuture.supplyAsync { isInitialized } } @@ -184,8 +207,21 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : } override fun executeTasks(message: TaskExecutionMessage): CompletableFuture { - return CompletableFuture.supplyAsync { - assertProjectInitialized() + return runBuild { + if (!isServerInitialized().get()) { + log.error("Cannot execute tasks: $PROJECT_NOT_INITIALIZED") + return@runBuild TaskExecutionResult(false, PROJECT_NOT_INITIALIZED) + } + + val lastInitParams = this.lastInitParams + if (lastInitParams != null) { + val projectDirectory = File(lastInitParams.directory) + val failureReason = validateProjectDirectory(projectDirectory) + if (failureReason != null) { + log.error("Cannot execute tasks: $failureReason") + return@runBuild TaskExecutionResult(isSuccessful = false, failureReason) + } + } log.debug("Received request to run tasks.", message) @@ -215,10 +251,10 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : builder.run() this.buildCancellationToken = null notifyBuildSuccess(message.tasks) - return@supplyAsync TaskExecutionResult.SUCCESS + return@runBuild TaskExecutionResult.SUCCESS } catch (error: Throwable) { notifyBuildFailure(message.tasks) - return@supplyAsync TaskExecutionResult(false, getTaskFailureType(error), error.message) + return@runBuild TaskExecutionResult(false, getTaskFailureType(error)) } } } @@ -330,6 +366,26 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) : else -> UNKNOWN } + private inline fun supplyAsync(crossinline action: () -> T): CompletableFuture = + CompletableFuture.supplyAsync { + action() + } + + private inline fun runBuild(crossinline action: () -> T): CompletableFuture = + supplyAsync { + if (isBuildInProgress) { + log.error("Cannot run build, build is already in prorgess!") + throw IllegalStateException("Build is already in progress") + } + + isBuildInProgress = true + try { + action() + } finally { + isBuildInProgress = false + } + } + private fun assertProjectInitialized() { if (!isServerInitialized().get()) { throw CompletionException(IllegalStateException("Project is not initialized!")) diff --git a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/MultiModuleAndroidProjectTest.kt b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/MultiModuleAndroidProjectTest.kt index 2e74c7caa..f38b9e056 100644 --- a/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/MultiModuleAndroidProjectTest.kt +++ b/subprojects/tooling-api-impl/src/test/java/com/itsaky/androidide/tooling/impl/MultiModuleAndroidProjectTest.kt @@ -28,6 +28,7 @@ import com.itsaky.androidide.tooling.api.IProject import com.itsaky.androidide.tooling.api.IToolingApiServer import com.itsaky.androidide.tooling.api.ProjectType import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage +import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult import com.itsaky.androidide.tooling.api.models.AndroidProjectMetadata import com.itsaky.androidide.tooling.api.models.JavaModuleExternalDependency import com.itsaky.androidide.tooling.api.models.JavaModuleProjectDependency @@ -37,11 +38,22 @@ import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.task.TaskStartEvent import com.itsaky.androidide.tooling.testing.ToolingApiTestLauncher import com.itsaky.androidide.tooling.testing.ToolingApiTestLauncher.MultiVersionTestClient +import com.itsaky.androidide.utils.FileProvider import com.itsaky.androidide.utils.ILogger import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 +import java.nio.file.Paths +import java.nio.file.attribute.PosixFilePermission import java.util.concurrent.CompletableFuture +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createDirectories +import kotlin.io.path.createFile +import kotlin.io.path.deleteIfExists +import kotlin.io.path.deleteRecursively +import kotlin.io.path.exists +import kotlin.io.path.setPosixFilePermissions +import kotlin.io.path.writeText /** @author Akash Yadav */ @RunWith(JUnit4::class) @@ -54,6 +66,26 @@ class MultiModuleAndroidProjectTest { doAssertions(project, server) } + @Test + fun `test non-existing project initialization`() { + val (_, _, result) = ToolingApiTestLauncher().launchServer( + projectDir = Paths.get("/directory/does/not/exist/")) + assertThat(result?.isSuccessful).isFalse() + assertThat(result?.failure).isEqualTo(TaskExecutionResult.Failure.PROJECT_NOT_FOUND) + } + + @Test + fun `test project initialization with file path`() { + val path = FileProvider.projectRoot().resolve("build").resolve("should-be-directory.txt") + path.deleteIfExists() + path.createFile() + + val (_, _, result) = ToolingApiTestLauncher().launchServer( + projectDir = path) + assertThat(result?.isSuccessful).isFalse() + assertThat(result?.failure).isEqualTo(TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY) + } + @Test fun `test task name should be shown if gradle configuration cache is enabled`() { diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/InitializeResult.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/InitializeResult.kt index 874c75980..2a3ca7820 100644 --- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/InitializeResult.kt +++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/InitializeResult.kt @@ -23,4 +23,4 @@ package com.itsaky.androidide.tooling.api.messages.result * @param isSuccessful Whether the project initialization was successful. * @author Akash Yadav */ -class InitializeResult(val isSuccessful: Boolean) +class InitializeResult(val isSuccessful: Boolean, val failure: TaskExecutionResult.Failure? = TaskExecutionResult.Failure.UNKNOWN) diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt index 551860258..81d5452a4 100644 --- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt +++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/result/TaskExecutionResult.kt @@ -22,11 +22,10 @@ package com.itsaky.androidide.tooling.api.messages.result * * @param isSuccessful The result of the task execution. * @param failure The type of failure. Non-null only if [isSuccessful] is `false`. - * @param failureMessage The message describing the failure. * * @author Akash Yadav */ -data class TaskExecutionResult(val isSuccessful: Boolean, val failure: Failure?, val failureMessage: String?) { +data class TaskExecutionResult(val isSuccessful: Boolean, val failure: Failure?) { companion object { @@ -34,11 +33,14 @@ data class TaskExecutionResult(val isSuccessful: Boolean, val failure: Failure?, * Result for a successful build. */ @JvmStatic - val SUCCESS = TaskExecutionResult(true, null, null) + val SUCCESS = TaskExecutionResult(true, null) } enum class Failure { PROJECT_NOT_FOUND, + PROJECT_NOT_INITIALIZED, + PROJECT_NOT_DIRECTORY, + PROJECT_DIRECTORY_INACCESSIBLE, UNKNOWN, UNSUPPORTED_GRADLE_VERSION, UNSUPPORTED_CONFIGURATION,