Skip to content

Commit 651ec20

Browse files
committed
fix(android): isolate timed out permission requests
1 parent 4f5e817 commit 651ec20

2 files changed

Lines changed: 244 additions & 47 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt

Lines changed: 115 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -17,81 +17,149 @@ import androidx.lifecycle.Lifecycle
1717
import androidx.lifecycle.LifecycleEventObserver
1818
import kotlinx.coroutines.CompletableDeferred
1919
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.TimeoutCancellationException
2021
import kotlinx.coroutines.suspendCancellableCoroutine
2122
import kotlinx.coroutines.sync.Mutex
2223
import kotlinx.coroutines.sync.withLock
2324
import kotlinx.coroutines.withContext
25+
import kotlinx.coroutines.withTimeout
2426
import java.util.concurrent.atomic.AtomicBoolean
2527
import kotlin.coroutines.resume
2628

27-
class PermissionRequester(
29+
class PermissionRequester internal constructor(
2830
private val activity: ComponentActivity,
31+
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
2932
) {
33+
private data class PendingPermissionRequest(
34+
val deferred: CompletableDeferred<Map<String, Boolean>>,
35+
var timedOut: Boolean = false,
36+
)
37+
38+
private class PermissionRequestSlot(
39+
val launcher: ActivityResultLauncher<Array<String>>,
40+
var request: PendingPermissionRequest? = null,
41+
)
42+
43+
constructor(activity: ComponentActivity) : this(
44+
activity = activity,
45+
launcherFactory = { callback ->
46+
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
47+
},
48+
)
49+
3050
private val mutex = Mutex()
31-
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
51+
private val requestSlotsLock = Any()
3252
private val mainHandler = Handler(Looper.getMainLooper())
33-
34-
private val launcher: ActivityResultLauncher<Array<String>> =
35-
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
36-
val p = pending
37-
pending = null
38-
p?.complete(result)
39-
}
53+
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
4054

4155
suspend fun requestIfMissing(
4256
permissions: List<String>,
4357
timeoutMs: Long = 20_000,
44-
): Map<String, Boolean> =
45-
mutex.withLock {
46-
val missing =
47-
permissions.filter { perm ->
48-
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
58+
): Map<String, Boolean> {
59+
return mutex.withLock {
60+
while (true) {
61+
val missing =
62+
permissions.filter { perm ->
63+
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
64+
}
65+
if (missing.isEmpty()) {
66+
return permissions.associateWith { true }
4967
}
50-
if (missing.isEmpty()) {
51-
return permissions.associateWith { true }
52-
}
5368

54-
val needsRationale =
55-
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
56-
if (needsRationale) {
57-
val proceed = showRationaleDialog(missing)
58-
if (!proceed) {
59-
return permissions.associateWith { perm ->
60-
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
69+
val needsRationale =
70+
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
71+
if (needsRationale) {
72+
val proceed = showRationaleDialog(missing)
73+
if (!proceed) {
74+
return permissions.associateWith { perm ->
75+
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
76+
}
6177
}
6278
}
63-
}
64-
65-
val deferred = CompletableDeferred<Map<String, Boolean>>()
66-
pending = deferred
67-
withContext(Dispatchers.Main) {
68-
launcher.launch(missing.toTypedArray())
69-
}
7079

71-
val result =
72-
withContext(Dispatchers.Default) {
73-
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
80+
val deferred = CompletableDeferred<Map<String, Boolean>>()
81+
val request = PendingPermissionRequest(deferred)
82+
val slot = reservePermissionRequestSlot(request)
83+
try {
84+
withContext(Dispatchers.Main) {
85+
slot.launcher.launch(missing.toTypedArray())
86+
}
87+
} catch (err: Throwable) {
88+
clearPermissionRequestSlot(slot, request)
89+
throw err
7490
}
7591

76-
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
77-
val merged =
78-
permissions.associateWith { perm ->
79-
val nowGranted =
80-
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
81-
result[perm] == true || nowGranted
82-
}
92+
val result =
93+
try {
94+
withTimeout(timeoutMs) { deferred.await() }
95+
} catch (err: TimeoutCancellationException) {
96+
request.timedOut = true
97+
throw err
98+
}
99+
100+
val merged =
101+
permissions.associateWith { perm ->
102+
val nowGranted =
103+
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
104+
result[perm] == true || nowGranted
105+
}
83106

84-
val denied =
85-
merged.filterValues { !it }.keys.filter {
86-
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
107+
val denied =
108+
merged.filterValues { !it }.keys.filter {
109+
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
110+
}
111+
if (denied.isNotEmpty()) {
112+
showSettingsDialog(denied)
87113
}
88-
if (denied.isNotEmpty()) {
89-
showSettingsDialog(denied)
114+
115+
return merged
90116
}
117+
error("unreachable")
118+
}
119+
}
120+
121+
private fun createPermissionRequestSlot(
122+
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
123+
): PermissionRequestSlot {
124+
var slot: PermissionRequestSlot? = null
125+
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
126+
val created = PermissionRequestSlot(launcher)
127+
slot = created
128+
return created
129+
}
91130

92-
return merged
131+
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
132+
synchronized(requestSlotsLock) {
133+
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
134+
slot.request = request
135+
slot
93136
}
94137

138+
private fun completePermissionRequest(
139+
slot: PermissionRequestSlot,
140+
result: Map<String, Boolean>,
141+
) {
142+
val request =
143+
synchronized(requestSlotsLock) {
144+
slot.request.also {
145+
slot.request = null
146+
}
147+
} ?: return
148+
if (request.timedOut) return
149+
request.deferred.complete(result)
150+
}
151+
152+
private fun clearPermissionRequestSlot(
153+
slot: PermissionRequestSlot,
154+
request: PendingPermissionRequest,
155+
) {
156+
synchronized(requestSlotsLock) {
157+
if (slot.request === request) {
158+
slot.request = null
159+
}
160+
}
161+
}
162+
95163
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
96164
withContext(Dispatchers.Main) {
97165
if (activity.isFinishing || activity.isDestroyed) {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package ai.openclaw.app
2+
3+
import android.Manifest
4+
import androidx.activity.ComponentActivity
5+
import androidx.activity.result.ActivityResultLauncher
6+
import androidx.activity.result.contract.ActivityResultContract
7+
import androidx.activity.result.contract.ActivityResultContracts
8+
import androidx.core.app.ActivityOptionsCompat
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.ExperimentalCoroutinesApi
11+
import kotlinx.coroutines.TimeoutCancellationException
12+
import kotlinx.coroutines.async
13+
import kotlinx.coroutines.test.StandardTestDispatcher
14+
import kotlinx.coroutines.test.advanceTimeBy
15+
import kotlinx.coroutines.test.resetMain
16+
import kotlinx.coroutines.test.runCurrent
17+
import kotlinx.coroutines.test.runTest
18+
import kotlinx.coroutines.test.setMain
19+
import org.junit.Assert.assertEquals
20+
import org.junit.Assert.assertFalse
21+
import org.junit.Assert.assertTrue
22+
import org.junit.Test
23+
import org.junit.runner.RunWith
24+
import org.robolectric.Robolectric
25+
import org.robolectric.RobolectricTestRunner
26+
import org.robolectric.annotation.Config
27+
28+
@RunWith(RobolectricTestRunner::class)
29+
@Config(sdk = [34])
30+
class PermissionRequesterTest {
31+
@Test
32+
@OptIn(ExperimentalCoroutinesApi::class)
33+
fun timedOutRequestCallbackDoesNotCompleteNextRequest() =
34+
runTest {
35+
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
36+
val launchers = mutableListOf<FakePermissionLauncher>()
37+
val requester =
38+
PermissionRequester(activity()) { callback ->
39+
FakePermissionLauncher(callback).also { launchers += it }
40+
}
41+
42+
try {
43+
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
44+
runCurrent()
45+
advanceTimeBy(11)
46+
runCurrent()
47+
48+
assertTrue(first.isCompleted)
49+
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
50+
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[0].launches)
51+
52+
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
53+
runCurrent()
54+
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
55+
56+
launchers[0].deliver(mapOf(Manifest.permission.CAMERA to false))
57+
runCurrent()
58+
59+
assertFalse(second.isCompleted)
60+
61+
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
62+
runCurrent()
63+
64+
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
65+
} finally {
66+
Dispatchers.resetMain()
67+
}
68+
}
69+
70+
@Test
71+
@OptIn(ExperimentalCoroutinesApi::class)
72+
fun timedOutRequestWithoutCallbackDoesNotBlockNextRequest() =
73+
runTest {
74+
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
75+
val launchers = mutableListOf<FakePermissionLauncher>()
76+
val requester =
77+
PermissionRequester(activity()) { callback ->
78+
FakePermissionLauncher(callback).also { launchers += it }
79+
}
80+
81+
try {
82+
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
83+
runCurrent()
84+
advanceTimeBy(11)
85+
runCurrent()
86+
87+
assertTrue(first.isCompleted)
88+
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
89+
90+
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
91+
runCurrent()
92+
93+
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
94+
95+
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
96+
runCurrent()
97+
98+
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
99+
} finally {
100+
Dispatchers.resetMain()
101+
}
102+
}
103+
104+
private fun activity(): ComponentActivity =
105+
Robolectric
106+
.buildActivity(ComponentActivity::class.java)
107+
.setup()
108+
.get()
109+
}
110+
111+
private class FakePermissionLauncher(
112+
private val callback: (Map<String, Boolean>) -> Unit,
113+
) : ActivityResultLauncher<Array<String>>() {
114+
val launches = mutableListOf<List<String>>()
115+
override val contract: ActivityResultContract<Array<String>, *> = ActivityResultContracts.RequestMultiplePermissions()
116+
117+
override fun launch(
118+
input: Array<String>,
119+
options: ActivityOptionsCompat?,
120+
) {
121+
launches += input.toList()
122+
}
123+
124+
override fun unregister() {}
125+
126+
fun deliver(result: Map<String, Boolean>) {
127+
callback(result)
128+
}
129+
}

0 commit comments

Comments
 (0)