diff --git a/common-ktx/build.gradle.kts b/common-ktx/build.gradle.kts index f601dcb..33e13fd 100644 --- a/common-ktx/build.gradle.kts +++ b/common-ktx/build.gradle.kts @@ -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") } @@ -39,6 +40,10 @@ android { } } + buildFeatures { + compose = true + } + publishing { singleVariant("release") { withSourcesJar() @@ -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) diff --git a/common-ktx/src/main/java/co/nimblehq/common/extensions/PermissionExt.kt b/common-ktx/src/main/java/co/nimblehq/common/extensions/PermissionExt.kt new file mode 100644 index 0000000..4407576 --- /dev/null +++ b/common-ktx/src/main/java/co/nimblehq/common/extensions/PermissionExt.kt @@ -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 +} diff --git a/common-ktx/src/test/java/co/nimblehq/common/extensions/PermissionExtTest.kt b/common-ktx/src/test/java/co/nimblehq/common/extensions/PermissionExtTest.kt new file mode 100644 index 0000000..fddb704 --- /dev/null +++ b/common-ktx/src/test/java/co/nimblehq/common/extensions/PermissionExtTest.kt @@ -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)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 891a42a..13cce35 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } @@ -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" }