Mematikan Kamera Pada Perangkat Android Dengan Device Administrator API

Walaupun Android adalah turunan dari sistem dari Linux, pengguna biasanya tidak memiliki akses ke user root seperti di Linux. Hal ini dilakukan demi alasan keamanan. Sebagai alternatifnya, aplikasi dapat menggunakan Device Administration API untuk memiliki kemampuan yang lebih spesial dibandingkan aplikasi lainnya. Bila sebuah aplikasi didaftarkan sebagai device administrator, aplikasi tersebut akan memiliki kemampuan untuk mengatur policy pada perangkat. Salah satu policy yang boleh diatur oleh device administrator adalah mematikan fasilitas kamera (tersedia sejak Android 4.0).

Sebagai latihan, saya akan membuat aplikasi yang dapat mematikan atau mengaktifkan kamera di perangkat Android. Saya akan mulai dengan sebuah proyek baru di Android Studio. Sebuah aplikasi device administrator harus memiliki sebuah class yang diturunkan dari DeviceAdminReceiver. Sebagai latihan, saya membuat implementasi tersebut seperti pada kode program berikut ini:

public class MyCameraDeviceAdminReceiver extends DeviceAdminReceiver {

    @Override
    public void onEnabled(Context context, Intent intent) {
        Toast.makeText(context, "MyCameraDeviceAdmin diaktifkan", Toast.LENGTH_LONG).show();
    }

    @Override
    public CharSequence onDisableRequested(Context context, Intent intent) {
        return "MyCameraDeviceAdmin akan dimatikan!";
    }

    @Override
    public void onDisabled(Context context, Intent intent) {
        Toast.makeText(context, "MyCameraDeviceAdmin dimatikan", Toast.LENGTH_LONG).show();
    }

}

DeviceAdminReceiver pada dasarnya adalah sebuah broadcast receiver. Oleh sebab itu, saya perlu mendaftarkannya pada AndroidManifest.xml seperti berikut ini:

<manifest ...>

  <application ...>
     ...
     <receiver android:name=".MyCameraDeviceAdminReceiver"
        android:label="MyCamera Device Administrator"
        android:permission="android.permission.BIND_DEVICE_ADMIN">
        <meta-data android:name="android.app.device_admin" android:resource="@xml/mycamera_device_admin" />
           <intent-filter>
              <action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
           </intent-filter>
     </receiver>
  </application>

</manifest>

Deklarasi broadcast receiver di atas membutuhkan referensi ke file XML lainnya. Untuk itu, saya membuat sebuah file bernama mycamera_device_admin.xml di folder xml pada resources dengan isi seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<device-admin>
    <uses-policies>
        <disable-camera />
    </uses-policies>
</device-admin>

Deklarasi di atas menunjukkan bahwa aplikasi device administrator ini hanya mengatur policy yang berkaitan dengan mematikan penggunaan kamera.

Setelah men-deploy aplikasi ke perangkat, saya bisa mendaftarkan aplikasi device administrator ini dengan memilih menu Settings, Security, Device administrators. Saya akan menemukan device administrator saya seperti pada gambar berikut ini:

Melihat daftar device administrator

Melihat daftar device administrator

Untuk mengaktifkannya, saya dapat memberikan tanda centang pada device administrator yang saya buat seperti pada gambar berikut ini:

Mengaktifkan device administrator

Mengaktifkan device administrator

Bila saya memilih Activate, kode program di onEnabled() akan dipanggil. Hal ini terlihat dari toast yang muncul seperti pada gambar berikut ini:

Device administrator yang aktif

Device administrator yang aktif

Bila saya menghilangkan tanda centang dan memilih Deactivate, sebuah dialog akan muncul sesuai dengan pesan yang dikembalikan oleh onDisableRequested() seperti pada gambar berikut ini:

Me-nonaktif-kan device administrator

Me-nonaktif-kan device administrator

Setelah itu kode program onDisabled() akan dipanggil, seperti yang terlihat pada gambar berikut ini:

Device administrator yang tidak aktif

Device administrator yang tidak aktif

Walaupun sekarang saya sudah bisa mendaftarkan aplikasi tersebut sebagai device administrator, tidak ada apa-apa yang dikerjakan olehnya. Aplikasi saya hanya menampilkan toast dan tidak menghubungi sebuah server yang berisi pusat kebijakan. Hal ini karena saya ingin aplikasi ini dikendalikan oleh pengguna melalui sebuah activity.

Sebagai contoh, saya akan membuat sebuah activity baru dengan nama MainActivity. Activity ini akan memungkinkan pengguna untuk mengendalikan apakah kamera hendak diaktifkan atau tidak, seperti yang terlihat pada kode program berikut ini:

public class MainActivity extends Activity implements CompoundButton.OnCheckedChangeListener {

    private DevicePolicyManager pm;
    private MyCameraDeviceAdminReceiver myCameraDeviceAdminReceiver;
    private ComponentName adminName;
    private CheckBox statusKamera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        myCameraDeviceAdminReceiver = new MyCameraDeviceAdminReceiver();
        pm = (DevicePolicyManager) getSystemService(Context.DEVICE_POLICY_SERVICE);

        // Tampilkan dialog untuk mendaftarkan aplikasi ini sebagai device administrator
        // bila belum pernah didaftarkan sebelumnya.
        adminName= myCameraDeviceAdminReceiver.getWho(this);
        if (!pm.isAdminActive(adminName)) {
            Intent intent = new Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN);
            intent.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, adminName);
            intent.putExtra(DevicePolicyManager.EXTRA_ADD_EXPLANATION,
                "Anda harus menjadikan aplikasi ini sebagai device administrator");
            startActivity(intent);
        }

        //
        // Tampilan hanya berupa sebuah checkbox untuk mengaktifkan dan mematikan
        // fasilitas kamera.
        //
        LinearLayout layout = new LinearLayout(this);
        statusKamera = new CheckBox(this);
        statusKamera.setText("Jangan aktifkan kamera di perangkat ini!");
        statusKamera.setOnCheckedChangeListener(this);
        statusKamera.setChecked(pm.getCameraDisabled(null));
        layout.addView(statusKamera);
        setContentView(layout);
    }

    @Override
    public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
        if (pm.isAdminActive(adminName)) {
            pm.setCameraDisabled(adminName, isChecked);
        } else {
            Toast.makeText(this, "Anda harus mendaftarkan aplikasi ini sebagai device administrator terlebih dahulu",
                Toast.LENGTH_SHORT).show();
        }
    }
}

Pada saat activity ini dijalankan, ia akan memeriksa apakah device administrator yang sebelumnya saya buat sudah aktif atau tidak dengan menggunakan method isAdminActive() dari DevicePolicyManager. Bila device administrator belum aktif, ia akan membuat Intent baru yang akan menampilkan kotak dialog pendaftaran device administrator tersebut (sehingga pengguna tidak perlu memilih masuk ke menu Settings untuk mengaktifkannya).

Tampilan program ini cukup sederhana karena ia hanya berisi sebuah CheckBox seperti pada gambar berikut ini:

Tampilan launcher activity

Tampilan launcher activity

Bila CheckBox tersebut dimodifikasi oleh pengguna, method setCameraDisabled() dari DevicePolicyManager akan dipanggil untuk mengatur apakah kamera boleh digunakan atau tidak.

Sebagai contoh, bila saya melarang penggunaan kamera, maka pada saat saya berusaha menjalankan aplikasi yang menggunakan kamera, saya akan menemukan pesan kesalahan seperti Security policy restricts use of Camera.

Membuat Keyboard Jarak Jauh Untuk Android

Salah satu nilai tambah Input Method Framework (IMF) di Android adalah ia memungkinkan programmer untuk membuat Input Method Editor (IME) baru. Bagi pengguna awam, ini berarti membuat “aplikasi keyboard”, walaupun IME tidak harus selalu keyboard virtual. IME juga bisa dalam bentuk pengenalan suara, deteksi tulisan tangan, hasil baca dari kamera, dan berbagai kemungkinan lainnya.

Salah satu masalah yang saya hadapi berhubungan dengan input adalah sulitnya mengetik secara cepat di keyboard virtual (soft keyboard) Android. Ada beberapa solusi yang dapat ditempuh untuk mengatasi kendala tersebut. Misalnya, karena Android mendukung pemograman USB, saya bisa membuat sebuah hardware yang berfungsi sebagai keyboard fisik dengan mengikuti Android Open Accessory Protocol (AOA). Ini membutuhkan pengetahuan merakit hardware dan pemograman USB yang memadai. Selain itu, sebagai cara yang lebih murah, mengingat perangkat Android juga bisa berfungsi sebagai USB Host, maka keyboard dan mouse yang kompatibel bisa dihubungkan secara langsung melalui USB. Akan tetapi, karena hampir semua perangkat hanya datang dengan kabel micro USB yang berujung pada USB A male (untuk adaptor dan koneksi ke PC), saya perlu kabel USB yang berbeda. Untuk itu, saya bisa membeli sebuah kabel micro USB male yang berujung pada USB A female.

Alternatif lainnya, sebagai solusi yang murni melibatkan software saja, saya akan membuat sebuah IME yang menerima input melalui jaringan Wifi dari komputer. Tujuannya adalah agar saya bisa mengetik melalui PC yang terhubung ke jaringan lokal melalui Wifi. Karena ini hanya latihan, saya tidak akan memperhatikan aspek reliabilitas dan keamanan. IME akan membuka socket di port 9090. Siapa saja yang dapat menghubungi IP perangkat Android di port 9090 dapat langsung mengirim tulisan yang hendak diketik. Karakter ganti baris (\n) akan dipakai untuk menandakan bahwa input sudah selesai.

Saya akan mulai dengan membuat sebuah proyek baru di Android Studio. Karena kode program ini akan membuat socket, saya perlu menambahkan penggunaan permission berikut ini pada AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>

  <uses-permission android:name="android.permission.INTERNET" />

  ...

</manifest>

Sebuah IME sebaiknya memiliki tampilan dimana pengguna dapat berinteraksi padanya. Pada soft keyboard, tampilan ini akan berupa deretan papan kunci yang dapat disentuh. Pada IME latihan ini, saya hanya akan menyediakan sebuah TextView yang berisi pesan informasi dan sebuah Button yang dapat dipakai untuk membatalkan input. Sebagai contoh, saya membuat layout bernama layout_input.xml yang isinya seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="match_parent"
    android:background="@android:color/black">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceLarge"
        android:id="@+id/message"
        android:textColor="@android:color/white"
        android:layout_alignParentEnd="true"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Batal"
        android:id="@+id/batal"
        android:layout_below="@+id/message"
        android:layout_centerHorizontal="true" />

</RelativeLayout>

Sebuah IME pada dasarnya sebuah service yang spesial dimana ia harus diturunkan dari InputMethodService. Sebagai contoh, berikut ini adalah implementasi lengkap dari service yang saya buat:

public class RemoteIME extends InputMethodService {

    private TextView pesanView;
    private String pesan;
    private ArrayBlockingQueue<String> pesanRemoteQueue = new ArrayBlockingQueue<>(100);
    private InputThread inputThread;

    @Override
    public void onCreate() {
        super.onCreate();
        ServerThread serverThread = new ServerThread();
        serverThread.start();
    }

    @Override
    public View onCreateInputView() {
        @SuppressLint("InflateParams") View view = getLayoutInflater().inflate(R.layout.layout_input, null);
        pesanView = (TextView) view.findViewById(R.id.message);
        pesanView.setText(pesan);
        view.findViewById(R.id.batal).setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                hideWindow();
                if (inputThread != null) {
                    inputThread.selesai();
                }
                return true;
            }
        });
        return view;
    }

    @Override
    public void onStartInputView(EditorInfo info, boolean restarting) {
        inputThread = new InputThread(getCurrentInputConnection());
        inputThread.start();
    }

    private void tampilkanPesan(final String pesan) {
        Handler handler = new Handler(Looper.getMainLooper());
        handler.post(new Runnable() {
            @Override
            public void run() {
                RemoteIME.this.pesan = pesan;
                if ((pesanView != null) && (pesan != null)) {
                    pesanView.setText(pesan);
                }
            }
        });
    }

    private class InputThread extends Thread {

        private InputConnection inputConnection;
        private boolean berjalan ;

        public InputThread(InputConnection inputConnection) {
            this.inputConnection = inputConnection;
        }

        @Override
        public void run() {
            berjalan = true;
            while(berjalan) {
                String pesan = pesanRemoteQueue.poll();
                if (pesan != null && inputConnection != null) {
                    getCurrentInputConnection().commitText(pesan, 1);
                    sendDefaultEditorAction(false);
                }
            }
            onFinishInput();
        }

        public void selesai() {
            berjalan = false;
        }

    }

    private class ServerThread extends Thread {

        @SuppressWarnings("InfiniteLoopStatement")
        @Override
        public void run() {
            ServerSocket serverSocket = null;
            try {
                serverSocket = new ServerSocket(9090);
                while (true) {
                    tampilkanPesan("Menunggu koneksi di port 9090");
                    try (Socket socket = serverSocket.accept()) {
                        tampilkanPesan("Terhubung dengan " + socket.getInetAddress().getHostAddress());
                        try (BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
                            String pesan;
                            while ((pesan = in.readLine()) != null) {
                                pesanRemoteQueue.offer(pesan);
                            }
                        }
                    }
                    tampilkanPesan("Koneksi ditutup");
                }
            } catch (final IOException e) {
                tampilkanPesan("Kesalahan koneksi: " + e.getMessage());
                Log.e("MyIME", "Kesalahan koneksi", e);
            } finally {
                if (serverSocket != null) {
                    try {
                        serverSocket.close();
                    } catch (IOException e) {
                        tampilkanPesan("Tidak dapat menutup socket: " + e.getMessage());
                        Log.e("MyIME", "Tidak dapat menutup socket", e);
                    }
                }
            }
        }

    }
}

