Alamat

Jl. Jatirejo No 29A, Jatirejo RT 02/21, Sendangadi, Mlati, Sleman, Daerah Istimewa Yogyakarta

Senin - Jum'at (09:00-17:00 WIB)

0878-2877-3103

0878-2877-3103

Belajar Android Studio : Membuat Aplikasi Android Untuk Mengambil Foto dan Menyimpan di Folder

Farid Efendi
  • 03 Dec 2020
  • 22 menit dibaca
Belajar Android Studio : Membuat Aplikasi Android Untuk Mengambil Foto dan Menyimpan di Folder

Apa yang akan dicapai dalam simply project ini adalah memahami bagaimana prosedur pengambilan foto di Android dengan bahasa pemrograman Java. Kemudian bagaimana cara menyimpannya dalam folder penyimpanan Handphone.

Jika teman - teman sudah mahir dengan development Android Apps, bisa skip saja uraian di bawah ini karena basic banget. Code dapat di download di github https://github.com/faridefendi58/take-picture-turorial, dan contoh apk-nya silakan download Apk dapat di download di sini .

Mau lanjut baca uraiannya?

Sudah terinstall Android Studio kan?, jika teman - teman belum punya Android Studio, bisa download dulu di https://developer.android.com/studio . Versi Android Studio yang saya pakai untuk membuat project ini adalah Android Studio 3.5.1.

Step #1 Buat Project Baru

Buka menu File -> New -> New Project hingga muncul seperti gambar di bawah ini.

Pilih Empty Activity kemudian isi nama project-nya (bebas namanya). Untuk kolom API level saya pilih Android 4.0 agar dapat diinstal di sebagian besar device Android yang ada saat ini. (Versi Android terbaru saat ini adalah Android 10).

Klik tombol Finish dan tunggu beberapa saat hingga Android Studio selesai build project-nya. Setelah benar - benar selesai, otomatis ada 1 Activity yang terbuat.

Sebelum kita memodifikasi file MainActivity, ada 2 hal penting yang perlu dilakukan yakni menambahkan library di file build.gradle dan permission penggunaan camera dan storage pada file AndroidManifest.xml.


Step #2 Tambah Library di File build.gradle

Ada 2 file build.gradle, klik dokumen yang bagian bawah.

Tambahkan 3 baris code di dalam dependencies 

implementation 'com.github.bumptech.glide:glide:4.7.1'
annotationProcessor 'com.github.bumptech.glide:compiler:4.7.1'
implementation 'com.google.android.material:material:1.1.0'

Library glide akan kita pakai untuk menampilkan gambar - gambar yang kita upload. Sedangkan android material diperlukan untuk membuat Bottom Navigation (Menu di bagian bawah). Saat membuat project baru di Android Studio 3.5.1, saat ini dipaksa untuk menggunakan artefak androidx, komponen BottomNavigationView tidak ada di androidx maka kita perlu menambah library material

com.google.android.material:material:1.1.0


Step #3 Tambah Permission di File AndroidManifest.xml

Agar bisa menggunakan device camera dan menyimpan hasil foto ke direktori penyimpanan, harus ada pendefinisian permintaan akses. Nanti saat aplikasi diinstall untuk pertama kali akan muncul popup perijinan 2 akses tersebut.

Android manifest permission

Line 5 sampai 8 inilah yang akan meminta akses ke kamera dan storage.

<uses-feature android:name="android.hardware.camera" android:required="true"></uses-feature>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

Tambahkan pula line 24 - 32 berikut :

<provider android:name="androidx.core.content.FileProvider"
   android:authorities="com.example.android.fileprovider"
   android:exported="false"
   android:grantUriPermissions="true">
    <meta-data
       android:name="android.support.FILE_PROVIDER_PATHS"
       android:resource="@xml/file_paths"></meta-data>
</provider>


Step #4 Buat Class Untuk Komunikasi Dengan Database

Foto yang diambil akan kita simpan history-nya di tabel database sqlite. Agar lebih rapi, cara komunikasi dengan database dibantu dengan 3 class. Setiap saat ingin menyimpan atau menghapus cukup dengan memanggil method atau function dari class tersebut. Buat folder atau package terlebih dahulu dengan nama utils, selanjutnya buat java class baru Database, DatabaseContents, dan DatabaseHelper di dalam folder tersebut. 

Class Database berisi method untuk select, insert, update, dan delete data.

package com.slightsite.tutorialuploadimage.utils;

import java.util.List;

public interface Database {

    /**
     * Selects something in database.
     * @param queryString query string for select in database.
     * @return list of object.
     */
    public List<Object> select(String queryString);

    /**
     * Inserts something in database.
     * @param tableName name of table in database.
     * @param content string for using in database.
     * @return id of row data.
     */
    public int insert(String tableName, Object content);

    /**
     * Updates something in database.
     * @param tableName name of table in database.
     * @param content string for using in database.
     * @return true if updates success ; otherwise false.
     */
    boolean update(String tableName, Object content);

    /**
     * Deletes something in database.
     * @param tableName name of table in database.
     * @param id a specific id of row data for deletion.
     * @return true if deletion success ; otherwise false.
     */
    boolean delete(String tableName, int id);

    /**
     * Directly execute to database.
     * @param queryString query string for execute.
     * @return true if executes success ; otherwise false.
     */
    boolean execute(String queryString);
}

Class DatabaseContents untuk mendefinisikan nama database dan tabel-tabelnya.

package com.slightsite.tutorialuploadimage.utils;

public enum DatabaseContents {

    DATABASE("uploadimage.db"),
    TABLE_PICTURES("tbl_pictures");

    private String name;

