Skip to content

Commit 12fe75e

Browse files
committed
ADFA-2354: Install the APK through CoGo when install<Variant> is run
RunTasksDialogFragment routes any selection containing an install<Variant> task through BuildViewModel.runTasks. After the build succeeds it picks the app-module variant whose assembleTaskName is assemble<Variant>, reads the APK from the output listing and emits BuildState.AwaitingInstall, so the existing installer flow installs it. The app is not launched automatically. Non-install selections keep going straight to the build service. The InProgress slot claim and terminal-state reporting are shared with runQuickBuild (claimBuildSlot, RunReporter); no behaviour change there. Verified on device: installDebug builds, prints the plugin message, and CoGo installs the APK; uninstallDebug prints its message and succeeds.
1 parent 2d9b71d commit 12fe75e

4 files changed

Lines changed: 173 additions & 33 deletions

File tree

app/src/main/java/com/itsaky/androidide/fragments/RunTasksDialogFragment.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.core.view.WindowInsetsCompat.Type.statusBars
3232
import androidx.core.view.updateLayoutParams
3333
import androidx.core.view.updateMargins
3434
import androidx.core.view.updatePadding
35+
import androidx.fragment.app.activityViewModels
3536
import androidx.fragment.app.viewModels
3637
import androidx.transition.TransitionManager
3738
import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -46,6 +47,7 @@ import com.itsaky.androidide.idetooltips.TooltipManager
4647
import com.itsaky.androidide.idetooltips.TooltipTag
4748
import com.itsaky.androidide.lookup.Lookup
4849
import com.itsaky.androidide.models.Checkable
50+
import com.itsaky.androidide.models.installTaskRequestsIn
4951
import com.itsaky.androidide.project.GradleModels
5052
import com.itsaky.androidide.projects.IProjectManager
5153
import com.itsaky.androidide.projects.builder.BuildService
@@ -57,6 +59,7 @@ import com.itsaky.androidide.utils.applyLongPressRecursively
5759
import com.itsaky.androidide.utils.doOnApplyWindowInsets
5860
import com.itsaky.androidide.utils.flashError
5961
import com.itsaky.androidide.utils.flashInfo
62+
import com.itsaky.androidide.viewmodel.BuildViewModel
6063
import com.itsaky.androidide.viewmodel.RunTasksViewModel
6164
import org.slf4j.LoggerFactory
6265