Pada service di atas, terdapat dua buah nested class yang masing-masing mewakili thread terpisah, yaitu InputThread dan ServerThread. Saya perlu melakukan operasi yang melibatkan jaringan di thread terpisah agar aplikasi yang membutuhkan input tetap responsif.

Pada saat service pertama kali dijalankan, method onCreate() akan dipanggil. Method ini akan membuat thread ServerThread baru. Thread ini menggunakan ServerSocket untuk membuka socket yang menunggu koneksi di port 9090. Karena saya membuat looping tak terhingga dengan while (true), ia tidak akan pernah selesai menunggu tulisan yang dikirim dari klien yang terkoneksi padanya. Setiap kali ada pesan yang dibaca, thread ini akan meletakkannya pada sebuah queue. Saya perlu meletakkan pesan yang dibaca ke dalam queue karena pada saat klien menulis, belum tentu ada editor yang ingin membaca. Selain itu, karena kode program yang membaca nilai queue berada di thread berbeda, saya perlu menggunakan struktur data yang thread-safe seperti ArrayBlockingQueue.

Begitu ada operasi yang perlu menampilkan IME ini, method onCreateInputView() akan dipanggil bila belum pernah dipanggil sebelumnya. Pada saat editor siap untuk menerima masukan, method onStartInputView() akan dipanggil. Pada contoh di atas, method ini akan membuat thread InputThread baru. Thread ini akan terus membaca dari pesanRemoteQueue (dengan memanggil method poll()) selama belum dibatalkan oleh pengguna. Bila ada pesan yang berhasil dibaca dari pesanRemoteQueue, ia akan menuliskan pesan tersebut ke editor dengan memanggil getCurrentInputConnection().commitText(). Method getCurrentInputConnection() akan mengembalikan sebuah InputConnection yang merupakan penghubung utama antara IME dan editor (komponen visual yang memungkinkan input dari pengguna). Selain itu, saya juga memanggil method sendDefaultEditorAction() untuk mensimulasikan aksi default untuk editor tersebut (seperti halnya menekan tombol Enter dari soft keyboard).

Agar IME ini dikenali oleh sistem operasi Android di perangkat, saya perlu membuat sebuah pengenal dalam bentuk resource XML. Sebagai contoh, saya membuat file bernama method.xml di folder xml dengan isi seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<input-method xmlns:android="http://schemas.android.com/apk/res/android">
    <subtype
        android:name="@string/label_subtype"
        android:imeSubtypeLocale="en_US"
        android:imeSubtypeMode="remote" />
</input-method>

Setelah itu, saya perlu mendaftarkan XML ini dengan mengubah isi AndroidManifest.xml menjadi seperti berikut ini:

<manifest ... >

  ...

  <application ...>
    <service ... android:permission="android.permission.BIND_INPUT_METHOD">
      <intent-filter>
        <action android:name="android.view.InputMethod" />
      </intent-filter>
      <meta-data android:name="android.view.im" android:resource="@xml/method" />
    </service>
  </application>

</manifest>

Setelah men-deploy aplikasi, saya kini bisa mengaktifkan IME baru ini dengan memilih Settings, Language and input. Saya bisa memberi centang pada nama aplikasi yang saya buat dan menjadikannya sebagai IME default.

Bila saya membuka sebuah aplikasi dan mencoba mengisi input dengan IME latihan ini, saya akan memperoleh tampilan seperti pada gambar berikut ini:

Tampilan IME

Tampilan IME

Untuk memulai mengetik dari PC, saya memastikan perangkat terhubung ke WiFi dan mencatat IP yang diberikan untuk perangkat Android tersebut. Kemudian, pada sebuah PC dengan sistem operasi Linux, saya memberikan perintah seperti berikut ini:

$ telnet x.x.x.x 9090
Trying x.x.x.x...
Connected to x.x.x.x.
Escape character is '^]'.
hi, lagi ngapain? aku sangat rindu padamu :)

Pada perangkat Android, apa yang saya ketik akan langsung muncul di aplikasi, seperti yang terlihat pada gambar berikut ini:

Hasil Pada Aplikasi Setelah Menerima Input

Hasil Pada Aplikasi Setelah Menerima Input

Akhirnya saya dapat mengetik dengan menggunakan keyboard fisik di perangkat Android tanpa mengeluarkan biaya sepeserpun untuk membeli kabel atau perangkat keras lainnya.

Belajar Membuat Live Wallpaper Untuk Android

Selain wallpaper dalam bentuk gambar statis, Android juga mendukung wallpaper animasi dan interaktif yang disebut sebagai live wallpaper. Sebagai latihan, pada kesempatan ini, saya akan mencoba membuat sebuah live wallpaper sederhana. Saya akan mulai dengan membuat sebuah proyek baru di Android Studio. Sebuah live wallpaper pada dasarnya adalah sebuah service. Oleh sebab itu, saya kemudian membuat service baru dengan nama LiveWallpaperService yang isinya seperti berikut ini:

public class LiveWallpaperService extends WallpaperService {

    @Override
    public Engine onCreateEngine() {
        return new MyAnimationEngine();
    }

}

Service yang mewakili live wallpaper harus diturunkan dari WallpaperService. Satu-satunya method yang perlu dibuat disini adalah onCreateEngine() yang menghasilkan sebuah WallpaperService.Engine.

Kode program yang berhubungan dengan live wallpaper justru lebih banyak terdapat di turunan WallpaperService.Engine ini. Sebagai contoh, saya membuat sebuah nested class dengan nama MyAnimationEngine dengan isi seperti berikut ini:

public class LiveWallpaperService extends WallpaperService {

    @Override
    public Engine onCreateEngine() {
        return new MyAnimationEngine();
    }

    class MyAnimationEngine extends Engine {

        private AnimasiThread animasiThread;
        private int width, height;

        private void startAnimation() {
            animasiThread = new AnimasiThread(getSurfaceHolder(), width, height, getApplicationContext());
            animasiThread.setRunning(true);
            animasiThread.start();
        }

        private void stopAnimation() {
            if (animasiThread != null) {
                animasiThread.setRunning(false);
                try {
                    animasiThread.join();
                } catch (InterruptedException e) {
                }
            }
        }

        private void restartAnimation() {
            if (animasiThread != null) {
                stopAnimation();
            }
            startAnimation();
        }

        @Override
        public void onSurfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            this.width = width;
            this.height = height;
            restartAnimation();
        }

        @Override
        public void onSurfaceCreated(SurfaceHolder holder) {
            startAnimation();
        }

        @Override
        public void onSurfaceDestroyed(SurfaceHolder holder) {
            stopAnimation();
        }

        @Override
        public void onVisibilityChanged(boolean visible) {
            if (!visible) {
                stopAnimation();
            } else {
                restartAnimation();
            }
        }

    }

}   

Sama seperti saat menggambar pada SurfaceView, saya memulai proses penggambaran pada onSurfaceCreated() yang akan dipanggil setelah SurfaceHolder dibuat. Selain itu, saya juga perlu memperbaharui layar bila terjadi perubahan ukuran layar yang akan memanggil onSurfaceChanged(). Bila proses penggambaran selesai, onSurfaceDestroyed() akan dipanggil.

Pada saat menggambar untuk live wallpaper, saya perlu menghemat sumber daya dan tidak mengerjakan sesuatu yang tidak perlu. Bila terlalu berlebihan, bukan saja CPU akan habis dikonsumsi kode program yang menggambar, tetapi baterai juga akan cepat habis. Oleh sebab itu, saya men-override method onVisiblityChanged() untuk mematikan proses penggambaran bila live wallpaper tidak visible.

Pada implementasi WallpaperService.Engine di atas, saya menggambar pada sebuah thread baru yang diwakili oleh sebuah class seperti berikut ini:

public class AnimasiThread extends Thread {

    private SurfaceHolder surfaceHolder;
    private boolean running = false;
    private int width, height;
    private List<Gambar> gambars;
    private final Object lock = new Object();

    public AnimasiThread(SurfaceHolder surfaceHolder, int width, int height, Context context) {
        this.surfaceHolder = surfaceHolder;
        this.width = width;
        this.height = height;
        gambars = new ArrayList<>();
        for (int i=0; i<20; i++) {
            gambars.add(new Gambar(width, height, BitmapFactory.decodeResource(context.getResources(), R.drawable.hati)));
        }
    }

    public void setRunning(boolean running) {
        this.running = running;
    }

    @Override
    public void run() {
        while (running) {
            Canvas c = null;
            try {
                c = surfaceHolder.lockCanvas();
                if (running) {
                    c.drawColor(Color.BLACK);
                    synchronized (lock) {
                        for (Gambar gambar: gambars) {
                            gambar.gambar(c);
                            gambar.gerak();
                        }
                    }
                }
            } finally {
                if (c != null) {
                    surfaceHolder.unlockCanvasAndPost(c);
                }
            }
        }
    }

}

Class di atas membutuhkan sebuah gambar yang saya letakkan dalam folder drawable. Saya memberi nama file tersebut sebagai hati.png.

Tidak ada yang spesial pada class AnimasiThread di atas. Saya hanya menciptakan beberapa object Gambar dan memanggil method gerak() pada setiap object yang ada secara terus menerus berulang kali. Isi dari class Gambar terlihat seperti berikut ini:

public class Gambar {

    private float x, y, xmax, ymax;
    private Bitmap gambar;
    private float kecepatan;

    public Gambar(float xmax, float ymax, Bitmap gambar) {
        this.xmax = xmax;
        this.ymax = ymax;
        this.gambar = gambar;
        this.kecepatan = 0.1f;
        Random random = new Random();
        this.x = random.nextFloat() * xmax;
        this.y = random.nextFloat() * ymax;
    }

    public void gerak() {
        y += kecepatan;
        kecepatan += 0.1f;
        if (y > ymax) {
            y = (float)(Math.random() * -ymax);
            x = (float)(Math.random() * xmax);
            kecepatan = 0.1f;
        }
    }

    public void gambar(Canvas c) {
        c.drawBitmap(gambar, x, y, null);
    }

}

Sebagai langkah terakhir, agar live wallpaper ini bisa dikenali, saya perlu membuat sebuah file XML yang berisi <wallpaper>. Untuk itu, saya men-klik kanan nama proyek dan memilih menu New, Android Resource File. Pada dialog yang muncul, saya mengisi File name dengan mylivewallpaper. Pada Resource type, saya memilih XML. Saya juga mengganti Root element dengan wallpaper. Setelah itu, saya men-klik tombol Ok. Saya kemudian mengubah file yang dihasilkan menjadi seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name">
</wallpaper>

Setelah itu, saya mengubah deklarasi service di AndroidManifest.xml menjadi seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<manifest... >

    <application ...>
        <service
            android:name=".LiveWallpaperService"
            android:permission="android.permission.BIND_WALLPAPER">
            <intent-filter>
                <action android:name="android.service.wallpaper.WallpaperService" />
            </intent-filter>
            <meta-data
                android:name="android.service.wallpaper"
                android:resource="@xml/mylivewallpaper" />
        </service>
    </application>

</manifest>

Saya tidak bisa begitu saja menjalankan aplikasi ini karena tidak ada activity yang berfungsi sebagai launcher. Hal ini terlihat dari tanda silang pada tombol yang biasa saya gunakan untuk menjalankan aplikasi di Android Studio:

Tidak ada launcher activity

Tidak ada launcher activity

Yang bisa saya lakukan hanya men-deploy aplikasi ke perangkat. Oleh sebab itu, saya memilih Edit Configurations…. Pada dialog yang muncul, saya mengubah Launch default Activity menjadi Do not launch Activity seperti pada gambar berikut ini:

Mendeploy project tanpa menjalankan activity

Mendeploy project tanpa menjalankan activity

Setelah men-klik OK, saya kini bisa men-deploy aplikasi ke perangkat Android.

Untuk menggunakan live wallpaper yang telah saya buat, saya perlu membuka Settings di perangkat Android lalu memilih menu Display, Wallpaper, Home screen. Pada daftar pilihan yang muncul, saya memilih Live wallpapers seperti pada gambar berikut ini:

Memilih live wallpaper untuk dipakai

