Skip to content

Commit e2f9c8e

Browse files
authored
Merge branch 'stage' into fix/ADFA-4387
2 parents 31340d6 + 698df84 commit e2f9c8e

14 files changed

Lines changed: 511 additions & 69 deletions

File tree

‎app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.core.net.toUri
3232
import androidx.fragment.app.viewModels
3333
import androidx.lifecycle.Lifecycle
3434
import androidx.lifecycle.repeatOnLifecycle
35+
import androidx.lifecycle.withResumed
3536
import androidx.recyclerview.widget.RecyclerView
3637
import com.github.appintro.SlidePolicy
3738
import com.github.appintro.SlideSelectionListener
@@ -45,6 +46,7 @@ import com.itsaky.androidide.databinding.LayoutOnboardingPermissionsBinding
4546
import com.itsaky.androidide.events.InstallationEvent
4647
import com.itsaky.androidide.preferences.internal.prefManager
4748
import com.itsaky.androidide.tasks.doAsyncWithProgress
49+
import com.itsaky.androidide.utils.OverlayPermissionGuide
4850
import com.itsaky.androidide.utils.PermissionsHelper
4951
import com.itsaky.androidide.utils.flashError
5052
import com.itsaky.androidide.utils.isTestMode
@@ -73,7 +75,6 @@ class PermissionsFragment :
7375
private var recyclerView: RecyclerView? = null
7476
private var finishButton: MaterialButton? = null
7577
private lateinit var pulseAnimation: Animation
76-
private var awaitingOverlayGrantResult = false
7778

7879
private val storagePermissionRequestLauncher =
7980
registerForActivityResult(
@@ -97,6 +98,8 @@ class PermissionsFragment :
9798
private val logger = LoggerFactory.getLogger(PermissionsFragment::class.java)
9899
private const val KEY_PRIVACY_DISCLOSURE_SHOWN = "privacy.disclosure.shown"
99100

101+
private var awaitingOverlayGrantResult = false
102+
100103
@JvmStatic
101104
fun newInstance(context: Context): PermissionsFragment =
102105
PermissionsFragment().apply {
@@ -231,12 +234,13 @@ class PermissionsFragment :
231234
}
232235
awaitingOverlayGrantResult = false
233236

234-
if (PermissionsHelper.canDrawOverlays(requireContext())) {
235-
return
237+
viewLifecycleScope.launch {
238+
viewLifecycleOwner.withResumed {
239+
if (!PermissionsHelper.canDrawOverlays(requireContext())) {
240+
OverlayPermissionGuide.showRestrictedSettingsDialog(requireContext())
241+
}
242+
}
236243
}
237-
238-
flashError(getString(R.string.permission_overlay_restricted_settings_hint))
239-
requestSettingsTogglePermission(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
240244
}
241245

242246
private fun startIdeSetup() {

‎app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,20 @@ class BuildOutputFragment : NonEditableEditorFragment() {
9090
super.onDestroyView()
9191
}
9292

93+
/** Clears the build output, guarding against access while the fragment is detached. */
9394
override fun clearOutput() {
95+
// Avoid forcing the activityViewModels lazy init (which calls requireActivity())
96+
// when the fragment is detached, otherwise an IllegalStateException is thrown.
97+
if (!isAdded || activity == null) return
9498
buildOutputViewModel.clear()
9599
super.clearOutput()
96100
}
97101

102+
/** Returns the shareable build output, or an empty string when the fragment is detached. */
98103
override fun getShareableContent(): String {
104+
// Same guard as clearOutput(): touching buildOutputViewModel while detached
105+
// triggers requireActivity() via activityViewModels and crashes.
106+
if (!isAdded || activity == null) return ""
99107
val snapshot = buildOutputViewModel.getCachedContentSnapshot()
100108
return if (snapshot.isEmpty()) "" else BuildInfoUtils.BASIC_INFO + System.lineSeparator() + snapshot
101109
}

‎app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ constructor(
452452
suppressedGradleWarnings.any { msg.contains(it) }
453453

454454
fun clearBuildOutput() {
455-
pagerAdapter.buildOutputFragment?.clearOutput()
455+
pagerAdapter.buildOutputFragment?.takeIf { it.isAdded }?.clearOutput()
456456
}
457457

458458
fun handleDiagnosticsResultVisibility(errorVisible: Boolean) {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.itsaky.androidide.utils
2+
3+
import android.app.Activity
4+
import android.content.ActivityNotFoundException
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.net.Uri
8+
import android.provider.Settings
9+
import android.text.SpannableStringBuilder
10+
import com.itsaky.androidide.resources.R
11+
import org.slf4j.LoggerFactory
12+
13+
object OverlayPermissionGuide {
14+
15+
private val log = LoggerFactory.getLogger(OverlayPermissionGuide::class.java)
16+
17+
fun showRestrictedSettingsDialog(context: Context) {
18+
val message = SpannableStringBuilder()
19+
message.append(context.getString(R.string.permission_overlay_restricted_dialog_message))
20+
message.append("\n\n")
21+
message.appendOrderedList(*context.resources.getStringArray(R.array.overlay_restricted_settings_steps))
22+
23+
DialogUtils
24+
.newMaterialDialogBuilder(context)
25+
.setTitle(R.string.permission_overlay_restricted_dialog_title)
26+
.setMessage(message)
27+
.setPositiveButton(R.string.permission_overlay_open_app_info) { dialog, _ ->
28+
dialog.dismiss()
29+
openAppInfo(context)
30+
}
31+
.setNegativeButton(android.R.string.cancel, null)
32+
.show()
33+
}
34+
35+
private fun openAppInfo(context: Context) {
36+
val intent = Intent(
37+
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
38+
Uri.fromParts("package", context.packageName, null),
39+
)
40+
if (context !is Activity) {
41+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
42+
}
43+
try {
44+
context.startActivity(intent)
45+
} catch (e: ActivityNotFoundException) {
46+
log.error("Failed to open App info settings", e)
47+
flashError(
48+
context.getString(
49+
R.string.err_no_activity_to_handle_action,
50+
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
51+
),
52+
)
53+
}
54+
}
55+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* This file is part of AndroidIDE.
3+
*
4+
* AndroidIDE is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* AndroidIDE is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.itsaky.androidide.fragments.output
19+
20+
import com.google.common.truth.Truth.assertThat
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import org.robolectric.RobolectricTestRunner
24+
25+
/**
26+
* Regression test for ADFA-3472.
27+
*
28+
* [BuildOutputFragment.clearOutput] and [BuildOutputFragment.getShareableContent] touch
29+
* `buildOutputViewModel`, which is created via `by activityViewModels()`. Forcing that lazy
30+
* delegate while the fragment is detached calls `requireActivity()`, which throws an
31+
* [IllegalStateException] ("not attached to an activity"). The run-tasks dialog / config-change
32+
* path can invoke these methods on a detached fragment, crashing the app (Sentry ADFA-3472).
33+
*
34+
* The fix guards both methods with `if (!isAdded || activity == null) return`. These tests
35+
* assert that a detached fragment does NOT crash and returns the safe no-op values.
36+
*
37+
* Mutation-mindset: on the pre-fix code (no guard), both calls force the activityViewModels
38+
* delegate -> requireActivity() -> IllegalStateException, so each test goes RED.
39+
*/
40+
@RunWith(RobolectricTestRunner::class)
41+
class BuildOutputFragmentDetachedTest {
42+
43+
/** Verifies clearOutput() is a safe no-op on a detached fragment instead of crashing. */
44+
@Test
45+
fun `clearOutput on a detached fragment does not crash`() {
46+
// A freshly-constructed fragment that was never added to an activity is "detached":
47+
// isAdded == false and activity == null, exactly the run-tasks / config-change state
48+
// in which the Sentry crash was observed.
49+
val fragment = BuildOutputFragment()
50+
51+
assertThat(fragment.isAdded).isFalse()
52+
53+
// Pre-fix: this forces the `by activityViewModels()` delegate, which calls
54+
// requireActivity() on a detached fragment and throws IllegalStateException.
55+
// Post-fix: the guard returns early, no exception.
56+
fragment.clearOutput()
57+
}
58+
59+
/** Verifies getShareableContent() returns an empty string on a detached fragment instead of crashing. */
60+
@Test
61+
fun `getShareableContent on a detached fragment returns empty without crashing`() {
62+
val fragment = BuildOutputFragment()
63+
64+
assertThat(fragment.isAdded).isFalse()
65+
66+
// Pre-fix: forces the activityViewModels delegate -> requireActivity() -> ISE.
67+
// Post-fix: guard returns "" without touching the view model.
68+
val content = fragment.getShareableContent()
69+
70+
assertThat(content).isEmpty()
71+
}
72+
}

‎common/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ dependencies {
4343
implementation(libs.monitor)
4444

4545
testImplementation(projects.testing.common)
46+
testImplementation(libs.tests.kotlinx.coroutines)
47+
testImplementation(libs.tests.google.truth)
4648
androidTestImplementation(projects.testing.android)
4749

4850
// brotli4j

‎common/src/main/java/com/itsaky/androidide/utils/KeyedDebouncingAction.kt‎

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package com.itsaky.androidide.utils
22

33
import com.itsaky.androidide.progress.ICancelChecker
44
import com.itsaky.androidide.tasks.JobCancelChecker
5+
import kotlinx.coroutines.CancellationException
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.Dispatchers
78
import kotlinx.coroutines.ExperimentalCoroutinesApi
89
import kotlinx.coroutines.Job
910
import kotlinx.coroutines.channels.Channel
11+
import kotlinx.coroutines.channels.ClosedReceiveChannelException
1012
import kotlinx.coroutines.currentCoroutineContext
1113
import kotlinx.coroutines.ensureActive
1214
import kotlinx.coroutines.isActive
@@ -29,9 +31,13 @@ class KeyedDebouncingAction<T: Any>(
2931
val channel: Channel<T>,
3032
val job: Job,
3133
) {
34+
/** Cancels this entry's worker job and closes its channel, in that order. */
3235
fun cancel() {
33-
channel.close()
36+
// Cancel the job FIRST, then close the channel. Closing the channel first
37+
// wakes a parked receive() with a ClosedReceiveChannelException before the
38+
// job is cancelled, which can crash a worker that has no exception handling.
3439
job.cancel()
40+
channel.close()
3541
}
3642
}
3743

@@ -50,38 +56,50 @@ class KeyedDebouncingAction<T: Any>(
5056
entry.channel.trySend(key)
5157
}
5258

59+
/**
60+
* Creates a new [ActionEntry]: a CONFLATED channel plus a worker coroutine that debounces
61+
* incoming keys and runs [action] for the latest one, stopping cleanly when the channel is closed.
62+
*/
5363
@OptIn(ExperimentalCoroutinesApi::class)
5464
private fun createEntry(): ActionEntry<T> {
5565
val channel = Channel<T>(Channel.CONFLATED)
5666
val job = scope.launch(actionContext) {
5767
while (isActive) {
58-
var latestKey = channel.receive()
59-
var debouncing = true
60-
while (debouncing) {
61-
debouncing = select {
62-
onTimeout(debounceDuration) { false }
63-
channel.onReceive { newKey ->
64-
latestKey = newKey
65-
true
68+
try {
69+
var latestKey = channel.receive()
70+
var debouncing = true
71+
while (debouncing) {
72+
debouncing = select {
73+
onTimeout(debounceDuration) { false }
74+
channel.onReceive { newKey ->
75+
latestKey = newKey
76+
true
77+
}
6678
}
6779
}
68-
}
6980

70-
ensureActive()
71-
val actionJob = launch {
72-
val cancelChecker = JobCancelChecker(currentCoroutineContext()[Job])
73-
action(latestKey, cancelChecker)
74-
}
81+
ensureActive()
82+
val actionJob = launch {
83+
val cancelChecker = JobCancelChecker(currentCoroutineContext()[Job])
84+
action(latestKey, cancelChecker)
85+
}
7586

76-
select<Unit> {
77-
actionJob.onJoin {}
78-
channel.onReceive { newerKey ->
79-
actionJob.cancel()
80-
channel.trySend(newerKey)
87+
select<Unit> {
88+
actionJob.onJoin {}
89+
channel.onReceive { newerKey ->
90+
actionJob.cancel()
91+
channel.trySend(newerKey)
92+
}
8193
}
82-
}
8394

84-
actionJob.join()
95+
actionJob.join()
96+
} catch (e: ClosedReceiveChannelException) {
97+
// The channel was closed (entry cancelled). Stop the worker cleanly
98+
// instead of letting the exception propagate to an uncaught handler.
99+
break
100+
} catch (e: CancellationException) {
101+
throw e
102+
}
85103
}
86104
}
87105

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.itsaky.androidide.utils
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import kotlinx.coroutines.CoroutineExceptionHandler
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.SupervisorJob
7+
import kotlinx.coroutines.channels.ClosedReceiveChannelException
8+
import kotlinx.coroutines.delay
9+
import kotlinx.coroutines.runBlocking
10+
import org.junit.Test
11+
import java.util.concurrent.atomic.AtomicReference
12+
import kotlin.coroutines.CoroutineContext
13+
import kotlin.time.Duration.Companion.milliseconds
14+
15+
/**
16+
* Repro for ADFA-4328: cancelling a [KeyedDebouncingAction] entry whose worker is
17+
* parked on `channel.receive()` must NOT let a [ClosedReceiveChannelException]
18+
* escape to the scope's uncaught-exception handler.
19+
*
20+
* On the pre-fix baseline, `ActionEntry.cancel()` did `channel.close()` BEFORE
21+
* `job.cancel()`. Closing the channel wakes the parked `receive()` with a
22+
* [ClosedReceiveChannelException] (NOT a CancellationException), which propagates
23+
* uncaught to the [CoroutineExceptionHandler] -> the Sentry crash this ticket fixes.
24+
*
25+
* The fix swaps the order (job.cancel() first) AND wraps the worker loop in a
26+
* try/catch that swallows ClosedReceiveChannelException, so no uncaught exception fires.
27+
*/
28+
class KeyedDebouncingActionCancelTest {
29+
30+
/** Cancelling an entry whose worker is parked on receive() must not surface an uncaught exception. */
31+
@Test
32+
fun `cancelling a parked worker does not leak a ClosedReceiveChannelException`() = runBlocking {
33+
val uncaught = AtomicReference<Throwable?>(null)
34+
// A plain Job (not Supervisor of the worker) + a handler that records anything
35+
// that escapes the debounce worker coroutine.
36+
val handler = CoroutineExceptionHandler { _, t -> uncaught.set(t) }
37+
val scope = CoroutineScope(SupervisorJob() + handler)
38+
39+
val ctx: CoroutineContext = scope.coroutineContext
40+
41+
val debouncer = KeyedDebouncingAction<String>(
42+
scope = scope,
43+
debounceDuration = 50.milliseconds,
44+
actionContext = ctx,
45+
action = { _, _ -> /* never invoked: we cancel while parked on receive */ },
46+
)
47+
48+
// schedule() creates the entry + launches the worker. With a CONFLATED channel and
49+
// no further sends, the worker debounces the single key, runs the (empty) action,
50+
// then loops back and parks on channel.receive() waiting for the next key.
51+
debouncer.schedule("k")
52+
53+
// Give the worker time to: receive "k", run the empty action, loop, and PARK on
54+
// the next channel.receive(). 200ms >> 50ms debounce window.
55+
delay(200)
56+
57+
// Cancel the entry while the worker is parked on receive().
58+
debouncer.cancelPending("k")
59+
60+
// Let any uncaught exception propagate to the handler.
61+
delay(200)
62+
63+
val leaked = uncaught.get()
64+
assertThat(leaked).isNull()
65+
}
66+
}

0 commit comments

Comments
 (0)