Search code examples
androidkotlinfile-uploadretrofit2spring-webflux

Uploading multiple files with Retrofit 2 to Spring Webflux backend


I get the following error when I try to upload multiple files with retrofit 2:

org.springframework.core.codec.DecodingException: Could not find first boundary

When I upload multiple files with postman on the same API endpoint, it works perfectly.

Server controller endpoint:

    @PostMapping("{loveSpotId}/photos")
    suspend fun uploadToSpot(
        @PathVariable loveSpotId: Long,
        @RequestPart("photos") filePartFlux: Flux<FilePart>
    ) {
        loveSpotPhotoService.uploadToSpot(loveSpotId, filePartFlux.asFlow())
    }

Retrofit API definition:


interface LoveSpotPhotoApi {

    @Multipart
    @POST("/lovespots/{loveSpotId}/photos")
    fun uploadToLoveSpot(
        @Path("loveSpotId") loveSpotId: Long,
        @Part photos: List<MultipartBody.Part>
    ): Call<ResponseBody>

    // ...
}

Reading photos on Android device:


if (activityResult.resultCode == Activity.RESULT_OK) {
    val itemCount: Int = activityResult.data?.clipData?.itemCount ?: 0
    val files = ArrayList<File>()
    for (i in 0 until itemCount) {
        val clipData = activityResult.data!!.clipData!!
        val uri = clipData.getItemAt(i).uri
        files.add(File(uri.path!!))
    }
    loveSpotPhotoService.uploadToLoveSpot(loveSpotId, files, this@LoveSpotDetailsActivity)
}

Client code using Retrofit:


    suspend fun uploadToLoveSpot(loveSpotId: Long, photos: List<File>, activity: Activity) {
        val loadingBarShower = LoadingBarShower(activity).show()
        withContext(Dispatchers.IO) {

            val parts: List<MultipartBody.Part> = photos.map { prepareFilePart(it) }
            val call = loveSpotPhotoApi.uploadToLoveSpot(loveSpotId, parts)
            try {
                val response = call.execute()
                loadingBarShower.onResponse()
                if (response.isSuccessful) {
                    toaster.showToast(R.string.photo_uploaded_succesfully)
                } else {
                    toaster.showResponseError(response)
                }
            } catch (e: Exception) {
                loadingBarShower.onResponse()
                toaster.showToast(R.string.photo_upload_failed)
            }
        }
    }

    private fun prepareFilePart(file: File): MultipartBody.Part {
        // create RequestBody instance from file
        val requestFile = RequestBody.create(
            MediaType.get("image/*"),
            file
        )

        // MultipartBody.Part is used to send also the actual file name
        return MultipartBody.Part.createFormData("photos", file.name, requestFile)
    }

Example headers logged on server when I upload with postman and it works:

[Authorization:"Bearer ...", User-Agent:"PostmanRuntime/7.30.0", Accept:"/", Postman-Token:"7ad875eb-2fe5-40ea-99f0-3ad34c3fa875", Host:"localhost:8090", Accept-Encoding:"gzip, deflate, br", Connection:"keep-alive", Content-Type:"multipart/form-data; boundary=--------------------------877409032202838734061440", content-length:"1555045"]

Example headers logged on server when I upload with retrofit client and it fails:

[Authorization:"Bearer ...", Content-Type:"multipart/form-data; boundary=c6177139-6b31-4d91-b66d-54772a51d963", Host:"192.168.0.143:8090", Connection:"Keep-Alive", Accept-Encoding:"gzip", User-Agent:"okhttp/3.14.9", content-length:"528"]


Solution

  • The problem was that I was not reading photos from the Android device properly. Here is my code that fixed that:

    launcher =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult ->
            handlePhotoPickerResult(activityResult)
        }
    
    fun startPickerIntent(launcher: ActivityResultLauncher<Intent>) {
        val intent = Intent()
        intent.type = "image/*"
        intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
        intent.action = Intent.ACTION_PICK
        launcher.launch(intent)
    }
    
    
    private fun handlePhotoPickerResult(activityResult: ActivityResult) {
        MainScope().launch {
            if (activityResult.resultCode == RESULT_OK) {
                val files = PhotoUploadUtils.readResultToFiles(activityResult, contentResolver)
                Log.i(this@LoveSpotDetailsActivity::class.simpleName, "Starting upload")
                val result: Boolean = if (photoUploadReviewId != null) {
                    loveSpotPhotoService.uploadToReview(
                        loveSpotId,
                        photoUploadReviewId!!,
                        files,
                        this@LoveSpotDetailsActivity
                    )
                } else {
                    loveSpotPhotoService.uploadToLoveSpot(
                        loveSpotId,
                        files,
                        this@LoveSpotDetailsActivity
                    )
                }
                photosLoaded = !result
                photosRefreshed = !result
                photoUploadReviewId = null
                if (result) {
                    Log.i(
                        this@LoveSpotDetailsActivity::class.simpleName,
                        "Upload finished, starting refreshing views."
                    )
                    startPhotoRefreshSequence()
                }
            } else {
                toaster.showToast(R.string.failed_to_access_photos)
            }
        }
    }
    
    fun readResultToFiles(
        activityResult: ActivityResult,
        contentResolver: ContentResolver
    ): List<File> {
        val itemCount: Int = activityResult.data?.clipData?.itemCount ?: 0
        val files = ArrayList<File>()
        for (i in 0 until itemCount) {
            val clipData = activityResult.data!!.clipData!!
            val uri = clipData.getItemAt(i).uri
            Log.i("uri", "$uri")
            addToFilesFromUri(uri, files, contentResolver)
        }
        return files
    }
    
    private fun addToFilesFromUri(
        uri: Uri,
        files: ArrayList<File>,
        contentResolver: ContentResolver
    ) {
        val projection = arrayOf(MediaStore.MediaColumns.DATA)
        contentResolver.query(uri, projection, null, null, null)
            ?.use { cursor ->
                if (cursor.moveToFirst()) {
                    val columnIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATA)
                    Log.i("columnIndex", "$columnIndex")
                    val filePath = cursor.getString(columnIndex)
                    Log.i("filePath", " $filePath")
                    if (filePath != null) {
                        val file = File(filePath)
                        Log.i("file", "$file")
                        files.add(file)
                    }
                }
            }
    }
    

    Rest of the code was fine.