Memilih live wallpaper untuk dipakai

Setelah itu, saya dapat memilih live wallpaper yang telah saya buat seperti pada gambar berikut ini:

Memilih live wallpaper untuk dipakai

Memilih live wallpaper untuk dipakai

Sekarang, tampilan live wallpaper akan muncul dalam bentuk animasi seperti pada video berikut ini:

Tampilan live wallpaper setelah dipakai

Tampilan live wallpaper setelah dipakai

Belajar Membaca Nilai Sensor Accelerometer Di Android

Perangkat Android biasanya dilengkapi dengan satu atau lebih sensor. Dari semua sensor yang ada, accelerometer termasuk sebuah sensor yang paling umum dijumpai di perangkat modern. Accelerometer dipakai untuk mengukur percepatan (a). Tentu saja sebuah sensor perangkat keras seperti ini tidak berguna bila tidak ada kode program untuk mengambil nilainya. Dengan menggunakan Sensor API di Android SDK, saya bisa membaca nilai percepatan di sumbu x, y, dan z dalam satuan m/s^2. Nilai ini bisa dipakai untuk mendeteksi guncangan pada perangkat dan gerakan memiringkan perangkat (misalnya untuk kendali pada game).

Agar bisa memahami nilai yang dikembalikan dari sensor secara mudah, pada latihan kali ini, saya akan membuat sebuah aplikasi sederhana yang menampilkan nilai yang dibaca dari accelerometer dalam bentuk grafis. Seperti biasa, saya mulai dengan membuat proyek baru di Android Studio. Kali ini saya akan menggunakan bahasa Groovy.

Nilai yang dikembalikan sensor (berlaku untuk semua sensor) akan disimpan dalam bentuk array sebagai nilai untuk attribute values di object SensorEvent. Bila saya ingin menampung nilai yang dikembalikan dari waktu ke waktu, saya tidak bisa begitu saja menyimpan referensi ke values karena array tersebut akan selalu di-isi ulang dengan nilai terbaru. Oleh sebab itu, saya akan membuat sebuah class baru yang berguna untuk menyimpan nilai sensor seperti berikut ini:

@CompileStatic
public class NilaiSensor {

    float[][] nilai;
    int maxN;
    int n;

    NilaiSensor(int maxN) {
        this.maxN = maxN
        nilai = new float[maxN][3]
        n = 0
    }

    void tambah(SensorEvent event) {
        nilai[n][0] = event.values[0]
        nilai[n][1] = event.values.length >= 2? event.values[1]: 0
        nilai[n][2] = event.values.length >= 3? event.values[2]: 0
        n++
        if (n >= maxN) {
            n = 0
        }
    }

    float getX(int n) {
        if (n >= nilai.length) return 0
        nilai[n][0]
    }

    float getY(int n) {
        if (n >= nilai.length) return 0
        nilai[n][1]
    }

    float getZ(int n) {
        if (n >= nilai.length) return 0
        nilai[n][2]
    }

}

Khusus untuk accelerometer, nilai untuk sumbu x diwakili oleh event.values[0], sumbu y diwakili oleh event.values[1] dan sumbu z diwakili oleh event.values[2].

Untuk menghindari aktifitas garbage collection yang berlebihan, saya membuat sebuah array besar di memori untuk menampung seluruh nilai yang akan dibuat grafisnya. Nilai ini mulai dari nilai n=0 sampai n=maxN. Setiap kali ada data baru dari sensor, method tambah() akan dipanggil. Bila array besar ini sudah tidak muat, maka nilai n akan dikembalikan ke semula (0) sehingga menimpa nilai yang berada di posisi awal.

Nilai yang dikembalikan sensor dapat berupa angka negatif atau positif. Saya akan menganggap tengah layar sebagai nilai 0. Bagian di atas tengah layar akan dialokasikan untuk nilai positif dan sebaliknya, bagian di bawah tengah layar akan dialokasikan untuk nilai negatif. Agar lebih menghemat memori, nilai yang disimpan di NilaiSensor harusnya adalah nilai yang sudah diterjemahkan ke koordinat layar sehingga siap untuk dipakai. Untuk itu, saya mengubah kode program NilaiSensor sehingga menjadi seperti berikut ini:

@CompileStatic
public class NilaiSensor {

    float[][] nilai;
    int maxN, height, mid;
    int n;

    NilaiSensor(int width, int height) {
        this.maxN = width
        this.height = height
        this.mid = (int)(height / 2)
        nilai = new float[maxN][3]
        n = 0
    }

    float translate(float nilai) {
        mid - nilai * 20
    }

    void tambah(SensorEvent event) {
        nilai[n][0] = translate(event.values[0])
        nilai[n][1] = translate(event.values.length >= 2? event.values[1]: 0f)
        nilai[n][2] = translate(event.values.length >= 3? event.values[2]: 0f)
        n++
        if (n >= maxN) {
            n = 0
        }
    }

    float getX(int n) {
        if (n >= nilai.length) return mid
        nilai[n][0]
    }

    float getY(int n) {
        if (n >= nilai.length) return mid
        nilai[n][1]
    }

    float getZ(int n) {
        if (n >= nilai.length) return mid
        nilai[n][2]
    }

}

Langkah berikutnya, untuk menggambar nilai yang telah dihitung, saya membuat sebuah turunan dari SurfaceView seperti berikut ini:

@CompileStatic
class PlotterView extends SurfaceView implements SurfaceHolder.Callback, SensorEventListener {

    Timer timer
    TimerTask timerTask
    volatile int width
    volatile int height
    NilaiSensor nilaiSensor
    Paint paintX, paintY, paintZ, paintSumbu

    PlotterView(Context context) {
        super(context)
        getHolder().addCallback(this)
        paintX = new Paint()
        paintX.setColor(Color.YELLOW)
        paintY = new Paint()
        paintY.setColor(Color.BLUE)
        paintZ = new Paint()
        paintZ.setColor(Color.CYAN)
        paintSumbu = new Paint()
        paintSumbu.setColor(Color.WHITE)
    }

    @Override
    void surfaceCreated(SurfaceHolder holder) {
        width = getWidth()
        height = getHeight()
        setup()
    }

    @Override
    void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        setWidth(width)
        this.width = width
        setHeight(height)
        setup()
    }

    void setup() {
        timerTask?.cancel()
        nilaiSensor = new NilaiSensor(width, height)
        timerTask = new TimerTask() {
            @Override
            void run() {
                updateAction()
            }
        }
        timer = new Timer()
        timer.schedule(timerTask, 0, 10)
    }

    @Override
    void surfaceDestroyed(SurfaceHolder holder) {
        timerTask.cancel()
        timer.cancel()
        timer.purge()
    }

    void updateAction() {
        SurfaceHolder holder = getHolder()
        Canvas c
        try {
            c = holder.lockCanvas()
            if (c) {
                c.drawColor(Color.BLACK)
                int n
                for (n=1; n <= nilaiSensor.n; n++) {
                    c.drawLine(n-1, nilaiSensor.getX(n-1), n, nilaiSensor.getX(n), paintX)
                    c.drawLine(n-1, nilaiSensor.getY(n-1), n, nilaiSensor.getY(n), paintY)
                    c.drawLine(n-1, nilaiSensor.getZ(n-1), n, nilaiSensor.getZ(n), paintZ)
                }
            }
        } finally {
            if (c) {
                holder.unlockCanvasAndPost(c)
            }
        }
    }

    @Override
    void onSensorChanged(SensorEvent event) {
        if (nilaiSensor) {
            nilaiSensor.tambah(event)
        }
    }

    @Override
    void onAccuracyChanged(Sensor sensor, int accuracy) {}

}

Class di atas mengimplementasikan SensorEventListener sehingga memiliki method onSensorChanged() dan onAccuracyChanged(). Bila method ini didaftarkan melalui SensorManager, maka method onSensorChanged() akan dikerjakan setiap kali ada nilai baru yang dapat dibaca dari sensor. Saya menggunakan TimerTask untuk memperbaharui layar setiap 10 ms. Method updateAction() berisi kode program yang akan dikerjakan setiap 10 detik tersebut. Pada dasarnya, method tersebut akan menggambar 3 garis masing-masing untuk mewakili nilai X, Y, dan Z yang diperoleh dari sensor.

Sebagai langkah terakhir, saya membuat sebuah Activity yang dapat dipanggil oleh pengguna, misalnya seperti berikut ini:

@CompileStatic
class MainActivity extends Activity {

    SensorManager sensorManager
    PlotterView plotterView

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState)
        plotterView = new PlotterView(this)
        setContentView(plotterView)
    }

    @Override
    protected void onStart() {
        super.onStart()
        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE)
        Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
        if (!sensor) {
            throw new RuntimeException("Tidak ada accelerometer")
        }
        sensorManager.registerListener(plotterView, sensor, SensorManager.SENSOR_DELAY_UI)
    }

    @Override
    protected void onPause() {
        super.onPause()
        sensorManager.unregisterListener(plotterView)
    }

}

Pada activity di atas, saya mendapatkan sebuah instance Sensor dengan memanggil method getDefaultSensor() dari SensorManager. Pada method ini, saya menyertakan jenis sensor yang dikehendaki yaitu Sensor.TYPE_ACCELEROMETER. Karena tidak semua perangkat memiliki sensor tersebut, method getDefaultSensor() bisa saja mengembalikan nilai null. Setelah itu, saya mendaftarkan PlotterView yang sudah saya buat sebelumnya sebagai listener untuk sensor yang baru saja saya dapatkan dengan menggunakan method registerListener() dari SensorManager. Agar tidak mengkonsumsi baterai berlebihan, saat aplikasi di-pause, saya memanggil method unregisterListener() dari SensorManager sehingga pembacaan nilai dari sensor tidak dilakukan lagi.

Bila saya menjalankan aplikasi, saya akan memperoleh tampilan seperti pada gambar berikut ini:

Tampilan awal

Tampilan awal

Agar lebih mudah dibaca, saya akan menambahkan informasi nilai. Untuk itu, saya mengubah kode program PlotterView sehingga menjadi seperti berikut ini:

@CompileStatic
class PlotterView extends SurfaceView implements SurfaceHolder.Callback, SensorEventListener {

  ...

  Map<String, Float> nilaiSumbu = [:]

  ...

  void setup() {
    ...
    int y = (int)((height / 2) / 20)
    while (nilaiSensor.translate(y) < height) {
      nilaiSumbu[y.toString()] = nilaiSensor.translate(y)
      y -= 1
    }
    ...
  }

  ...

  void updateAction() {
    SurfaceHolder holder = getHolder()
    Canvas c
    try {
      c = holder.lockCanvas()
      if (c) {
        ...
        //Gambar sumbu
        n = nilaiSensor.n
        c.drawLine(n , 0,  n, height, paintSumbu)
        for (String k: nilaiSumbu.keySet()) {
          float v = nilaiSumbu[k]
          c.drawText(k, n, v, paintSumbu)
          c.drawLine(n-5, v, n, v, paintSumbu)
        }
      }
    } finally {
      if (c) {
        holder.unlockCanvasAndPost(c)
      }
    }
  }

  ...

}

Sekarang, bila saya menjalankan aplikasi, saya akan memperoleh informasi sumbu. Sebagai contoh, bila saya meletakkan perangkat secara mendatar di atas meja, saya memperoleh hasil seperti pada gambar berikut ini:

Nilai accelerometer saat perangkat diletakkan terbaring diam dengan layar menghadap ke atas

Nilai accelerometer saat perangkat diletakkan terbaring diam dengan layar menghadap ke atas

Terlihat bahwa pada posisi diam pun, accelerometer tidak serta merta mengembalikan nilai 0. Hal ini karena nilai yang dikembalikan dipengaruhi oleh gaya gravitasi. Sebagai contoh, garis warna biru terang mewakili nilai percepatan pada sumbu yang menghadap ke layar (pada sensor Android, ini disebut sumbu Z). Pada saat perangkat berada dalam keadaan dibaringkan (dimana layar menghadap ke atas), maka sumbu Z sepenuhnya dipengaruhi oleh gaya gravitasi bumi (sekitar 9,8 m/s^2).

Bila saya menegakkan perangkat Android di atas meja dimana perangkat tersebut dalam keadaan diam, saya memperoleh hasil dari accelerometer seperti pada gambar berikut ini:

Nilai accelerometer saat perangkat tegak dalam keadaan diam

Nilai accelerometer saat perangkat tegak dalam keadaan diam

Walaupun saya mengubah posisi perangkat dari terbaring di atas meja menjadi berdiri tegak, lokasi sumbu tidak berubah. Sebagai contoh, sumbu Z tetap adalah sumbu yang menghadap ke arah layar. Pengaruh gravitasi padanya kini menjadi lebih kecil pada sumbu tersebut. Sebaliknya, sumbu Y yang menghadap ke arah atas perangkat (vertikal) kini memperoleh pengaruh besar dari gravitasi.

Contoh lainnya, bila saya menggoyangkan perangkat dengan keras, tampilan grafis akan terlihat seperti pada gambar berikut ini:

Nilai accelerometer saat perangkat diguncang dengan keras ke kiri dan ke kanan

Nilai accelerometer saat perangkat diguncang dengan keras ke kiri dan ke kanan

Pada gambar di atas, saya mengguncang perangkat sebanyak 4 kali (ke kiri dan ke kanan). Terlihat pada percepatan pada sumbu X naik turun secara drastis pada saat saya menggoyang perangkat. Berdasarkan informasi ini, saya dapat membuat aplikasi yang mendeteksi apakah perangkat bergoyang (dengan memeriksa apakah ada perubahan drastis nilai X positif menjadi negatif dalam waktu singkat).

Pola lain yang menarik adalah nilai yang diperoleh pada saat pengguna berjalan. Sebagai contoh, bila saya merekam nilai dari accelerometer pada saat saya melangkahkan kaki sambil memegang perangkat di tangan, saya memperoleh hasil seperti pada gambar berikut ini:

Nilai accelerometer saat perangkat dipegang di tangan sambil berjalan

Nilai accelerometer saat perangkat dipegang di tangan sambil berjalan

Menampilkan Peta Dengan Google Maps API

Pada artikel Memakai Database SQLite Di Android, saya membuat aplikasi yang mencari dan menyimpan koordinat lokasi yang diperoleh melalui GPS. Walaupun, saya bisa menampilkan lokasi ini satu per satu melalui Intent, akan lebih baik bila saya menambahkan fitur untuk menampilkan seluruh lokasi yang tersimpan di database ke dalam sebuah peta tunggal. Dengan demikian, saya bisa menganalisa hubungan setiap lokasi secara mudah. Untuk itu, saya perlu menyisipkan peta langsung di dalam aplikasi sehingga bisa saya atur sesuka hati.

Akan tetapi membuat kode program untuk menampilkan peta bukanlah hal mudah dan bisa membutuhkan waktu lama. Salah satu solusi yang lebih mudah adalah dengan memakai Google Maps yang bisa disisipkan ke dalam aplikasi Android melalui Google Maps API yang merupakan bagian dari layanan dari Google Play Services. Fasilitas ini bukan bagian dari Android Open Source Project (AOSP) melainkan layanan tersendiri dari Google. Hal ini berarti perangkat Android yang murni memakai AOSP (produsennya tidak ‘membayar’ lebih kepada Google) tidak bisa menjalankan aplikasi yang memakai Google Play Services. Selain itu, walaupun Google Maps API bisa dipakai secara gratis oleh programmer Android, penggunaan yang berlebihan seperti membuka lebih dari 25.000 peta setiap hari selama lebih dari 90 hari berturut-turut akan dikenakan biaya.

Untuk memakai Google Play Services, saya perlu menambahkan baris berikut ini pada build.gradle (modul):

...
dependencies {
    ...
    compile 'com.android.support:appcompat-v7:22.0.0'
    compile 'com.google.android.gms:play-services:7.0.0'
}

Saya kemudian men-klik Sync Now agar Android Studio men-download dependency (file JAR) yang dibutuhkan. Setelah itu, saya juga menambahkan baris berikut ini pada AndroidManifest.xml:

<manifest ...>

  ...

  <application ...>

    ...

    <meta-data android:name="com.google.android.gms.version"
       android:value="@integer/google_play_services_version" />

  </application>

</manifest>

Setiap aplikasi Android selalu di-sign oleh sebuah sertifikat digital. Khusus untuk aplikasi yang dijalankan pada perangkat atau emulator sebagai debug release, saya dapat menemukan lokasi sertifikat digital tersebut di ~/.android/debug.keystore. Sebagai contoh, di Linux, saya dapat memberikan perintah berikut ini:

$  keytool -list -v -keystore ~/.android/debug.keystore 
-alias androiddebugkey -storepass android -keypass android

untuk menampilkan informasi sertifikat digital yang dipakai oleh Android Studio saat menghasilkan aplikasi pada debug release. Dengan kata lain, semua aplikasi yang saya jalankan pada debug release bisa dikenali melalui sertifikat digital ini.

Saya kemudian mencatat nilai SHA1 pada output di atas. Nilai ini dibutuhkan untuk mendapatkan sebuah Android API key yang dapat dipakai di aplikasi saya. Untuk memperoleh key ini, saya membuka https://console.developers.google.com. Pada situs tersebut, saya men-klik tombol Create Project. Setelah proyek dibuat, saya memilih APIs & auth, APIs, Google Maps APIs, dan men-klik pada Google Maps Android API. Saya kemudian men-klik tombol Enable API. Setelah itu, saya memilih APIs & auth, Credentials dan men-klik pada Create new Key. Pada dialog yang muncul, saya memilih Android key. Saya perlu mengisi input yang ada dengan nilai SHA1 yang saya peroleh dari sertifikat digital diikuti dengan tanda titik koma beserta nama package seperti XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX;com.snake.latihanku. Setelah selesai, saya akan memperoleh sebuah API key yang bisa dipakai pada proyek Android saya.

Untuk memakai API key yang dihasilkan dari Google, saya menambahkan baris berikut ini pada AndroidManifest.xml:

<manifest ...>

  ...

  <application ...>

    ...

    <meta-data android:name="com.google.android.geo.API_KEY"
       android:value="xxxxxxxxxxxxxxxxx" />

  </application>

</manifest>

Berkat key unik ini, Google dapat mengenali seberapa sering aplikasi saya memanggil Google Maps API. Pengunaan berlebihan yang membutuhkan pembayaran dihitung berdasarkan seberapa sering API dipanggil melalui key ini.

Untuk memakai Google Maps API, saya perlu terhubung ke server Google Maps melalui internet. Selain itu, ia juga akan men-cache data ke penyimpanan eksternal guna mengirit akses internet. Oleh sebab itu, saya menambahkan permission berikut ini di AndroidManifest.xml:

<manifest ...>

  ...

  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

  ...

</manifest>

Sekarang, saya siap untuk membuat kode program. Saya akan mulai dengan membuat sebuah menu baru seperti:

<menu ...>

  ...

  <item android:id="@+id/menu_semua" app:showAsAction="ifRoom"
     android:title="Lihat Semua" />

  ...

</menu>

Pada saat menu ini di-klik, kode program berikut ini akan dikerjakan:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

  ...

  @Override
  public boolean onCreateOptionsMenu(Menu menu) {
    ...
    // Tampilkan semua
    menu.findItem(R.id.menu_semua).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
      @Override
      public boolean onMenuItemClick(MenuItem item) {
        startActivity(new Intent(LokasiActivity.this, SemuaLokasiActivity.class));
        return true;
      }
    });

    return true;
  }

  ...

}

Kode program di atas akan menjalankan SemuaLokasiActivity yang isinya seperti berikut ini:

public class SemuaLokasiActivity extends Activity implements OnMapReadyCallback {

    private MapFragment mapFragment;
    private LinearLayout layout;
    private List<Lokasi> daftarLokasi;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        layout = new LinearLayout(this);
        layout.setId(View.generateViewId());
        setContentView(layout);
        GoogleMapOptions options = new GoogleMapOptions();
        options.mapType(GoogleMap.MAP_TYPE_SATELLITE);
        mapFragment = MapFragment.newInstance(options);
        FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction();
        fragmentTransaction.add(layout.getId(), mapFragment);
        fragmentTransaction.commit();

        // Baca semua lokasi dari database SQLite
        daftarLokasi = new ArrayList<>();
        new Thread(new Runnable() {
            @Override
            public void run() {
                DbHelper dbHelper = new DbHelper(SemuaLokasiActivity.this);
                SQLiteDatabase db = dbHelper.getReadableDatabase();
                Cursor cursor = db.rawQuery("SELECT * FROM lokasi", null);
                while (cursor.moveToNext()) {
                    Lokasi lokasi = new Lokasi(
                        cursor.getString(cursor.getColumnIndexOrThrow("nama")),
                        cursor.getDouble(cursor.getColumnIndexOrThrow("longitude")),
                        cursor.getDouble(cursor.getColumnIndexOrThrow("latitude")),
                        cursor.getDouble(cursor.getColumnIndexOrThrow("altitude")),
                        cursor.getFloat(cursor.getColumnIndexOrThrow("akurasi")),
                        cursor.getFloat(cursor.getColumnIndexOrThrow("azimuth"))
                    );
                    daftarLokasi.add(lokasi);

                }
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        mapFragment.getMapAsync(SemuaLokasiActivity.this);
                    }
                });
            }
        }).start();
    }


    @Override
    public void onMapReady(GoogleMap map) {
        for (Lokasi lokasi: daftarLokasi) {
            map.addMarker(new MarkerOptions()
                .position(new LatLng(lokasi.getLatitude(), lokasi.getLongitude()))
                .title(lokasi.getNama())
                .flat(true)
                .rotation(lokasi.getAzimuth())
        );
        }
    }
}

Pada method onCreate() di atas, saya mendaftarkan sebuah MapFragment baru sebagai tampilan untuk activity ini. Kode program options.mapType(GoogleMap.MAP_TYPE_SATELLITE) menyebabkan peta yang ditampilkan adalah hasil pencitraan dari satelit. Setelah itu, saya membuat thread baru untuk membaca data lokasi dari database. Di dalam thread baru tersebut, setelah berhasil membaca dari database, saya memanggil mapFragment.getMapAsync() untuk menambahkan titik marker pada peta. Karena getMapAsync() harus dikerjakan di UI thread (main thread), maka saya memanggilnya melalui runOnUiThread().

Setelah peta berhasil ditampilkan, kode program onMapReady() akan dikerjakan. Pada kode program di method onMapReady(), saya menambahkan marker pada peta untuk masing-masing lokasi yang tersimpan di database. Saya juga menggunakan method rotation() untuk memutar marker sesuai dengan arah kompas yang terekam.

Sampai disini, bila aplikasi dijalankan dan saya memilih menu Lihat Semua, maka sebuah peta (yang berdasarkan Google Maps) akan ditampilkan dimana terdapat marker untuk setiap posisi yang pernah tersimpan, seperti yang terlihat pada gambar berikut ini:

Contoh tampilan aplikasi yang mengandung Google Maps beserta marker buatan sendiri

Contoh tampilan aplikasi yang mengandung Google Maps beserta marker buatan sendiri

Agar lebih jelas, saya bisa menampilkan garis yang menghubungkan masing-masing posisi tersebut. Untuk itu, saya mengubah kode program untuk method onMapReady() menjadi seperti berikut ini:

...
@Override
public void onMapReady(GoogleMap map) {
  PolylineOptions lineOptions = new PolylineOptions();
  for (Lokasi lokasi: daftarLokasi) {
    LatLng posisi = new LatLng(lokasi.getLatitude(), lokasi.getLongitude());
    map.addMarker(new MarkerOptions()
      .position(posisi)
      .title(lokasi.getNama())
      .flat(true)
      .rotation(lokasi.getAzimuth())
    );
    lineOptions.add(posisi);
  }
  map.addPolyline(lineOptions.color(Color.BLUE));
}
...

Pada kode program di atas, saya memanggil method addPolyline() dari GoogleMap untuk menggambar garis pada peta. Bila saya menjalankan aplikasi, kali ini setiap titik yang tersimpan di database akan ditampilkan dengan dihubungkan oleh sebuah garis berwarna biru, seperti yang terlihat pada gambar berikut ini:

Menggambar garis di Google Maps

Menggambar garis di Google Maps

Sebagai pelengkap, saya akan menampilkan informasi jarak sebuah lokasi terhadap lokasi sebelumnya, dengan mengubah method onMapReady() menjadi seperti berikut ini:

...
@Override
public void onMapReady(GoogleMap map) {
  PolylineOptions lineOptions = new PolylineOptions();
  Lokasi lokasiSebelumnya = null;
  float[] jarak = new float[1];
  for (Lokasi lokasi: daftarLokasi) {
    LatLng posisi = new LatLng(lokasi.getLatitude(), lokasi.getLongitude());
    MarkerOptions markerOptions = new MarkerOptions()
      .position(posisi)
      .title(lokasi.getNama())
      .flat(true)
      .rotation(lokasi.getAzimuth());
    if (lokasiSebelumnya != null) {
      Location.distanceBetween(lokasiSebelumnya.getLatitude(), lokasiSebelumnya.getLongitude(),
        posisi.latitude, posisi.longitude, jarak);
      markerOptions.snippet("Jarak Ke " + lokasiSebelumnya.getNama() + " = " + jarak[0] + " m");
    }
    map.addMarker(markerOptions);
    lineOptions.add(posisi);
    lokasiSebelumnya = lokasi;
  }
  map.addPolyline(lineOptions.color(Color.BLUE));
}
...

Pada kode program di atas, saya menghitung jarak dari lokasi dengan menggunakan method Location.distanceBetween(). Method ini akan menyimpan nilai jarak dalam satuan meter pada elemen pertama dari array yang dilewatkan sebagai argumen. Untuk menampilkan informasi jarak ini pada saat pengguna menyentuh sebuah marker, saya menggunakan method snippet() pada MarkerOptions untuk marker tersebut. Bila saya menyentuh sebuah marker, saya akan memperoleh informasi jarak seperti yang terlihat pada gambar berikut ini:

