I have a contract on the backend that is described as shown in the image below:
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.
When I send the image through the Android app, you can see that it is actually being sent, as shown in the screenshot below:
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.
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>
}
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
)