androidioskotlinpermissionscompose-multiplatform

iOS Photo Library Access Issues in Compose Multiplatform App


I'm developing a Compose Multiplatform app that works correctly on Android but encounters issues when accessing the photo library on iOS. The app uses an ImagePicker to allow users to select images, and implements permission handling for photo library access. The error log shows:

   [PAAccessLogger] Failed to log access with error: access=<PATCCAccess 0x280102c40> accessor:<<PAApplication 0x282cac500 identifierType:auditToken identifier:{pid:42414, version:125940}>> identifier:0284C142-B9D2-4134-99B0-337308EC891E kind:intervalEnd timestampAdjustment:0 tccService:kTCCServicePhotos, error=Error Domain=PAErrorDomain Code=10 "Possibly incomplete access interval automatically ended by daemon"
    [core] "Error returned from daemon: Error Domain=com.apple.accounts Code=7 "(null)""

This error suggests that there might be issues with how the app is handling photo library access permissions or how it's interacting with the iOS photo library API. I've implemented permission requests and image picking functionality in my Compose Multiplatform project, with separate implementations for iOS in the iosMain source set. Despite these implementations, the app is still encountering errors when trying to access the photo library on iOS devices. I'm looking for insights into what might be causing this iOS-specific issue and how to resolve it while maintaining cross-platform compatibility in my Compose Multiplatform project. CommonMain:

@Composable
fun PostScreen(onEventSubmitted: () -> Unit) {
    val viewModel: PostsViewModel = KoinPlatform.getKoin().get()
    val imagePicker: ImagePicker = KoinPlatform.getKoin().get()
    val cameraPermissionManager = remember { RequestPhotoLibraryPermission() }
    val uiState by viewModel.uiState.collectAsState()

    var selectedImage by remember { mutableStateOf<ByteArray?>(null) }
    rememberCoroutineScope()

    if (uiState.pickImage) {
        imagePicker.PickImage { imageBytes ->
            viewModel.setEvent(PostsEvents.OnPickImage(false))
            selectedImage = imageBytes
        }

    }

    cameraPermissionManager.RequestCameraPermission(
        onPermissionGranted = {
            AddPostEvent(
                selectedImage = selectedImage,
                onEventSubmitted = onEventSubmitted,
                setEvent = { event ->
                    viewModel.setEvent(event)
                })
        },
        onPermissionDenied = {
            BodyMedium(text = "La fotocamera non è disponibile senza permessi")
        })


    LaunchedEffect(viewModel) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is PostsUIEffects.ShowSuccess -> {
                    println("XXXXX success")
                }

                is PostsUIEffects.ShowErrorMessage -> {
                    println("XXXXX error: ${effect.message}")
                }
            }
        }
    }
}

iosMain: request permission

actual class RequestPhotoLibraryPermission {
    @Composable
    actual fun RequestCameraPermission(
        onPermissionGranted: @Composable () -> Unit,
        onPermissionDenied: @Composable () -> Unit
    ) {
        var permissionStatus by remember { mutableStateOf(PHAuthorizationStatusNotDetermined) }

        LaunchedEffect(Unit) {
            permissionStatus = PHPhotoLibrary.authorizationStatus()
            if (permissionStatus == PHAuthorizationStatusNotDetermined) {
                permissionStatus = requestPhotoLibraryPermission()
            }
        }

        when (permissionStatus) {
            PHAuthorizationStatusAuthorized,
            PHAuthorizationStatusLimited -> onPermissionGranted()

            else -> onPermissionDenied()
        }
    }

    private suspend fun requestPhotoLibraryPermission(): PHAuthorizationStatus =
        suspendCancellableCoroutine { continuation ->
            PHPhotoLibrary.requestAuthorization { status ->
                continuation.resume(status)
            }
        }
}

iosMain ImagePicker:

actual class ImagePicker {
    private var onImagePickedCallback: ((ByteArray?) -> Unit)? = null
    private val pickerController = UIImagePickerController()

    init {
        pickerController.sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary
        pickerController.setDelegate(object : NSObject(), UIImagePickerControllerDelegateProtocol, UINavigationControllerDelegateProtocol {
            override fun imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo: Map<Any?, *>) {
                val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage
                picker.dismissViewControllerAnimated(true) {
                    if (image != null) {
                        val data = UIImageJPEGRepresentation(image, 0.8)
                        val bytes = data?.toByteArray()
                        onImagePickedCallback?.invoke(bytes)
                    } else {
                        onImagePickedCallback?.invoke(null)
                    }
                    onImagePickedCallback = null
                }
            }

            override fun imagePickerControllerDidCancel(picker: UIImagePickerController) {
                picker.dismissViewControllerAnimated(true) {
                    onImagePickedCallback?.invoke(null)
                    onImagePickedCallback = null
                }
            }
        })
    }

    @Composable
    actual fun PickImage(onImagePicked: (ByteArray?) -> Unit) {
        val scope = rememberCoroutineScope()

        LaunchedEffect(Unit) {
            scope.launch(Dispatchers.Main) {
                showImagePicker(onImagePicked)
            }
        }
    }

    private suspend fun showImagePicker(onImagePicked: (ByteArray?) -> Unit) = suspendCancellableCoroutine { continuation ->
        onImagePickedCallback = { bytes ->
            onImagePicked(bytes)
            continuation.resume(Unit)
        }

        UIApplication.sharedApplication.keyWindow?.rootViewController?.let { rootViewController ->
            rootViewController.presentViewController(pickerController, animated = true, completion = null)
        } ?: run {
            onImagePickedCallback?.invoke(null)
            continuation.resume(Unit)
        }
    }
}

@OptIn(ExperimentalForeignApi::class)
fun NSData.toByteArray(): ByteArray = ByteArray(this.length.toInt()).apply {
    usePinned {
        memcpy(it.addressOf(0), this@toByteArray.bytes, this@toByteArray.length)
    }
}

Solution

  • There are some KMP libraries that can help you pick files and images on all the platforms easily:

    Here is how to pick an image using FileKit in a Compose Multiplatform app:

    In your build.gradle:

    // Install
    sourceSets {
        commonMain.dependencies {
            implementation("io.github.vinceglb:filekit-compose:0.8.2")  
        }
    }  
    

    In your compose file:

    // FileKit Compose
    val launcher = rememberFilePickerLauncher(
        type = PickerType.Image,
    ) { file ->
        // Handle the picked file
    }
    
    Button(onClick = { launcher.launch() }) {
        Text("Pick an image")
    }
    

    You can found the documentation about this here. Also, to my knowledge, there is no need of any permissions in order to access the gallery photos when using those libraries.