Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions common-ktx/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
id("maven-publish")
}

Expand Down Expand Up @@ -39,6 +40,10 @@ android {
}
}

buildFeatures {
compose = true
}

publishing {
singleVariant("release") {
withSourcesJar()
Expand All @@ -52,6 +57,14 @@ dependencies {
implementation(libs.material)
implementation(libs.gson)

// Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.runtime.saveable)

// Permissions
implementation(libs.accompanist.permissions)

androidTestImplementation(libs.test.ext.junit)
androidTestImplementation(libs.test.espresso.core)
androidTestImplementation(libs.test.hamcrest)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package co.nimblehq.common.extensions

import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale

/**
* Handler for managing permission requests in Compose.
*
* @param permission The permission string to request.
* @param isGranted Whether the permission is granted.
* @param shouldShowRationale Whether to show rationale before requesting.
* @param isPermanentlyDenied Whether the permission is permanently denied.
* @param launchRequest Function to launch the permission request.
* @param openSettings Function to open app settings.
*/
@Immutable
data class PermissionsHandler(
val permission: String,
val isGranted: Boolean,
val shouldShowRationale: Boolean,
val isPermanentlyDenied: Boolean,
val launchRequest: () -> Unit,
val openSettings: (Context) -> Intent,
)

/**
* Remember a permission handler for managing permission requests.
*
* @param permission The permission string to request.
* @param onPermissionResult Callback invoked when permission result changes.
*
* @return A PermissionsHandler instance for managing the permission.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun rememberPermissionHandler(
permission: String,
onPermissionResult: ((Boolean) -> Unit)? = null,
): PermissionsHandler {
val permissionState = rememberPermissionState(permission = permission)

val isPermanentlyDenied = rememberPermanentDenialTracking(
isGranted = permissionState.status.isGranted,
shouldShowRationale = permissionState.status.shouldShowRationale,
)

onPermissionResult?.let { callback ->
LaunchedEffect(permissionState.status.isGranted) {
callback(permissionState.status.isGranted)
}
}

return remember(
permission,
permissionState.status.isGranted,
permissionState.status.shouldShowRationale,
isPermanentlyDenied,
) {
PermissionsHandler(
permission = permission,
isGranted = permissionState.status.isGranted,
shouldShowRationale = permissionState.status.shouldShowRationale,
isPermanentlyDenied = isPermanentlyDenied,
launchRequest = permissionState::launchPermissionRequest,
openSettings = { context ->
context.getAppSettingsIntent(context.packageName)
},
)
}
}

/**
* Handle permission requests with callbacks for granted and denied states.
*
* @param permission The permission string to request.
* @param onGranted Callback invoked when permission is granted.
* @param onDenied Callback invoked when permission is denied.
* @param content Content to display with the permission handler.
*/
@Composable
fun HandlePermissionsRequest(
permission: String,
onGranted: () -> Unit,
onDenied: (isPermanent: Boolean) -> Unit = {},
content: @Composable (handler: PermissionsHandler) -> Unit,
) {
val handler = rememberPermissionHandler(permission = permission)

LaunchedEffect(handler.isGranted, handler.isPermanentlyDenied) {
when {
handler.isGranted -> onGranted()
handler.isPermanentlyDenied -> onDenied(true)
handler.shouldShowRationale -> onDenied(false)
}
}

content(handler)
}

/**
* Track if permission is permanently denied.
*
* @param isGranted Whether the permission is granted.
* @param shouldShowRationale Whether to show rationale.
*
* @return True if permission is permanently denied.
*/
@Stable
@Composable
private fun rememberPermanentDenialTracking(
isGranted: Boolean,
shouldShowRationale: Boolean,
): Boolean {
var previousShouldShowRationale by rememberSaveable { mutableStateOf(shouldShowRationale) }
var rationaleTransitionedToFalse by rememberSaveable { mutableStateOf(false) }

LaunchedEffect(shouldShowRationale, isGranted) {
// Permanent denial: rationale was true, now false, and permission not granted
if (previousShouldShowRationale && !shouldShowRationale && !isGranted) {
rationaleTransitionedToFalse = true
}

previousShouldShowRationale = shouldShowRationale

if (isGranted) {
rationaleTransitionedToFalse = false
}
}

return rationaleTransitionedToFalse && !isGranted
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package co.nimblehq.common.extensions

import android.content.Intent
import android.provider.Settings
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.CoreMatchers.notNullValue
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test

class PermissionExtTest {

@Test
fun `when creating PermissionsHandler, all properties are correctly initialized`() {
val permission = "android.permission.CAMERA"
val isGranted = true
val shouldShowRationale = false
val isPermanentlyDenied = false
val launchRequest: () -> Unit = {}
val openSettings: (android.content.Context) -> Intent = { Intent() }

val handler = PermissionsHandler(
permission = permission,
isGranted = isGranted,
shouldShowRationale = shouldShowRationale,
isPermanentlyDenied = isPermanentlyDenied,
launchRequest = launchRequest,
openSettings = openSettings
)

assertThat(handler.permission, `is`(permission))
assertThat(handler.isGranted, `is`(isGranted))
assertThat(handler.shouldShowRationale, `is`(shouldShowRationale))
assertThat(handler.isPermanentlyDenied, `is`(isPermanentlyDenied))
}

@Test
fun `when copying PermissionsHandler, it creates new instance with modified properties`() {
val permission = "android.permission.CAMERA"
val original = PermissionsHandler(
permission = permission,
isGranted = false,
shouldShowRationale = false,
isPermanentlyDenied = false,
launchRequest = {},
openSettings = { Intent() }
)

val modified = original.copy(isGranted = true)

assertThat(modified.isGranted, `is`(true))
assertThat(modified.permission, `is`(original.permission))
assertThat(original.isGranted, `is`(false)) // Original unchanged
}

@Test
fun `when comparing PermissionsHandler instances with same properties, they are equal`() {
val permission = "android.permission.CAMERA"
val launchRequest: () -> Unit = {}
val openSettings: (android.content.Context) -> Intent = { Intent() }

val handler1 = PermissionsHandler(
permission = permission,
isGranted = true,
shouldShowRationale = false,
isPermanentlyDenied = false,
launchRequest = launchRequest,
openSettings = openSettings
)

val handler2 = PermissionsHandler(
permission = permission,
isGranted = true,
shouldShowRationale = false,
isPermanentlyDenied = false,
launchRequest = launchRequest,
openSettings = openSettings
)

assertThat(handler1, `is`(handler2))
}

@Test
fun `when comparing PermissionsHandler instances with different permissions, they are not equal`() {
val launchRequest: () -> Unit = {}
val openSettings: (android.content.Context) -> Intent = { Intent() }

val handler1 = PermissionsHandler(
permission = "android.permission.CAMERA",
isGranted = true,
shouldShowRationale = false,
isPermanentlyDenied = false,
launchRequest = launchRequest,
openSettings = openSettings
)

val handler2 = PermissionsHandler(
permission = "android.permission.LOCATION",
isGranted = true,
shouldShowRationale = false,
isPermanentlyDenied = false,
launchRequest = launchRequest,
openSettings = openSettings
)

assertThat(handler1 == handler2, `is`(false))
}

@Test
fun `when destructuring PermissionsHandler, all properties are correctly extracted`() {
val expectedPermission = "android.permission.CAMERA"

val handler = PermissionsHandler(
permission = expectedPermission,
isGranted = true,
shouldShowRationale = false,
isPermanentlyDenied = false,
launchRequest = {},
openSettings = { Intent() }
)

val (permission, isGranted, shouldShowRationale, isPermanentlyDenied, _, _) = handler

assertThat(permission, `is`(expectedPermission))
assertThat(isGranted, `is`(true))
assertThat(shouldShowRationale, `is`(false))
assertThat(isPermanentlyDenied, `is`(false))
}
}
11 changes: 11 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ androidVersionCode = "1"
androidVersionName = "1.0.0"
libVersionName = "0.2.0"

accompanist = "0.34.0"
androidx-appcompat = "1.7.1"
androidx-compose-bom = "2024.12.01"
androidx-core-ktx = "1.17.0"
androidx-test-espresso = "3.7.0"
androidx-test-junit = "1.3.0"
Expand All @@ -25,6 +27,14 @@ material = "1.13.0"
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }

# Compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-runtime-saveable = { group = "androidx.compose.runtime", name = "runtime-saveable" }

# Accompanist
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }

# Material
material = { group = "com.google.android.material", name = "material", version.ref = "material" }

Expand All @@ -41,6 +51,7 @@ detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-form
[plugins]
android-application = { id = "com.android.application", version.ref = "gradle" }
android-library = { id = "com.android.library", version.ref = "gradle" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }