From 3a65a62addf80ef6569073c3f99ae427f857bca7 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 31 Aug 2026 15:26:13 -0500 Subject: [PATCH 1/3] fix(app): clear the foreground activity reference on destroy (ADFA-5252) _foregroundActivity was only cleared for finishing activities, so one destroyed by an unhandled config change or a background reclaim stayed retained by the StateFlow. --- .../java/com/itsaky/androidide/app/IDEApplication.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index d987d688aa..d88d1a77f8 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -171,6 +171,17 @@ class IDEApplication : _foregroundActivity.update { activity } } + /** + * [onActivityPostPaused] only clears the reference for *finishing* activities, so a rotation + * or a system kill would leave the destroyed activity retained by this global [StateFlow]. + * The compare-and-set keeps an already-resumed successor (e.g. A finishes into B) in place. + */ + override fun onActivityDestroyed(activity: Activity) { + if (_foregroundActivity.compareAndSet(activity, null)) { + logger.debug("foregroundActivity = null (destroyed {})", activity.javaClass) + } + } + @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { instance = this From ef7384173d3bc69fdcb07448246441d5208ab028 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 2 Sep 2026 15:20:10 -0500 Subject: [PATCH 2/3] test(app): cover foreground activity tracking (ADFA-5252) Robolectric tests for the three foreground-activity callbacks: resume publishes, destroy of the current activity clears, destroy of a stale one keeps its successor, and both sides of the isFinishing guard on pause. Reverting the onActivityDestroyed override fails givenTheForegroundActivity_whenItIsDestroyed_thenTheReferenceIsCleared with "expected: null but was: Activity" and nothing else. Pinned to SDK 29 because Robolectric defaults to targetSdk (28), where ActivityLifecycleCallbacks has no onActivityPreResumed/onActivityPostPaused and the super call throws NoSuchMethodError. Also documents why a plain (non-finishing) pause keeps the reference. --- .../itsaky/androidide/app/IDEApplication.kt | 5 + .../app/ForegroundActivityTrackingTest.kt | 109 ++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index d88d1a77f8..f5ed908b5b 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -157,6 +157,11 @@ class IDEApplication : val cachedFilesDir: File by lazy { instance.filesDir } } + /** + * Only a *finishing* activity releases the reference here. A plain pause leaves the activity + * alive - backgrounded, or something drawn on top of it - and callers like FlashbarUtils' + * `withActivity` still need it; [onActivityDestroyed] covers the rest. + */ override fun onActivityPostPaused(activity: Activity) { super.onActivityPostPaused(activity) if (foregroundActivity == activity && activity.isFinishing) { diff --git a/app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt b/app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt new file mode 100644 index 0000000000..1aebb06961 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt @@ -0,0 +1,109 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.app + +import android.app.Activity +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * [IDEApplication.foregroundActivityState] is process-global, so anything it still points at + * outlives the activity it names. ADFA-5252: only *finishing* activities were cleared, which + * leaked an activity destroyed by an unhandled configuration change or a background reclaim. + * + * Every case sets its own starting state through the resume callback, since the application under + * test is the process singleton. + */ +@RunWith(RobolectricTestRunner::class) +// Robolectric defaults to targetSdk (28), where Application.ActivityLifecycleCallbacks has no +// onActivityPreResumed/onActivityPostPaused at all -- resolving the super call throws +// NoSuchMethodError. Both callbacks were added in API 29. +@Config(sdk = [29]) +class ForegroundActivityTrackingTest { + private lateinit var application: IDEApplication + + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + application = ApplicationProvider.getApplicationContext() + } + + @Test + fun givenNoTrackedActivity_whenOneIsResumed_thenItBecomesTheForegroundActivity() { + val activity = activity(finishing = false) + + application.onActivityPreResumed(activity) + + assertThat(application.foregroundActivity).isSameInstanceAs(activity) + } + + @Test + fun givenTheForegroundActivity_whenItIsDestroyed_thenTheReferenceIsCleared() { + val activity = activity(finishing = false) + application.onActivityPreResumed(activity) + + application.onActivityDestroyed(activity) + + assertThat(application.foregroundActivity).isNull() + } + + @Test + fun givenASuccessorHasResumed_whenThePreviousActivityIsDestroyed_thenTheSuccessorIsKept() { + val previous = activity(finishing = true) + val current = activity(finishing = false) + application.onActivityPreResumed(previous) + application.onActivityPreResumed(current) + + // A finishes into B: B resumes before A is destroyed, so A's destroy must not win. + application.onActivityDestroyed(previous) + + assertThat(application.foregroundActivity).isSameInstanceAs(current) + } + + @Test + fun givenTheForegroundActivityIsFinishing_whenItPauses_thenTheReferenceIsCleared() { + val activity = activity(finishing = true) + application.onActivityPreResumed(activity) + + application.onActivityPostPaused(activity) + + assertThat(application.foregroundActivity).isNull() + } + + @Test + fun givenTheForegroundActivityIsNotFinishing_whenItPauses_thenTheReferenceIsKept() { + val activity = activity(finishing = false) + application.onActivityPreResumed(activity) + + application.onActivityPostPaused(activity) + + assertThat(application.foregroundActivity).isSameInstanceAs(activity) + } + + private fun activity(finishing: Boolean): Activity = + mockk(relaxed = true).also { + every { it.isFinishing } returns finishing + } +} From 67cb50c3cfd7ff2fb2c3dc113a5ae8b527e1e213 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 3 Sep 2026 08:37:28 -0500 Subject: [PATCH 3/3] test(app): restore androidide.test.mode after each case (ADFA-5252) setUp() left the JVM-global property set, leaking it into any test that ran after this class in the same worker. --- .../app/ForegroundActivityTrackingTest.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt b/app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt index 1aebb06961..6328ba7d8d 100644 --- a/app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt +++ b/app/src/test/java/com/itsaky/androidide/app/ForegroundActivityTrackingTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -43,13 +44,26 @@ import org.robolectric.annotation.Config @Config(sdk = [29]) class ForegroundActivityTrackingTest { private lateinit var application: IDEApplication + private var previousTestMode: String? = null @Before fun setUp() { - System.setProperty("androidide.test.mode", "true") + previousTestMode = System.setProperty("androidide.test.mode", "true") application = ApplicationProvider.getApplicationContext() } + @After + fun tearDown() { + // The property is JVM-global, so leaving it set would leak into any test that runs after + // this class in the same worker. + val previous = previousTestMode + if (previous == null) { + System.clearProperty("androidide.test.mode") + } else { + System.setProperty("androidide.test.mode", previous) + } + } + @Test fun givenNoTrackedActivity_whenOneIsResumed_thenItBecomesTheForegroundActivity() { val activity = activity(finishing = false)