Replace Accompanist Permissions with first-party activity-compose API

Accompanist Permissions (0.36.0) is deprecated and experimental. Migrate
to the stable ActivityResultContracts.RequestPermission /
RequestMultiplePermissions APIs already available via activity-compose.

Adds explicit state tracking with a cameraResultReceived flag to
correctly distinguish "never asked" from "permanently denied" — an
improvement over the previous Accompanist-based detection.

Bump version to 1.1.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 16:43:56 +01:00
commit 878c23bf89
5 changed files with 76 additions and 35 deletions

View file

@ -99,6 +99,5 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after
## Known limitations / future work ## Known limitations / future work
- `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing. - `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing.
- Accompanist Permissions (`0.36.0`) is deprecated; should migrate to first-party `activity-compose` API.
- Dependencies are pinned to late-2024 versions; periodic bumps recommended. - Dependencies are pinned to late-2024 versions; periodic bumps recommended.
- Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00. - Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00.

View file

@ -112,9 +112,6 @@ dependencies {
// Location // Location
implementation(libs.play.services.location) implementation(libs.play.services.location)
// Permissions
implementation(libs.accompanist.permissions)
// Debug // Debug
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
} }

View file

@ -2,12 +2,15 @@ package no.naiv.tiltshift
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -26,23 +29,24 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import no.naiv.tiltshift.ui.CameraScreen import no.naiv.tiltshift.ui.CameraScreen
import no.naiv.tiltshift.ui.theme.AppColors import no.naiv.tiltshift.ui.theme.AppColors
@ -66,21 +70,47 @@ class MainActivity : ComponentActivity() {
} }
} }
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
private fun TiltShiftApp() { private fun TiltShiftApp() {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) val context = LocalContext.current
val locationPermissions = rememberMultiplePermissionsState( val activity = context as? ComponentActivity
listOf(
Manifest.permission.ACCESS_FINE_LOCATION, var cameraGranted by remember {
Manifest.permission.ACCESS_COARSE_LOCATION mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) )
) }
var locationGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED
)
}
// Track whether the camera permission dialog has returned a result,
// so we can distinguish "never asked" from "permanently denied"
var cameraResultReceived by remember { mutableStateOf(false) }
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
cameraGranted = granted
cameraResultReceived = true
}
val locationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
locationGranted = permissions.values.any { it }
}
// Request camera permission on launch // Request camera permission on launch
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!cameraPermission.status.isGranted) { if (!cameraGranted) {
cameraPermission.launchPermissionRequest() cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
} }
} }
@ -90,28 +120,47 @@ private fun TiltShiftApp() {
.background(Color.Black) .background(Color.Black)
) { ) {
when { when {
cameraPermission.status.isGranted -> { cameraGranted -> {
// Camera permission granted - show camera // Camera permission granted - show camera
CameraScreen() CameraScreen()
// Request location in background (for EXIF GPS) // Request location in background (for EXIF GPS)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!locationPermissions.allPermissionsGranted) { if (!locationGranted) {
locationPermissions.launchMultiplePermissionRequest() locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
} }
} }
} }
else -> { else -> {
// Permanently denied: not granted AND rationale not shown // Permanently denied: user has responded to the dialog, but permission
val cameraPermanentlyDenied = !cameraPermission.status.isGranted && // is still denied and the system won't show the dialog again
!cameraPermission.status.shouldShowRationale val cameraPermanentlyDenied = cameraResultReceived &&
activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(
it, Manifest.permission.CAMERA
)
} ?: false
// Show permission request UI // Show permission request UI
PermissionRequestScreen( PermissionRequestScreen(
onRequestCamera = { cameraPermission.launchPermissionRequest() }, onRequestCamera = {
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() }, cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
cameraGranted = cameraPermission.status.isGranted, },
locationGranted = locationPermissions.allPermissionsGranted, onRequestLocation = {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
},
cameraGranted = false,
locationGranted = locationGranted,
cameraPermanentlyDenied = cameraPermanentlyDenied cameraPermanentlyDenied = cameraPermanentlyDenied
) )
} }

View file

@ -6,7 +6,6 @@ lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3" activityCompose = "1.9.3"
composeBom = "2024.12.01" composeBom = "2024.12.01"
camerax = "1.4.1" camerax = "1.4.1"
accompanist = "0.36.0"
exifinterface = "1.3.7" exifinterface = "1.3.7"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
@ -36,9 +35,6 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa
# Location # Location
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
# Accompanist for permissions
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

View file

@ -1,4 +1,4 @@
versionMajor=1 versionMajor=1
versionMinor=1 versionMinor=1
versionPatch=1 versionPatch=2
versionCode=3 versionCode=4