1

I found so many questions on the topic but none had a full working example.

My case is simple:

  1. get picked image
  2. compress image
  3. upload compressed image to Firebase Storage

My current code (without compression):

Fragment:

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when (requestCode) {
            RC_PICK_IMAGE ->
                if (resultCode == Activity.RESULT_OK)
                    data?.data?.let { viewModel.updateUserPicture(it) }
        }
    }

ViewModel:

    fun updatePhotoUrl(photoUrl: Uri?): Task<Void> =
        Storage.uploadFile("/$PS_USERS/$uid", photoUrl)
                    .continueWithTask { ... }

Storage: (object to wrap Firebase interactions)

    fun uploadFile(path: String, uri: Uri): Task<Uri> {
        val imageRef = storageRef.child(path)
        val uploadTask = imageRef.putFile(uri)

        // Return the task getting the download URL
        return uploadTask.continueWithTask { task ->
            if (!task.isSuccessful) {
                task.exception?.let {
                    throw it
                }
            }
            imageRef.downloadUrl
        }
    }

This works perfectly.

Now my question is: what is the right way to add compression in this process?

  • I found many answers pointing to the Compressor library (like here & here) and tried it, but it doesn't work with gallery result uris. I found ways to get the actual uri from that (like here), but they feel like a lot of boilerplate code, so it doesn't feel like the best practice.
  • I also found many answers using bitmap.compress (like here & here) and tried it too, but it asks for a bitmap. Getting the bitmap is easy with MediaStore.Images.Media.getBitmap, but it is deprecated, and this solution made me doubt if it's the right direction. Also, holding the bitmap in my LiveData object (to show on screen until the actual save, in the edit screen), instead of a uri, feels weird (I use Glide for presenting the images).

On top of that, both solutions demand a context. I think compression is a process that should belong in the backend (or Repository class. The Storage object in my case), So they feel a bit off.

Can someone please share a full working example of this trivial use case?

Omer Levy
  • 347
  • 4
  • 11

3 Answers3

4

Use this it might help: Call compressAndSetImage from onActivityResult by passing compressAndSetImage(data.data)

fun compressAndSetImage(result: Uri){
        val job = Job()
        val uiScope = CoroutineScope(Dispatchers.IO + job)
        val fileUri = getFilePathFromUri(result, context!!) 
        uiScope.launch {
            val compressedImageFile = Compressor.compress(context!!, File(fileUri.path)){
                quality(50) // combine with compressor constraint
                format(Bitmap.CompressFormat.JPEG)
            }
            resultUri = Uri.fromFile(compressedImageFile)

            activity!!.runOnUiThread {
                resultUri?.let {
                    //set image here 
                }
            }
        }
    }

To work around this issue i had to do first convert this path to a real path and i was able to solve this issue that way.. First of all this is the dependency to be added in the build.gradle (app) : //image compression dependency

implementation 'id.zelory:compressor:3.0.0'

//converting uri path to real path

@Throws(IOException::class)
fun getFilePathFromUri(uri: Uri?, context: Context?): Uri? {
    val fileName: String? = getFileName(uri, context)
    val file = File(context?.externalCacheDir, fileName)
    file.createNewFile()
    FileOutputStream(file).use { outputStream ->
        context?.contentResolver?.openInputStream(uri).use { inputStream ->
            copyFile(inputStream, outputStream)
            outputStream.flush()
        }
    }
    return Uri.fromFile(file)
}

@Throws(IOException::class)
private fun copyFile(`in`: InputStream?, out: OutputStream) {
    val buffer = ByteArray(1024)
    var read: Int? = null
    while (`in`?.read(buffer).also({ read = it!! }) != -1) {
        read?.let { out.write(buffer, 0, it) }
    }
}//copyFile ends

fun getFileName(uri: Uri?, context: Context?): String? {
    var fileName: String? = getFileNameFromCursor(uri, context)
    if (fileName == null) {
        val fileExtension: String? = getFileExtension(uri, context)
        fileName = "temp_file" + if (fileExtension != null) ".$fileExtension" else ""
    } else if (!fileName.contains(".")) {
        val fileExtension: String? = getFileExtension(uri, context)
        fileName = "$fileName.$fileExtension"
    }
    return fileName
}

fun getFileExtension(uri: Uri?, context: Context?): String? {
    val fileType: String? = context?.contentResolver?.getType(uri)
    return MimeTypeMap.getSingleton().getExtensionFromMimeType(fileType)
}

