Android Kotlin #12: Upload Gambar dari Galeri ke API
Halo lagi, calon developer aplikasi wisata!
Ini adalah artikel terakhir dari seri Android Kotlin kita. Setelah belajar autentikasi dan CRUD, saatnya kita menambahkan fitur yang bikin aplikasi makin hidup: upload gambar dari galeri atau kamera. Di API Wisata kita, ada endpoint untuk upload gambar (misal POST /api/wisata/upload/{id} atau POST /api/wisata/withimage). Nah, sekarang kita akan buat fitur di Android untuk memilih gambar, kompres biar nggak terlalu gede, lalu kirim ke server.
Yang Akan Kita Pelajari
- Memilih gambar dari galeri menggunakan ActivityResultContracts.GetContent() [citation:6].
- (Opsional) Mengambil gambar dari kamera menggunakan intent.
- Mengkompres gambar agar ukurannya kecil (menghemat bandwidth dan storage) [citation:3].
- Mengirim gambar ke API dengan Retrofit @Multipart [citation:4][citation:8].
- Menampilkan preview gambar yang dipilih.
Langkah 0: Pastikan API Mendukung Upload Gambar
API Wisata kita (dari seri ASP.NET Core) sudah memiliki endpoint upload gambar. Misalnya:
POST /api/wisata/withimage– membuat wisata baru sekaligus upload gambar (form-data dengan field "Gambar" dan field teks lainnya).POST /api/wisata/upload/{id}– upload gambar untuk wisata yang sudah ada (field "file").
Pastikan API berjalan dan bisa diakses dari emulator (http://10.0.2.2:7000).
Langkah 1: Tambahkan Permission dan Dependency
Buka AndroidManifest.xml. Tambahkan izin untuk membaca media (untuk Android versi baru) dan izin kamera jika ingin pakai kamera [citation:5]:
<!-- Untuk mengakses galeri (Android 13+) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Untuk Android 12 ke bawah -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Untuk kamera (opsional) -->
<uses-permission android:name="android.permission.CAMERA" />
Juga, pastikan di build.gradle (Module: app) sudah ada library yang diperlukan (semua sudah dari artikel sebelumnya).
Langkah 2: Buat Layout untuk Upload Gambar
Kita akan membuat activity sederhana untuk menambah wisata dengan gambar. Buat activity baru: AddWisataActivity.kt dan layout activity_add_wisata.xml.
Contoh layout (sederhana):
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<EditText
android:id="@+id/editNama"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Nama Wisata" />
<EditText
android:id="@+id/editLokasi"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Lokasi"
android:layout_marginTop="8dp" />
<EditText
android:id="@+id/editHarga"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Harga Tiket"
android:inputType="numberDecimal"
android:layout_marginTop="8dp" />
<EditText
android:id="@+id/editDeskripsi"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Deskripsi"
android:inputType="textMultiLine"
android:layout_marginTop="8dp" />
<EditText
android:id="@+id/editKategoriId"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Kategori ID"
android:inputType="number"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/btnPilihGambar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Pilih Gambar"
android:layout_marginTop="16dp" />
<ImageView
android:id="@+id/imagePreview"
android:layout_width="match_parent"
android:layout_height="200dp"
android:scaleType="centerCrop"
android:visibility="gone"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/btnUpload"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Simpan Wisata"
android:layout_marginTop="16dp" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="16dp"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
Langkah 3: Memilih Gambar dari Galeri (atau Kamera)
Di AddWisataActivity.kt, kita akan menggunakan ActivityResultContracts.GetContent() untuk memilih gambar dari galeri [citation:6].
Pertama, deklarasikan launcher di dalam Activity:
private val getImageLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let {
// Tampilkan preview
imagePreview.setImageURI(it)
imagePreview.visibility = View.VISIBLE
selectedImageUri = it
}
}
Kemudian di tombol "Pilih Gambar", panggil launcher:
btnPilihGambar.setOnClickListener {
getImageLauncher.launch("image/*") // MIME type untuk gambar
}
Jika ingin menambahkan opsi kamera, kita bisa gunakan intent kamera [citation:9]. Tapi untuk sederhana, galeri dulu.
🧹 Langkah 4: Kompresi Gambar (Opsional Tapi Penting!)
Gambar dari galeri biasanya berukuran besar (bisa sampai beberapa MB). Kita perlu kompres biar upload cepat dan nggak membebani server [citation:3].
Buat fungsi extension atau fungsi helper untuk kompresi:
private fun compressImage(uri: Uri): File? {
return try {
// Buka input stream dari URI
val inputStream = contentResolver.openInputStream(uri)
// Decode jadi bitmap
val bitmap = BitmapFactory.decodeStream(inputStream)
// Buat file sementara
val file = File(cacheDir, "compressed_${System.currentTimeMillis()}.jpg")
val outputStream = FileOutputStream(file)
// Kompres ke JPEG dengan kualitas 70% (bisa disesuaikan)
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
outputStream.close()
inputStream?.close()
bitmap.recycle()
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
Catatan: kompresi ini dilakukan di thread utama? Sebaiknya pindahkan ke background thread (pakai coroutine) agar tidak membekukan UI. Kita akan panggil di viewModelScope nanti.
Langkah 5: Tambahkan Endpoint Upload di ApiService
Buka ApiService.kt, tambahkan endpoint untuk upload (sesuai dengan API yang kita punya). Misalnya endpoint /api/wisata/withimage yang menerima form-data [citation:4][citation:8].
@Multipart
@POST("api/wisata/withimage")
suspend fun addWisataWithImage(
@Part("Nama") nama: RequestBody,
@Part("Lokasi") lokasi: RequestBody,
@Part("HargaTiket") hargaTiket: RequestBody,
@Part("Deskripsi") deskripsi: RequestBody,
@Part("KategoriId") kategoriId: RequestBody,
@Part file: MultipartBody.Part
): Response<DaftarWisata>
Atau jika ingin upload ke endpoint terpisah (misal upload gambar saja untuk wisata yang sudah ada):
@Multipart
@POST("api/wisata/upload/{id}")
suspend fun uploadImage(
@Path("id") id: Int,
@Part file: MultipartBody.Part
): Response<ResponseBody>
Langkah 6: Buat ViewModel untuk AddWisata
Buat ViewModel baru: AddWisataViewModel.kt
package com.example.wisataapp
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
class AddWisataViewModel : ViewModel() {
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> get() = _isLoading
private val _errorMessage = MutableLiveData<String?>()
val errorMessage: LiveData<String?> get() = _errorMessage
private val _uploadSuccess = MutableLiveData<DaftarWisata?>()
val uploadSuccess: LiveData<DaftarWisata?> get() = _uploadSuccess
fun addWisataWithImage(
nama: String,
lokasi: String,
hargaTiket: String,
deskripsi: String,
kategoriId: Int,
imageFile: File?
) {
viewModelScope.launch {
_isLoading.value = true
try {
// Buat RequestBody untuk field teks
val namaBody = RequestBody.create("text/plain".toMediaTypeOrNull(), nama)
val lokasiBody = RequestBody.create("text/plain".toMediaTypeOrNull(), lokasi)
val hargaBody = RequestBody.create("text/plain".toMediaTypeOrNull(), hargaTiket)
val deskripsiBody = RequestBody.create("text/plain".toMediaTypeOrNull(), deskripsi)
val kategoriIdBody = RequestBody.create("text/plain".toMediaTypeOrNull(), kategoriId.toString())
// Buat MultipartBody.Part untuk file
val filePart = if (imageFile != null && imageFile.exists()) {
val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull())
MultipartBody.Part.createFormData("Gambar", imageFile.name, requestFile)
} else null
// Panggil API
val response = if (filePart != null) {
RetrofitInstance.api.addWisataWithImage(
namaBody, lokasiBody, hargaBody, deskripsiBody, kategoriIdBody, filePart
)
} else {
// Jika tidak ada gambar, gunakan endpoint lain? atau panggil tanpa file
// Kita asumsikan ada endpoint tanpa gambar
// Untuk sementara, kirim dengan filePart null? tidak bisa, harus ada @Part.
// Alternatif: buat endpoint terpisah tanpa gambar.
// Di sini kita sederhanakan: gambar wajib.
null
}
if (response != null && response.isSuccessful) {
_uploadSuccess.value = response.body()
} else {
_errorMessage.value = "Gagal upload: ${response?.code()}"
}
} catch (e: Exception) {
_errorMessage.value = "Error: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
}
Langkah 7: Implementasi di Activity
Sekarang kita hubungkan semua di AddWisataActivity.kt:
package com.example.wisataapp
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import java.io.File
class AddWisataActivity : AppCompatActivity() {
private lateinit var editNama: EditText
private lateinit var editLokasi: EditText
private lateinit var editHarga: EditText
private lateinit var editDeskripsi: EditText
private lateinit var editKategoriId: EditText
private lateinit var btnPilihGambar: Button
private lateinit var imagePreview: ImageView
private lateinit var btnUpload: Button
private lateinit var progressBar: ProgressBar
private var selectedImageUri: Uri? = null
private var compressedImageFile: File? = null
private val addWisataViewModel: AddWisataViewModel by viewModels()
// Launcher untuk memilih gambar dari galeri
private val getImageLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let {
selectedImageUri = it
imagePreview.setImageURI(it)
imagePreview.visibility = android.view.View.VISIBLE
// Kompres gambar di background (pakai coroutine)
lifecycleScope.launch(Dispatchers.IO) {
val compressedFile = compressImage(it)
withContext(Dispatchers.Main) {
compressedImageFile = compressedFile
if (compressedFile == null) {
Toast.makeText(this@AddWisataActivity, "Gagal kompres gambar", Toast.LENGTH_SHORT).show()
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_wisata)
initViews()
setupObservers()
btnPilihGambar.setOnClickListener {
getImageLauncher.launch("image/*")
}
btnUpload.setOnClickListener {
val nama = editNama.text.toString().trim()
val lokasi = editLokasi.text.toString().trim()
val harga = editHarga.text.toString().trim()
val deskripsi = editDeskripsi.text.toString().trim()
val kategoriId = editKategoriId.text.toString().trim()
if (nama.isEmpty() || lokasi.isEmpty() || kategoriId.isEmpty()) {
Toast.makeText(this, "Nama, lokasi, dan kategori wajib diisi", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (compressedImageFile == null) {
Toast.makeText(this, "Pilih gambar terlebih dahulu", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
addWisataViewModel.addWisataWithImage(
nama, lokasi, harga, deskripsi, kategoriId.toInt(), compressedImageFile
)
}
}
private fun initViews() {
editNama = findViewById(R.id.editNama)
editLokasi = findViewById(R.id.editLokasi)
editHarga = findViewById(R.id.editHarga)
editDeskripsi = findViewById(R.id.editDeskripsi)
editKategoriId = findViewById(R.id.editKategoriId)
btnPilihGambar = findViewById(R.id.btnPilihGambar)
imagePreview = findViewById(R.id.imagePreview)
btnUpload = findViewById(R.id.btnUpload)
progressBar = findViewById(R.id.progressBar)
}
private fun setupObservers() {
addWisataViewModel.isLoading.observe(this, Observer { loading ->
progressBar.visibility = if (loading) android.view.View.VISIBLE else android.view.View.GONE
btnUpload.isEnabled = !loading
})
addWisataViewModel.errorMessage.observe(this, Observer { error ->
error?.let {
Toast.makeText(this, it, Toast.LENGTH_LONG).show()
}
})
addWisataViewModel.uploadSuccess.observe(this, Observer { wisata ->
wisata?.let {
Toast.makeText(this, "Wisata ${it.nama} berhasil ditambahkan", Toast.LENGTH_LONG).show()
finish() // kembali ke activity sebelumnya
}
})
}
private suspend fun compressImage(uri: Uri): File? {
return withContext(Dispatchers.IO) {
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
val file = File(cacheDir, "compressed_${System.currentTimeMillis()}.jpg")
val outputStream = java.io.FileOutputStream(file)
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 70, outputStream)
outputStream.close()
inputStream?.close()
bitmap.recycle()
file
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
}
Langkah 8: Uji Coba
- Pastikan API berjalan.
- Jalankan aplikasi, login jika perlu.
- Buka halaman tambah wisata (misal dari MainActivity dengan FAB).
- Isi data, pilih gambar dari galeri (pastikan emulator punya gambar, bisa drag & drop).
- Klik Simpan. Tunggu proses upload.
- Jika berhasil, akan kembali ke MainActivity dan data baru muncul di RecyclerView.
Bonus: Menambahkan Resize Gambar
Untuk menghemat lebih banyak, kita bisa resize gambar ke dimensi tertentu (misal lebar maks 1024px) [citation:3]. Modifikasi fungsi compress:
// Setelah bitmap didapat, hitung skala
val maxWidth = 1024
val maxHeight = 1024
val width = bitmap.width
val height = bitmap.height
val (newWidth, newHeight) = if (width > height) {
if (width > maxWidth) maxWidth to (height * maxWidth / width) else width to height
} else {
if (height > maxHeight) (width * maxHeight / height) to maxHeight else width to height
}
val scaledBitmap = android.graphics.Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
// lalu kompres scaledBitmap
Selamat! Aplikasi Kamu Sudah Bisa Upload Gambar
Sekarang aplikasi wisata kamu benar-benar lengkap: bisa menampilkan daftar, detail, login, CRUD, dan upload gambar. Kamu telah belajar banyak dari instalasi hingga fitur kompleks. Luar biasa! 👏
Rangkuman
- Gunakan
ActivityResultContracts.GetContent()untuk memilih gambar dari galeri [citation:6]. - Kompres gambar dengan
Bitmap.compress()untuk memperkecil ukuran file [citation:3]. - Gunakan Retrofit
@Multipartdan@Partuntuk mengirim file beserta field teks [citation:4][citation:8]. - Selalu lakukan operasi file di background thread (gunakan coroutine).
- Jangan lupa tambahkan izin yang diperlukan di manifest [citation:5].
Apa Selanjutnya?
Ini adalah akhir dari seri Android Kotlin. Kamu sekarang punya bekal yang cukup untuk membuat aplikasi Android sederhana hingga menengah. Beberapa ide pengembangan:
- Menambahkan fitur pencarian dan filter.
- Menggunakan Paging 3 untuk RecyclerView dengan data banyak.
- Mengimplementasikan offline mode dengan Room database.
- Migrasi ke Jetpack Compose untuk UI modern.
- Deploy aplikasi ke Play Store (tapi pastikan sudah di-signing).
Ditulis oleh Kakak programmer yang dulu juga susah payah belajar upload gambar. Kalau ada pertanyaan, tulis di komentar ya!