Skip to content

feat: Add 'PermissionManager' module#745

Open
NicolasBourdin88 wants to merge 3 commits intomainfrom
feat/permission-manager
Open

feat: Add 'PermissionManager' module#745
NicolasBourdin88 wants to merge 3 commits intomainfrom
feat/permission-manager

Conversation

@NicolasBourdin88
Copy link
Copy Markdown
Contributor

Add a 'PermissionManager' that allow you to easily manage permission across your application.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 15, 2026

PR Reviewer Guide 🔍

(Review updated until commit d8bb9c7)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Coroutine Leak

In waitUntilGranted, when status.isGranted is already true, the returned lambda executes action() immediately but never completes the actionFired job. This leaves the LaunchedEffect suspended indefinitely at actionFired.join(), causing a coroutine leak every time this code path is taken (e.g., when a user triggers an action while the permission is already granted).

return fun() {
    if (status.isGranted) {
        action()
    } else {
        launchPermissionRequest()
        actionFired.complete()
    }
}
Missing Remember Keys

The remember call on line 38 lacks keys. If permissionState or shouldDisplayRationale references change (e.g., during recomposition with different inputs), the stale remembered instance will be used. Add remember(permissionState, shouldDisplayRationale) to ensure correctness.

remember { SupportedApiPermissionManagerState(permissionState, shouldDisplayRationale) }

Comment on lines +36 to +38
val shouldShow = rememberSaveable { mutableStateOf(false) }
remember { SupportedApiManager(permissionState, shouldShow) }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add permissionString as a key parameter to both rememberSaveable and remember calls. This ensures that internal state is properly reset when the customPermission parameter changes, preventing stale state from persisting across different permission types. [possible issue, importance: 7]

Suggested change
val shouldShow = rememberSaveable { mutableStateOf(false) }
remember { SupportedApiManager(permissionState, shouldShow) }
}
val permissionState = rememberPermissionState(permissionString)
val shouldShow = rememberSaveable(permissionString) { mutableStateOf(false) }
remember(permissionString) { SupportedApiManager(permissionState, shouldShow) }