    /**
     * Constructs DatabaseContents.
     * @param name name of this content for using in database.
     */
    private DatabaseContents(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return name;
    }
}

Sedangkan class DatabaseHelper adalah anak dari class SQLiteOpenHelper yang berisi perintah sql untuk CRUD (Create, Read, Update, Delete) data.

package com.slightsite.tutorialuploadimage.utils;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

import java.util.ArrayList;
import java.util.List;

public class DatabaseHelper extends SQLiteOpenHelper implements Database {

    // Database Version
    private static final int DATABASE_VERSION = 1;

    // Database Name
    private static final String DATABASE_NAME = DatabaseContents.DATABASE.toString();


    public DatabaseHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    // Creating Tables
    @Override
    public void onCreate(SQLiteDatabase db) {

        db.execSQL("CREATE TABLE " + DatabaseContents.TABLE_PICTURES + "("

                + "_id INTEGER PRIMARY KEY,"
                + "file_name TEXT(100),"
                + "file_type TEXT(8) DEFAULT 'JPG',"
                + "file_size INTEGER DEFAULT 0,"
                + "file_dir TEXT(256),"
                + "group_id INTEGER DEFAULT 0,"
                + "date_added DATETIME"
                + ");");
    }

    // Upgrading database
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // Drop older table if existed
        db.execSQL("DROP TABLE IF EXISTS " + DatabaseContents.TABLE_PICTURES);

        // Create tables again
        onCreate(db);
    }

    @Override
    public List<Object> select(String queryString) {
        try {
            SQLiteDatabase database = this.getWritableDatabase();
            List<Object> list = new ArrayList<Object>();
            Cursor cursor = database.rawQuery(queryString, null);

            if (cursor != null) {
                if (cursor.moveToFirst()) {
                    do {
                        ContentValues content = new ContentValues();
                        String[] columnNames = cursor.getColumnNames();
                        for (String columnName : columnNames) {
                            content.put(columnName, cursor.getString(cursor
                                    .getColumnIndex(columnName)));
                        }
                        list.add(content);
                    } while (cursor.moveToNext());
                }
            }
            cursor.close();
            database.close();
            return list;

        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    @Override
    public int insert(String tableName, Object content) {
        try {
            SQLiteDatabase database = this.getWritableDatabase();

            int id = (int) database.insert(tableName, null,
                    (ContentValues) content);

            database.close();
            return id;
        } catch (Exception e) {
            e.printStackTrace();
            return -1;
        }
    }

    @Override
    public boolean update(String tableName, Object content) {
        try {
            SQLiteDatabase database = this.getWritableDatabase();
            ContentValues cont = (ContentValues) content;
            // this array will always contains only one element.
            String[] array = new String[]{cont.get("_id")+""};
            database.update(tableName, cont, " _id = ?", array);
            return true;

        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    @Override
    public boolean delete(String tableName, int id) {
        try {
            SQLiteDatabase database = this.getWritableDatabase();
            database.delete(tableName, " _id = ?", new String[]{id+""});
            return true;

        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    @Override
    public boolean execute(String queryString) {
        try{
            SQLiteDatabase database = this.getWritableDatabase();
            database.execSQL(queryString);
            return true;
        }catch(Exception e){
            e.printStackTrace();
            return false;
        }
    }
}

Perlu satu class lagi di folder utils untuk formatting tanggal. Buat java class baru dengan nama DateTimeStrategy.

package com.slightsite.tutorialuploadimage.utils;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;

public class DateTimeStrategy {
    private static Locale locale;
    private static SimpleDateFormat dateFormat;

    private DateTimeStrategy() {
        // Static class
    }

    /**
     * Set local of time for use in application.
     * @param lang Language.
     * @param reg Region.
     */
    public static void setLocale(String lang, String reg) {
        locale = new Locale(lang, reg);
        dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", locale);
    }

    /**
     * Sets current time format.
     * @param date date of this format.
     * @return current time format.
     */
    public static String format(String date) {
        return dateFormat.format(Calendar.getInstance(locale).getTime());
    }

    /**
     * Returns current time.
     * @return current time.
     */
    public static String getCurrentTime() {
        return dateFormat.format(Calendar.getInstance().getTime()).toString();
    }

    /**
     * Convert the calendar format to date format for adapt in SQL.
     * @param instance calendar .
     * @return date format.
     */
    public static String getSQLDateFormat(Calendar instance) {
        return dateFormat.format(instance.getTime()).toString().substring(0,10);
    }

    public static String parseDate(String time, String outputPattern) {
        String inputPattern = "yyyy-MM-dd HH:mm:ss";
        SimpleDateFormat inputFormat = new SimpleDateFormat(inputPattern);
        SimpleDateFormat outputFormat = new SimpleDateFormat(outputPattern);

        Date date = null;
        String str = null;

        try {
            date = inputFormat.parse(time);
            str = outputFormat.format(date);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return str;
    }
}


Step #5 Buat Class Untuk Memanipulasi Database

Urusan database beres, namun kita masih perlu bantuan sebuah class yang mudah mengeksekusi perintah create, read, update, delete image. Jika di framework php biasanya kita mengenal istilah controller untuk mengeksekusi CRUD. Oleh karena itu biar lebih mudah, kita buat folder baru dengan nama controlers lalu di dalamnya buat class java baru dengan nama PictureController.

controller picture

Isi dari file PrictureController.java :

package com.slightsite.tutorialuploadimage.controllers;

import android.content.ContentValues;

import com.slightsite.tutorialuploadimage.models.Pictures;
import com.slightsite.tutorialuploadimage.utils.Database;
import com.slightsite.tutorialuploadimage.utils.DatabaseContents;
import com.slightsite.tutorialuploadimage.utils.DateTimeStrategy;

import java.util.List;

public class PictureController {
    private static Database database;
    private static PictureController instance;

    private PictureController() {}

    public static PictureController getInstance() {
        if (instance == null)
            instance = new PictureController();
        return instance;
    }

    /**
     * Sets database for use in this class.
     * @param db database.
     */
    public static void setDatabase(Database db) {
        database = db;
    }

    public List<Object> getItems() {
        List<Object> contents = database.select("SELECT * FROM " + DatabaseContents.TABLE_PICTURES);

        return contents;
    }

    public int addPicture(Pictures picture) {
        ContentValues content = new ContentValues();
        content.put("file_name", picture.getName());
        content.put("file_type", picture.getType());
        content.put("file_size", picture.getSize());
        content.put("file_dir", picture.getDir());
        content.put("group_id", 0);
        content.put("date_added", DateTimeStrategy.getCurrentTime());

        int id = database.insert(DatabaseContents.TABLE_PICTURES.toString(), content);

        return id;
    }

    public boolean removePicture(int id) {
        return database.delete(DatabaseContents.TABLE_PICTURES.toString(), id);
    }
}


Step #6 Buat Fagment Ambil Foto dan Library Foto

Aplikasi Android yang akan dibuat ini rencananya akan ada 2 fragmet. Fragment pertama untuk mengambil foto dan fragment kedua untuk menampikan daftar foto yang telah diambil. Jadi database di atas nantinya berguna banget sebagai library dari foto-foto yang diambil dari fragment pertama.

Fragment pertama kita kasih nama CameraFragment dan yang kedua LibraryFragment. Buat di class java untuk kedua fragment tersebut dalam folder fragments.

CameraFragment.java

package com.slightsite.tutorialuploadimage.fragments;

import android.Manifest;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Point;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.DecelerateInterpolator;
import android.webkit.MimeTypeMap;
import android.widget.AdapterView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.Toast;

import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;

import com.slightsite.tutorialuploadimage.R;
import com.slightsite.tutorialuploadimage.adapters.ImageListAdapter;
import com.slightsite.tutorialuploadimage.controllers.PictureController;
import com.slightsite.tutorialuploadimage.models.Pictures;

import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;

public class CameraFragment extends Fragment {

    private static final int CAPTURE_IMAGE_ACTIVITY_REQUEST_CODE = 1888;
    private static final String LOG_TAG = "MainActivity";

    private View rootView;

    Button button;
    Button button_done;
    Button button_remove;
    Button btn_minimize;
    ImageView imageView;
    ListView image_list_view;
    LinearLayout list_container;
    LinearLayout btn_container;

    private AlertDialog dialog;

    private ArrayList<HashMap<String, Bitmap>> imageList = new ArrayList<>();
    private ArrayList<HashMap<String,String>> imageListAttributes = new ArrayList<>();
    private int current_preview;
    private int group_id = 0;

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {

        rootView = inflater.inflate(R.layout.fragment_camera,
                container, false);

        verifyStoragePermissions(getActivity());

        button = (Button) rootView.findViewById(R.id.btn_take_picture);
        button_done = (Button) rootView.findViewById(R.id.btn_done);
        button_remove = (Button) rootView.findViewById(R.id.btn_remove);
        btn_minimize = (Button) rootView.findViewById(R.id.btn_minimize);
        imageView = (ImageView) rootView.findViewById(R.id.result_image);
        image_list_view = (ListView) rootView.findViewById(R.id.image_list_view);
        list_container = (LinearLayout) rootView.findViewById(R.id.list_container);
        btn_container = (LinearLayout) rootView.findViewById(R.id.btn_container);

        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                dispatchTakePictureIntent();

            }
        });

        button_remove.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                AlertDialog.Builder alert = new AlertDialog.Builder(getActivity());
                alert.setMessage(getResources().getString(R.string.confirm_delete))
                        .setIcon(android.R.drawable.ic_dialog_alert)
                        .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {

                            public void onClick(DialogInterface dialog, int whichButton) {
                                _remove_item();
                                Toast.makeText(
                                        getContext(),
                                        getResources().getString(R.string.delete_success_message),
                                        Toast.LENGTH_SHORT).show();
                            }})
                        .setNegativeButton(android.R.string.no, null).show();
            }
        });

        imageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                btn_minimize.setVisibility(View.VISIBLE);
                zoomImageFromThumb(imageView, R.drawable.ic_picture_holder_256);
            }
        });
        mShortAnimationDuration = getResources().getInteger(
                android.R.integer.config_shortAnimTime);

        btn_minimize.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                list_container.setVisibility(View.VISIBLE);
                btn_minimize.setVisibility(View.GONE);
            }
        });

        button_done.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());

                builder.setMessage(getResources().getString(R.string.confirm_save))
                        .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
                            public void onClick(DialogInterface dialog, int whichButton) {
                                saveImage();
                            }})
                        .setNegativeButton(android.R.string.no, null).show();
            }
        });

        return rootView;

    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        if (requestCode == CAPTURE_IMAGE_ACTIVITY_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
            Bundle extras = data.getExtras();
            Bitmap imageBitmap = (Bitmap) extras.get("data");
            imageView.setImageBitmap(imageBitmap);

            HashMap<String,Bitmap> map = new HashMap<>();
            map.put("img", imageBitmap);
            imageList.add(map);

            HashMap<String,String> mapAttr = new HashMap<>();
            mapAttr.put("file_name", mCurrentFileName);
            mapAttr.put("file_path", mCurrentPhotoPath);
            mapAttr.put("file_dir", mCurrentFilePath);
            mapAttr.put("file_type", mCurrentFileType);
            mapAttr.put("file_size", ""+ imageBitmap.getByteCount());
            imageListAttributes.add(mapAttr);

            current_preview = imageList.size() - 1;

            button_done.setVisibility(View.VISIBLE);
            button_remove.setVisibility(View.VISIBLE);
            list_container.setVisibility(View.VISIBLE);

            rebuildTheImageList();
        }
        if (requestCode == REQUEST_TAKE_PHOTO && resultCode == Activity.RESULT_OK) {
            // Get the dimensions of the View
            int targetW = imageView.getWidth();
            int targetH = imageView.getHeight();

            // Get the dimensions of the bitmap
            BitmapFactory.Options bmOptions = new BitmapFactory.Options();
            bmOptions.inJustDecodeBounds = true;
            BitmapFactory.decodeFile(mCurrentPhotoPath, bmOptions);
            int photoW = bmOptions.outWidth;
            int photoH = bmOptions.outHeight;

            // Determine how much to scale down the image
            int scaleFactor = Math.min(photoW/targetW, photoH/targetH);

            // Decode the image file into a Bitmap sized to fill the View
            bmOptions.inJustDecodeBounds = false;
            bmOptions.inSampleSize = scaleFactor;
            bmOptions.inPurgeable = true;

            Bitmap bitmap = BitmapFactory.decodeFile(mCurrentPhotoPath, bmOptions);
            imageView.setImageBitmap(bitmap);

            HashMap<String,Bitmap> map = new HashMap<>();
            map.put("img", bitmap);
            imageList.add(map);

            HashMap<String,String> mapAttr = new HashMap<>();
            mapAttr.put("file_name", mCurrentFileName);
            mapAttr.put("file_path", mCurrentPhotoPath);
            mapAttr.put("file_dir", mCurrentFilePath);
            mapAttr.put("file_type", mCurrentFileType);
            mapAttr.put("file_size", "0");
            imageListAttributes.add(mapAttr);

            current_preview = imageList.size() - 1;

            button_done.setVisibility(View.VISIBLE);
            button_remove.setVisibility(View.VISIBLE);
            list_container.setVisibility(View.VISIBLE);

            rebuildTheImageList();
        }
    }

    private void rebuildTheImageList() {
        ImageListAdapter adapter = new ImageListAdapter(getActivity(), imageList);
        image_list_view.setAdapter(adapter);
        imageListViewListener();
    }

    private void imageListViewListener() {
        image_list_view.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                imageView.setImageBitmap(imageList.get(position).get("img"));
                current_preview = position;
            }
        });
    }

    private void _remove_item() {
        File file = new File(imageListAttributes.get(current_preview).get("file_path"));
        boolean deleted = false;
        if (file.isFile()) {
            deleted = file.delete();
        }

        imageList.remove(current_preview);
        imageListAttributes.remove(current_preview);
        int size = imageList.size();
        if (size == 0) {
            button_done.setVisibility(View.GONE);
            button_remove.setVisibility(View.GONE);
            list_container.setVisibility(View.GONE);
            imageView.setImageDrawable(getResources().getDrawable(R.drawable.ic_picture_holder_256));
        } else {
            rebuildTheImageList();
            imageView.setImageBitmap(imageList.get(size-1).get("img"));
        }
    }

    public void saveImage() {
        // save to dbs
        for (int i = 0; i < imageListAttributes.size(); i++) {
            File file = new File(storageDir.getPath() + "/" + imageListAttributes.get(i).get("file_name"));
            int file_size = Integer.parseInt(String.valueOf(file.length()/1024));

            Pictures pictures = new Pictures(
                    imageListAttributes.get(i).get("file_name"),
                    imageListAttributes.get(i).get("file_type"),
                    file_size,
                    imageListAttributes.get(i).get("file_dir"),
                    group_id
            );
            int id = PictureController.getInstance().addPicture(pictures);
        }

        imageList.clear();
        imageListAttributes.clear();

        button_done.setVisibility(View.GONE);
        button_remove.setVisibility(View.GONE);
        list_container.setVisibility(View.GONE);
        imageView.setImageDrawable(getResources().getDrawable(R.drawable.ic_picture_holder_256));

        //remove the backup if any
        String[] fileNames = storageDir.list();

        for (String fileName : fileNames) {
            File file = new File(storageDir.getPath() + "/" + fileName);
            int file_size = Integer.parseInt(String.valueOf(file.length()/1024));
            if (file_size == 0)
                file.delete();
        }

        Toast.makeText(
                getContext(),
                getResources().getString(R.string.save_success_message),
                Toast.LENGTH_SHORT).show();
    }

    String mCurrentPhotoPath;
    String mCurrentFileName;
    String mCurrentFilePath;
    String mCurrentFileType;
    private File storageDir;

    private File createImageFile() throws IOException {
        // Create an image file name
        String timeStamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        String imageFileName = "IMG" + timeStamp + "_";

        storageDir = Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_PICTURES + File.separator +
                        getResources().getString(R.string.folder_name) + File.separator);

        if (!storageDir.isDirectory()) {
            if (!storageDir.mkdirs()) {
                Log.e(getActivity().getClass().getSimpleName(), "Directory not created");
            }
        }

        File image = File.createTempFile(
                imageFileName,
                ".jpg",
                storageDir
        );

        // Save a file: path for use with ACTION_VIEW intents
        mCurrentPhotoPath = image.getAbsolutePath();
        mCurrentFileName = image.getName();
        mCurrentFilePath = storageDir.getPath();

        MimeTypeMap mime = MimeTypeMap.getSingleton();
        int index = image.getName().lastIndexOf('.')+1;
        String ext = image.getName().substring(index).toLowerCase();
        String type = mime.getMimeTypeFromExtension(ext);
        mCurrentFileType = type;

        return image;
    }

    static final int REQUEST_TAKE_PHOTO = 1;
    private Intent takePictureIntent;
    private File photoFile = null;

    private void dispatchTakePictureIntent() {
        takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        // Ensure that there's a camera activity to handle the intent
        if (takePictureIntent.resolveActivity(getActivity().getPackageManager()) != null) {
            // Create the File where the photo should go
            try {
                photoFile = createImageFile();
            } catch (IOException ex) {
                // Error occurred while creating the File
                ex.printStackTrace();
            }
            if (photoFile != null) {
                Uri photoURI = FileProvider.getUriForFile(getContext(),
                        "com.example.android.fileprovider",
                        photoFile);

                takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
                startActivityForResult(takePictureIntent, REQUEST_TAKE_PHOTO);
            }
        }
    }

    /* Checks if external storage is available for read and write */
    public boolean isExternalStorageWritable() {
        String state = Environment.getExternalStorageState();
        if (Environment.MEDIA_MOUNTED.equals(state)) {
            return true;
        }
        return false;
    }

    // Storage Permissions
    private static final int REQUEST_EXTERNAL_STORAGE = 1;
    private static String[] PERMISSIONS_STORAGE = {
            Manifest.permission.READ_EXTERNAL_STORAGE,
            Manifest.permission.WRITE_EXTERNAL_STORAGE
    };

    /**
     * Checks if the app has permission to write to device storage
     *
     * If the app does not has permission then the user will be prompted to grant permissions
     *
     * @param activity
     */
    public static void verifyStoragePermissions(Activity activity) {
        // Check if we have write permission
        int permission = ActivityCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);

        if (permission != PackageManager.PERMISSION_GRANTED) {
            // We don't have permission so prompt the user
            ActivityCompat.requestPermissions(
                    activity,
                    PERMISSIONS_STORAGE,
                    REQUEST_EXTERNAL_STORAGE
            );
        }
    }

    private Animator mCurrentAnimator;
    private int mShortAnimationDuration;

    public void zoomImageFromThumb(final View thumbView, int imageResId) {
        // If there's an animation in progress, cancel it
        // immediately and proceed with this one.
        if (mCurrentAnimator != null) {
            mCurrentAnimator.cancel();
        }

        // Load the high-resolution "zoomed-in" image.
        final ImageView expandedImageView = (ImageView) rootView.findViewById(R.id.expanded_image);
        if (imageList.size() > 0) {
            expandedImageView.setImageBitmap(imageList.get(current_preview).get("img"));
        } else {
            expandedImageView.setImageResource(imageResId);
        }

        list_container.setVisibility(View.GONE);
        btn_container.setVisibility(View.GONE);

        // Calculate the starting and ending bounds for the zoomed-in image.
        // This step involves lots of math. Yay, math.
        final Rect startBounds = new Rect();
        final Rect finalBounds = new Rect();
        final Point globalOffset = new Point();

        // The start bounds are the global visible rectangle of the thumbnail,
        // and the final bounds are the global visible rectangle of the container
        // view. Also set the container view's offset as the origin for the
        // bounds, since that's the origin for the positioning animation
        // properties (X, Y).
        thumbView.getGlobalVisibleRect(startBounds);
        rootView.findViewById(R.id.fcamera_container)
                .getGlobalVisibleRect(finalBounds, globalOffset);
        startBounds.offset(-globalOffset.x, -globalOffset.y);
        finalBounds.offset(-globalOffset.x, -globalOffset.y);

        // Adjust the start bounds to be the same aspect ratio as the final
        // bounds using the "center crop" technique. This prevents undesirable
        // stretching during the animation. Also calculate the start scaling
        // factor (the end scaling factor is always 1.0).
        float startScale;
        if ((float) finalBounds.width() / finalBounds.height()
                > (float) startBounds.width() / startBounds.height()) {
            // Extend start bounds horizontally
            startScale = (float) startBounds.height() / finalBounds.height();
            float startWidth = startScale * finalBounds.width();
            float deltaWidth = (startWidth - startBounds.width()) / 2;
            startBounds.left -= deltaWidth;
            startBounds.right += deltaWidth;
        } else {
            // Extend start bounds vertically
            startScale = (float) startBounds.width() / finalBounds.width();
            float startHeight = startScale * finalBounds.height();
            float deltaHeight = (startHeight - startBounds.height()) / 2;
            startBounds.top -= deltaHeight;
            startBounds.bottom += deltaHeight;
        }

        // Hide the thumbnail and show the zoomed-in view. When the animation
        // begins, it will position the zoomed-in view in the place of the
        // thumbnail.
        thumbView.setAlpha(0f);
        expandedImageView.setVisibility(View.VISIBLE);

        // Set the pivot point for SCALE_X and SCALE_Y transformations
        // to the top-left corner of the zoomed-in view (the default
        // is the center of the view).
        expandedImageView.setPivotX(0f);
        expandedImageView.setPivotY(0f);

        // Construct and run the parallel animation of the four translation and
        // scale properties (X, Y, SCALE_X, and SCALE_Y).
        AnimatorSet set = new AnimatorSet();
        set
                .play(ObjectAnimator.ofFloat(expandedImageView, View.X,
                        startBounds.left, finalBounds.left))
                .with(ObjectAnimator.ofFloat(expandedImageView, View.Y,
                        startBounds.top, finalBounds.top))
                .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X,
                        startScale, 1f))
                .with(ObjectAnimator.ofFloat(expandedImageView,
                        View.SCALE_Y, startScale, 1f));
        set.setDuration(mShortAnimationDuration);
        set.setInterpolator(new DecelerateInterpolator());
        set.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                mCurrentAnimator = null;
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                mCurrentAnimator = null;
            }
        });
        set.start();
        mCurrentAnimator = set;

        // Upon clicking the zoomed-in image, it should zoom back down
        // to the original bounds and show the thumbnail instead of
        // the expanded image.
        final float startScaleFinal = startScale;
        expandedImageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (mCurrentAnimator != null) {
                    mCurrentAnimator.cancel();
                }

                if (imageList.size() > 0) {
                    list_container.setVisibility(View.VISIBLE);
                }
                btn_container.setVisibility(View.VISIBLE);

                // Animate the four positioning/sizing properties in parallel,
                // back to their original values.
                AnimatorSet set = new AnimatorSet();
                set.play(ObjectAnimator
                        .ofFloat(expandedImageView, View.X, startBounds.left))
                        .with(ObjectAnimator
                                .ofFloat(expandedImageView,
                                        View.Y,startBounds.top))
                        .with(ObjectAnimator
                                .ofFloat(expandedImageView,
                                        View.SCALE_X, startScaleFinal))
                        .with(ObjectAnimator
                                .ofFloat(expandedImageView,
                                        View.SCALE_Y, startScaleFinal));
                set.setDuration(mShortAnimationDuration);
                set.setInterpolator(new DecelerateInterpolator());
                set.addListener(new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        thumbView.setAlpha(1f);
                        expandedImageView.setVisibility(View.GONE);
                        mCurrentAnimator = null;
                    }

                    @Override
                    public void onAnimationCancel(Animator animation) {
                        thumbView.setAlpha(1f);
                        expandedImageView.setVisibility(View.GONE);
                        mCurrentAnimator = null;
                    }
                });
                set.start();
                mCurrentAnimator = set;
            }
        });
    }

    public void closeDialog(View view) {
        dialog.hide();
    }
}

