Skip to content

Commit 558680e

Browse files
ADFA-2354: Fix "permission denied" on installDebug/uninstallDebug; installDebug now installs via CoGo run tasks (#1803)
* ADFA-2354: Replace adb-backed install/uninstall task actions on device The shipped SDK has a 0-byte platform-tools/adb stub and adb cannot run on the device, so AGP's install<Variant>/uninstall<Variant> tasks fail with "Exec failed, error: 13 (Permission denied)". The IDE plugin now replaces the actions of every AGP InstallVariantTask and UninstallTask with a lifecycle message. Main-variant install and uninstall succeed with the message (the IDE takes over installing); *AndroidTest variants fail with a clear message because nothing installs test APKs on device. Task dependencies are kept, so installDebug still builds the APK. Matched by task class rather than name so user tasks named install* are untouched and com.android.test modules are covered. Matching on task group does not work here: the init script applies this plugin from an afterEvaluate registered in projectsLoaded, which runs before AGP's own afterEvaluate sets the group. Also fixes ADFA-2356 (uninstall permission denied). * 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. * style: spotless reformat MainDispatcherRule, no functional change * ADFA-2354: Report a refused install run and test the new paths runTasks now returns whether the build slot was claimed. When another build is in progress the task dialog flashes the existing "build in progress" message and stays open instead of dismissing and silently dropping the selected install task. Tests: - BuildViewModelTest: runTasks is refused and reported while a build is queued, claims the slot before its coroutine runs, and ends in a single reported error when no build service is registered. - InstallTaskReplacementTest (gradle-plugin, ProjectBuilder): AGP InstallVariantTask/UninstallTask are recognised by class and their action replaced by one message action, *AndroidTest install tasks fail with the unsupported message, a user task named install* keeps its own actions, and task dependencies survive the replacement. MainDispatcherRule exposes its dispatcher so tests can advance it. * ADFA-2354: Route only app-variant install tasks to the installer The IDE half matched any task named install<Capital>, so :installDist or :app:installGitHooks went through runTasks and, after a successful build, hit "No Android application variant is assembled by ..." and a red error. Review feedback from jatezzz and dara-abijo-adfa. BuildViewModel now resolves an install task to an app-module variant by assembleTaskName up front (installsAnAppVariant); the task dialog routes to the installer only when that resolves, and the post-build lookup returns null instead of throwing, so non-variant install tasks behave exactly as before. The project manager is injected as a provider so this is unit-testable. Also restores the result == null guard that runQuickBuild has for the same executeTasks future, so a null JSON-RPC result reports the failure instead of an NPE-turned-"unknown error". --------- Co-authored-by: Daniel Alome <astrocoder007@gmail.com>
1 parent 60e4970 commit 558680e

9 files changed

Lines changed: 395 additions & 43 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
@@ -57,6 +58,7 @@ import com.itsaky.androidide.utils.applyLongPressRecursively
5758
import com.itsaky.androidide.utils.doOnApplyWindowInsets
5859
import com.itsaky.androidide.utils.flashError
5960
import com.itsaky.androidide.utils.flashInfo
61+
import com.itsaky.androidide.viewmodel.BuildViewModel
6062
import com.itsaky.androidide.viewmodel.RunTasksViewModel
6163
import org.slf4j.LoggerFactory
6264

@@ -70,6 +72,7 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() {
7072
private lateinit var binding: LayoutRunTaskDialogBinding
7173
private lateinit var run: LayoutRunTaskBinding
7274
private val viewModel: RunTasksViewModel by viewModels()
75+
private val buildViewModel: BuildViewModel by activityViewModels()
7376

7477
private val searchRunner =
7578
Runnable {
@@ -199,8 +202,13 @@ class RunTasksDialogFragment : BottomSheetDialogFragment() {
199202
return@setOnClickListener
200203
}
201204

202-
val toRun = viewModel.selected.toTypedArray()
203-
buildService.executeTasks(*toRun)
205+
val toRun = viewModel.selected.toList()
206+
if (!buildViewModel.installsAnAppVariant(toRun)) {
207+
buildService.executeTasks(*toRun.toTypedArray())
208+
} else if (!buildViewModel.runTasks(toRun)) {
209+
flashError(R.string.build_in_progress_warning)
210+
return@setOnClickListener
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: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ 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.InstallTaskRequest
8+
import com.itsaky.androidide.models.installTaskRequestsIn
79
import com.itsaky.androidide.project.AndroidModels
810
import com.itsaky.androidide.projects.IProjectManager
911
import com.itsaky.androidide.projects.api.AndroidModule
@@ -24,7 +26,9 @@ import org.slf4j.LoggerFactory
2426
import java.io.File
2527
import kotlin.coroutines.cancellation.CancellationException
2628

27-
class BuildViewModel : ViewModel() {
29+
class BuildViewModel(
30+
private val projectManager: () -> IProjectManager = { IProjectManager.getInstance() },
31+
) : ViewModel() {
2832
private val log = LoggerFactory.getLogger(BuildViewModel::class.java)
2933

3034
private val _buildState = MutableStateFlow<BuildState>(BuildState.Idle)
@@ -46,37 +50,14 @@ class BuildViewModel : ViewModel() {
4650
gradleArgs: List<String> = emptyList(),
4751
onTerminalState: ((BuildState) -> Unit)? = null,
4852
) {
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-
}
53+
if (!claimBuildSlot(onTerminalState)) return
6354

6455
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-
}
56+
val reporter = RunReporter(onTerminalState)
7657

7758
val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
7859
if (buildService == null) {
79-
finish(BuildState.Error("Build service not found."))
60+
reporter.finish(BuildState.Error("Build service not found."))
8061
return@launch
8162
}
8263

@@ -118,10 +99,10 @@ class BuildViewModel : ViewModel() {
11899
val cgpFile =
119100
withContext(Dispatchers.IO) { findPluginCgpFile(projectRoot, variant) }
120101
if (cgpFile != null) {
121-
finish(BuildState.AwaitingPluginInstall(cgpFile))
102+
reporter.finish(BuildState.AwaitingPluginInstall(cgpFile))
122103
} else {
123104
log.warn("Plugin built successfully but .cgp file not found")
124-
finish(
105+
reporter.finish(
125106
BuildState.Error("Plugin built but output file (.cgp) not found in build/plugin"),
126107
)
127108
}
@@ -140,7 +121,7 @@ class BuildViewModel : ViewModel() {
140121
throw RuntimeException("APK file specified does not exist: $apkFile")
141122
}
142123

143-
finish(
124+
reporter.finish(
144125
BuildState.AwaitingInstall(
145126
apkFile,
146127
launchInDebugMode,
@@ -150,13 +131,100 @@ class BuildViewModel : ViewModel() {
150131
} catch (e: Exception) {
151132
if (e is CancellationException) {
152133
log.info("Build was cancelled by the user.")
153-
finish(BuildState.Idle)
134+
reporter.finish(BuildState.Idle)
154135
} else {
155136
log.error("Quick Run failed.", e)
156-
finish(BuildState.Error(e.message ?: "An unknown error occurred."))
137+
reporter.finish(BuildState.Error(e.message ?: "An unknown error occurred."))
138+
}
139+
}
140+
}
141+
}
142+
143+
private inner class RunReporter(
144+
private val onTerminalState: ((BuildState) -> Unit)?,
145+
) {
146+
private var reported = false
147+
148+
fun finish(state: BuildState) {
149+
_buildState.value = state
150+
if (!reported) {
151+
reported = true
152+
onTerminalState?.invoke(state)
153+
}
154+
}
155+
}
156+
157+
private fun claimBuildSlot(onTerminalState: ((BuildState) -> Unit)?): Boolean {
158+
while (true) {
159+
val current = _buildState.value
160+
if (current is BuildState.InProgress) {
161+
log.warn("Build is already in progress. Ignoring new request.")
162+
onTerminalState?.invoke(BuildState.Error("A build is already in progress."))
163+
return false
164+
}
165+
if (_buildState.compareAndSet(current, BuildState.InProgress)) return true
166+
}
167+
}
168+
169+
fun runTasks(
170+
tasks: List<String>,
171+
onTerminalState: ((BuildState) -> Unit)? = null,
172+
): Boolean {
173+
if (!claimBuildSlot(onTerminalState)) return false
174+
viewModelScope.launch {
175+
val reporter = RunReporter(onTerminalState)
176+
val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE)
177+
if (buildService == null) {
178+
reporter.finish(BuildState.Error("Build service not found."))
179+
return@launch
180+
}
181+
try {
182+
val result = withContext(Dispatchers.IO) { buildService.executeTasks(tasks) }.await()
183+
if (result == null || !result.isSuccessful) {
184+
throw RuntimeException("Task execution failed: ${result?.failure}")
185+
}
186+
val apkFile = withContext(Dispatchers.IO) { apkForInstallRequests(tasks) }
187+
if (apkFile == null) {
188+
reporter.finish(BuildState.Idle)
189+
} else {
190+
reporter.finish(BuildState.AwaitingInstall(apkFile, launchInDebugMode = false))
191+
}
192+
} catch (e: Exception) {
193+
if (e is CancellationException) {
194+
log.info("Build was cancelled by the user.")
195+
reporter.finish(BuildState.Idle)
196+
} else {
197+
log.error("Task run failed.", e)
198+
reporter.finish(BuildState.Error(e.message ?: "An unknown error occurred."))
157199
}
158200
}
159201
}
202+
return true
203+
}
204+
205+
fun installsAnAppVariant(tasks: List<String>): Boolean = installTaskRequestsIn(tasks).any { appVariantFor(it) != null }
206+
207+
private fun appVariantFor(request: InstallTaskRequest): AndroidModels.AndroidVariant? =
208+
projectManager()
209+
.getAndroidAppModules()
210+
.filter { request.modulePath == null || it.path == request.modulePath }
211+
.firstNotNullOfOrNull { module ->
212+
module.variantList.firstOrNull { it.mainArtifact.assembleTaskName == request.assembleTaskName }
213+
}
214+
215+
private fun apkForInstallRequests(tasks: List<String>): File? {
216+
val resolved = installTaskRequestsIn(tasks).mapNotNull { request -> appVariantFor(request)?.let { request to it } }
217+
val (request, variant) = resolved.firstOrNull() ?: return null
218+
if (resolved.size > 1) {
219+
log.warn("Several install tasks were requested; only {} is installed.", request)
220+
}
221+
val apkFile =
222+
ApkMetadata.findApkFile(variant.mainArtifact.assembleTaskOutputListingFile)
223+
?: throw RuntimeException("No APK found in output listing file.")
224+
if (!apkFile.exists()) {
225+
throw RuntimeException("APK file specified does not exist: $apkFile")
226+
}
227+
return apkFile
160228
}
161229

162230
/** Call this after the installation attempt to reset the state. */
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)