Android Kotlin #12: Upload Gambar dari Galeri ke API


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.

🤣 Kenapa gambar perlu dikompres? Karena kalau nggak, bisa bikin server kegemukan! 😆

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.
💡 Tips: Jika upload gagal dengan error 413 (Payload Too Large), kemungkinan file terlalu besar meski sudah dikompres. Turunkan kualitas kompresi (misal ke 50%) atau batasi resolusi gambar dengan melakukan resize.

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 @Multipart dan @Part untuk 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).
😎 Terima kasih sudah mengikuti seri ini sampai akhir! Ingat, programmer hebat bukan yang tidak pernah error, tapi yang tidak menyerah saat error. Selamat berkarya dan terus belajar!

Ditulis oleh Kakak programmer yang dulu juga susah payah belajar upload gambar. Kalau ada pertanyaan, tulis di komentar ya!

Lebih baru Lebih lama

نموذج الاتصال