LibraryFragment.java 

package com.slightsite.tutorialuploadimage.fragments;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.ContentValues;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Point;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.DecelerateInterpolator;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.GridView;
import android.widget.ImageView;

import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

import com.bumptech.glide.Glide;
import com.slightsite.tutorialuploadimage.R;
import com.slightsite.tutorialuploadimage.controllers.PictureController;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

public class LibraryFragment extends Fragment {

    private View rootView;
    private ArrayList<String> images;

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        rootView = inflater.inflate(R.layout.fragment_library, null);

        final GridView gallery = (GridView) rootView.findViewById(R.id.galleryGridView);

        gallery.setAdapter(new ImageAdapter(getActivity()));
        gallery.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> arg0, View arg1,
                                    int position, long arg3) {
                if (null != images && !images.isEmpty())
                    zoomImageFromThumb(gallery, images.get(position), position);

            }
        });

        return rootView;
    }

    private Animator mCurrentAnimator;
    private int mShortAnimationDuration;

    public void zoomImageFromThumb(final View thumbView, String file_path, int i) {
        if (mCurrentAnimator != null) {
            mCurrentAnimator.cancel();
        }

        // Load the high-resolution "zoomed-in" image.
        final ImageView expandedImageView = (ImageView) rootView.findViewById(R.id.expanded_image);
        File file = new File(file_path);
        BitmapFactory.Options bmOptions = new BitmapFactory.Options();
        Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(),bmOptions);
        expandedImageView.setImageBitmap(bitmap);

        final Rect startBounds = new Rect();
        final Rect finalBounds = new Rect();
        final Point globalOffset = new Point();

        thumbView.getGlobalVisibleRect(startBounds);
        rootView.getRootView().findViewById(R.id.fragment_container)
                .getGlobalVisibleRect(finalBounds, globalOffset);
        startBounds.offset(-globalOffset.x, -globalOffset.y);
        finalBounds.offset(-globalOffset.x, -globalOffset.y);

        float startScale;
        if ((float) finalBounds.width() / finalBounds.height()
                > (float) startBounds.width() / startBounds.height()) {
            // Extend start bounds horizontally
            startScale = (float) startBounds.height() / finalBounds.height();
            float startWidth = startScale * finalBounds.width();
            float deltaWidth = (startWidth - startBounds.width()) / 2;
            startBounds.left -= deltaWidth;
            startBounds.right += deltaWidth;
        } else {
            // Extend start bounds vertically
            startScale = (float) startBounds.width() / finalBounds.width();
            float startHeight = startScale * finalBounds.height();
            float deltaHeight = (startHeight - startBounds.height()) / 2;
            startBounds.top -= deltaHeight;
            startBounds.bottom += deltaHeight;
        }

        thumbView.setAlpha(0f);
        expandedImageView.setVisibility(View.VISIBLE);

        expandedImageView.setPivotX(0f);
        expandedImageView.setPivotY(0f);

        AnimatorSet set = new AnimatorSet();
        set
                .play(ObjectAnimator.ofFloat(expandedImageView, View.X,
                        startBounds.left, finalBounds.left))
                .with(ObjectAnimator.ofFloat(expandedImageView, View.Y,
                        startBounds.top, finalBounds.top))
                .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X,
                        startScale, 1f))
                .with(ObjectAnimator.ofFloat(expandedImageView,
                        View.SCALE_Y, startScale, 1f));
        set.setDuration(mShortAnimationDuration);
        set.setInterpolator(new DecelerateInterpolator());
        set.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                mCurrentAnimator = null;
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                mCurrentAnimator = null;
            }
        });
        set.start();
        mCurrentAnimator = set;

        // Upon clicking the zoomed-in image, it should zoom back down
        // to the original bounds and show the thumbnail instead of
        // the expanded image.
        final float startScaleFinal = startScale;
        expandedImageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (mCurrentAnimator != null) {
                    mCurrentAnimator.cancel();
                }

                // Animate the four positioning/sizing properties in parallel,
                // back to their original values.
                AnimatorSet set = new AnimatorSet();
                set.play(ObjectAnimator
                        .ofFloat(expandedImageView, View.X, startBounds.left))
                        .with(ObjectAnimator
                                .ofFloat(expandedImageView,
                                        View.Y,startBounds.top))
                        .with(ObjectAnimator
                                .ofFloat(expandedImageView,
                                        View.SCALE_X, startScaleFinal))
                        .with(ObjectAnimator
                                .ofFloat(expandedImageView,
                                        View.SCALE_Y, startScaleFinal));
                set.setDuration(mShortAnimationDuration);
                set.setInterpolator(new DecelerateInterpolator());
                set.addListener(new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        thumbView.setAlpha(1f);
                        expandedImageView.setVisibility(View.GONE);
                        mCurrentAnimator = null;
                    }

                    @Override
                    public void onAnimationCancel(Animator animation) {
                        thumbView.setAlpha(1f);
                        expandedImageView.setVisibility(View.GONE);
                        mCurrentAnimator = null;
                    }
                });
                set.start();
                mCurrentAnimator = set;
            }
        });
    }

    /**
     * The Class ImageAdapter.
     */
    private class ImageAdapter extends BaseAdapter {

        private Activity context;

        public ImageAdapter(Activity localContext) {
            context = localContext;
            //images = getAllShownImagesPath(context);
            images = getAllShownImages();
        }

        public int getCount() {
            return images.size();
        }

        public Object getItem(int position) {
            return position;
        }

        public long getItemId(int position) {
            return position;
        }

        public View getView(final int position, View convertView,
                            ViewGroup parent) {
            ImageView picturesView;
            if (convertView == null) {
                picturesView = new ImageView(context);
                picturesView.setScaleType(ImageView.ScaleType.CENTER_CROP);
                int h = context.getResources().getDisplayMetrics().densityDpi;
                picturesView
                        .setLayoutParams(new GridView.LayoutParams(h+20, h+20));

            } else {
                picturesView = (ImageView) convertView;
            }

            Glide.with(context).load(images.get(position))
                    .into(picturesView);

            return picturesView;
        }

        private ArrayList<String> getAllShownImages() {
            List<Object> pictures = PictureController.getInstance().getItems();

            ArrayList<String> listOfAllImages = new ArrayList<String>();
            for (Object object: pictures) {
                ContentValues content = (ContentValues) object;
                if (content.getAsInteger("file_size") > 0) {
                    File file = new File(content.get("file_dir")+ "/" +content.get("file_name"));
                    listOfAllImages.add(file.getAbsolutePath());
                } else {
                    boolean remove = PictureController.getInstance().removePicture(content.getAsInteger("_id"));
                }
            }
            return listOfAllImages;
        }
    }
}