fun getFileNameFromCursor(uri: Uri?, context: Context?): String? {
    val fileCursor: Cursor? = context?.contentResolver
        ?.query(uri, arrayOf<String>(OpenableColumns.DISPLAY_NAME), null, null, null)
    var fileName: String? = null
    if (fileCursor != null && fileCursor.moveToFirst()) {
        val cIndex: Int = fileCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
        if (cIndex != -1) {
            fileName = fileCursor.getString(cIndex)
        }
    }
    return fileName
}
Hascher
  • 486
  • 1
  • 3
  • 12
  • Hi, thanks for your answer! I tried that (already and now again to share error) but it does not work with uri result from image picker, as it is not a real file (which I understand is the core problem in all the solutions I found). `Compressor.compress(context!!, File(result.path))` throws: `kotlin.io.NoSuchFileException: /-1/1/content:/media/external/images/media/25/ORIGINAL/NONE/1161564586: The source file doesn't exist.`. How can I get a uri that works in that function?? – Omer Levy Jan 21 '21 at 20:43
  • I have edited my answer.. Please review it and if any issue still arises please let me know. But hopefully your issue will be solved this way. – Hascher Jan 22 '21 at 10:05
  • Ok, now this works. My problem with this solution is that it's like 75 lines of boilerplate code, that actually opens a new file and copies all the data from the uri to it. Is that really the best way to compress an image from the standard gallery picker? Please review my answer [below](https://stackoverflow.com/a/65836426/13674106) - it achieves the same result in less than 10 lines of code - without using the Compressor library. I wonder why I never saw anyone suggesting it, while most people point to the Compressor library - could you please tell me the advantages of your solution? – Omer Levy Jan 22 '21 at 14:35
  • Actually the issue that you were facing earlier about the NoSuchFleException, to resolve that we had to work around like we did.. I wouldnt call it the best solution but to resolve that issue, i guess this is the way of doing it since i myself faced alot of times this same problem.. And besides take an example what if we want to choose a photo from a drive or somewhere online and not in gallery, then this is the way of making things work.. Yes your solution is also upto the mark but again couldnt you try to pick an image not from gallery and see if your solution works.. – Hascher Jan 23 '21 at 11:55
  • 1
    Let me know if further help needed and do let me know so that i too get more information :) – Hascher Jan 23 '21 at 11:56
  • I checked my method when choosing photo from Google photos and it works good. I did find a problem though: The image orientation changes sometimes! Just like [here](https://stackoverflow.com/questions/51310535/image-rotates-after-bitmap-compress) (he uses the exact code I use - couldn't I find it before?? XD lol). I guess your solution does not face that problem (as it copies the file and not just the bitmap). I'll try to solve that in my approach and than compare the two workarounds. Thanks for all your help and I'll keep you tuned! :) – Omer Levy Jan 24 '21 at 15:57
  • Sure thing i will be glad to also gain more insight :) – Hascher Jan 24 '21 at 16:28
  • There are solutions for the orientation problem [here](https://stackoverflow.com/questions/13596500/android-image-resizing-and-preserving-exif-data-orientation-rotation-etc) but they actually demand a new file themselves. That makes my approach creating a file as well, while copying the data and metadata separately. As I understand from the solutions in the link - the Exif metadata fields can change between android versions. Thus, I think your solution is more robust - it is a one time dirty job, but it'll probably "hold" for longer before a refactor is needed. Thanks again! – Omer Levy Jan 25 '21 at 10:40
  • Just a small question as I'm new to Kotlin Coroutines: Is it possible to get a return value from the Job? I would like to return the `resultUri` and then run an `onCompletion` block and use it there, but I can't seem to find a way to do that without blocking the main thread (which I think is silly) - is there a way to achieve that? – Omer Levy Jan 25 '21 at 14:19
  • Yes you sure can do that. It can be achieved as below: uiScope.launch { val result = async { //your code comes here } result?.let { //further processing } } This will do the trick. – Hascher Jan 25 '21 at 14:57
  • But both are in the launch block - I meant getting the uri of the compressed image when the launch block has finished (like setting a listener). I guess I'll have to read about Coroutines to get familiar with them haha. – Omer Levy Jan 25 '21 at 16:38
0

The process should be:

  1. User picks the image and it gets displayed in an ImageView (scaleTyle should be centerCrop).

  2. User clicks on save button and we start the upload task with the image bytes, like this:

     val uploadTask = profilePicturesReference.putBytes(getImageBytes())
    
     private fun getImageBytes(): ByteArray {
         val bitmap = Bitmap.createBitmap(my_profile_imageView.width, my_profile_imageView.height, Bitmap.Config.ARGB_8888)
    
         val canvas = Canvas(bitmap)
    
         my_profile_imageView.draw(canvas)
    
         val outputStream = ByteArrayOutputStream()
    
         bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)    //here, 100 is the quality in %
    
         return outputStream.toByteArray()
     }
    

Demo: https://www.youtube.com/watch?v=iTXCn3NVqDM


Upload Original Size Image to Firebase Storage:

val uploadTask = profilePicturesReference.putFile(fileUri)
    
Sam Chen
  • 7,597
  • 2
  • 40
  • 73
  • Thanks for your answer! Interesting approach, but what if I want a compressed version of the original picture? In my case, the edit screen shows the image in a small ImageView, and in the display screen it is shown in a much bigger view. In addition, it is circle cropped, and might show in a square crop in the future. If I follow your approach I'll get a picture matching specifically the small edit screen ImageView size and crop (or smaller views). – Omer Levy Jan 21 '21 at 18:49
0

Best solution I could come with is that:

Fragment:

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when (requestCode) {
            RC_PICK_IMAGE ->
                if (resultCode == Activity.RESULT_OK)
                    data?.data?.let { uri ->
                        context?.let {
                        viewModel.updateUserPicture(uriToCompressedBitmap(it, uri)) 
                }
            }
        }
    }

    fun uriToCompressedBitmap(context: Context, uri: Uri): ByteArray {
        val pfd = context.contentResolver.openFileDescriptor(uri, "r")
        val bitmap =
            BitmapFactory.decodeFileDescriptor(pfd?.fileDescriptor, null, null)
        val baos = ByteArrayOutputStream()
        bitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos)
        return baos.toByteArray()
    }

(And of course: The Uri argument of updateUserPicture and uploadFile is changed to ByteArray, and the call to putFile is changed to putBytes)

This link was helpful to me.

I don't really like the use of bitmaps instead of uris all across the app, but this is the best that worked for me up to now. If anyone has a better solution please share:)

EDIT:

This solution suffers from loss of metadata like described here. It is solvable of course (here), but I think that because the metadata fields can change between android versions, Hascher7's solution above is more robust.

Omer Levy
  • 347
  • 4
  • 11