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
- `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.
- 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
implementation(libs.play.services.location)
// Permissions
implementation(libs.accompanist.permissions)
// Debug
debugImplementation(libs.androidx.ui.tooling)
}

View file

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

View file

@ -6,7 +6,6 @@ lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
camerax = "1.4.1"
accompanist = "0.36.0"
exifinterface = "1.3.7"
playServicesLocation = "21.3.0"
@ -36,9 +35,6 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa
# Location
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]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

View file

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