Jika tiba saatnya nanti menampilkan daftar foto di LibraryFragment, di Android ini kita perlu adapter untuk menampilkan datanya. Jika di PHP hanya perlu foreach dan echo, Android perlu sedikit ribet dengan Adapter segala macem. Sebenarnya disediakan class Adapter standard oleh Android, tetapi saya pribadi lebih prefer bikin custom Adapter agar tampilannya sesuai yang saya mau.

Buat java class baru di folder adapters dengan nama ImageListAdapter yang isinya :

package com.slightsite.tutorialuploadimage.adapters;

import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;

import com.slightsite.tutorialuploadimage.R;

import java.util.ArrayList;
import java.util.HashMap;

public class ImageListAdapter extends BaseAdapter {

    private Activity activity;
    private ArrayList<HashMap<String, Bitmap>> data;
    private static LayoutInflater inflater = null;

    public ImageListAdapter(Activity a, ArrayList<HashMap<String, Bitmap>> d) {
        activity = a;
        data = d;
        inflater = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

    public int getCount() {
        return data.size();
    }

    public Object getItem(int position) {
        return position;
    }

    public long getItemId(int position) {
        return position;
    }

    public View getView(int position, View convertView, ViewGroup parent) {
        View vi = convertView;
        if(convertView == null)
            vi = inflater.inflate(R.layout.list_view_image, null);

        ImageView thumb_image = (ImageView)vi.findViewById(R.id.list_image); // thumb image

        thumb_image.setImageBitmap(data.get(position).get("img"));

        return vi;
    }
}

Saat foto diambil dengan kamera, informasi image yang berupa nama file, ukuran, dan nama direktorinya akan disimpan di database. Kumpulan informasi sebuah file foto tersebut perlu dikemas dalam sebuah object. Buat class baru dengan nama Pictures untuk merepresentasikan informasi file foto.

File Pictures.java :

package com.slightsite.tutorialuploadimage.models;

import java.util.HashMap;
import java.util.Map;

public class Pictures {
    private int id;
    private String file_name;
    private String file_type;
    private int file_size;
    private String file_dir;
    private int group_id;