Menambahkan snippet berisi informasi jarak ke marker di Google Maps

Menambahkan snippet berisi informasi jarak ke marker di Google Maps

Memakai Database SQLite Di Android

Pada artikel Mencari Lokasi Dengan GPS Di Android, saya membuat aplikasi yang mencari koordinat lokasi perangkat saat ini melalui GPS. Akan lebih baik bila aplikasi latihan tersebut memiliki fasilitas untuk menyimpan koordinat yang telah saya kunjungi. Ini akan mempermudah saya mencari kembali rumah seseorang atau tempat tertentu yang sudah pernah saya kunjungi bila saya lupa (atau tersesat) di kemudian hari. Salah satu solusi yang bisa saya pakai adalah dengan menyimpan data lokasi ke dalam database.

Pada Android, setiap aplikasi memiliki akses ke database SQLite untuk menyimpanan data secara internal. Database yang dibuat oleh sebuah aplikasi hanya bisa dipakai (dilihat dan diubah) oleh aplikasi tersebut saja. Aplikasi lain akan memakai database berbeda yang khusus untuk aplikasi tersebut. Dengan demikian, database SQLite bawaan ini tidak ditujukan untuk pertukaran data seperti halnya pada database client server umumnya. Ia hanya ditujukan sebagai penampungan data permanen bagi masing-masing aplikasi; menulis data ke database dapat dianggap sebagai solusi penampungan data yang lebih terstruktur dibandingkan dengan menulis data ke file.

Untuk memakai database, langkah pertama yang saya lakukan adalah membuat sebuah turunan dari SQLiteOpenHelper seperti pada kode program berikut ini:

public class DbHelper extends SQLiteOpenHelper {

    public static final int DATABASE_VERSION = 1;

    public DbHelper(Context context) {
        super(context, "MyDb", null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL("CREATE TABLE lokasi (" +
            " _id INTEGER PRIMARY KEY, " +
            "nama TEXT NOT NULL, " +
            "longitude REAL, " +
            "latitude REAL, " + 
            "altitude REAL, " +
            "akurasi REAL, " +
            "azimuth REAL)"
        );
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    }

}

Method onCreate() pada SQLiteOpenHelper akan dikerjakan pada saat database pertama kali dibuat. Saya bisa membuat seluruh tabel yang dibutuhkan serta mengisi nilai default pada method ini. Selain itu, SQLiteOpenHelper juga memiliki informasi versi database. Bila suatu saat saya melakukan perubahan pada struktur tabel di database, saya bisa meningkatkan angka milik DATABASE_VERSION. Dengan demikian, bila database versi sebelumnya sudah ada di perangkat pengguna, maka method onUpgrade() akan dikerjakan (bukan lagi onCreate()!). Saya bisa mengerjakan SQL ALTER TABLE di method ini untuk mengubah struktur tabel lama menjadi sesuai dengan struktur yang baru.

Sekarang, saya akan membuat sebuah menu baru di menu_main.xml seperti berikut ini:

<menu ...>

  ...

  <item android:id="@+id/menu_simpan" app:showAsAction="ifRoom"
     android:title="Simpan" />

</menu>

Setelah itu, saya menambahkan aksi yang akan dikerjakan saat menu di-klik seperti berikut ini:

...
// Simpan ke database
menu.findItem(R.id.menu_simpan).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
    @Override
    public boolean onMenuItemClick(MenuItem item) {
        if (lokasiTerakhir != null) {
            final Lokasi lokasi = new Lokasi(lokasiTerakhir);
            // Input nama lokasi
            AlertDialog.Builder builder = new AlertDialog.Builder(LokasiActivity.this);
            final EditText txtNamaLokasi = new EditText(LokasiActivity.this);
            builder.setView(txtNamaLokasi).setTitle("Nama Lokasi").setPositiveButton("Simpan", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    lokasi.setNama(txtNamaLokasi.getText().toString());
                    simpan(lokasi);
                }
            }).setNegativeButton("Batal", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    dialog.cancel();
                }
            }).create().show();
        }
        return true;
    };
});
...

Kode program di atas membuat sebuah object Lokasi baru berdasarkan yang sudah ada dengan menggunakan copy constructor yang saya tambahkan. Setelah itu, ia akan menampilkan sebuah dialog untuk mengisi nama lokasi seperti pada gambar berikut ini:

Tampilan Dialog

Tampilan Dialog

Bila pengguna memilih Simpan, maka method simpan() akan dikerjakan. Isi method tersebut terlihat seperti pada contoh berikut ini:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

    ...

    public void simpan(final Lokasi lokasi) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                DbHelper dbHelper = new DbHelper(LokasiActivity.this);
                SQLiteDatabase db = dbHelper.getWritableDatabase();
                ContentValues values = new ContentValues();
                values.put("nama", lokasi.getNama());
                values.put("longitude", lokasi.getLongitude());
                values.put("latitude", lokasi.getLatitude());
                values.put("altitude", lokasi.getAltitude());
                values.put("akurasi", lokasi.getAkurasi());
                values.put("azimuth", lokasi.getAzimuth());
                db.insert("lokasi", null, values);
                LokasiActivity.this.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(LokasiActivity.this, "Lokasi berhasil disimpan!", Toast.LENGTH_SHORT).show();
                    }
                });
            }
        }).start();
    }

}

Pada kode program di atas, saya mengerjakan operasi yang mengakses database pada sebuah thread terpisah diluar main thread. Setelah memperoleh sebuah SQLiteDatabase melalui getWritableDatabase(), saya bisa mengerjakan method seperti execSQL() dan insert() untuk menambah record baru di tabel. Pada contoh di atas, saya menggunakan insert() yang lebih aman dari SQL injection karena ia akan membuat prepared statement yang menggunakan parameter.

Untuk menampilkan seluruh record yang ada di database, saya akan membuat sebuah activity baru. Sebagai contoh, saya menamakannya RiwayatActivity dan memakai kode program seperti berikut ini:

public class RiwayatActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        DbHelper dbHelper = new DbHelper(this);
        SQLiteDatabase db = dbHelper.getReadableDatabase();
        Cursor cursor = db.rawQuery("SELECT * FROM lokasi", null);
        ListView listView = new ListView(this);
        listView.setAdapter(new RiwayatCursorAdapter(this, cursor));
        setContentView(listView);
    }

}

Kode program di atas memanggil rawQuery() untuk memberikan perintah SQL SELECT untuk mengambil data. Hasil kembaliannya berupa Cursor yang memungkinkan saya melakukan navigasi record per record. Selain menggunakan rawQuery(), saya juga bisa menggunakan query() yang tidak memakai SQL secara langsung. Untuk menampilkan Cursor ke dalam ListView, saya membuat sebuah turunan CursorAdapter seperti berikut ini:

public class RiwayatCursorAdapter extends CursorAdapter {

    public RiwayatCursorAdapter(Context context, Cursor c) {
        super(context, c, false);
    }

    @Override
    public View newView(Context context, Cursor cursor, ViewGroup parent) {
        TextView textView = new TextView(context);
        textView.setTextSize(18);
        textView.setPadding(16, 16, 16, 16);
        return textView;
    }

    @Override
    public void bindView(View view, Context context, Cursor cursor) {
        String nama = cursor.getString(cursor.getColumnIndexOrThrow("nama"));
        ((TextView) view).setText(nama);
    }

}

Saya kemudian menambahkan sebuah menu baru pada menu_main.xml seperti berikut ini:

<menu...>

  ...

  <item android:id="@+id/menu_riwayat" app:showAsAction="never"
    android:title="Riwayat" />

</menu>

Bila menu tersebut dipilih, kode program berikut ini akan dikerjakan:

...
// Tampilkan riwayat
menu.findItem(R.id.menu_riwayat).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
    @Override
    public boolean onMenuItemClick(MenuItem item) {
        startActivity(new Intent(LokasiActivity.this, RiwayatActivity.class));
        return true;
    }
});
...

Kode program di atas akan memanggil RiwayatActivity sehingga daftar lokasi yang tersimpan di database akan ditampilkan. Nilai yang ditampilkan pada ListView hanya nama lokasi. Bila pengguna memilih salah satu lokasi di ListView tersebut, saya ingin LokasiActivity kembali dipanggil untuk menampilkan informasi khusus untuk lokasi tersebut. Dengan demikian, bila LokasiActivity dipanggil tanpa parameter, maka ia akan menampilkan lokasi dari GPS; sebaliknya, bila ia dipanggil dengan parameter berupa sebuah Lokasi, maka ia akan menampilkan informasi untuk Lokasi tersebut. Oleh sebab itu, saya mengubah LokasiActivity menjadi seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

  ...

  @Override
  protected void onStart() {
    super.onStart();
    Intent intent = getIntent();
    if (intent.hasExtra("lokasi")) {
      lokasiTerakhir = intent.getParcelableExtra("lokasi");
      refreshTampilan();
    } else {
       ...
    }
  }

  public void refreshTampilan() {
    TextView output = (TextView) findViewById(R.id.output);
    String strOutput = "DMS: " + lokasiTerakhir.koordinatDMS() + "n" +
      "DD: " + lokasiTerakhir.koordinatDD() + "n" +
      "Altitude: " + lokasiTerakhir.getAltitude() + " mn" +
      "Akurasi: " + lokasiTerakhir.getAkurasi() + " mn" +
      "Azimuth: " + lokasiTerakhir.getAzimuth() + " derajatn";
    output.setText(strOutput);

    // Share lokasi DD
    if ((lokasiTerakhir != null) && (shareActionProvider != null)) {
      Intent lokasiDDIntent = new Intent();
      lokasiDDIntent.setAction(Intent.ACTION_SEND);
      lokasiDDIntent.putExtra(Intent.EXTRA_TEXT, lokasiTerakhir.koordinatDD());
      lokasiDDIntent.setType("text/plain");
      shareActionProvider.setShareIntent(lokasiDDIntent);
    }
  }

  @Override
  public void onLocationChanged(Location location) {
    // Hitung rotasi
    ...
    lokasiTerakhir.setAzimuth(azimuth);
    refreshTampilan();
  }

  @Override
  protected void onPause() {
    super.onPause();
    if (locationManager != null) {
      locationManager.removeUpdates(this);
    }
    if (sensorManager != null) {
      sensorManager.unregisterListener(this);
    }
  }

  ...

}

Pada kode program di atas, saya memeriksa apakah ada sebuah parameter "lokasi" yang berisi sebuah Parceable yang mewakili object Lokasi. Karena sekarang Lokasi perlu dilewatkan antar activity, saya perlu mengimplementasikan Parceable pada Lokasi seperti pada contoh berikut ini:

public class Lokasi implements Parcelable {

  ...

  @Override
  public int describeContents() {
    return 0;
  }

  @Override
  public void writeToParcel(Parcel dest, int flags) {
    dest.writeString(nama);
    dest.writeDouble(longitude);
    dest.writeDouble(latitude);
    dest.writeDouble(altitude);
    dest.writeFloat(akurasi);
    dest.writeFloat(azimuth);
  }

  public static final Creator<Lokasi> CREATOR = new Creator<Lokasi>() {
    @Override
    public Lokasi createFromParcel(Parcel source) {
      return new Lokasi(source);
    }

    @Override
    public Lokasi[] newArray(int size) {
      return new Lokasi[size];
    }
  };

  private Lokasi(Parcel source) {
    nama = source.readString();
    longitude = source.readDouble();
    latitude = source.readDouble();
    altitude = source.readDouble();
    akurasi = source.readFloat();
    azimuth = source.readFloat();
  }

}

Komunikasi antar activity tidak bisa sekedar melewatkan referensi object seperti di pemograman Java pada umumnya. Hal ini karena activity yang berisi object yang dilewatkan bisa saja sudah dimusnahkan Android pada saat activity baru membutuhkan memori lebih. Dengan demikian, bila variabel dilewatkan melalui referensi, maka referensi tersebut akan merujuk pada memori yang sudah dimusnahkan. Salah satu solusi yang bisa ditempuh adalah dengan melewatkan Parceable sehingga object bisa di-‘buat ulang’ dengan mudah. Pada sebuah Parcelable, saya wajib menyediakan sebuah CREATOR yang memberi tahu bagaimana cara menghasilkan object ini. Urutan disini harus sama dengan urutan pada writeToParcel().

Sekarang, saya menambahkan listener yang akan dikerjakan bila salah satu item di RiwayatActivity dipilih oleh pengguna:

public class RiwayatActivity extends Activity implements AdapterView.OnItemClickListener {

  private RiwayatCursorAdapter riwayatCursorAdapter;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    ...
    listView.setOnItemClickListener(this);
  }

  @Override
  public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
    Cursor cursor = (Cursor) riwayatCursorAdapter.getItem(position);
    Lokasi lokasi = new Lokasi(
      cursor.getString(cursor.getColumnIndexOrThrow("nama")),
      cursor.getDouble(cursor.getColumnIndexOrThrow("longitude")),
      cursor.getDouble(cursor.getColumnIndexOrThrow("latitude")),
      cursor.getDouble(cursor.getColumnIndexOrThrow("altitude")),
      cursor.getFloat(cursor.getColumnIndexOrThrow("akurasi")),
      cursor.getFloat(cursor.getColumnIndexOrThrow("azimuth"))
    );
    Intent intent = new Intent(this, LokasiActivity.class);
    intent.putExtra("lokasi", lokasi);
    startActivity(intent);
  }
}

Pada kode program di atas, saya mendapatkan Cursor untuk baris yang dipilih dengan memanggil getItem() milik CursorAdapter. Setelah itu, saya membaca nilai setiap kolom pada record tersebut guna membuat sebuah Lokasi baru. Untuk melewatkan object sebagai sebagai parameter bagi activity yang dipanggil, saya menggunakan putExtra() dari Intent.

Sampai disini, saya sudah bisa melihat informasi posisi untuk setiap record yang tersimpan di database SQLite. Sebagai latihan terakhir, saya akan menambahkan fasilitas untuk menghapus record. Langkah pertama yang saya lakukan adalah membuat sebuah context menu baru yang akan ditampilkan bila pengguna menahan jari agak lama pada salah satu baris di ListView yang berisi lokasi. Sebagai contoh, saya memberinya nama menu_context_riwayat.xml dan mendeklarasikan sebuah item menu seperti berikut ini:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@+id/hapus" android:title="Hapus" />

</menu>

Setelah itu, saya mengubah kode program untuk RiwayatActivity menjadi seperti berikut ini:

public class RiwayatActivity extends Activity implements AdapterView.OnItemClickListener {

  private RiwayatCursorAdapter riwayatCursorAdapter;
  SQLiteDatabase db;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    ...
    db = dbHelper.getReadableDatabase();
    Cursor cursor = db.rawQuery("SELECT * FROM lokasi", null);
    ...
    listView.setOnItemClickListener(this);
    registerForContextMenu(listView);
  }

  ...

  @Override
  public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
    getMenuInflater().inflate(R.menu.menu_context_riwayat, menu);
  }

  @Override
  public boolean onContextItemSelected(MenuItem item) {
    if (item.getItemId() == R.id.menu_riwayat_hapus) {
      long id = ((AdapterView.AdapterContextMenuInfo)item.getMenuInfo()).id;
      int jumlahDihapus = db.delete("lokasi", "_id=?", new String[] {String.valueOf(id)});
      Toast.makeText(this, jumlahDihapus + " berhasil dihapus.", Toast.LENGTH_SHORT).show();
      Cursor cursor = db.rawQuery("SELECT * FROM lokasi", null);
      riwayatCursorAdapter.changeCursor(cursor);
      riwayatCursorAdapter.notifyDataSetChanged();
      return true;
    }
    return false;
  }

}

Pada kode program di atas, saya mendaftarkan context menu ListView dengan menggunakan registerForContextMenu(). Setelah itu, saya membuat menu ini pada onCreateContextMenu(). Bila salah satu item di context menu dipilih, saya bisa mengetahui id atau posisi (di ListView) yang sedang aktif dengan memanggil getMenuInfo() dari MenuItem. Pada contoh di atas, saya menghapus record dengan nilai field _id yang sesuai dengan item menu terpilih. Saya bisa menghapus record dengan memanggil method delete() dari SQLiteDatabase (selain itu, saya juga bisa membuat query SQL secara manual dengan memanggil rawQuery()). Setelah record dihapus, saya perlu memperbaharui ListView. Oleh sebab itu, saya men-query ulang isi tabel untuk memperoleh Cursor terbaru dan memakainya sebagai Cursor terbaru dengan memanggil method changeCursor() dari CursorAdapter. Saya juga perlu memanggil notifyDataSetChanged() dari CursorAdapter untuk memberi tahu ListView bahwa ia perlu digambar ulang. Pada aplikasi yang serius, seluruh kode program yang berhubungan dengan database sebaiknya dikerjakan pada thread terpisah agar tidak membuat UI menjadi tidak responsif. Pada contoh disini, saya tidak melakukannya agar kode program tetap sederhana.

Mencari Arah Dengan Kompas Digital Di Android

Pada artikel Mencari Lokasi Dengan GPS Di Android, saya menggunakan GPS untuk menentukan posisi perangkat Android di peta. Pada artikel ini, saya akan menambahkan sebuah informasi lain berupa arah perangkat pada saat posisi tersebut diambil. Beberapa (tidak semua!) perangkat Android dilengkapi dengan sensor yang disebut sebagai magnetometer atau sering disebut sebagai kompas digital yang mengukur nilai dalam satuan micro-Tesla (uT).

Sama seperti pada kompas analog, sensor ini pada dasarnya dipengaruhi oleh medan magnet disekitarnya, terutama oleh medan magnet bumi. Akan tetapi, berbeda pada kompas analog yang hanya mengukur pada satu arah, perangkat Android biasanya menggunakan vector magnetometer yang mengukur medan magnet pada masing-masing 3 arah (x, y, dan z). Untuk menunjukkannya, saya akan menampilkan medan magnet yang dibaca pada 3 vector tersebut dengan menambahkan kode program berikut ini di LokasiActivity:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

    ...

    private float magnetX, magnetY, magnetZ;
    private SensorManager sensorManager;

    @Override
    protected void onStart() {
        ...
        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
        Sensor magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
        if (magnetometer != null) {
            sensorManager.registerListener(this, magnetometer, SensorManager.SENSOR_DELAY_UI);
        }
    }

    @Override
    public void onLocationChanged(Location location) {
        ...
        String strOutput = "DMS: " + lokasiTerakhir.koordinatDMS() + "n" +
            "DD: " + lokasiTerakhir.koordinatDD() + "n" +
            "Altitude: " + lokasiTerakhir.getAltitude() + " mn" +
            "Akurasi: " + lokasiTerakhir.getAkurasi() + " mn" +
            "Magnet X: " + magnetX + " uTn" +
            "Magnet Y: " + magnetY + " uTn" +
            "Magnet Z: " + magnetZ + " uTn";
        output.setText(strOutput);
        ...
    }

    @Override
    protected void onPause() {
        ...
        sensorManager.unregisterListener(this);
    }

    ...

    @Override
    public void onSensorChanged(SensorEvent event) {
        magnetX = event.values[0];
        magnetY = event.values[1];
        magnetZ = event.values[2];
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {

    }

}

Pada saat aplikasi dijalankan, saya akan memperoleh nilai besarnya medan magnet pada sumbu x, y, dan z. Walaupun demikian, saya tidak bisa memperoleh sesuatu yang bisa dimengerti atau berguna secara langsung dari nilai ini. Yang saya butuhkan adalah sebuah nilai tunggal seperti azimuth. Nilai azimuth adalah derajat yang menunjukkan jarak dari utara searah dengan jarum jam (misalnya, nilai azimuth untuk posisi di utara adalah 0 derajat, posisi timur 90 derajat, posisi selatan 180 derajat dan posisi barat 270 derajat).

Beruntungnya, Android Sensor API sudah menyediakan method yang akan melakukan kalkulasi rumit bagi saya. Langkah pertama yang saya perlu saya lakukan adalah mendapatkan nilai rotation matrix (R) yang berguna untuk memetakan koordinat perangkat ke koordinat dunia nyata. Koordinat dunia nyata adalah koordinat dimana X selalu menghadap timur, Y selalu menghadap kutub utara dan Z selalu menghadap ke langit, tanpa peduli pada orientasi perangkat. Untuk mendapatkan nilai R, saya perlu memperoleh nilai dari sensor accelerometer dan magnetometer. Nilai yang diperoleh dari sensor akan dilewatkan sebagai parameter pada method getRotationMatrix() milik SensorManager. Sebagai contoh, saya membatalkan perubahan sebelumnya dan mengubah kode program saya menjadi seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener, SensorEventListener {

    ...

    private float[] magnet, percepatan;
    private SensorManager sensorManager;

    @Override
    protected void onStart() {
        ...
        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
        Sensor magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
        if (magnetometer != null) {
            sensorManager.registerListener(this, magnetometer, SensorManager.SENSOR_DELAY_UI);
        }
        Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
        if (accelerometer != null) {
            sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI);
        }
    }

    @Override
    public void onLocationChanged(Location location) {
        // Hitung rotasi
        if ((magnet!=null) && (percepatan!=null)) {
            float R[] = new float[9];
            float I[] = new float[9];
            if (SensorManager.getRotationMatrix(R, I, percepatan, magnet)) {
                // Proses disini nanti!
            }
        }
        ...
    }

    ...

    @Override
    protected void onPause() {
        super.onPause();
        locationManager.removeUpdates(this);
        sensorManager.unregisterListener(this);
    }


    @Override
    public void onSensorChanged(SensorEvent event) {
        if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
            percepatan = event.values;
        } else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
            magnet = event.values;
        }
    }

    ...
}

Pada kode program di atas, saya sudah memperoleh matrix R yang mewakili rotation matrix. Sekarang, saya bisa menggunakan nilai tersebut untuk dipakai pada method getOrientation() di SensorManager, seperti pada contoh berikut ini:

...
@Override
public void onLocationChanged(Location location) {
  // Hitung rotasi
  float azimuth = 0;
  if ((magnet!=null) && (percepatan!=null)) {
    float R[] = new float[9];
    float I[] = new float[9];
    if (SensorManager.getRotationMatrix(R, I, percepatan, magnet)) {
      float orientation[] = new float[3];
      azimuth = orientation[0];
    }
  }
  ...
}
....

Nilai azimuth yang dikembalikan oleh getOrientation() berada dalam satuan radian dimana nilai positif mewakili arah berlawanan dengan jarum jam. Untuk menerjemahkan nilai ini ke dalam derajat, saya menambahkan kode program berikut ini:

...
azimuth = (float) Math.toDegrees(orientation[0]);
if (orientation[0] < 0) {
    azimuth += 360;
}
...

Setelah itu, saya bisa menambahkan property baru pada Lokasi untuk menampung informasi azimuth. Saya juga perlu mengubah sebuah constructor pada class tersebut sehingga dapat menerima nilai azimuth. Ssebagai langkah terakhir, saya tinggal menampilkan nilai azimuth dari Lokasi ke dalam TextView (bersamaan dengan nilai yang diperoleh dari GPS).

Nilai azimuth yang diperoleh adalah azimuth compass yang relatif terhadap magnetic north bukan terhadap true north. Walaupun mendekati true north, nilai magnetic north selalu memiliki selisih tergantung pada wilayah dan waktu dimana pengguna berada. Selisih ini disebut sebagai magnetic declination. Struktur wilayah, jumlah besi yang terkandung di dalam tanah dan faktor lainnya merupakan contoh faktor yang mempengaruhi magnetic declination. Selain itu, metal dan magnet di sekitar perangkat bisa mempengaruhi nilai yang dibaca oleh magnetometer. Kelemahan lainnya adalah nilai azimuth hanya akurat bila perangkat berada dalam keadaan mendatar terhadap bumi dimana layar menghadap ke atas (sama seperti layaknya saat kompas digunakan). Bila perangkat berada dalam keadaan vertikal, gimbal lock akan menyebabkan hasil perhitungan getOrientation() milik SensorManager menjadi tidak akurat sehingga dibutuhkan perhitungan yang lebih kompleks untuk memperoleh azimuth yang diharapkan.

Mencari Lokasi Dengan GPS Di Android

Saat ini sudah terdapat banyak perangkat Android yang dilengkapi dengan perangkat GPS receiver. Hardware ini akan membaca informasi dari satelit GPS untuk menentukan lokasi perangkat. Jarak perangkat ke sebuah satelit dapat ditentukan dengan mengirim gelombang radio ke satelit dan mengukur selisih waktu dari receiver ke satelit. Setelah menemukan jarak untuk beberapa satelit, posisi perangkat dapat ditemukan dengan menggunakan perhitungan yang disebut trilateration. Sebagai programmer untuk perangkat Android, saya tidak perlu terlibat dalam proses yang kompleks ini karena saya hanya perlu menggunakan class yang ada di package android.location untuk memperoleh data lokasi melalui GPS.

Untuk bisa memakai GPS, saya mendaftarkan penggunaan permission berikut ini pada AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Pada Android API, sebuah lokasi diwakili oleh class Location. Karena class ini terlalu umum, saya akan membuat versi yang lebih spesifik dengan kebutuhan saya (domain class) seperti berikut ini:

public class Lokasi {

    private String nama;
    private double longitude;
    private double latitude;    
    private double altitude;
    private float akurasi;

    public Lokasi(Location location) {
        this.longitude = location.getLongitude();
        this.latitude = location.getLatitude();  
        this.altitude = location.getAltitude();
        this.akurasi = location.getAccuracy();
    }

