Skip to content

Commit 1999a76

Browse files
fryanpanclaude
andcommitted
ADFA-4128: qb 11 review fixes — install rotation window, flag-off intent, test gaps
Important 1 (daemon idle-timeout/Metaspace tuner un-gated): kept un-gated by design — the 384m Metaspace floor fixes real OOM-killed builds and the tiered idle timeouts keep low-RAM devices from losing the IDE to lmkd; GradleBuildTuner now states this in its KDoc. Ships flag-off; needs Bryan sign-off in the PR body. Important 2 (generateSources narrowing un-gated): judged a genuine all-users improvement, not QB-specific — the old code ran a Gradle generateSources after EVERY save-all (and after any XML save in SaveFileAction), a per-save build tax; flag-off the deferral degenerates to the same immediate call, so the narrowing is the only behavior change. Known trade (manifest-only edits leave generated Manifest/R intermediates stale until the next resource save or build) now stated at both call sites. Ships flag-off; needs sign-off in the PR body. Important 3 (install dropped on rotation, flag on): installApk's async path now re-arms AwaitingInstall (BuildViewModel.reArmInstall, fires only from Idle) from the coroutine's drop path, so a configuration change during the APK-manifest parse makes the recreated activity's collector retry the install instead of silently losing a successful build. Covered by BuildViewModelInstallReArmTest. Test gap (zip-slip guard): extraction loop extracted to QuickBuildArtifactStager.extractDaemonZip(InputStream, File); the guard is watched going red by QuickBuildArtifactStagerTest (a ../ entry throws and nothing lands outside the daemon dir). Test gap (InstallationEventFlow mapping): InstallationEventFlowTest pins the PackageInstaller status mapping, including the ABORTED-vs-FAILURE branch order and the no-extras / no-status paths. Test gap (service-side output capture): suppress/capture/drain routing extracted from GradleBuildService.logOutput into InternalBuildOutputCapture; bounded tail, drain-clears, throwing progress listener and editor-listener routing pinned by InternalBuildOutputCaptureTest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kj9YeCDHGp9DU8LPtfWJ7W
1 parent db1fb3f commit 1999a76

12 files changed

Lines changed: 530 additions & 62 deletions

File tree

