Search code examples
androidkotlinretrofitmultipartfile

Sending image with multipart file format with retrofit never reaches the backend


Context

I have a contract on the backend that is described as shown in the image below:

enter image description here

As you can see, the backend expects an image file per a multipart/form-data contract.

My backend is working perfectly, because when I test the service by a postman/insomnia sending the image as a file, the image is indeed registered.

The problem

When I send the image through the Android app, you can see that it is actually being sent, as shown in the screenshot below:

enter image description here

But for some reason, this parameter is always coming up as null in my backend. And again, it works normally when the image is sent by a program that simulates http requests such as postman or insomnia.

My code

I'm providing my retrofit instance with hilt:


import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory


@Module
@InstallIn(SingletonComponent::class)
object RetrofitModule {

    private const val BASE_URL = "http://10.0.2.2:8080"
    private const val TIMEOUT_MINUTES = 3L

    @Provides
    @Singleton
    fun providesOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(TIMEOUT_MINUTES, TimeUnit.MINUTES)
        .readTimeout(TIMEOUT_MINUTES, TimeUnit.MINUTES)
        .writeTimeout(TIMEOUT_MINUTES, TimeUnit.MINUTES)
        .build()

    @Provides
    @Singleton
    fun providesRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(okHttpClient)
        .build()
}

The code I'm using to generate this image is as follows:

import android.net.Uri
import okhttp3.MultipartBody

interface MultipartImageProvider {

    fun getImage(uri: Uri): MultipartBody.Part
}
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import com.blitzsplit.create_group.domain.model.MultipartImageProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody

class AndroidMultipartImageProvider @Inject constructor(
    @ApplicationContext private val context: Context,
    private val contentResolver: ContentResolver,
) : MultipartImageProvider  {

    override fun getImage(uri: Uri): MultipartBody.Part {
        val fileDir = context.filesDir
        val file = File(fileDir, "image.png")
        val inputStream = contentResolver.openInputStream(uri)
        val outputStream = FileOutputStream(file)
        inputStream?.copyTo(outputStream)
        val requestBody = file.asRequestBody(MEDIA_TYPE_IMAGE.toMediaTypeOrNull())
        return MultipartBody.Part.createFormData(
            "image",
            file.name,
            requestBody
        )
    }


    companion object {
        private const val MEDIA_TYPE_IMAGE = "image/*"
    }
}
data class CreateGroupRequestModel(
    val userId: String,
    val name: String,
    val image: MultipartBody.Part?,
)
class CreateGroupUseCase @Inject constructor(
    private val createGroupRepository: CreateGroupRepository,
    private val userRepository: UserRepository,
    private val loadGroups: LoadGroupsUseCase,
    private val multipartImageProvider: MultipartImageProvider,
) {
    suspend operator fun invoke(
        groupName: String,
        groupImageType: GroupImageType,
    ): Result<Unit> = Result.runCatching {
        createGroupRepository.createGroup(
            CreateGroupRequestModel(
                userId = userRepository.getUser()?.id ?: throw IllegalStateException("User not found"),
                name = groupName,
                image = (groupImageType as? GroupImageType.Loaded)?.let {
                    multipartImageProvider.getImage(it.uri)
                }
            )
        ).onSuccess {
            loadGroups()
        }
    }
}
interface CreateGroupRepository {

    suspend fun createGroup(
        requestModel: CreateGroupRequestModel
    ) : Result<Unit>
}
class CreateGroupRepositoryImpl @Inject constructor(
    private val dataSource: CreateGroupDataSource,
) : CreateGroupRepository {

    override suspend fun createGroup(
        requestModel: CreateGroupRequestModel
    ): Result<Unit> = dataSource.creteGroup(
        requestModel
    )
}
class CreateGroupDataSource @Inject constructor(
    private val api: CreateGroupApi,
    private val networkDataSource: NetworkDataSource,
) {
    suspend fun creteGroup(
        requestModel: CreateGroupRequestModel
    ): Result<Unit> = networkDataSource.call {
        api.createGroup(
            id = requestModel.userId,
            photo = requestModel.image,
            name = requestModel.name.toMultipartBody()
        )
    }
}
import com.quare.blitzsplit.utils.Endpoints
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part

interface CreateGroupApi {

    @Multipart
    @POST(Endpoints.GROUPS)
    suspend fun createGroup(
        @Header("userId") id: String,
        @Part photo: MultipartBody.Part?,
        @Part name: MultipartBody.Part,
    ): Response<Unit>
}
import retrofit2.Response

interface NetworkDataSource {

    suspend fun <T> call(callback: suspend () -> Response<T>): Result<T>
}

Solution

  • The solution is simpler than it seems: It is enough that the first parameter of the createFormData method of MultipartBody.Part has the same value as the parameter expected by your backend (it works similar to gson's @SerializedName). So, in my case where the backend parameter is "photo" the only thing I should do is call the method like:

        return MultipartBody.Part.createFormData(
            "photo",
            file.name,
            requestBody
        )
    

    Before I was calling (it fails):

        return MultipartBody.Part.createFormData(
            "image",
            file.name,
            requestBody
        )
    

    enter image description here