@@ -70,6 +73,7 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() {
7073
private lateinit var binding: LayoutRunTaskDialogBinding
7174
private lateinit var run: LayoutRunTaskBinding
7275
private val viewModel: RunTasksViewModel by viewModels()
76+
private val buildViewModel: BuildViewModel by activityViewModels()
7377

7478
private val searchRunner =
7579
Runnable {
@@ -199,8 +203,12 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() {
199203
return@setOnClickListener
200204
}
201205

202-
val toRun = viewModel.selected.toTypedArray()
203-
buildService.executeTasks(*toRun)
206+
val toRun = viewModel.selected.toList()
207+
if (installTaskRequestsIn(toRun).isEmpty()) {
208+
buildService.executeTasks(*toRun.toTypedArray())
209+
} else {
210+
buildViewModel.runTasks(toRun)
211+
}
204212
dismiss()
205213
}
206214
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.itsaky.androidide.models
2+
3+
data class InstallTaskRequest(
4+
val modulePath: String?,
5+
val taskSuffix: String,
6+
) {
7+
val assembleTaskName: String get() = "assemble$taskSuffix"
8+
}
9+
10+
private val INSTALL_TASK = Regex("^install([A-Z]\\w*)$")
11+
12+
fun installTaskRequestsIn(tasks: List<String>): List<InstallTaskRequest> =
13+
tasks.mapNotNull { path ->
14+
val suffix = INSTALL_TASK.matchEntire(path.substringAfterLast(':'))?.groupValues?.get(1) ?: return@mapNotNull null
15+
if (suffix.endsWith("AndroidTest")) return@mapNotNull null
16+
InstallTaskRequest(
17+
modulePath = path.substringBeforeLast(':', "").takeIf { it.isNotEmpty() },
18+
taskSuffix = suffix,
19+
)
20+
}

app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt

Lines changed: 97 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
55
import com.itsaky.androidide.lookup.Lookup
66
import com.itsaky.androidide.models.ApkMetadata
7+
import com.itsaky.androidide.models.installTaskRequestsIn
78
import com.itsaky.androidide.project.AndroidModels
89
import com.itsaky.androidide.projects.IProjectManager
910
import com.itsaky.androidide.projects.api.AndroidModule
@@ -46,37 +47,14 @@ class BuildViewModel : ViewModel() {
4647
gradleArgs: List<String> = emptyList(),
4748
onTerminalState: ((BuildState) -> Unit)? = null,
4849
) {
49-
// Claim the slot before the coroutine is scheduled, and in one step: a check here and a set
50-
// inside the launched block let two callers both read a free state and both reach
51-
// executeTasks, running duplicate build-and-install flows.
52-
while (true) {
53-
val current = _buildState.value
54-
if (current is BuildState.InProgress) {
55-
log.warn("Build is already in progress. Ignoring new request.")
56-
onTerminalState?.invoke(BuildState.Error("A build is already in progress."))
57-
return
58-
}
59-
if (_buildState.compareAndSet(current, BuildState.InProgress)) {
60-
break
61-
}
62-
}
50+
if (!claimBuildSlot(onTerminalState)) return
6351

6452
viewModelScope.launch {
65-
var reported = false
66-
67-
// Publishes a terminal state and notifies the caller once, from the one place that
68-
// knows the run is over. Called only on the main dispatcher, so the flag needs no lock.
69-
fun finish(state: BuildState) {
70-
_buildState.value = state
71-
if (!reported) {
72-
reported = true
73-
onTerminalState?.invoke(state)
74-
}
75-
}
53+
val reporter = RunReporter(onTerminalState)
7654

7755
val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
7856
if (buildService == null) {
79-
finish(BuildState.Error("Build service not found."))
57+
reporter.finish(BuildState.Error("Build service not found."))
8058
return@launch
8159
}
8260

@@ -118,10 +96,10 @@ class BuildViewModel : ViewModel() {
11896
val cgpFile =
11997
withContext(Dispatchers.IO) { findPluginCgpFile(projectRoot, variant) }
12098
if (cgpFile != null) {
121-
finish(BuildState.AwaitingPluginInstall(cgpFile))
99+
reporter.finish(BuildState.AwaitingPluginInstall(cgpFile))
122100
} else {
123101
log.warn("Plugin built successfully but .cgp file not found")
124-
finish(
102+
reporter.finish(
125103
BuildState.Error("Plugin built but output file (.cgp) not found in build/plugin"),
126104
)
127105
}
@@ -140,7 +118,7 @@ class BuildViewModel : ViewModel() {
140118
throw RuntimeException("APK file specified does not exist: $apkFile")
141119
}
142120

143-
finish(
121+
reporter.finish(
144122
BuildState.AwaitingInstall(
145123
apkFile,
146124
launchInDebugMode,
@@ -150,15 +128,103 @@ class BuildViewModel : ViewModel() {
150128
} catch (e: Exception) {
151129
if (e is CancellationException) {
152130
log.info("Build was cancelled by the user.")
153-
finish(BuildState.Idle)
131+
reporter.finish(BuildState.Idle)
154132
} else {
155133
log.error("Quick Run failed.", e)
156-
finish(BuildState.Error(e.message ?: "An unknown error occurred."))
134+
reporter.finish(BuildState.Error(e.message ?: "An unknown error occurred."))
135+
}
136+
}
137+
}
138+
}
139+
140+
private inner class RunReporter(
141+
private val onTerminalState: ((BuildState) -> Unit)?,
142+
) {
143+
private var reported = false
144+
145+
fun finish(state: BuildState) {
146+
_buildState.value = state
147+
if (!reported) {
148+
reported = true
149+
onTerminalState?.invoke(state)
150+
}
151+
}
152+
}
153+
154+
private fun claimBuildSlot(onTerminalState: ((BuildState) -> Unit)?): Boolean {
155+
while (true) {
156+
val current = _buildState.value
157+
if (current is BuildState.InProgress) {
158+
log.warn("Build is already in progress. Ignoring new request.")
159+
onTerminalState?.invoke(BuildState.Error("A build is already in progress."))
160+
return false
161+
}
162+
if (_buildState.compareAndSet(current, BuildState.InProgress)) return true
163+
}
164+
}
165+
166+
fun runTasks(
167+
tasks: List<String>,
168+
onTerminalState: ((BuildState) -> Unit)? = null,
169+
) {
170+
if (!claimBuildSlot(onTerminalState)) return
171+
viewModelScope.launch {
172+
val reporter = RunReporter(onTerminalState)
173+
val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
174+
if (buildService == null) {
175+
reporter.finish(BuildState.Error("Build service not found."))
176+
return@launch
177+
}
178+
try {
179+
val result = withContext(Dispatchers.IO) { buildService.executeTasks(tasks) }.await()
180+
if (!result.isSuccessful) {
181+
throw RuntimeException("Task execution failed: ${result.failure}")
182+
}
183+
val apkFile = withContext(Dispatchers.IO) { apkForInstallRequests(tasks) }
184+
if (apkFile == null) {
185+
reporter.finish(BuildState.Idle)
186+
} else {
187+
reporter.finish(BuildState.AwaitingInstall(apkFile, launchInDebugMode = false))
188+
}
189+
} catch (e: Exception) {
190+
if (e is CancellationException) {
191+
log.info("Build was cancelled by the user.")
192+
reporter.finish(BuildState.Idle)
193+
} else {
194+
log.error("Task run failed.", e)
195+
reporter.finish(BuildState.Error(e.message ?: "An unknown error occurred."))
157196
}
158197
}
159198
}
160199
}
161200

201+
private fun apkForInstallRequests(tasks: List<String>): File? {
202+
val requests = installTaskRequestsIn(tasks)
203+
if (requests.isEmpty()) return null
204+
if (requests.size > 1) {
205+
log.warn("Several install tasks were requested; only {} is installed.", requests.first())
206+
}
207+
val request = requests.first()
208+
val variant =
209+
IProjectManager
210+
.getInstance()
211+
.getAndroidAppModules()
212+
.filter { request.modulePath == null || it.path == request.modulePath }
213+
.firstNotNullOfOrNull { module ->
214+
module.variantList.firstOrNull { it.mainArtifact.assembleTaskName == request.assembleTaskName }
215+
}
216+
?: throw RuntimeException(
217+
"No Android application variant is assembled by '${request.assembleTaskName}' in ${request.modulePath ?: "the project"}.",
218+
)
219+
val apkFile =
220+
ApkMetadata.findApkFile(variant.mainArtifact.assembleTaskOutputListingFile)
221+
?: throw RuntimeException("No APK found in output listing file.")
222+
if (!apkFile.exists()) {
223+
throw RuntimeException("APK file specified does not exist: $apkFile")
224+
}
225+
return apkFile
226+
}
227+
162228
/** Call this after the installation attempt to reset the state. */
163229
fun installationAttempted() {
164230
if (_buildState.value is BuildState.AwaitingInstall) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.itsaky.androidide.models
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import org.junit.Test
5+
6+
class InstallTaskRequestTest {
7+
@Test
8+
fun givenAQualifiedInstallTask_thenTheModuleAndTaskSuffixAreExtracted() {
9+
assertThat(installTaskRequestsIn(listOf(":app:installDebug")))
10+
.containsExactly(InstallTaskRequest(":app", "Debug"))
11+
}
12+
13+
@Test
14+
fun givenANestedModule_thenTheFullModulePathIsKept() {
15+
assertThat(installTaskRequestsIn(listOf(":feature:app:installFreeRelease")))
16+
.containsExactly(InstallTaskRequest(":feature:app", "FreeRelease"))
17+
}
18+
19+
@Test
20+
fun givenAnUnqualifiedInstallTask_thenTheModuleIsNull() {
21+
assertThat(installTaskRequestsIn(listOf("installDebug", ":installRelease")))
22+
.containsExactly(InstallTaskRequest(null, "Debug"), InstallTaskRequest(null, "Release"))
23+
.inOrder()
24+
}
25+
26+
@Test
27+
fun givenUppercaseOrUnderscoredVariantNames_thenTheSuffixIsKeptVerbatim() {
28+
assertThat(installTaskRequestsIn(listOf(":app:installQA", ":app:installFree_betaDebug")))
29+
.containsExactly(InstallTaskRequest(":app", "QA"), InstallTaskRequest(":app", "Free_betaDebug"))
30+
.inOrder()
31+
}
32+
33+
@Test
34+
fun givenARequest_thenItNamesTheAssembleTaskOfTheSameVariant() {
35+
assertThat(InstallTaskRequest(":app", "FreeRelease").assembleTaskName).isEqualTo("assembleFreeRelease")
36+
}
37+
38+
@Test
39+
fun givenAndroidTestUninstallAndUnrelatedTasks_thenTheyAreIgnored() {
40+
assertThat(
41+
installTaskRequestsIn(
42+
listOf(":app:installDebugAndroidTest", ":app:uninstallDebug", ":app:assembleDebug", ":app:install"),
43+
),
44+
).isEmpty()
45+
}
46+
}

0 commit comments

Comments
 (0)