    public Lokasi(double longitude, double latitude, double altitude, float akurasi) {
        this.longitude = longitude;
        this.latitude = latitude;
        this.altitude = altitude;
        this.akurasi = akurasi;
    }

    public Lokasi(String nama, double longitude, double latitude, double altitude, float akurasi) {
        this(longitude, latitude, altitude, akurasi);
        this.nama = nama;                
    }

    public String getNama() {
        return nama;
    }

    public void setNama(String nama) {
        this.nama = nama;
    }

    public double getLongitude() {
        return longitude;
    }

    public void setLongitude(double longitude) {
        this.longitude = longitude;
    }

    public double getLatitude() {
        return latitude;
    }

    public void setLatitude(double latitude) {
        this.latitude = latitude;
    }

    public double getAltitude() {
        return altitude;
    }

    public void setAltitude(double altitude) {
        this.altitude = altitude;
    }

    public float getAkurasi() {
        return akurasi;
    }

    public void setAkurasi(float akurasi) {
        this.akurasi = akurasi;
    }

}

Nilai latitude (posisi horizontal terhadap khatulistiwa) dan longitude (posisi vertikal terhadap prime meridian) dapat dipakai untuk menentukan posisi sebuah lokasi di bumi. Nilai yang dimiliki oleh Location (sekaligus dipakai di Lokasi) berada dalam format yang disebut sebagai Decimal Degrees (DD). Sebagai contoh, nilai latitude berupa 38,8897 derajat sama dengan 38 derajat 53 menit 23 detik utara pada format Degrees, Minutes, and Seconds (DMS). Angka positif pada latitude mewakili utara dan angka negatif mewakili selatan relatif terhadap khatulistiwa. Pada longitude, angka positif mewakili timur dan negatif mewakili barat relatif terhadap prime meridian.

Agar lebih mudah dibaca oleh pengguna, saya akan menambahkan method koordinatDMS() dan koordinatDD() di class Lokasi yang akan mengembalikan koordinat lokasi dalam format yang dikehendaki seperti pada:

class Lokasi {

  ...

  public String koordinatDMS() {
    return Location.convert(latitude, Location.FORMAT_SECONDS) + " " +
      Location.convert(longitude, Location.FORMAT_MINUTES);
  }

  public String koordinatDD() {
    return latitude + "," + longitude;
  }

}

Nilai altitude pada Location (sekaligus dipakai di Lokasi) adalah nilai dalam satuan meter yang mewakili ketinggian perangkat dihitung mulai dari posisi WGS-84 reference ellipsoid. Perlu diperhatikan bahwa nilai ini bukan ketinggian yang dihitung dari permukaan laut. Hal ini karena nilai permukaan laut yang sesungguhnya terlalu kompleks untuk diproses oleh GPS receiver.

Nilai accuracy pada Location (sekaligus dipakai sebagai akurasi di Lokasi) menunjukkan seberapa akurat nilai latitude dan longitude yang diperoleh dari GPS receiver. Bila sebuah lingkaran digambar dengan jari-jari sepanjang nilai accuracy (dalam satuan meter), maka ada kemungkinan 68% lokasi perangkat berada di dalam lingkaran ini.

Untuk memperoleh sebuah Location, saya perlu memanggil method requestLocationUpdates() dari LocationManager untuk mendaftarkan sebuah LocationListener. Pada listener tersebut, bila lokasi dari GPS telah diperoleh, method onLocationChanged() akan dipanggil. Sebagai latihan, saya membuat sebuah activity dengan nama LokasiActivity yang isinya seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener {

    private LocationManager locationManager;
    private Lokasi lokasiTerakhir;

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

    @Override
    protected void onStart() {
        super.onStart();
        locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
        locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this);
    }

    @Override
    public void onLocationChanged(Location location) {
        lokasiTerakhir = new Lokasi(location);
        TextView output = (TextView) findViewById(R.id.output);
        String strOutput = "DMS: " + lokasiTerakhir.koordinatDMS() + "n" +
            "DD: " + lokasiTerakhir.koordinatDD() + "n" +
            "Altitude: " + lokasiTerakhir.getAltitude() + " mn" +
            "Akurasi: " + lokasiTerakhir.getAkurasi() + " mn";
        output.setText(strOutput);
    }

    @Override
    public void onStatusChanged(String provider, int status, Bundle extras) {

    }

    @Override
    public void onProviderEnabled(String provider) {

    }

    @Override
    public void onProviderDisabled(String provider) {

    }

    @Override
    protected void onPause() {
        super.onPause();
        locationManager.removeUpdates(this);
    }

}

Bila activity di atas dijalankan, informasi dari GPS akan ditampilkan pada sebuah TextView.

Apa yang bisa saya lakukan selain hanya melihat koordinat dari GPS? Sebagai contoh, saya men-share koordinat ini. Untuk itu, saya membuat sebuah deklarasi menu XML seperti berikut ini:

<menu ...>

   <item android:id="@+id/menu_share" app:showAsAction="ifRoom"
      android:title="Share"
      android:actionProviderClass="android.widget.ShareActionProvider" />

</menu>

Saya kemudian memakai menu baru ini dengan menambahkan method onCreateOptionsMenu() pada LokasiActivity:

public class LokasiActivity extends Activity implements LocationListener {

  prvate ShareActionProvider shareActionProvider;
  ...

  @Override
  public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_main, menu);
    MenuItem menuShare =  menu.findItem(R.id.menu_share);
    shareActionProvider = (ShareActionProvider) menuShare.getActionProvider();
    return true;
  }

}

Selanjutnya, saya menentukan nilai lokasi yang bisa di-share dengan menambahkan kode program berikut ini pada method onLocationChanged():

public class LokasiActivity extends Activity implements LocationListener {

  ...

  @Override
  public void onLocationChanged(Location location) {
    ...

    // Share lokasi DD
    Intent lokasiDDIntent = new Intent();
    lokasiDDIntent.setAction(Intent.ACTION_SEND);
    lokasiDDIntent.putExtra(Intent.EXTRA_TEXT, lokasiTerakhir.koordinatDD());
    lokasiDDIntent.setType("text/plain");
    shareActionProvider.setShareIntent(lokasiDDIntent);
  }

}

Selain itu, saya juga bisa menambahkan menu untuk menampilkan lokasi pada aplikasi peta default seperti Google Map atau Google Earth. Sebagai contoh, saya menambahkan sebuah item menu baru di deklarasi XML:

<menu ...>

  ...

  <item android:id="@+id/menu_tampil" app:showAsAction="ifRoom"
     android:title="Tampilkan" />

</menu>

Setelah itu, saya menambahkan aksi bila menu tersebut dipilih dengan menggunakan kode program seperti berikut ini:

public class LokasiActivity extends Activity implements LocationListener {

  ...

  @Override
  public boolean onCreateOptionsMenu(Menu menu) {
    ...
    // Tampilkan di aplikasi peta
    menu.findItem(R.id.menu_tampil).setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
      @Override
      public boolean onMenuItemClick(MenuItem item) {
        if (lokasiTerakhir != null) {
          Intent intent = new Intent(Intent.ACTION_VIEW);
          intent.setData(Uri.parse("geo:" + lokasiTerakhir.koordinatDD()));
          if (intent.resolveActivity(getPackageManager()) != null) {
            startActivity(intent);
          }
        }
        return true;
      }
    });

    return true;
  }

  ...

}

Sekarang, bila menu Tampilkan dipilih, lokasi akan ditampilkan oleh aplikasi yang dapat menampilkan koordinat peta seperti Google Map dan Google Earth.

Mengatur Wilayah Focus Pada Kamera Di Android

Kamera pada dasarnya bekerja mirip seperti pada mata manusia. Bedanya, pada mata manusia, tergantung pada jarak benda yang dilihat, lensa mata manusia akan melakukan perubahan bentuk. Proses yang disebut accomodation ini dibutuhkan agar cahaya tetap dipantulkan secara benar pada retina. Sebagai contoh, pada saat melihat benda yang jauh, lensa mata akan menjadi pipih (sehingga terlihat tipis) dan pada saat melihat benda dalam jarak sangat dekat, lensa mata akan berkontraksi sehingga menjadi tebal.

Lalu bagaimana dengan lensa pada kamera? Karena tidak bisa fleksibel seperti mata manusia, lensa kamera perlu melakukan pergeseran posisi. Bila tidak tepat, gambar yang dihasilkan oleh kamera akan terlihat kabur. Pada era manual focus, pengguna kamera perlu mengukur jarak objek yang hendak dipotret secara manual lalu mengubah posisi lensa kamera sesuai dengan jarak tersebut. Kamera pada perangkat Android umumnya tidak memiliki kemampuan manual focus, melainkan auto focus.

Berkat auto focus, pengguna tidak perlu mengatur fokus secara manual. Driver kamera akan melakukan kalkulasi dan menentukan apakah gambar buram atau tidak. Bila buram, fokus akan terus berubah hingga tercapai sebuah gambar yang jernih. Selain itu, bila method startFaceDetection() dari Camera dipanggil, driver kamera akan mendeteksi wajah dan secara otomatis melakukan fokus ke wajah supaya tidak ada wajah yang ‘buram’. Bila tidak memotret wajah, driver kamera juga bisa melakukan auto focus berdasarkan informasi wilayah di preview kamera yang diberikan oleh pengguna melalui method setFocusAreas() milik Camera.Parameters. Informasi ini hanya sebagai masukan bagi driver kamera sehingga hasil akhir fokus kamera bisa saja berbeda.

Sebagai latihan, saya akan meneruskan latihan pada proyek Memakai Efek Warna Di Kamera Pada Android dan menambahkan fitur dimana pengguna bisa menentukan wilayah dan nilai fokus. Saya akan membuat sebuah View yang memiliki ukuran yang sama dengan CameraPreview dimana pengguna dapat menarik layar untuk membuat persegi panjang. Sebagai contoh, saya akan memberinya nama FocusEditView dengan isi kode program seperti berikut ini:

public class FocusEditView extends View {

    private CameraPreview cameraPreview;
    private RectF kotakFokus;
    private Paint paintKotak;
    private boolean drawing;
    private int width, height;

    public FocusEditView(Context context, CameraPreview cameraPreview) {
        super(context);
        paintKotak = new Paint();
        paintKotak.setStyle(Paint.Style.STROKE);
        paintKotak.setColor(Color.YELLOW);
        paintKotak.setStrokeWidth(2);
        drawing = false;
        this.cameraPreview = cameraPreview;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (kotakFokus != null) {
            canvas.drawRect(kotakFokus, paintKotak);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                kotakFokus = new RectF(event.getX(), event.getY(), event.getX(), event.getY());
                drawing = true;
                break;
            case MotionEvent.ACTION_MOVE:
                if (drawing) {
                    kotakFokus.set(kotakFokus.left, kotakFokus.top, event.getX(), event.getY());
                    invalidate();
                }
                break;
            case MotionEvent.ACTION_UP:
                kotakFokus.set(kotakFokus.left, kotakFokus.top, event.getX(), event.getY());
                invalidate();
                drawing = false;                
                break;
        }
        return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        width = cameraPreview.getMeasuredWidth();
        height = cameraPreview.getMeasuredHeight();
        setMeasuredDimension(width, height);
    }

    public Camera.Area getArea() {
        RectF kotakF = new RectF();
        Matrix matrix = new Matrix();
        matrix.postScale(2000.0f / width, 2000.0f / height);
        matrix.postTranslate(-1000, -1000);
        matrix.mapRect(kotakF, kotakFokus);
        Rect kotak = new Rect();
        kotakF.round(kotak);
        Camera.Area hasil = new Camera.Area(kotak, 1000);
        return hasil;
    }

}

Pada View di atas, pengguna dapat menggambar sebuah kotak dengan menahan dan menggeser jarinya di layar sentuh. Saya menyimpan kotak yang dibuat oleh pengguna dalam sebuah variabel bernama kotakFokus yang bertipe RectF. Agar kotak ini dapat dipakai sebagai wilayah fokus kamera, saya perlu menerjemahkannya ke dalam koordinat yang dibutuhkan oleh Camera.Area. Pada Canvas, titik (0,0) adalah posisi kiri atas layar dan titik (width,height) adalah posisi kanan bawah layar. Sementara itu, pada Camera.Area, titik (-1000,-1000) adalah posisi kiri atas layar dan titik (1000,1000) adalah posisi kanan bawah layar. Nilai 0 sampai 1.000 ini tidak dipengaruhi oleh ukuran layar sehingga layar selalu dianggap sebagai sebuah kotak dengan ukuran 2.000 x 2.000. Untuk menerjemahkan koordinat Canvas menjadi koordinat Camera.Area, saya menggunakan bantuan Matrix yang diaplikasikan pada RectF dengan menggunakan method mapRect(). Setelah itu, saya perlu menerjemahkan RectF yang memiliki nilai dalam tipe float menjadi Rect yang memiliki nilai dalam tipe integer. Caranya adalah dengan memanggil method round() milik RectF. Setelah selesai, saya menghasilkan sebuah Camera.Area dengan nilai weight berupa 1.000 yang berarti fokus maksimal pada wilayah ini.