Comment thread gradle/core.versions.toml Outdated
Comment on lines +23 to +28
sealed interface CustomPermissionManager {
val shouldShow: Boolean

fun askPermission()
fun dismissAndAskPermission()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe PermissionManagerState or something similar?

Comment thread PermissionManager/proguard-rules.pro Outdated
Copy link
Copy Markdown
Member

@sirambd sirambd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to support required permissions as well

@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit 830a2eb

remember { UnsupportedApiManager }
} else {
val permissionState = rememberPermissionState(permissionString)
val shouldShow = rememberSaveable { mutableStateOf(false) }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add permissionType as a key to rememberSaveable to prevent state restoration issues when the permission type changes. Without a key, the shouldShow value persists across different permission types if the composable is recomposed at the same tree position, causing incorrect UI state (e.g., showing rationale dialog for a different permission). [possible issue, importance: 8]

Suggested change
val shouldShow = rememberSaveable { mutableStateOf(false) }
val shouldShow = rememberSaveable(permissionType) { mutableStateOf(false) }

Comment on lines +27 to +32
@Stable
@OptIn(ExperimentalPermissionsApi::class)
internal class SupportedApiManager(
private val permissionState: PermissionState,
private val _shouldShow: MutableState<Boolean>,
) : PermissionManagerState {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Remove the @Stable annotation because PermissionState from Accompanist is likely an unstable type. Marking the class as @Stable when it holds an unstable property can cause Compose to skip necessary recompositions when the permission status changes, leading to stale UI state. [possible issue, importance: 7]

Suggested change
@Stable
@OptIn(ExperimentalPermissionsApi::class)
internal class SupportedApiManager(
private val permissionState: PermissionState,
private val _shouldShow: MutableState<Boolean>,
) : PermissionManagerState {
@OptIn(ExperimentalPermissionsApi::class)
internal class SupportedApiManager(
private val permissionState: PermissionState,
private val _shouldShow: MutableState<Boolean>,
) : PermissionManagerState {

@NicolasBourdin88 NicolasBourdin88 force-pushed the feat/permission-manager branch from 830a2eb to fdc6f09 Compare April 16, 2026 06:38
@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit fdc6f09

remember { UnsupportedApiPermissionManagerState }
} else {
val permissionState = rememberPermissionState(permissionString)
val shouldDisplayRationale = rememberSaveable { mutableStateOf(false) }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add permissionType as a key parameter to rememberSaveable to ensure the rationale display state is properly associated with the specific permission type. Without this key, the saved state could incorrectly persist across different permission types if the parameter changes during recompositions. [general, importance: 6]

Suggested change
val shouldDisplayRationale = rememberSaveable { mutableStateOf(false) }
val shouldDisplayRationale = rememberSaveable(permissionType) { mutableStateOf(false) }

Comment on lines +35 to +41
override fun askPermission() {
if (permissionState.status.shouldShowRationale) {
_shouldDisplayRationale.value = true
} else {
permissionState.launchPermissionRequest()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Check if the permission is already granted before launching the permission request. Currently, if the permission is granted, shouldShowRationale is false and launchPermissionRequest() is called unnecessarily. Add an explicit check for permissionState.status.isGranted to return early and avoid redundant system calls. [general, importance: 5]

Suggested change
override fun askPermission() {
if (permissionState.status.shouldShowRationale) {
_shouldDisplayRationale.value = true
} else {
permissionState.launchPermissionRequest()
}
}
override fun askPermission() {
when {
permissionState.status.isGranted -> return
permissionState.status.shouldShowRationale -> _shouldDisplayRationale.value = true
else -> permissionState.launchPermissionRequest()
}
}

@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit 1678945

Comment on lines +58 to +77
@Composable
override fun guardedCallback(action: () -> Unit): () -> Unit = with(permissionState) {
val isGrantedFlow = remember(permission) { snapshotFlow { status.isGranted } }
val actionFired: CompletableJob = remember(permission) { Job() }

LaunchedEffect(permission) {
actionFired.join()
isGrantedFlow.first { it }
action()
}

return {
if (status.isGranted) {
action()
} else {
launchPermissionRequest()
actionFired.complete()
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Replace the CompletableJob with a MutableState flag to prevent coroutine leaks and crashes. The current implementation leaks the LaunchedEffect coroutine when permission is already granted (since actionFired is never completed), and crashes with IllegalStateException if the callback is invoked multiple times while permission is denied (completing an already-completed job). [possible issue, importance: 9]

Suggested change
@Composable
override fun guardedCallback(action: () -> Unit): () -> Unit = with(permissionState) {
val isGrantedFlow = remember(permission) { snapshotFlow { status.isGranted } }
val actionFired: CompletableJob = remember(permission) { Job() }
LaunchedEffect(permission) {
actionFired.join()
isGrantedFlow.first { it }
action()
}
return {
if (status.isGranted) {
action()
} else {
launchPermissionRequest()
actionFired.complete()
}
}
}
@Composable
override fun guardedCallback(action: () -> Unit): () -> Unit = with(permissionState) {
val isGrantedFlow = remember(permission) { snapshotFlow { status.isGranted } }
val pendingAction = remember { mutableStateOf(false) }
LaunchedEffect(permission, pendingAction.value) {
if (pendingAction.value) {
isGrantedFlow.first { it }
action()
pendingAction.value = false
}
}
return {
if (status.isGranted) {
action()
} else {
pendingAction.value = true
launchPermissionRequest()
}
}
}

@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit 6d914f3

Comment on lines +58 to +77
@Composable
override fun guardedUnlessPermissionIsGranted(action: () -> Unit): () -> Unit = with(permissionState) {
val isGrantedFlow = remember(permission) { snapshotFlow { status.isGranted } }
val actionFired: CompletableJob = remember(permission) { Job() }

LaunchedEffect(permission) {
actionFired.join()
isGrantedFlow.first { it }
action()
}

return {
if (status.isGranted) {
action()
} else {
launchPermissionRequest()
actionFired.complete()
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The CompletableJob approach has two critical issues: 1) If permission is already granted when the returned lambda is invoked, actionFired.complete() is never called, causing the LaunchedEffect coroutine to leak by suspending indefinitely at actionFired.join(). 2) If the user invokes the returned lambda multiple times while permission is not granted, actionFired.complete() throws an IllegalStateException (already completed).

Replace CompletableJob with a MutableState<Boolean> flag to safely trigger the permission observation and prevent both the coroutine leak and the crash on multiple invocations. [possible issue, importance: 9]

Suggested change
@Composable
override fun guardedUnlessPermissionIsGranted(action: () -> Unit): () -> Unit = with(permissionState) {
val isGrantedFlow = remember(permission) { snapshotFlow { status.isGranted } }
val actionFired: CompletableJob = remember(permission) { Job() }
LaunchedEffect(permission) {
actionFired.join()
isGrantedFlow.first { it }
action()
}
return {
if (status.isGranted) {
action()
} else {
launchPermissionRequest()
actionFired.complete()
}
}
}
@Composable
override fun guardedUnlessPermissionIsGranted(action: () -> Unit): () -> Unit = with(permissionState) {
val isGrantedFlow = remember(permission) { snapshotFlow { status.isGranted } }
val pendingAction = remember(permission) { mutableStateOf(false) }
LaunchedEffect(permission, pendingAction.value) {
if (pendingAction.value) {
isGrantedFlow.first { it }
action()
pendingAction.value = false
}
}
return {
if (status.isGranted) {
action()
} else {
launchPermissionRequest()
pendingAction.value = true
}
}
}

@NicolasBourdin88 NicolasBourdin88 force-pushed the feat/permission-manager branch from 6d914f3 to b91aed2 Compare April 16, 2026 13:45
@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit d8bb9c7

@sonarqubecloud
Copy link
Copy Markdown

Comment on lines +69 to +76
return fun() {
if (status.isGranted) {
action()
} else {
launchPermissionRequest()
actionFired.complete()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When the permission is already granted, the action callback is invoked twice: once immediately in this lambda and again inside the LaunchedEffect after actionFired.complete() resumes the coroutine. Use an atomic flag or CompletableDeferred to ensure the action executes only once. [possible issue, importance: 9]

Suggested change
return fun() {
if (status.isGranted) {
action()
} else {
launchPermissionRequest()
actionFired.complete()
}
}
val actionExecuted = remember(permission) { AtomicBoolean(false) }
return fun() {
if (status.isGranted) {
if (actionExecuted.compareAndSet(false, true)) action()
} else {
launchPermissionRequest()
actionFired.complete()
}
}

Comment on lines +36 to +38
val permissionState = rememberPermissionState(permissionString)
val shouldDisplayRationale = rememberSaveable { mutableStateOf(false) }
remember { SupportedApiPermissionManagerState(permissionState, shouldDisplayRationale) }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The remember block for SupportedApiPermissionManagerState lacks keys, causing it to retain the old permissionState instance if permissionType changes (e.g., from Notification to WriteExternalStorage). Add permissionState as a key to ensure the state manager updates correctly when the permission type changes. [possible issue, importance: 8]

Suggested change
val permissionState = rememberPermissionState(permissionString)
val shouldDisplayRationale = rememberSaveable { mutableStateOf(false) }
remember { SupportedApiPermissionManagerState(permissionState, shouldDisplayRationale) }
val permissionState = rememberPermissionState(permissionString)
val shouldDisplayRationale = rememberSaveable { mutableStateOf(false) }
remember(permissionState) { SupportedApiPermissionManagerState(permissionState, shouldDisplayRationale) }

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new PermissionManager Android library module intended to centralize permission handling via Jetpack Compose + Accompanist, and wires it into the multi-module build.

Changes:

  • Registers the new :PermissionManager Gradle module and publishes a new version-catalog alias for it.
  • Introduces a Compose-first PermissionManagerState API with supported/unsupported-API implementations.
  • Adds accompanist-permissions to the version catalog and uses it in the new module.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
settings.gradle.kts Includes the new :PermissionManager module in the build.
gradle/core.versions.toml Adds Accompanist permissions dependency + a new infomaniak-core-permissionmanager catalog entry.
PermissionManager/build.gradle.kts New Android library module configuration and dependencies (Compose + Accompanist).
PermissionManager/src/main/kotlin/com/infomaniak/core/permissionmanager/PermissionManagerState.kt Public rememberPermissionManagerState entry point + PermissionManagerState API.
PermissionManager/src/main/kotlin/com/infomaniak/core/permissionmanager/SupportedApiPermissionManagerState.kt Supported-permission implementation backed by Accompanist PermissionState.
PermissionManager/src/main/kotlin/com/infomaniak/core/permissionmanager/UnsupportedApiPermissionManagerState.kt No-op implementation for permissions not applicable on the current API level.
PermissionManager/src/main/kotlin/com/infomaniak/core/permissionmanager/PermissionType.kt Adds PermissionType enum mapping app concepts to Android permission strings.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

} else {
val permissionState = rememberPermissionState(permissionString)
val shouldDisplayRationale = rememberSaveable { mutableStateOf(false) }
remember { SupportedApiPermissionManagerState(permissionState, shouldDisplayRationale) }
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SupportedApiPermissionManagerState(...) is created with remember { ... } without a key, so if permissionType changes across recompositions the returned PermissionManagerState will keep referencing the old permissionState/rationale state and may request the wrong permission. Key the remember to permissionString/permissionState (or avoid remember here) so the state instance is recreated when the permission changes.

Suggested change
remember { SupportedApiPermissionManagerState(permissionState, shouldDisplayRationale) }
remember(permissionString, permissionState, shouldDisplayRationale) {
SupportedApiPermissionManagerState(permissionState, shouldDisplayRationale)
}

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +67
val isGrantedFlow = remember(permission) { snapshotFlow { status.isGranted } }
val actionFired: CompletableJob = remember(permission) { Job() }

LaunchedEffect(permission) {
actionFired.join()
isGrantedFlow.first { it }
action()
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waitUntilGranted() starts a LaunchedEffect that suspends on actionFired.join(). If the returned lambda only ever takes the status.isGranted branch, actionFired is never completed and the coroutine will remain suspended for the lifetime of the composition. Consider restructuring so no long-lived suspended effect is created unless a permission request was actually initiated (e.g., gate the effect behind a requested state).

Copilot uses AI. Check for mistakes.
@NicolasBourdin88 NicolasBourdin88 requested a review from LunarX April 16, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants