In this post I will show you a sample of how to upload base64 encoded images to a PHP server in Android without needing to utilize the multipart data forms in Retrofit.
The Need
I recently had a project that required me to send images to a PHP server from Android using Retrofit.
My previous post below shows how to implement this in both Android and on a PHP server using Retrofit Multipart form data.
Upload Image Files To PHP Server In Android Using Retrofit
The issue I had was processing the JSON data sent from Android to the PHP server needed to be parsed a particular way. The sample above uses multipart form data. In my use case, I required the data to be sent inside the JSON data somehow for less processing overhead on the server side. NOTE: Meaning I would have needed to re-query the server data again to process the file a certain way.
Solution
The solution I found was to encode the image into base64 for the transfer then decode it on the server side. This will allow me to upload base64 encoded images to PHP server in Android using Retrofit without needing multipart data forms.
Code
AccessAPI
The AccessAPI for Retrofit to send the data to the server. This is the big change I needed for my use case. I couldn't use the MultiPart form data. I needed to send similar to what is stated below. Annotation of @POST then the data being sent annotated with @Body.
interface AccessApi {
@POST("<server_addr>.php")
fun testBaseImageFormUpload(@Body formData : FormData) : Call<FormResponse>
}
FormData
FormData holds the formatted data being sent to the PHP server.
data class FormData(
@SerializedName("desc")
val desc : String,
@SerializedName("base_image")
val baseImage : String? = null,
@SerializedName("ext")
val ext : String? = null,
)
FormResponse
FormResponse format to read response from PHP server.
data class FormResponse(
@SerializedName("code")
val code : Int,
@SerializedName("response")
val response : String
)
UserForm
UserForm is the Composable used for the user UI.
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun UserForm(
viewModel : ViewModel,
modifier: Modifier,
state: FormState,
handleEvent: (FormIntent) -> Unit
) {
var image by remember { mutableStateOf(ImageItemType("", "","")) }
val imageHandle = rememberCoroutineScope()
val selectImage =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { result ->
if (result != null) {
imageHandle.launch {
if(image.filepath.isNotEmpty()){
viewModel.deleteTempFile(image.filepath)
}
image = viewModel.createTempFileFromUriAndEncode(result)
handleEvent(FormIntent.updateImageData(image))
}
}
}
var desc by remember { mutableStateOf("") }
Column(modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = state.desc)
if(state.imageData != null) {
Text(text = state.imageData.filepath)
GlideImage(modifier = Modifier.size(200.dp), model = state.imageData.filepath, contentDescription = null)
}
Button(onClick = {
selectImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}) {
Text(text = "Select Image")
}
TextField(value = desc, onValueChange = { desc = it })
if(!state.isLoading) {
Button(onClick = {
handleEvent(FormIntent.updateBaseImageFormState(state.copy(isLoading = true, desc = desc)))
}) {
Text(text = "Post Data")
}
}
}
}
FormIntent
FormIntent is used as the interface with the UI and the viewmodel in the MVI architecture.
sealed interface FormIntent {
data class updateBaseImageFormState(val formState : FormState) : FormIntent
data class updateImageData(val imageData : ImageItemType) : FormIntent
}
FormState
FormState hold the user UI state and is updated using the viewmodel.
data class FormState (
val response : Int = 0,
val isLoading : Boolean = false,
val desc : String = "",
val imageData : ImageItemType? = null
)
ImageItemType
I created a simple data class name ImageItemType to hold required data.
data class ImageItemType(
val filepath: String,
val base : String,
val ext : String
)
ViewModel
ViewModel performs the actions for the UI. I also attached the ViewModelFactory class at bottom. I have it in a separate class file but that is your choice.
class ViewModel(val applicationContext: Application) : ViewModel() {
private val _mvistate: MutableStateFlow<FormState> = MutableStateFlow<FormState>(FormState())
val mvistate: StateFlow<FormState> = _mvistate.asStateFlow()
val fileTools = FileTools(applicationContext)
fun handleEvent(event: FormIntent) {
when (event) {
is FormIntent.updateBaseImageFormState -> {
updateBaseImageFormState(event)
}
is FormIntent.updateImageData -> {
updateImageData(event)
}
}
}
private fun updateImageData(event : FormIntent.updateImageData){
_mvistate.update {
_mvistate.value.copy(imageData = event.imageData)
}
}
private fun updateBaseImageFormState(event: FormIntent.updateBaseImageFormState) {
viewModelScope.launch {
_mvistate.update { event.formState }
if(_mvistate.value.imageData == null){
return@launch
}
val query = RetrofitHelper.getInstance(applicationContext).create(AccessApi::class.java)
val call = query.testBaseImageFormUpload(
FormData(
desc = event.formState.desc,
baseImage = event.formState.imageData?.base,
ext = event.formState.imageData?.ext
)
)
call.enqueue(object : Callback<FormResponse> {
override fun onFailure(p0: Call<FormResponse>, error: Throwable) {
viewModelScope.launch {
_mvistate.update { _mvistate.value.copy(isLoading = false, response = -1) }
}
}
override fun onResponse(p0: Call<FormResponse>, response: Response<FormResponse>) {
if (response.code() == 200) {
_mvistate.update {
_mvistate.value.copy(
isLoading = false,
response = 1
)
}
} else {
_mvistate.update {
_mvistate.value.copy(
isLoading = false,
response = -1
)
}
}
}
})
}
}
fun deleteTempFile(path : String){
viewModelScope.launch {
fileTools.deleteTempFile(File(path))
}
}
suspend fun createTempFileFromUriAndEncode(uri : Uri) : ImageItemType {
val job = viewModelScope.async {
val file = fileTools.createTempFileFromUri(uri)
var base: String? = null
if (file != null) {
base = fileTools.encodeImageToBase64(file)
val imageItem = ImageItemType(file.absolutePath, base, file.extension)
return@async imageItem
} else {
return@async ImageItemType("", "","")
}
}
return job.await()
}
}
class ViewModelFactory(private val application : Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if(modelClass.isAssignableFrom(com.itgeek25.samplebase64imageupload.viewmodel.ViewModel::class.java)){
ViewModel(application) as T
} else {
throw IllegalArgumentException("ViewModel Not Found!!")
}
}
}
FormResponses
FormResponses is posted in the previous post link above but I will add here as well. This wasn't changed.
data class FormResponse(
@SerializedName("code")
val code : Int,
@SerializedName("response")
val response : String
)
MainActivity
Finally in the MainActivity to put all this together.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val factory = ViewModelFactory(this.application)
val viewModel = ViewModelProvider(
this,
factory
)[ViewModel::class.java]
setContent {
SampleBase64ImageUploadTheme {
val state = viewModel.mvistate.collectAsStateWithLifecycle().value
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
UserForm(
viewModel,
modifier = Modifier.padding(innerPadding),
state,
viewModel::handleEvent)
}
}
}
}
}
Note About the FileTools
I added more to the FileTools class and rewrote it a bit. After the user selects an image from the Android picker dialog, the FileTools class is used.
FileTools Functions Exampled
createTempFileFromUri | method will get called with the Uri of the selected image. This uri is used in the query of the Android contentresolver to get the image location. This image is temporarily created in the app cache directory |
createTempFileScaleBitmap | method does the same as above but just scales the image to a set value hardcoded in the method |
deleteTempFile | method is just to clean things up after the image is successfully uploaded to server or could be used if the user changes their mind and wants to upload a different image form the Android picker dialog |
encodeImageToBase64 | method to encode the given file to base64 and return a string |
FileTools
class FileTools(val context: Context) : FileToolsInf {
override suspend fun createTempFileFromUri(uri: Uri): File? {
var filename = ""
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
if (cursor != null) {
val columnName = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
filename = cursor.getString(columnName) ?: ""
}
}
Log.i("Filename: ", filename)
if (filename.isEmpty()) return null
val tempFile = File(context.cacheDir, filename)
withContext(Dispatchers.IO) {
tempFile.createNewFile()
val inputStream = context.contentResolver.openInputStream(uri)
val outputStream = withContext(Dispatchers.IO) {
FileOutputStream(tempFile)
}
outputStream.use {
inputStream?.copyTo(it)
}
}
return tempFile
}
override suspend fun createTempFileScaleBitmap(uri: Uri): File? {
var filename = ""
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
if (cursor != null) {
val columnName = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
filename = cursor.getString(columnName) ?: ""
}
}
Log.i("Filename: ", filename)
if (filename.isEmpty()) return null
val tempFile = File(context.cacheDir, filename)
withContext(Dispatchers.IO) {
tempFile.createNewFile()
val inputStream = context.contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
Log.d("Original", "Width: ${bitmap.width} - Height: ${bitmap.height}")
val width = 480
val aspectRatio = bitmap.width / bitmap.height.toFloat()
val height = width / aspectRatio
Log.d("Scaling", "Width: $width - Aspect: $aspectRatio - Height: $height")
val newBitmap = Bitmap.createScaledBitmap(bitmap, width, height.toInt(), false)
val outputStream = withContext(Dispatchers.IO) {
FileOutputStream(tempFile)
}
newBitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
outputStream.use {
inputStream?.copyTo(it)
}
}
return tempFile
}
override suspend fun deleteTempFile(tempFile: File) {
Log.i("TempFile", tempFile.absolutePath)
if (tempFile.exists()) {
Log.i("TempFile", "Exists!")
if (tempFile.delete()) Log.i("TempFile", "Cached file deleted")
}
}
override suspend fun getFile(uri: Uri): File {
return uri.toFile()
}
override suspend fun encodeImageToBase64(file: File): String {
return Base64.encodeToString(file.readBytes(), Base64.DEFAULT).trim()
}
}
interface FileToolsInf {
suspend fun createTempFileFromUri(uri: Uri): File?
suspend fun createTempFileScaleBitmap(uri: Uri): File?
suspend fun deleteTempFile(tempFile: File)
suspend fun getFile(uri: Uri): File
suspend fun encodeImageToBase64(file: File): String
}
PHP
Finally this is the main code to get the image in PHP on your server.
<?php
if(!isset($_POST)){
echo json_encode(array(
'code' => 2,
'response' => "Error"
));
exit;
}
$data = json_decode(file_get_contents('php://input'));
$baseImage = $data->base;
$image = base64_decode($baseImage);
$desc = $data->desc;
$fileExt = ".".$data->ext;
$imageDir = "./images/";
if(!file_exists($imageDir)){
mkdir($imageDir);
}
$finalImg = $imageDir.$desc.$fileExt;
file_put_contents($finalImg, $image);
echo json_encode(array(
'code' => 0,
'response' => $desc . "-" . $finalImg
));
exit;
?>
Hope this was useful.