FocusEditView harus ditampilkan secara transparan di atas CameraPreview sehingga memberikan kesan seolah-olah pengguna menggambar kotak pada CameraPreview. Untuk itu, saya menambahkan baris berikut ini pada method onCreateView() di PreviewFragment:

public class PreviewFragment extends Fragment implements View.OnClickListener {

    private CameraPreview cameraPreview;
    private FocusEditView focusEditView;

    public PreviewFragment() {}

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        ...
        FrameLayout framePreview = (FrameLayout) view.findViewById(R.id.preview);
        cameraPreview = new CameraPreview(activity, activity.getCamera());
        framePreview.addView(cameraPreview);
        focusEditView = new FocusEditView(activity, cameraPreview);
        focusEditView.setBackgroundResource(android.R.color.transparent);
        FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT);
        focusEditView.setLayoutParams(lp);
        framePreview.addView(focusEditView);
        ...
    }

}

Sekarang, bila saya menjalankan aplikasi, saya bisa menggambar kotak di layar seperti pada gambar berikut ini:

Menggambar Kotak Pada Preview Kamera

Menggambar Kotak Pada Preview Kamera

Untuk mengatur fokus pada kotak yang digambar pengguna, saya menambahkan sebuah Button baru di preview_fragment.xml dengan id berupa btnFokus. Handler untuk Button ini akan memanggil method pilihFokus() yang isinya seperti berikut ini:

public void pilihFokus(final Camera.Area areaFokus) {
  camera.cancelAutoFocus();
  Camera.Parameters params = camera.getParameters();
  params.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO);
  List<Camera.Area> daftarFokus = new ArrayList<>();
  daftarFokus.add(areaFokus);
  params.setFocusAreas(daftarFokus);
  camera.setParameters(params);
  camera.autoFocus(new Camera.AutoFocusCallback() {
    @Override
    public void onAutoFocus(boolean success, Camera camera) {
        Log.d("MyCam", "Mengatur fokus ke " + areaFokus.rect + ". Status sukses: " + success);
    }
  });
}

Pada kode program di atas, saya berpindah ke modus Camera.Parameters.FOCUS_MODE_AUTO dengan menggunakan setFocusMode(). Setelah itu, saya mengatur wilayah yang disarankan sebagai sasaran fokus dengan menggunakan setFocusAreas(). Setelah itu, saya harus memanggil method autoFocus() dari Camera agar pencarian fokus dilakukan.

Memakai Efek Warna Di Kamera Pada Android

Bagi seorang engineer seperti saya, sebuah kamera hanyalah sebuah sensor yang membaca data cahaya. Namun bagi kalangan umum, kamera di perangkat Android sering dianggap sebagai alat yang penting. Tidak sedikit pengguna yang memilih perangkat Android berdasarkan kejernihan kameranya. Untuk memanfaatkan kamera secara maksimal, pengguna juga sering kali men-install aplikasi yang menawarkan efek menarik. Sebagai latihan, pada tulisan kali ini, saya akan mencoba membuat sebuah aplikasi yang mengambil gambar dari kamera dan memanfaatkan efek warna bawaan driver kamera.

Saya akan mulai dengan membuat sebuah proyek Android baru yang menggunakan API 19 (Android 4.4 KitKat). Pada saat membuat proyek, saya memilih Blank Activity with Fragment. Karena ingin menggunakan kamera dan menulis hasil rekaman kamera, saya perlu menambahkan izin penggunaan permission berikut ini pada AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Untuk melakukan preview kamera, saya akan membuat sebuah turunan SurfaceView baru seperti yang saya jumpai pada dokumentasi Android:

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback {

    private SurfaceHolder holder;
    private Camera camera;

    public CameraPreview(Context context, Camera camera) {
        super(context);
        this.camera = camera;
        holder = getHolder();
        holder.addCallback(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        try {
            camera.setPreviewDisplay(holder);
            camera.startPreview();
        } catch (IOException e) {
            Log.d("CameraPreview", "Error setting camera preview: " + e.getMessage());
        }
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        if (this.holder.getSurface() == null) {
            return;
        }
        try {
            camera.stopPreview();
        } catch (Exception e) {}
        try {
            camera.setPreviewDisplay(this.holder);
            camera.startPreview();
        } catch (Exception e) {
            Log.d("CameraPreview", "Error starting camera preview: " + e.getMessage());
        }
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {

    }
}

Setelah itu, saya menambahkan sebuah fragment baru dengan PreviewFragment.java yang isinya seperti berikut ini:

public class PreviewFragment extends Fragment {

    public PreviewFragment() {}

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        MainActivity activity = (MainActivity) getActivity();
        View view = inflater.inflate(R.layout.preview_fragment, container, false);
        FrameLayout framePreview = (FrameLayout) view.findViewById(R.id.preview);
        framePreview.addView(new CameraPreview(activity, activity.getCamera()));
        return view;
    }

}

PreviewFragment adalah sebuah fragment yang akan menampilkan preview kamera. Fragment ini membutuhkan sebuah android.hardware.Camera yang diperolehnya dengan memanggil method getCamera() dari MainActivity. Selain itu, saya juga perlu mendefinisikan layout yang dibutuhkan oleh fragment ini. Sebagai contoh, saya membuat preview_fragment.xml yang isinya berupa:

<RelativeLayout ...>

    <FrameLayout
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:id="@+id/preview" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Efek Warna"
        android:id="@+id/btnEfekWarna"
        android:layout_gravity="left|top" />

</RelativeLayout>

Sekarang, saya akan mengubah kode program MainActivity agar menampilkan PreviewFragment, seperti:

public class MainActivity extends Activity {

    private Camera camera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        camera = Camera.open();
        Camera.Parameters params = camera.getParameters();
        params.setJpegQuality(100);
        camera.setParameters(params);
        camera.setDisplayOrientation(90);

        getFragmentManager().beginTransaction()
            .add(R.id.container, new PreviewFragment())
            .commit();
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (camera != null) {
            camera.release();
        }
    }

    public Camera getCamera() {
        return camera;
    }

}

Google menambahkan API baru untuk mengakses kamera pada API 21 (Android 5.0 Lollipop). Selain itu, kode program yang mengakses kamera dengan android.hardware.Camera sudah di-deprecate pada API 21. Karena tidak memiliki perangkat Lollipop, saya akan tetap membuat kode program yang menggunakan android.hardware.Camera. Pada kode program di atas, saya mengakses kamera belakang dengan memanggil method open() dari android.hardware.Camera. Selain itu, agar latihan ini tetap sederhana, saya menggangap orientasi kamera selalu tegak (dengan setDisplayOrientation(90)). Tanpa kode program yang memanggil setDisplayOrientation() milik Camera sesuai dengan orientasi kamera, hasil preview akan ‘miring’ saat ditampilkan kamera berada dalam orientasi landscape.

Aplikasi kamera biasanya ditampilkan secara full-screen. Untuk itu, saya mengubah android:theme di AndroidManifest.xml menjadi seperti berikut ini:

android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"

Sekarang, bila saya menjalankan aplikasi, saya akan memperoleh hasil seperti pada gambar berikut ini:

Hasil preview kamera

Hasil preview kamera

Sampai disini, saya sudah bisa melihat preview kamera, akan tetapi saya masih belum bisa mengubah efek warna. Saya perlu membuat sebuah fragment baru dimana pengguna bisa memilih efek warna yang diinginkan. Untuk itu, saya membuat sebuah class baru dengan nama EfekWarnaFragment.java yang isinya seperti berikut ini:

public class EfekWarnaFragment extends Fragment implements AdapterView.OnItemClickListener {

    private Camera camera;
    private EfekWarnaAdapter efekWarnaAdapter;

    public EfekWarnaFragment() {}

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        MainActivity activity = (MainActivity) getActivity();
        camera = activity.getCamera();
        ListView daftarEfek = new ListView(activity);
        daftarEfek.setOnItemClickListener(this);
        TextView label = new TextView(activity);
        efekWarnaAdapter = new EfekWarnaAdapter(camera.getParameters().getSupportedColorEffects());
        daftarEfek.setAdapter(efekWarnaAdapter);
        return daftarEfek;
    }

    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        camera.stopPreview();
        Camera.Parameters params = camera.getParameters();
        params.setColorEffect((String) efekWarnaAdapter.getItem(position));
        camera.setParameters(params);
        getFragmentManager().popBackStack();
    }

    class EfekWarnaAdapter extends BaseAdapter {

        List<String> daftarEfek;

        public EfekWarnaAdapter(List<String> daftarEfek) {
            this.daftarEfek = daftarEfek;
        }

        @Override
        public int getCount() {
            return daftarEfek.size();
        }

        @Override
        public Object getItem(int position) {
            return daftarEfek.get(position);
        }

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

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            TextView view;
            if (convertView == null) {
                view = new TextView(getActivity());
                view.setGravity(Gravity.CENTER);
                view.setTextSize(20);
            } else {
                view = (TextView) convertView;
            }
            view.setText((String) getItem(position));
            return view;
        }

    }

}

Pada kode program di atas, saya menggunakan getSupportedColorEffects() milik Camera.Parameters untuk memperoleh sebuah List yang berisi daftar efek warna yang disediakan oleh driver kamera. Setelah itu, saya menampilkan daftar efek warna tersebut dalam sebuah ListView. Bila pengguna memilih salah satu efek warna, saya kemudian menggunakan setColorEffect() milik Camera.Parameters untuk mengaplikasikan efek warna tersebut pada kamera.

Kali ini, bila saya menjalankan aplikasi dan menyentuh tombol Efek Warna, saya akan memperoleh hasil seperti pada gambar berikut ini:

Daftar efek warna yang didukung oleh driver kamera

Daftar efek warna yang didukung oleh driver kamera

Bila saya memilih salah satu efek warna, misalnya negative, saya akan memperoleh hasil seperti pada gambar berikut ini:

Hasil preview kamera setelah memilih efek negative

Hasil preview kamera setelah memilih efek negative

Sebagai langkah terakhir, saya akan menambahkan kode program yang menyimpan file gambar bila layar preview disentuh. Untuk itu, saya menambahkan OnTouchListener pada CameraPreview seperti:

public class CameraPreview extends SurfaceView implements SurfaceHolder.Callback, View.OnTouchListener {

  ...

  public CameraPreview(Context context, Camera camera) {
    ...
    setOnTouchListener(this);
  }

  ...

  @Override
  public boolean onTouch(View v, MotionEvent event) {
    Camera.PictureCallback pictureCallback = new Camera.PictureCallback() {
      @Override
      public void onPictureTaken(byte[] data, Camera camera) {
        File picDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "MyCam");
        if (!picDir.exists()) {
          picDir.mkdirs();
        }
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
        try {
          File outputFile = new File(picDir, "IMG_" + timeStamp + ".jpg");
          FileOutputStream fos = new FileOutputStream(outputFile);
          fos.write(data);
          fos.close();
          MediaScannerConnection.scanFile(getContext(), new String[]{outputFile.getPath()}, new String[]{"image/jpeg"}, null);
          camera.startPreview();
        } catch (IOException e) {
          Log.e("MyCam", "Terjadi kesalahan saat menulis file gambar", e);
        }
      }
    };
    camera.takePicture(null, null, pictureCallback);
    return false;
  }

}

Untuk menyimpan hasil preview di kamera ke dalam sebuah file JPEG, saya memanggil method takePicture() milik Camera. Pada method ini, saya melewatkan sebuah Camera.PictureCallback yang akan dikerjakan saat file gambar telah diolah oleh driver kamera. onPictureTaken() mengandung byte[] yang merupakan sebuah gambar dalam format JPEG. Yang perlu saya lakukan adalah menulis file ini ke direktori penyimpanan gambar. Selain itu, saya juga perlu memanggil method scanFile() milik MediaScannerConnection agar file gambar baru ini dapat langsung dilihat melalui aplikasi lain.

Sekarang, saya bisa membuat file gambar dari kamera dengan menyentuh preview kamera. Walaupun demikian, rotasi pada preview kamera berbeda dengan yang ada di file JPEG yang dihasilkan. Ternyata camera.setDisplayOrientation(90) hanya memiliki efek pada preview, tetapi tidak berpengaruh pada gambar yang dihasilkan. Oleh sebab itu, saya menambahkan setRotation() milik Camera.Parameters sehingga method onCreate() pada MainActivity terlihat seperti berikut ini:

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  camera = Camera.open();
  Camera.Parameters params = camera.getParameters();
  params.setJpegQuality(100);
  params.setRotation(90);
  camera.setParameters(params);
  camera.setDisplayOrientation(90);

  getFragmentManager().beginTransaction()
    .add(R.id.container, new PreviewFragment())
    .commit()
}

Sekarang, hasil file gambar yang diperoleh akan memiliki orientasi yang sama dengan orientasi di preview (sama-sama portrait).

Ikuti

Get every new post delivered to your Inbox.

Bergabunglah dengan 44 pengikut lainnya.