Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import com.itsaky.androidide.tooling.api.messages.result.failure
import com.itsaky.androidide.tooling.api.messages.result.isSuccessful
import com.itsaky.androidide.tooling.api.models.BuildVariantInfo
import com.itsaky.androidide.tooling.api.models.mapToSelectedVariants
import com.itsaky.androidide.tooling.api.sync.ProjectSyncHelper
import com.itsaky.androidide.utils.DURATION_INDEFINITE
import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder
import com.itsaky.androidide.utils.RecursiveFileSearcher
Expand Down Expand Up @@ -548,7 +549,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() {
protected open fun onProjectInitialized(result: InitializeResult.Success) {
editorActivityScope.launch(Dispatchers.IO) {
val manager = ProjectManagerImpl.getInstance()
val gradleBuildResult = manager.readGradleBuild()
val gradleBuildResult = ProjectSyncHelper.readGradleBuild(result.cacheFile)
if (gradleBuildResult.isFailure) {
val error = gradleBuildResult.exceptionOrNull()
log.error("Failed to read project cache", error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,20 +213,6 @@ class ProjectManagerImpl :
return false
}

/**
* Read the Gradle build model from the currently opened project directory.
*
* @return The Gradle build model result.
*/
suspend fun readGradleBuild(): Result<GradleModels.GradleBuild> {
if (!projectDir.exists()) {
return Result.failure(IllegalStateException("Project directory does not exist: ${projectDir.absolutePath}"))
}

val cacheFile = ProjectSyncHelper.cacheFileForProject(projectDir)
return ProjectSyncHelper.readGradleBuild(cacheFile)
}

override fun destroy() {
log.info("Destroying project manager")
this.workspace = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import org.gradle.tooling.UnsupportedVersionException
import org.gradle.tooling.exceptions.UnsupportedBuildArgumentException
import org.gradle.tooling.exceptions.UnsupportedOperationConfigurationException
import org.gradle.tooling.internal.consumer.DefaultGradleConnector
import org.jetbrains.annotations.VisibleForTesting
import org.slf4j.LoggerFactory
import java.io.File
import java.util.concurrent.CompletableFuture
Expand Down Expand Up @@ -105,7 +106,8 @@ internal class ToolingApiServerImpl : IToolingApiServer {
private val log = LoggerFactory.getLogger(ToolingApiServerImpl::class.java)
}

private fun getOrConnectProject(
@VisibleForTesting
internal fun getOrConnectProject(
projectDir: File,
forceConnect: Boolean = false,
initParams: InitializeProjectParams? = null,
Expand Down Expand Up @@ -141,95 +143,92 @@ internal class ToolingApiServerImpl : IToolingApiServer {
override fun initialize(params: InitializeProjectParams): CompletableFuture<InitializeResult> {
return runBuild {
try {
log.debug("Received project initialization request with params: {}", params)
return@runBuild doInitialize(params)
} catch (err: Throwable) {
log.error("Failed to initialize project", err)
notifyBuildFailure(emptyList())
return@runBuild InitializeResult.Failure(getTaskFailureType(err))
}
}
}

if (params.gradleDistribution.type == GradleDistributionType.GRADLE_WRAPPER) {
Main.checkGradleWrapper()
}
@VisibleForTesting
internal fun doInitialize(params: InitializeProjectParams): InitializeResult {
log.debug("Received project initialization request with params: {}", params)

if (buildCancellationToken != null) {
cancelCurrentBuild().get()
}
if (params.gradleDistribution.type == GradleDistributionType.GRADLE_WRAPPER) {
Main.checkGradleWrapper()
}

val projectDir = File(params.directory)
val failureReason = validateProjectDirectory(projectDir)
if (buildCancellationToken != null) {
cancelCurrentBuild().get()
}

if (failureReason != null) {
log.error("Cannot initialize project: {}", failureReason)
return@runBuild InitializeResult.Failure(failureReason)
}
val projectDir = File(params.directory)
val failureReason = validateProjectDirectory(projectDir)

val stopWatch = StopWatch("Connection to project")
val isReinitializing =
connector != null && connection != null && params == lastInitParams
if (failureReason != null) {
log.error("Cannot initialize project: {}", failureReason)
return InitializeResult.Failure(failureReason)
}

if (isReinitializing) {
log.info("Project is being reinitialized")
log.info("Reusing connector instance...")
}
val stopWatch = StopWatch("Connection to project")
val isReinitializing =
connector != null && connection != null && params == lastInitParams

val (_, connection) =
getOrConnectProject(
projectDir = projectDir,
forceConnect = !isReinitializing,
initParams = params,
)

lastInitParams = params

// we're now ready to run Gradle tasks
isInitialized = true

val cacheFile = ProjectSyncHelper.cacheFileForProject(projectDir)
val syncMetaFile = ProjectSyncHelper.syncMetaFileForProject(projectDir)

if (params.needsGradleSync) {
var failure: Throwable? = null
try {
val cancellationToken = GradleConnector.newCancellationTokenSource()
buildCancellationToken = cancellationToken
notifyBeforeBuild(BuildInfo(emptyList()))
val modelBuilderParams =
RootProjectModelBuilderParams(
projectConnection = connection,
cancellationToken = cancellationToken.token(),
projectCacheFile = cacheFile,
projectSyncMetaFile = syncMetaFile,
gradleArgs = params.gradleArgs,
jvmArgs = params.jvmArgs,
)

RootModelBuilder.build(params, modelBuilderParams)
} catch (err: Throwable) {
failure = err
} finally {
when (failure) {
null -> notifyBuildSuccess(emptyList())
is BuildCancelledException -> throw failure
else -> notifyBuildFailure(emptyList())
}
}
}
if (isReinitializing) {
log.info("Project is being reinitialized")
log.info("Reusing connector instance...")
}

stopWatch.log()
return@runBuild InitializeResult.Success(cacheFile)
} catch (err: Throwable) {
log.error("Failed to initialize project", err)
notifyBuildFailure(emptyList())
return@runBuild InitializeResult.Failure(getTaskFailureType(err))
}
val (_, connection) =
getOrConnectProject(
projectDir = projectDir,
forceConnect = !isReinitializing,
initParams = params,
)

lastInitParams = params

// we're now ready to run Gradle tasks
isInitialized = true

val cacheFile = ProjectSyncHelper.cacheFileForProject(projectDir)
val syncMetaFile = ProjectSyncHelper.syncMetaFileForProject(projectDir)

if (params.needsGradleSync || !ProjectSyncHelper.areSyncFilesReadable(projectDir)) {
val cancellationToken = GradleConnector.newCancellationTokenSource()
buildCancellationToken = cancellationToken
notifyBeforeBuild(BuildInfo(emptyList()))
val modelBuilderParams =
RootProjectModelBuilderParams(
projectConnection = connection,
cancellationToken = cancellationToken.token(),
projectCacheFile = cacheFile,
projectSyncMetaFile = syncMetaFile,
gradleArgs = params.gradleArgs,
jvmArgs = params.jvmArgs,
)

RootModelBuilder.build(params, modelBuilderParams)
notifyBuildSuccess(emptyList())
}

stopWatch.log()
return InitializeResult.Success(cacheFile)
}

private fun validateProjectDirectory(projectDirectory: File) =
@VisibleForTesting
internal 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<Boolean> = CompletableFuture.supplyAsync { isInitialized }
override fun isServerInitialized(): CompletableFuture<Boolean> =
CompletableFuture.supplyAsync { isInitialized }

override fun executeTasks(message: TaskExecutionMessage): CompletableFuture<TaskExecutionResult> {
return runBuild {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.itsaky.androidide.tooling.impl

import com.google.common.truth.Truth.assertThat
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.isSuccessful
import com.itsaky.androidide.tooling.api.sync.ProjectSyncHelper
import com.itsaky.androidide.tooling.impl.sync.RootModelBuilder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import io.mockk.verify
import org.gradle.tooling.GradleConnector
import org.gradle.tooling.ProjectConnection
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.io.File
import java.util.concurrent.TimeUnit

/**
* @author Akash Yadav
*/
@RunWith(JUnit4::class)
class ToolingApiServerImplTest {

private fun testInitParams(
directory: String = "/does/not/exist",
forceSync: Boolean = false,
) = InitializeProjectParams(
directory = directory, needsGradleSync = forceSync
)

private data class MockServer(
val server: ToolingApiServerImpl,
val connector: GradleConnector,
val connection: ProjectConnection
)

private fun mockkToolingServer(): MockServer {
val server = spyk(ToolingApiServerImpl())
val connector = mockk<GradleConnector>(relaxed = true)
Comment thread
itsaky-adfa marked this conversation as resolved.
val connection = mockk<ProjectConnection>(relaxed = true)

// ensure that we do not start actual Gradle build
every {
server.getOrConnectProject(
projectDir = any(), forceConnect = true, initParams = any(), gradleDist = any()
)
} returns (connector to connection)

return MockServer(server, connector, connection)
}

@Test
fun `GIVEN any initialization params WHEN project init fails THEN report as failure`() {

mockkObject(RootModelBuilder)
every {
// Simulate a Gradle sync failure
RootModelBuilder.build(
any(), any()
)
} throws RuntimeException("intentional failure")

val (server) = mockkToolingServer()

every {
// ensure we don't fail on non-existent project directory
server.validateProjectDirectory(any())
} returns null

val result = server.initialize(testInitParams()).get(5, TimeUnit.SECONDS)
assertThat(result).isNotNull()
assertThat(result.isSuccessful).isFalse()
assertThat(result).isInstanceOf(InitializeResult.Failure::class.java)

// unknown error because of the mocked runtime exception
assertThat((result as InitializeResult.Failure).failure).isEqualTo(TaskExecutionResult.Failure.UNKNOWN)
}

@Test
fun `GIVEN force sync not requested WHEN sync files are unreadable THEN sync anyway`() {

val initParams = testInitParams(forceSync = false)
val cacheFile = ProjectSyncHelper.cacheFileForProject(File(initParams.directory))

mockkObject(RootModelBuilder)
every {
// simulate a successful cache write
RootModelBuilder.build(
any(), any()
)
} returns cacheFile

mockkObject(ProjectSyncHelper)
every {
// simulate unreadable cache files
ProjectSyncHelper.areSyncFilesReadable(any(), any())
} returns false

val (server) = mockkToolingServer()

every {
// ensure we don't fail on non-existent project directory
server.validateProjectDirectory(any())
} returns null

val result = server.initialize(initParams).get(5, TimeUnit.SECONDS)
assertThat(result).isNotNull()
assertThat(result.isSuccessful).isTrue()
assertThat(result).isInstanceOf(InitializeResult.Success::class.java)
assertThat((result as InitializeResult.Success).cacheFile).isEqualTo(cacheFile)

verify(exactly = 1) {
// ensure gradle sync was requested
RootModelBuilder.build(initParams, any())
}
}
}
Loading