    /**
     * Static value for UNDEFINED ID.
     */
    public static final int UNDEFINED_ID = -1;


    public Pictures(int id, String file_name, String file_type, int file_size, String file_dir, int group_id) {
        this.id = id;
        this.file_name = file_name;
        this.file_type = file_type;
        this.file_size = file_size;
        this.file_dir = file_dir;
        this.group_id = group_id;
    }

    public Pictures(String file_name, String file_type, int file_size, String file_dir, int group_id) {
        this(UNDEFINED_ID, file_name, file_type, file_size, file_dir, group_id);
    }

    public void setName(String file_name) {
        this.file_name = file_name;
    }

    public void setType(String file_type) {
        this.file_type = file_type;
    }

    public void setSize(String file_size) {
        this.file_size = Integer.parseInt(file_size);
    }

    public void setDir(String file_dir) {
        this.file_dir = file_dir;
    }

    public void setGroup(Integer group_id) {
        this.group_id = group_id;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return file_name;
    }

    public String getType() {
        return file_type;
    }

    public int getSize() {
        return file_size;
    }

    public String getDir() {
        return file_dir;
    }

    public int gerGroup() {
        return group_id;
    }

    public Map<String, String> toMap() {
        Map<String, String> map = new HashMap<String, String>();
        map.put("id", id + "");
        map.put("file_name", file_name);
        map.put("file_type", file_type);
        map.put("file_size", ""+ file_size);
        map.put("file_dir", file_dir);
        map.put("group_id", group_id + "");

        return map;
    }
}


Step #7 Buat Menu Untuk Berpindah Fragment

Selanjutnya kita buat bottom navigation menu di file MainActivity.java. Bottom menu adalah menu di bagian bawah layar. Menu berupa 2 icon untuk lompat ke CameraFragment dan LibraryFragment. Ubah content dari MainActivity.java menjadi seperti berikut :

package com.slightsite.tutorialuploadimage;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;

import android.os.Bundle;
import android.view.MenuItem;

import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.slightsite.tutorialuploadimage.controllers.PictureController;
import com.slightsite.tutorialuploadimage.fragments.CameraFragment;
import com.slightsite.tutorialuploadimage.fragments.LibraryFragment;
import com.slightsite.tutorialuploadimage.utils.Database;
import com.slightsite.tutorialuploadimage.utils.DatabaseHelper;
import com.slightsite.tutorialuploadimage.utils.DateTimeStrategy;

public class MainActivity extends AppCompatActivity
        implements BottomNavigationView.OnNavigationItemSelectedListener {

    private androidx.appcompat.app.ActionBar actionBar;
    public Database database;

    @Override
    public boolean onNavigationItemSelected(@NonNull MenuItem item) {
        Fragment fragment = null;

        switch (item.getItemId()) {
            case R.id.navigation_library: //navigasi untuk lompat ke fragment library
                fragment = new LibraryFragment();
                actionBar.setTitle(getResources().getString(R.string.title_library));
                break;

            case R.id.navigation_camera: // navigasi untuk lompat ke fragment camera
                fragment = new CameraFragment();
                actionBar.hide();
                break;
        }

        return loadFragment(fragment);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // init core apps
        initCoreApp();

        //loading the default fragment
        loadFragment(new CameraFragment());

        BottomNavigationView navigation = (BottomNavigationView) findViewById(R.id.navigation);
        navigation.setOnNavigationItemSelectedListener(this);

        actionBar = getSupportActionBar();

        navigation.setSelectedItemId(R.id.navigation_camera);
        navigation.getMenu().getItem(1).setChecked(true);
    }

    private void initCoreApp() { //membuat koneksi ke database
        database = new DatabaseHelper(this);
        PictureController.setDatabase(database);

        DateTimeStrategy.setLocale("id", "ID");
    }

    private boolean loadFragment(Fragment fragment) { //perintah untuk load fragment
        if (fragment != null) {
            getSupportFragmentManager()
                    .beginTransaction()
                    .replace(R.id.fragment_container, fragment)
                    .commit();
            return true;
        }
        return false;
    }
}


Step #8 Kelola Tata Letak Tampilan (View) dan Tambah Icon Yang Diperlukan

Jika terbiasa dengan pemrograman web, view biasanya kita pakai HTML, namun di Android menggunakan file XML. Letak file - file xml ini terpisah dengan file java, yakni di folder res. Saya pribadi sangat suka dengan struktur yang dibuat oleh tim pengembang Android ini karena sangat memikirkan kerapian hingga tata letak document diperhatikan. Jika rapi maka orang lain yang membaca code-nya pun lebih mudah paham.

struktur view android

Dalam folder res terdapat 5 folder yakni drawable, layout, menu, mipmap, dan values. Folder drawable isinya icon, Layout berisi file xml untuk tampilan halaman (sebagai contoh activity_main.xml adalah tampilan dari halaman utama). Struktur menu dipisahkan dari folder layout ke folder Menu, sedangkan folder Mipmap untuk menampung gambar termasuk gambar icon aplikasi, dan folder terakhir Values untuk data styling baik variable warna, padding, margin, dan translation di file strings.xml.

Untuk lebih memahami strukturnya, silakan buka source code-nya di github https://github.com/faridefendi58/take-picture-turorial . Aplikasi ini baru saya cobakan test maksimal di versi Android 9. Apk dapat di download di di sini .

Jika teman - teman terkendala dengan versi Android yang lebih tinggi boleh tinggalkan komentar di bawah ini.

Farid Efendi
Farid Efendi

Seneng mempelajari hal baru, gak cuma di Pemrograman, SEO, IM, tapi di luar topik internet juga OK. Biar sakti kayak James Bond.

Tinggalkan Komentar :

bikin website

Ingin belajar membuat website?

Bikin website tuh gak sulit amat, si Amat aja bisa loh bikin website yang bagus dan murah. Mau tau caranya?

Lihat Rahasianya