MohamedRejeb / Calf

Calf is a library that allows you to easily create adaptive UIs and access platform specific APIs with Compose Multiplatform (Adaptive UI, File Picker, WebView, Permissions...).
https://mohamedrejeb.github.io/Calf/
Apache License 2.0
907 stars 41 forks source link

PHPicker NSUrl is only a temporary file #54

Closed iruizmar closed 4 months ago

iruizmar commented 8 months ago

According to PHPicker's documentation, the NSUrl returned on the loadFileRepresetion handler is only temporary and will get deleted when the callback finishes:

https://developer.apple.com/documentation/foundation/nsitemprovider/2888338-loadfilerepresentation/#discussion

This method writes a copy of the file’s data to a temporary file, which the system deletes when the completion handler returns.

This means that if you get the byteArray right away from the KmpFile on the callback in compose everything will work as expected, but if you store the KmpFile for a future usage, for instance uploading to the server, this won't work.

I'm not sure what's the best solution. WDYT?

amrfarid140 commented 7 months ago

I am thinking the image picker can make its own temporary file copy, update KmpFile to be disposable and expose .dispose() method to delete the file when it's not needed anymore.

We can also add safeguards to call dispose when the file picker is removed from composition. (i.e. use a DisposableEffect).

How does that sound?

iruizmar commented 7 months ago

That sounds like the correct way, yes. The problem is how to "make its own temporary file copy". Do you know how to achieve this?

bmc08gt commented 7 months ago

I think we might need https://developer.apple.com/documentation/foundation/nsitemprovider/2888335-loadinplacefilerepresentation

amrfarid140 commented 7 months ago

I ended up forking and going with my own logic. Basically replace usage of KmpFile type with kotlinx.io.Path and here's my open and copy to temp file logic. Not the prettiest but works™️

val pickerDelegate = remember(type, selectionMode, onResult) {
        object : NSObject(), PHPickerViewControllerDelegateProtocol {
            override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
                picker.dismissViewControllerAnimated(true, null)
                println("didFinishPicking: $didFinishPicking")
                didFinishPicking.forEach {
                    val result = it as? PHPickerResult ?: return@forEach

                    // this gives us NSData instead of the file 
                    result.itemProvider.loadDataRepresentationForTypeIdentifier(
                        typeIdentifier = UTTypeImage.identifier
                    ) { data, error ->
                        if (error != null) {
                            println("Error: $error")
                            return@loadDataRepresentationForTypeIdentifier
                        }
                        coroutineScope.launch(Dispatchers.IO) {
                            // if we have data then read it as a UIImage. I ended up adding some logic to handle image rotation properly
                            // We might not need the image rotation logic here.                        
                            data?.let {
                                UIImage.imageWithData(it)
                                    ?.let { image ->
                                        if (image.imageOrientation == UIImageOrientation.UIImageOrientationUp) {
                                            image
                                        } else {
                                            UIGraphicsBeginImageContextWithOptions(
                                                image.size,
                                                false,
                                                image.scale
                                            )
                                            val rect = CGRectMake(
                                                x = 0.0,
                                                y = 0.0,
                                                width = image.size.useContents { this.width },
                                                height = image.size.useContents { this.height }
                                            )
                                            image.drawInRect(rect)
                                            val normalisedImage = UIGraphicsGetImageFromCurrentImageContext()
                                            UIGraphicsEndImageContext()
                                            normalisedImage
                                        }
                                    }
                                    ?.let { image ->
                                        // Use NSFileManager to write the image data into a file in the cache directory. We can as well use the 
                                        // temp directory here.
                                        NSFileManager.defaultManager.URLForDirectory(
                                            NSCachesDirectory,
                                            NSUserDomainMask,
                                            null,
                                            true,
                                            null
                                        )?.let {
                                            val fileName = result.itemProvider.suggestedName ?: "temp_image"
                                            val url = NSURL.fileURLWithPath("$fileName.jpg", it)
                                            val jpeg = UIImageJPEGRepresentation(image, 0.5)
                                            val hasWritten = jpeg?.writeToURL(url, true)
                                            if (hasWritten == true) {
                                                onResult(listOfNotNull(url.toPath()))
                                            }
                                        }
                                    }
                            }
                        }
                    }
                }
            }
        }
    }
bmc08gt commented 7 months ago

I ended up forking and going with my own logic. Basically replace usage of KmpFile type with kotlinx.io.Path and here's my open and copy to temp file logic. Not the prettiest but works™️

val pickerDelegate = remember(type, selectionMode, onResult) {
        object : NSObject(), PHPickerViewControllerDelegateProtocol {
            override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) {
                picker.dismissViewControllerAnimated(true, null)
                println("didFinishPicking: $didFinishPicking")
                didFinishPicking.forEach {
                    val result = it as? PHPickerResult ?: return@forEach

                    // this gives us NSData instead of the file 
                    result.itemProvider.loadDataRepresentationForTypeIdentifier(
                        typeIdentifier = UTTypeImage.identifier
                    ) { data, error ->
                        if (error != null) {
                            println("Error: $error")
                            return@loadDataRepresentationForTypeIdentifier
                        }
                        coroutineScope.launch(Dispatchers.IO) {
                            // if we have data then read it as a UIImage. I ended up adding some logic to handle image rotation properly
                            // We might not need the image rotation logic here.                        
                            data?.let {
                                UIImage.imageWithData(it)
                                    ?.let { image ->
                                        if (image.imageOrientation == UIImageOrientation.UIImageOrientationUp) {
                                            image
                                        } else {
                                            UIGraphicsBeginImageContextWithOptions(
                                                image.size,
                                                false,
                                                image.scale
                                            )
                                            val rect = CGRectMake(
                                                x = 0.0,
                                                y = 0.0,
                                                width = image.size.useContents { this.width },
                                                height = image.size.useContents { this.height }
                                            )
                                            image.drawInRect(rect)
                                            val normalisedImage = UIGraphicsGetImageFromCurrentImageContext()
                                            UIGraphicsEndImageContext()
                                            normalisedImage
                                        }
                                    }
                                    ?.let { image ->
                                        // Use NSFileManager to write the image data into a file in the cache directory. We can as well use the 
                                        // temp directory here.
                                        NSFileManager.defaultManager.URLForDirectory(
                                            NSCachesDirectory,
                                            NSUserDomainMask,
                                            null,
                                            true,
                                            null
                                        )?.let {
                                            val fileName = result.itemProvider.suggestedName ?: "temp_image"
                                            val url = NSURL.fileURLWithPath("$fileName.jpg", it)
                                            val jpeg = UIImageJPEGRepresentation(image, 0.5)
                                            val hasWritten = jpeg?.writeToURL(url, true)
                                            if (hasWritten == true) {
                                                onResult(listOfNotNull(url.toPath()))
                                            }
                                        }
                                    }
                            }
                        }
                    }
                }
            }
        }
    }

Nice. Your fork published by chance? Considering just rolling the file picker source into an SDK im working on in the interim.

amrfarid140 commented 7 months ago

That fork is part of a private project, happy to share snippets but the git repo isn't public.

bmc08gt commented 7 months ago

I was able to make this work without a complete fork actually :)

bmc08gt commented 7 months ago

Have another solution that I'll be putting up as a PR soonish

iruizmar commented 7 months ago

 @bmc08gt Can you share your solution? :) Maybe we can help you with the PR.

MohamedRejeb commented 6 months ago

With KmpFile you can access NSUrl in iosMain and then you can use the `NSFileManager to save that picked file permanently. I don't think that it's the best option to handle this logic in the library unless there is an official API for that.

iruizmar commented 6 months ago

The problem is that if this is not handled by the library, then the behaviour is different on Android than on iOS. You wouldn't want to make the copy on Android since the obtained URL is permanent.

Would it make sense to add a configuration parameter for this?

bmc08gt commented 5 months ago

@bmc08gt Can you share your solution? :) Maybe we can help you with the PR.

Sorry for the delay. Had some personal stuff come up that prevented me from getting this extracted into a PR from our private repo. Good news is that its now OSS.

https://github.com/BotStacks/mobile-sdk/blob/main/chat-sdk/src/iosMain/kotlin/com/mohamedrejeb/calf/io/TemporaryFileURL.kt

https://github.com/BotStacks/mobile-sdk/blob/main/chat-sdk/src/iosMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.ios.kt#L80

MohamedRejeb commented 4 months ago

Hopefully, I will release a new version today. Now you won't need to create a temp file manually, it will be the default behavior and you can store KmpFile and use it later without issues.

Edit: v0.4.1 contains the fix.