Implementing REST Clients and Multipart Uploads with Retrofit on Android
Gradle Dependencies
Add the core Retrofit library, a JSON converter, and an OkHttp logging interceptor to the module-level build.gradle file.
dependencies {
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.10.0"
}
Manifest and Security Configuration
Declare internet access and storage permissions. Configure cleartext traffic allowance and register a FileProvider for secure file URI sharing.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:networkSecurityConfig="@xml/security_policy"
... >
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.file_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
Create res/xml/security_policy.xml to permit HTTP traffic during development:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
Define res/xml/file_paths.xml for external storage access:
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="shared_media" path="." />
</paths>
API Contract Definition
Define endpoints using Retrofit annotations. Form-encoded POST requests and multipart file uploads are declared as interface methods.
public interface RemoteApiContract {
@POST("v1/catalog")
@FormUrlEncoded
Call<ApiResponse> fetchCatalog(
@Field("page_index") int pageIndex,
@Field("page_size") int pageSize
);
@POST("v1/media/publish")
@Multipart
Call<UploadResponse> submitPhoto(
@Part MultipartBody.Part filePart
);
}
Runtime Permission Handler
Android 6.0+ requires dynamic permission requests. A utility class simplifies checking and requesting storage access.
public class PermissionManager {
public static final String[] STORAGE_ACCESS = {
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
};
public static final int REQUEST_STORAGE = 202;
public static boolean ensureGranted(Activity host, String[] permissions, int requestCode) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true;
}
for (String perm : permissions) {
if (ContextCompat.checkSelfPermission(host, perm) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(host, permissions, requestCode);
return false;
}
}
return true;
}
public static boolean allApproved(int[] results) {
if (results == null || results.length == 0) return false;
for (int res : results) {
if (res != PackageManager.PERMISSION_GRANTED) return false;
}
return true;
}
}
Layout Resource
A simple interface containing a single trigger button.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/btn_trigger_upload"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Select & Upload Image"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.8" />
</androidx.constraintlayout.widget.ConstraintLayout>
Client Initialization and Request Execution
Configure the HTTP client with a logging interceptor, attach the Gson converter, and instantiate the API interface. Synchronous calls must run on a background thread, while asynchronous calls utilize callbacks on the main thread.
public class NetworkDemoActivity extends AppCompatActivity {
private RemoteApiContract remoteApi;
private static final String BASE_URL = "http://192.168.1.100:8080/";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_network_demo);
HttpLoggingInterceptor logger = new HttpLoggingInterceptor(msg -> Log.d("HttpTrace", msg));
logger.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient httpEngine = new OkHttpClient.Builder()
.addInterceptor(logger)
.connectTimeout(15, TimeUnit.SECONDS)
.build();
Retrofit adapter = new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(httpEngine)
.addConverterFactory(GsonConverterFactory.create())
.build();
remoteApi = adapter.create(RemoteApiContract.class);
findViewById(R.id.btn_trigger_upload).setOnClickListener(v -> initiateMediaPicker());
executeBackgroundSync();
}
private void executeBackgroundSync() {
new Thread(() -> {
try {
Response<ApiResponse> syncResult = remoteApi.fetchCatalog(0, 10).execute();
if (syncResult.isSuccessful() && syncResult.body() != null) {
Log.i("SyncTask", "Received: " + syncResult.body().toString());
}
} catch (IOException e) {
Log.e("SyncTask", "Network failure", e);
}
}).start();
}
private void executeAsyncCall() {
remoteApi.fetchCatalog(0, 10).enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(Call<ApiResponse> call, Response<ApiResponse> response) {
if (response.isSuccessful()) {
Log.d("AsyncTask", "Data loaded successfully");
}
}
@Override
public void onFailure(Call<ApiResponse> call, Throwable t) {
Log.w("AsyncTask", "Request cancelled or failed", t);
}
});
}
}
Media Selection and Multipart Upload
Launch the system gallery, resolve the selected URI to a file path, construct a multipart request body, and transmit it to the server.
private void initiateMediaPicker() {
if (PermissionManager.ensureGranted(this, PermissionManager.STORAGE_ACCESS, PermissionManager.REQUEST_STORAGE)) {
Intent galleryIntent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
startActivityForResult(galleryIntent, 1001);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == 1001 && resultCode == RESULT_OK && data != null) {
Uri imageUri = data.getData();
String realPath = resolveFilePath(imageUri);
if (realPath != null) {
transmitImage(realPath);
}
}
}
private String resolveFilePath(Uri contentUri) {
String[] projection = { MediaStore.Images.Media.DATA };
try (Cursor cursor = getContentResolver().query(contentUri, projection, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
return cursor.getString(index);
}
}
return null;
}
private void transmitImage(String absolutePath) {
File targetFile = new File(absolutePath);
RequestBody fileBody = RequestBody.create(MediaType.parse("image/jpeg"), targetFile);
MultipartBody.Part multipartPart = MultipartBody.Part.createFormData("attachment", targetFile.getName(), fileBody);
remoteApi.submitPhoto(multipartPart).enqueue(new Callback<UploadResponse>() {
@Override
public void onResponse(Call<UploadResponse> call, Response<UploadResponse> response) {
if (response.isSuccessful() && response.body() != null) {
Log.i("Upload", "Server response: " + response.body().getStatus());
}
}
@Override
public void onFailure(Call<UploadResponse> call, Throwable t) {
Log.e("Upload", "Transmission error", t);
}
});
}
Data Models
Plain Java objects representing API payloads. Gson automatically maps JSON keys to class fields.
public class ApiResponse {
public int total_count;
public List<CatalogItem> items;
public String next_cursor;
}
public class UploadResponse {
public String status;
public String file_url;
public long uploaded_at;
}
public class CatalogItem {
public String item_id;
public String title;
public double price;
public boolean in_stock;
public String thumbnail_url;
}