‎app/src/main/java/com/itsaky/androidide/actions/file/SaveFileAction.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ class SaveFileAction(
9999
val saveResult = result.result
100100
// Only a resource save can change R, so only it warrants the Gradle generateSources run
101101
// (Java R.jar freshness + ViewBinding accessors - see SaveResult.resourceXmlSaved).
102+
// Deliberately un-gated (experiments flag off included): previously ANY XML save
103+
// triggered this, so skipping it on non-resource XML is a save-latency win for every
104+
// user. Known trade: a manifest-only edit no longer refreshes the generated Manifest/R
105+
// intermediates until the next resource save or build.
102106
// Routed through the deferral: immediate with no Quick Build session, parked and
103107
// coalesced until the session pipeline settles with one (see GenerateSourcesDeferral).
104108
if (saveResult.resourceXmlSaved) {

‎app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,10 @@ open class EditorHandlerActivity :
10021002

10031003
// Only a resource save can change R, so only it warrants the Gradle generateSources run
10041004
// (Java R.jar freshness + ViewBinding accessors - see SaveResult.resourceXmlSaved).
1005+
// Deliberately un-gated (experiments flag off included): previously this ran after EVERY
1006+
// save here, so skipping it on Kotlin/Java and non-resource saves is a save-latency win
1007+
// for every user. Known trade: a manifest-only edit no longer refreshes the generated
1008+
// Manifest/R intermediates until the next resource save or build.
10051009
// Routed through the deferral: immediate with no Quick Build session, parked and
10061010
// coalesced until the session pipeline settles with one (see GenerateSourcesDeferral).
10071011
if (processResources && result.resourceXmlSaved) {

‎app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt‎

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -535,37 +535,50 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() {
535535
}
536536
val answerAtTap = buildViewModel.consumeClobberAnswerAtTap()
537537
lifecycleScope.launch {
538-
// Reading the APK's manifest is disk work, and on emulated storage that is not free.
539-
val apkApplicationId = withContext(Dispatchers.IO) { apkApplicationId(state.apkFile) }
540-
if (isDestroyed || isFinishing) {
541-
return@launch
542-
}
543-
val now =
544-
quickBuildClobberConfirmation(apkApplicationId, clobberCheck::standardRunNeedsConfirm)
545-
val onProceed = {
546-
// The Quick Build session's installed baseline is about to be replaced; stop it.
547-
// Keyed off the re-check rather than off whether a dialog was shown: a tap that
548-
// already confirmed this exact clobber skips the dialog but still clobbers.
549-
if (now != QuickBuildClobberConfirmation.NotNeeded) {
550-
quickBuildSessionManager()?.restartSession()
538+
// installationAttempted() has already reset the build state, so an activity destroyed
539+
// (rotation) during the IO parse below cancels this coroutine and would silently drop
540+
// the whole install - a successful build with no install and no message. Until the
541+
// install (or its confirm dialog) is actually dispatched, the drop path re-arms
542+
// AwaitingInstall in the surviving ViewModel so the recreated activity retries.
543+
var dispatched = false
544+
try {
545+
// Reading the APK's manifest is disk work, and on emulated storage that is not free.
546+
val apkApplicationId = withContext(Dispatchers.IO) { apkApplicationId(state.apkFile) }
547+
if (isDestroyed || isFinishing) {
548+
return@launch
551549
}
552-
doInstallApk(state)
553-
}
554-
when (val decision = installTimeClobberConfirmation(answerAtTap, now)) {
555-
QuickBuildClobberConfirmation.NotNeeded -> {
556-
onProceed()
550+
dispatched = true
551+
val now =
552+
quickBuildClobberConfirmation(apkApplicationId, clobberCheck::standardRunNeedsConfirm)
553+
val onProceed = {
554+
// The Quick Build session's installed baseline is about to be replaced; stop it.
555+
// Keyed off the re-check rather than off whether a dialog was shown: a tap that
556+
// already confirmed this exact clobber skips the dialog but still clobbers.
557+
if (now != QuickBuildClobberConfirmation.NotNeeded) {
558+
quickBuildSessionManager()?.restartSession()
559+
}
560+
doInstallApk(state)
557561
}
562+
when (val decision = installTimeClobberConfirmation(answerAtTap, now)) {
563+
QuickBuildClobberConfirmation.NotNeeded -> {
564+
onProceed()
565+
}
558566

559-
QuickBuildClobberConfirmation.NeededForUnknownAppId -> {
560-
confirmUnknownOccupantSwitch(onProceed)
561-
}
567+
QuickBuildClobberConfirmation.NeededForUnknownAppId -> {
568+
confirmUnknownOccupantSwitch(onProceed)
569+
}
562570

563-
is QuickBuildClobberConfirmation.Needed -> {
564-
confirmBuildTypeSwitch(
565-
getString(string.quick_build_switch_to_standard_title),
566-
getString(string.quick_build_switch_to_standard_message, decision.applicationId),
567-
onProceed,
568-
)
571+
is QuickBuildClobberConfirmation.Needed -> {
572+
confirmBuildTypeSwitch(
573+
getString(string.quick_build_switch_to_standard_title),
574+
getString(string.quick_build_switch_to_standard_message, decision.applicationId),
575+
onProceed,
576+
)
577+
}
578+
}
579+
} finally {
580+
if (!dispatched) {
581+
buildViewModel.reArmInstall(state)
569582
}
570583
}
571584
}

‎app/src/main/java/com/itsaky/androidide/quickbuild/QuickBuildArtifactStager.kt‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory
66
import java.io.File
77
import java.io.FileNotFoundException
88
import java.io.IOException
9+
import java.io.InputStream
910
import java.util.zip.ZipInputStream
1011

1112
/**
@@ -52,8 +53,25 @@ object QuickBuildArtifactStager {
5253
}
5354
Environment.mkdirIfNotExists(daemonDir)
5455

56+
val count = extractDaemonZip(context.assets.open(ASSET_DAEMON_ZIP).buffered(), daemonDir)
57+
log.info("Staged {} daemon files into {}", count, daemonDir)
58+
}
59+
60+
/**
61+
* Unpacks the daemon zip from [input] into [daemonDir]. Internal so the JVM test can watch
62+
* the zip-slip guard go red without an Android [Context].
63+
*
64+
* @return the number of files extracted.
65+
* @throws IOException on a zip entry escaping [daemonDir].
66+
* @throws FileNotFoundException when the zip contains no files.
67+
*/
68+
@Throws(IOException::class)
69+
internal fun extractDaemonZip(
70+
input: InputStream,
71+
daemonDir: File,
72+
): Int {
5573
val canonicalRoot = daemonDir.canonicalFile
56-
ZipInputStream(context.assets.open(ASSET_DAEMON_ZIP).buffered()).use { zip ->
74+
ZipInputStream(input).use { zip ->
5775
var entry = zip.nextEntry
5876
var count = 0
5977
while (entry != null) {
@@ -75,7 +93,7 @@ object QuickBuildArtifactStager {
7593
if (count == 0) {
7694
throw FileNotFoundException("Daemon zip contained no files")
7795
}
78-
log.info("Staged {} daemon files into {}", count, daemonDir)
96+
return count
7997
}
8098
}
8199
}

‎app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt‎

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,11 @@ class GradleBuildService :
117117
private set
118118

119119
/**
120-
* Gradle output captured while the editor's listener is suppressed, oldest line first. Bounded
121-
* by [MAX_INTERNAL_OUTPUT_LINES]; guarded by itself, since it is written from the tooling
122-
* API's thread and drained from the caller's.
120+
* Gradle output captured while the editor's listener is suppressed, oldest line first.
121+
* Bounded by [MAX_INTERNAL_OUTPUT_LINES]; the routing/capture/drain logic lives in
122+
* [InternalBuildOutputCapture] so it is JVM-testable without this service.
123123
*/
124-
private val internalBuildOutput = ArrayDeque<String>()
124+
private val internalBuildOutput = InternalBuildOutputCapture(MAX_INTERNAL_OUTPUT_LINES)
125125

126126
/**
127127
* Whether an INTERNAL build is running - a build the user never asked for that goes through the
@@ -134,7 +134,7 @@ class GradleBuildService :
134134
InternalBuildBracket(
135135
// Outermost internal build: drop any tail a previous one left unread, so a failure
136136
// report quotes this build and not the last one.
137-
onFirstAcquire = { synchronized(internalBuildOutput) { internalBuildOutput.clear() } },
137+
onFirstAcquire = { internalBuildOutput.clear() },
138138
// postValue, not setValue: the bracket releases on the tooling API's thread.
139139
onHeldChanged = { held -> _internalBuildInProgress.postValue(held) },
140140
)
@@ -453,27 +453,10 @@ class GradleBuildService :
453453
}
454454

455455
override fun logOutput(line: String) {
456-
val listener = editorListener()
457-
if (listener != null) {
458-
listener.onOutput(line)
459-
return
460-
}
461-
// Suppressed because an internal build is running. Keep a bounded tail anyway: if that
462-
// build FAILS this is the only copy of Gradle's reason, since the tooling API's own
463-
// failure is a bare enum. See takeInternalBuildOutput.
464-
synchronized(internalBuildOutput) {
465-
if (internalBuildOutput.size >= MAX_INTERNAL_OUTPUT_LINES) {
466-
internalBuildOutput.removeFirst()
467-
}
468-
internalBuildOutput.addLast(line)
469-
}
470-
internalBuildProgress?.let { report ->
471-
try {
472-
report(line)
473-
} catch (e: Exception) {
474-
log.warn("Internal build progress listener threw", e)
475-
}
476-
}
456+
// When the editor's listener is suppressed (an internal build is running), a bounded
457+
// tail is kept anyway: if that build FAILS it is the only copy of Gradle's reason,
458+
// since the tooling API's own failure is a bare enum. See takeInternalBuildOutput.
459+
internalBuildOutput.onLine(line, editorListener(), internalBuildProgress)
477460
}
478461

479462
/**
@@ -484,12 +467,7 @@ class GradleBuildService :
484467
*
485468
* @return the captured lines, oldest first; empty when nothing was captured.
486469
*/
487-
fun takeInternalBuildOutput(): List<String> =
488-
synchronized(internalBuildOutput) {
489-
val captured = internalBuildOutput.toList()
490-
internalBuildOutput.clear()
491-
captured
492-
}
470+
fun takeInternalBuildOutput(): List<String> = internalBuildOutput.drain()
493471

494472
override fun prepareBuild(buildInfo: BuildInfo): CompletableFuture<ClientGradleBuildConfig> =
495473
CompletableFuture.supplyAsync {

‎app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildTuner.kt‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import com.itsaky.androidide.tooling.api.messages.BuildId
1010
import com.itsaky.androidide.tooling.api.messages.GradleBuildParams
1111
import org.slf4j.LoggerFactory
1212

13-
/** @author Akash Yadav */
13+
/**
14+
* Applies to EVERY Gradle build, Quick Build experiments flag on or off - deliberately NOT
15+
* gated behind FeatureFlags.isExperimentsEnabled: the 384m Metaspace floor fixes builds that
16+
* previously died in OutOfMemoryError: Metaspace (see BalancedStrategy.GRADLE_METASPACE_MB),
17+
* and the tiered daemon idle timeouts are what keep the IDE itself (and, flag on, the
18+
* quick-build daemon) resident on low-RAM devices after the user stops building.
19+
*
20+
* @author Akash Yadav
21+
*/
1422
object GradleBuildTuner {
1523
private val logger = LoggerFactory.getLogger(GradleBuildTuner::class.java)
1624

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.itsaky.androidide.services.builder
2+
3+
import org.slf4j.LoggerFactory
4+
5+
/**
6+
* Routes one Gradle output line while an internal build may be suppressing the editor's build
7+
* UI (see [GradleBuildService.logOutput]): lines go to the editor's listener when it is
8+
* listening, otherwise into a bounded tail plus the internal progress listener.
9+
*
10+
* The tail exists because a suppressed build that FAILS has no other copy of Gradle's reason -
11+
* the tooling API's own failure is a bare enum. Guarded by the deque itself: written from the
12+
* tooling API's thread, drained from the caller's.
13+
*
14+
* @param maxLines how much tail to keep; oldest lines are dropped beyond it. Gradle puts the
15+
* cause at the END of the stream, so a tail is the right shape.
16+
*/
17+
class InternalBuildOutputCapture(
18+
private val maxLines: Int,
19+
) {
20+
private val lines = ArrayDeque<String>()
21+
22+
/**
23+
* Routes one Gradle output line.
24+
*
25+
* @param editorListener the editor's build listener, or null while it is suppressed.
26+
* @param progressListener where suppressed lines are additionally reported; it cannot veto
27+
* the capture - a throwing listener is logged and the line is kept.
28+
*/
29+
fun onLine(
30+
line: String,
31+
editorListener: GradleBuildService.EventListener?,
32+
progressListener: ((String) -> Unit)?,
33+
) {
34+
if (editorListener != null) {
35+
editorListener.onOutput(line)
36+
return
37+
}
38+
synchronized(lines) {
39+
if (lines.size >= maxLines) {
40+
lines.removeFirst()
41+
}
42+
lines.addLast(line)
43+
}
44+
progressListener?.let { report ->
45+
try {
46+
report(line)
47+
} catch (e: Exception) {
48+
log.warn("Internal build progress listener threw", e)
49+
}
50+
}
51+
}
52+
53+
/**
54+
* Takes and clears the captured lines, oldest first; empty when nothing was captured.
55+
* Draining rather than reading, so one failure's report can never be quoted against the
56+
* next build.
57+
*/
58+
fun drain(): List<String> =
59+
synchronized(lines) {
60+
val captured = lines.toList()
61+
lines.clear()
62+
captured
63+
}
64+
65+
/** Drops any tail a previous internal build left unread. */
66+
fun clear() {
67+
synchronized(lines) { lines.clear() }
68+
}
69+
70+
companion object {
71+
private val log = LoggerFactory.getLogger(InternalBuildOutputCapture::class.java)
72+
}
73+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ class BuildViewModel : ViewModel() {
169169
}
170170
}
171171

172+
/**
173+
* Re-arms [BuildState.AwaitingInstall] when an install dispatch was dropped before anything
174+
* user-visible happened (ADFA-4128): the flag-on install path parses the APK on IO after
175+
* [installationAttempted] has already reset the state, so a configuration change mid-parse
176+
* cancels the dispatch and would otherwise turn a successful build into no install and no
177+
* message. This ViewModel outlives the activity, so the recreated activity's collector sees
178+
* the re-armed state and retries. Only fires from [BuildState.Idle], so it cannot stomp a
179+
* build the user started in the meantime.
180+
*/
181+
fun reArmInstall(state: BuildState.AwaitingInstall) {
182+
if (_buildState.value is BuildState.Idle) {
183+
_buildState.value = state
184+
}
185+
}
186+
172187
/** Call this after the error has been shown once, so a lifecycle replay does not re-flash it. */
173188
fun errorDisplayed() {
174189
if (_buildState.value is BuildState.Error) {

0 commit comments

Comments
 (0)