Memakai Graph Database Dengan Neo4j

Selama beberapa dekade terakhir ini, database relasional berbasis tabel boleh dibilang merupakan jenis database yang paling sering dipakai dan paling dewasa. Database relasional tidak akan hilang dalam jangka waktu dekat ini, tapi mereka memiliki keterbatasan sendiri. Sebagai contoh, database relasional akan kewalahan melakukan query yang dibutuhkan untuk keperluan jejaring sosial seperti Facebook dan Twitter. Database relasional memang memiliki JOIN untuk menggabungkan tabel, tapi JOIN yang mereka miliki tidak memiliki arah dan label. Salah satu jenis database yang diciptakan untuk mengembalikan makna ‘relasional’ tersebut adalah graph database.

Graph yang dimaksud pada graph database adalah definisi graph pada graph theory yang merupakan bagian dari ilmu matematika diskrit. Paper pertama yang membahas graph theory ditulis oleh Leonhard Eulear pada tahun 1736 yang membahas tentang permasalahan Konigsberg Bridge.

Pada kesempatan ini, saya akan mencoba menggunakan sebuah graph database populer, Neo4j. Saya harus mulai dari mana? Pada database relasional, saya bisa mulai dengan merancang tabel dengan merancang skema dengan menggunakan ERD. Berbeda dari database relasional, sebuah graph database tidak memiliki skema seperti tabel dan kolom yang berbeda dengan isi (record). Segala sesuatunya adalah data! Oleh sebab itu, pada saat merancang untuk graph database, saya bisa langsung memakai contoh data. Sebagai latihan, saya akan membuat database yang menampung produk yang dibeli oleh pengguna seperti berikut ini:

Contoh rancangan untuk graph database

Contoh rancangan untuk graph database

Pada rancangan di atas, masing-masing lingkaran mewakili sebuah node. Setiap node terhubung ke node lain melalui garis yang memiliki arah yang disebut sebagai edge. Baik node maupun edge boleh memiliki properties. Berkebalikan dari database relasional, nilai properties pada node atau edge akan lebih baik bila sebisa mungkin di-‘normalisasi’ menjadi node sehingga bisa dipakai secara efisien di query.

Setelah menentukan rancangan database, sekarang saatnya untuk memasukkan data. Saya sudah menyiapkan beberapa data dalam bentuk CSV untuk dimasukkan sebagai isi dari database Neo4j. Saya akan mulai dengan membuat seluruh node yang mewakili produk. Untuk itu, setelah menjalankan Neo4j, saya membuka browser dan menampilkan http://localhost:7474. Ini adalah halaman web dimana saya bisa mengelola database Neo4j dan juga mengerjakan query (dalam bentuk Cypher). Sebagai contoh, saya memberikan perintah seperti pada gambar berikut ini:

Mengisi data dari file CSV

Mengisi data dari file CSV

Saya juga melakukan hal yang sama untuk menciptakan node yang mewakili pengguna, misalnya dengan memberikan perintah Cypher berikut ini:

USING PERIODIC COMMIT
LOAD CSV WITH HEADERS FROM "file:C:/user.csv" AS row
CREATE (:User {id: row.id});

Karena kedua node ini akan sering dicari berdasarkan id, maka saya bisa memberikan index. id bersifat unik sehingga saya bisa menggunakan perintah CREATE CONSTRAINTS yang akan menciptakan unique index yang lebih cepat dibandingkan index biasa. Sebagai contoh, saya memberikan perintah berikut ini:

CREATE CONSTRAINT ON (p:Produk) ASSERT p.id IS UNIQUE;
CREATE CONSTRAINT ON (u:User) ASSERT u.id IS UNIQUE

Sekarang adalah saatnya untuk membuat edge BELI dari User ke Produk. Saya melakukannya dengan memberikan perintah seperti berikut ini:

USING PERIODIC COMMIT
LOAD CSV WITH HEADERS FROM "file:C:/buys.csv" AS row
MATCH (user:User {id: row.user_id})
MATCH (produk:Produk {id: row.produk_id})
MERGE (user)-[:BELI]->(produk);

Setelah ini, saya bisa mulai belajar mencari data. Bila pada database relasional terdapat SQL, maka Neo4j menggunakan query khusus yang disebut sebagai Cypher. Bahasa query ini juga deklaratif seperti SQL dan dirancang agar mudah dipahami. Sebagai contoh, untuk menampilkan 25 node user pertama, saya dapat menggunakan Cyhper seperti:

MATCH (n:User) RETURN n LIMIT 25;
Hasil query dalam bentuk tabel

Hasil query dalam bentuk tabel

Tidak ada yang berbeda dari database relasional bila node dikembalikan dalam bentuk tabel. Agar lebih menarik, saya bisa menampilkan hasil query dalam bentuk grafis seperti yang terlihat pada gambar berikut ini:

Hasil query dalam bentuk graph

Hasil query dalam bentuk graph

Tidak seperti hasil query dari database relasional yang sulit dicerna secara langsung, hasil query pada graph database cukup menarik untuk diamati. Pengguna juga bisa menemukan pola data secara visual.

Contoh lain dari query Cypher adalah:

MATCH (u:User {id:"xxx"})-[:BELI]->(p:Produk)
RETURN p;

Klausa MATCH pada query di atas akan mencari node User yang memiliki id "xxx" dan memiliki asosiasi BELI terhadap node Produk. Setelah itu, klausa RETURN akan mengembalikan seluruh node Produk yang ada. Dengan kata lain, query ini akan mengembalikan seluruh produk yang dibeli oleh pengguna dengan id "xxx". Tentu saja query seperti ini juga bisa dilakukan secara mudah dengan menggunakan SQL di database relasional.

Untuk menunjukkan sesuatu yang tidak gampang dilakukan melalui SQL, saya akan memberikan query Cypher seperti berikut ini:

MATCH (u1:User)-[:BELI]->(p:Produk)<-[:BELI]-(u2:User) 
RETURN p;

Hasilnya akan terlihat seperti pada gambar berikut ini:

Hasil query dalam bentuk graph

Hasil query dalam bentuk graph

Dengan menggunakan arah panah seperti -[:BELI]-> dan <-[:BELI]-, query di atas akan mengembalikan seluruh node Produk yang minimal sudah dibeli oleh 2 pengguna. Karena saya tidak peduli dengan node User pada query di atas, saya bisa mengabaikannya dengan mengubah query menjadi seperti berikut ini:

MATCH ()-[:BELI]->(p:Produk)<-[:BELI]-()
RETURN p;

Selain mengembalikan node, saya juga bisa mengembalikan property sehingga memperoleh hasil dalam bentuk tabel. Sebagai contoh, saya bisa memberikan query seperti berikut ini:

MATCH (u1:User)-[:BELI]->(p:Produk)<-[:BELI]-(u2:User)
RETURN u1.id AS user1, p.url AS produk, u2.id AS user2;

Ingin sesuatu yang lebih sulit dilakukan melalui query SQL? Sebagai contoh, saya akan mencari produk lain yang dibeli oleh pengguna lain yang juga membeli produk yang sama dengan seorang pengguna dengan menggunakan query seperti berikut ini:

MATCH (u1:User)-[:BELI]->(p1:Produk)<-[:BELI]-(u2:User),
      (u2)-[:BELI]->(p2:Produk)
RETURN p1, p2;
Hasil query dalam bentuk graph

Hasil query dalam bentuk graph

Karena graph database memang dirancang untuk keperluan seperti Cypher di atas, ia bukan hanya menawarkan kemudahan, tapi juga kinerja yang lebih baik dibandingkan query serupa yang memakai database relasional. Walaupun penelusuran tetapi dilakukan satu per satu seperti pada JOIN di database relasional, setiap node bisa memiliki path yang berbeda. Begitu path untuk node sudah melenceng dari yang diharapkan, ia bisa segera diabaikan sehingga penelusuran bisa segera dilanjutkan.

Saya juga menggabungkan MATCH di Cypher di atas sehingga menjadi seperti berikut ini:

MATCH (u1:User)-[:BELI]->(p1:Produk)<-[:BELI]-(u2:User)-[:BELI]->(p2:Produk)
RETURN p1, p2;

Tergantung pada selera, versi yang terakhir ini mungkin lebih mudah dipahami dibandingkan dengan versi sebelumnya.

Sama seperti di SQL, untuk mebatasi query di Cypher saya juga dapat menggunakan klausa WHERE. Sebagai contoh, bila saya hanya tertarik pada produk dengan id 'xxx', maka saya bisa memberikan query seperti berikut ini:

MATCH (u1:User)-[:BELI]->(p1:Produk)<-[:BELI]-(u2:User)-[:BELI]->(p2:Produk)
WHERE p1.id = "xxx"
RETURN u1, p1, u2, p2;
Hasil query dalam bentuk graph

Hasil query dalam bentuk graph

Pada gambar di atas, id 5969 adalah nilai internal yang diberikan untuk produk 'xxx'. Terlihat bahwa user seperti user 329, 3953, 5149 dan sejenisnya membeli produk 'xxx' bersamaan dengan produk lain.

Pada query diatas, akan ada banyak produk ganda yang ditampilkan karena beberapa pengguna berbeda bisa saja melihat banyak produk yang sama. Sama seperti di SQL, untuk mengembalikan hanya produk yang unik, saya dapat menggunakan klausa DISTINCT seperti pada contoh berikut ini:

MATCH (u1:User)-[:BELI]->(p1:Produk)<-[:BELI]-(u2:User)-[:BELI]->(p2:Produk)
WHERE p1.id = "xxx" 
RETURN DISTINCT p1, u2, p2;

Membuat Fitur “Produk Serupa” Dengan PredictionIO

PredictionIO (https://prediction.io) adalah sebuah engine yang dirancang untuk mudah dipakai dalam menerapkan machine learning. Lalu apa manfaatnya? Produk ini tidak hanya berguna untuk peneliti, tetapi juga bisa diterapkan langsung pada situs e-commerce. Salah satunya adalah PredictionIO bisa digunakan untuk menghasilkan daftar “produk serupa” berdasarkan perilaku dari pengunjung lain. Tanpa machine learning, pembuat situs biasanya menampilkan produk yang acak berdasarkan tag tertentu. Dengan menghasilkan daftar “produk serupa” secara pintar, pengguna akan memperoleh saran yang lebih akurat sehingga penjualan bisa meningkat, terutama bagi situs e-commerce yang memiliki banyak produk bervariasi.

Sebagai latihan, saya akan menjalankan engine PredictionIO pada sebuah server Linux yang terpisah dari server web. Salah satu kelebihan PredictionIO adalah masing-masing algoritma dikelompokkan dalam apa yang disebut dengan engine template. Saya dapat menemukan daftar engine template siap pakai di https://templates.prediction.io. Sesungguhnya sebuah engine template bukan hanya mengandung algoritma, tetapi komponen lengkap yang disebut DASE (Data, Algorithm, Serving, Evaluator). Developer juga bisa memodifikasi kode program engine template (dalam bahasa Scala) sesuai dengan kebutuhan.

Karena ingin memunculkan “produk serupa”, saya akan menggunakan template PredictionIO/template-scala-parallel-complementarypurchase yang dapat dijumpai di https://templates.prediction.io/PredictionIO/template-scala-parallel-complementarypurchase. Berdasarkan informasi dari dokumentasi, algoritma yang dipakai oleh engine template ini mengikuti konsep association rule learning (https://en.wikipedia.org/wiki/Association_rule_learning).

Sebagai langkah awal, saya memberikan perintah berikut ini untuk men-download kode program engine template:

$ pio template get PredictionIO/template-scala-parallel-complementarypurchase latihanEngine
Please enter author's name: latihan
Please enter the template's Scala package name (e.g. com.mycompany): com.latihan
Please enter author's e-mail address: 
Author's name:         latihan
Author's e-mail:       
Author's organization: com.latihan
Would you like to be informed about new bug fixes and security updates of this template? (Y/n) n
Retrieving PredictionIO/template-scala-parallel-complementarypurchase
There are 5 tags
Using tag v0.3.1
Going to download https://github.com/PredictionIO/template-scala-parallel-complementarypurchase/archive/v0.3.1.zip
Redirecting to https://codeload.github.com/PredictionIO/template-scala-parallel-complementarypurchase/zip/v0.3.1
Replacing org.template.complementarypurchase with com.latihan...
Processing latihanEngine/build.sbt...
Processing latihanEngine/engine.json...
Processing latihanEngine/src/main/scala/Algorithm.scala...
Processing latihanEngine/src/main/scala/DataSource.scala...
Processing latihanEngine/src/main/scala/Engine.scala...
Processing latihanEngine/src/main/scala/Preparator.scala...
Processing latihanEngine/src/main/scala/Serving.scala...
Engine template PredictionIO/template-scala-parallel-complementarypurchase is now ready at latihanEngine

Perintah di atas akan menciptakan sebuah folder bernama latihanEngine yang berisi kode program yang bisa saya modifikasi sesuai keperluan. Karena akan bekerja pada folder ini, maka saya perlu pindah ke folder ini dengan memberikan perintah:

$ cd latihanEngine

Setelah itu, saya siap untuk menjalankan PredictionIO dengan memberikan perintah seperti berikut ini:

$ pio-start-all

Saya bisa memerika apakah semua komponen dijalankan dengan baik dengan menggunakan perintah seperti berikut ini:

$ pio status
PredictionIO
  Installed at: /home/snake/PredictionIO
  Version: 0.9.2

Apache Spark
  Installed at: /home/snake/PredictionIO/vendors/spark-1.3.0
  Version: 1.3.0 (meets minimum requirement of 1.3.0)

Storage Backend Connections
  Verifying Meta Data Backend
  Verifying Model Data Backend
  Verifying Event Data Backend
  Test write Event Store (App Id 0)
[INFO] [HBLEvents] The table predictionio_eventdata:events_0 doesn't exist yet. Creating now...
[INFO] [HBLEvents] Removing table predictionio_eventdata:events_0...

(sleeping 5 seconds for all messages to show up...)
Your system is all ready to go.

Perintah di atas memerika apakah komponen yang dipakai oleh PredictionIO semuanya sudah siap dipakai. Terlihat bahwa PredictionIO memakai Apache Spark untuk mengerjakan algoritma secara paralel dan scalable. Selain itu, data yang dikumpulkan akan disimpan ke dalam Apache HBase. Ini adalah sebuah database No-SQL yang dirancang untuk dipakai pada HDFS (Hadoop File System).

Langkah awal pada machine learning adalah mengumpulkan data sebanyak mungkin untuk keperluan training. PredictionIO akan membuat sebuah web service server berbasis REST yang dapat dipakai oleh aplikasi web untuk memberikan data. Untuk itu, saya perlu memperoleh sebuah access key terlebih dahulu dengan memberikan perintah:

$ pio app new LatihanWeb
[INFO] [HBLEvents] The table predictionio_eventdata:events_2 doesn't exist yet. Creating now...
[INFO] [App$] Initialized Event Store for this app ID: 2.
[INFO] [App$] Created new app:
[INFO] [App$]       Name: LatihanWeb
[INFO] [App$]         ID: 2
[INFO] [App$] Access Key: A7iLxxkf7RGBpiTtxMGu8zi4EZ0Kfcox05HTwYDCteCn5weqtdnoGyEaRCRYX2CM

Bagian yang paling penting dari output di atas adalah access key yang perlu saya berikan saat memanggil event server yang diciptakan oleh PredictionIO. Untuk memastikan event server berjalan dengan baik, saya bisa mengakses URL seperti http://192.168.10.100:7070 dari browser dimana 192.168.10.100 adalah IP server yang menjalankan PredictionIO. Saya akan memperoleh hasil seperti {"status":"alive"}. Bila memakai terminal, saya juga bisa menggunakan perintah curl seperti berikut ini:

$ curl 192.168.10.100:7070
{"status":"alive"}

Sekarang, saya siap untuk mengirimkan event kepada event server. Sebagai latihan, saya akan memakai access logs untuk web distribution dari Amazon CloudFront milik sebuah situs. Saya hanya tertarik pada kolom ke-12 (cs-uri-query) dimana saya mengambil nilai parameter item_id yang mewakili item yang dilihat pengguna dan visitor_id yang mewakili pengenal unik untuk pengguna tersebut. Engine complementary purchase yang saya pakai secara default mendukung event buy yang membutuhkan parameter berupa user dan item. Agar sederhana, saya tidak akan melakukan perubahan kode program dan menganggap event buy tersebut sama seperti view. Saya kemudian membuat sebuah script Perl untuk membaca dan mengakses event server seperti berikut ini:

#!/usr/bin/perl

$LOGFILE = "access.log";
open(LOGFILE) or die("Tidak dapat membaca $LOGFILE");
while () {
  next if 1..2;
  $uri = (split(' ', $_))[11];
  if ($uri =~ m/item_id%253D(\w+)%2526/) {
    $itemId = $1;
  } else {
    print "Tidak dapat menemukan item_id di $_\n";
  }
  if ($uri =~ m/visitor_id%253D([\w-]+)&/) {
    $browserId = $1;
  } else {
    print "Tidak dapat menemukan visitor_id di $_\n";
  }
  ($postEvent = <<"CURL") =~ s/\n+//gm;
curl -i -X POST http://192.168.10.100:7070/events.json?accessKey=A7iLxxkf7RGBpiTtxMGu8zi4EZ0Kfcox05HTwYDCteCn5weqtdnoGyEaRCRYX2CM \
-H "Content-Type: application/json" \
-d '{
  "event": "buy",
  "entityType": "user",
  "entityId": "$browserId",
  "targetEntityType": "item",
  "targetEntityId": "$itemId"
}'
CURL
  system($postEvent);
}

Script di atas pada dasarnya akan menggunakan curl untuk menambahkan event pada event server. Seluruh event yang terkumpul akan diletakkan pada database Apache HBase. Untuk melihat apakah event sudah tersimpan dengan baik, saya bisa membuka URL seperti http://192.168.10.100:7070/events.json?accessKey=A7iLxxkf7RGBpiTtxMGu8zi4EZ0Kfcox05HTwYDCteCn5weqtdnoGyEaRCRYX2CM melalui browser.

Bila selama percobaan, event yang diberikan tidak benar atau perlu dihapus, saya bisa menggunakan perintah seperti berikut ini:

$ pio app data-delete LatihanWeb
[INFO] [App$] Data of the following app (default channel only) will be deleted. Are you sure?
[INFO] [App$]     App Name: LatihanWeb
[INFO] [App$]       App ID: 2
[INFO] [App$]  Description: None
Enter 'YES' to proceed: YES
[INFO] [HBLEvents] Removing table predictionio_eventdata:events_2...
[INFO] [App$] Removed Event Store for this app ID: 2
[INFO] [HBLEvents] The table predictionio_eventdata:events_2 doesn't exist yet. Creating now...
[INFO] [App$] Initialized Event Store for this app ID: 2.
[INFO] [App$] Done.

Setelah event terkumpul, sekarang saatnya untuk menjalankan engine. Tapi sebelumnya, saya perlu membuka file engine.json dan menguna nilai appName menjadi latihanWeb (sesuai dengan nama yang sama berikan saat mengerjakan pio app new). Setelah itu, saya memberikan perintah berikut ini:

$ pio build --verbose
...
[INFO] [Console$] Your engine is ready for training.

Perintah di atas akan men-download dependency Ivy yang dibutuhkan (dengan menggunakan Scala sbt, sejenis Gradle di Groovy) dan men-compile kode program yang sebelumnya dihasilkan oleh engine template. Setelah proses building selesai, langkah berikutnya adalah melakukan training untuk predictive model dengan memberikan perintah ini:

$ pio train
...
[INFO] [CoreWorkflow$] Training completed successfully.

Perintah ini akan menjalankan proses training di Apache Spark. Bila terdapat kesalahan yang berkaitan dengan java.lang.StackOverflowError, maka stack size bisa ditingkatkan menjadi lebih besar, misalnya 16 MB dengan membuat environment variable bernama SPARK_JAVA_OPTS yang memiliki isi seperti -Xss16M.

Setelah proses training selesai, saya bisa men-deploy engine agar hasilnya dapat diakses melalui REST. Sebagai contoh, saya memberikan perintah berikut ini:

$ pio deploy
...
[INFO] [MasterActor] Bind successful. Ready to serve.

Untuk memastikan engine sudah berjalan dengan baik, saya bisa mencoba mengakses URL seperti http://192.168.10.100:8000. Bila hasilnya muncul seperti harapan, maka saya siap memodifikasi aplikasi web untuk mengakses engine ini.

Untuk mendapatkan rekomendasi produk sejenis, server web (atau server lain yang membutuhkan) perlu mengirimkan JSON yang mengandung sebuah id item di key "items" dan jumlah rekomendasi yang dibutuhkan di key "num". Sebagai contoh, saya bisa mengakses engine seperti berikut ini:

$ curl -H "Content-Type: application/json" -d '{"items": ["xxx"], "num": 3}' http://192.168.10.100:8000/queries.json
{
  "rules": [
    {
     "cond": ["xxx"], 
     "itemScores":[
        {"item":"hasil1", "support":3.92541707556427E-4, "confidence":0.166666,"lift":424.583333},
        {"item":"hasil2","support":3.925417075564279E-4,"confidence":0.166666,"lift":424.58333333},
        {"item":"hasil3", "support":3.925417075564279E-4,"confidence":0.1666666,"lift":424.58333333}]
    }
  ]
}

Sebagai contoh, pada hasil yang saya peroleh, 3 rekomendasi untuk sebuah produk iPhone 5s adalah produk ZenFone 2, iPhone 5s dari penjual berbeda, dan iPhone 6. Hasil ini cukup masuk akal secara sekilas. Yang perlu diperhatikan adalah hasil ini tidak menyertakan riwayat produk yang sudah pernah dilihat oleh user tersebut, melainkan rekomendasi produk serupa berdasarkan pola kunjungan pengguna lain.

Bila mencoba dengan data dengan variasi kunjungan yang terbatas, misalnya snapshot untuk beberapa menit dimana masing-masing item barang hanya dikunjungi satu dua kali oleh pengguna, saya bisa mengubah parameter algoritma di engine.json menjadi seperti berikut ini:

"algorithms": [
    {
      "name": "algo",
      "params": {
        "basketWindow" : 300,
        "maxRuleLength" : 2,
        "minSupport": 0,
        "minConfidence": 0,
        "minLift" : 0,
        "minBasketSize" : 2,
        "maxNumRulesPerCond": 5
      }
    }
]

Mengubah nilai minSupport, minConfidence dan minLift menjadi 0 akan mengurangi kualitas hasil yang diperoleh. Sebagai contoh, nilai default untuk minSupport adalah 0.1 yang berarti item harus muncul minimal 10% untuk seluruh transaksi agar ia disertakan pada hasil. Oleh sebab itu, pengaturan seperti ini sebaiknya tidak dipakai pada kasus nyata.

Belajar Memakai Specification Di Spring Data JPA

Pada artikel Memakai Querydsl Dengan Spring Data JPA, saya menunjukkan bagaimana memakai Querydsl di Spring Data JPA. Pada kesempatan kali ini, saya akan mencoba membuat query dinamis dengan menggunakan fasilitas bawaan Spring Data JPA yaitu Specification. Karena interface ini merupakan bawaan dari Spring Data JPA, saya tidak perlu lagi menambahkan referensi ke Jar eksternal.

Mengapa perlu membuat query secara dinamis? Sebagai contoh, saya memiliki sebuah tabel PrimeFaces di facelet seperti berikut ini:

<p:dataTable var="vProduk" value="#{entityList}" selection="#{entityList.selected}" selectionMode="single" rowKey="#{vProduk.id}" 
    paginator="true" rows="10" id="tabelProduk" lazy="true" filterDelay="1000" resizableColumns="true">
    <p:column headerText="Kode" sortBy="#{vProduk.kode}" filterBy="#{vProduk.kode}">
        <h:outputText value="#{vProduk.kode}" />
    </p:column>
    <p:column headerText="Nama" sortBy="#{vProduk.nama}" filterBy="#{vProduk.nama}">
    <h:outputText value="#{vProduk.nama}" />
    </p:column>
    <p:column headerText="Jenis" sortBy="#{vProduk.jenis.nama}" filterBy="#{vProduk.jenis}" filterOptions="#{filterJenisProduk}" width="150">              
        <h:outputText value="#{vProduk.jenis.nama}" />
    </p:column>
    <p:column headerText="Harga (Rp)" sortBy="#{vProduk.harga}" filterBy="#{vProduk.harga}">
        <h:outputText value="#{vProduk.harga}" style="float: right">
            <f:convertNumber type="number" />
        </h:outputText>
    </p:column>
    <p:column headerText="Qty" sortBy="#{vProduk.qty}" filterBy="#{vProduk.qty}" width="100">
        <h:outputText value="#{vProduk.qty}" style="float: right">
            <f:convertNumber type="number" />
        </h:outputText>
    </p:column>               
    <p:ajax event="rowSelect" update=":mainForm:buttonPanel" />               
</p:dataTable>

Tampilan HTML untuk tabel di atas akan terlihat seperti pada gambar berikut ini:

Tabel dengan fasilitas filtering per kolom

Tabel dengan fasilitas filtering per kolom

Pada tampilan di atas, pengguna bisa melakukan pencarian secara bebas di setiap kolom. Sebagai contoh, pengguna bisa mencari berdasarkan kode dan jenis. Selain itu, pengguna juga bisa mencari berdasarkan kode dan jenis dan harga. Atau, pengguna juga bisa memilih mencari berdasarkan jenis dan harga. Kombinasi seperti ini akan sangat banyak bila ingin dibuat query-nya secara manual satu per satu. Ini adalah contoh kasus yang tepat untuk memakai fasilitas seperti Querydsl dan Specification.

Karena filter table seperti ini sering digunakan pada tabel yang berbeda, saya akan mulai dengan membuat sebuah implementasi Specification yang bisa dipakai ulang seperti pada kode program berikut ini:

public class FilterSpecification<T> implements Specification<T> {

  public enum Operation { EQ, LIKE, MIN };

  private List<Filter> filters = new ArrayList<>();

  public void and(String name, Operation op, Object...args) {
    filters.add(new Filter(name, op, args));
  }

  public void and(Filter filter) {
    filters.add(filter);
  }

  @Override
  public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb) {              
    Predicate result = cb.conjunction();
    for (Filter filter: filters) {
      result = cb.and(result, filter.toPredicate(cb, root));            
    }
    return result;      
  }

  class Filter {

    private String name;
    private Operation op;
    private Object[] args;

    public Filter(String name, Operation op, Object... args) {
      this.name = name;
      this.op = op;
      this.args = args;
    }

    @SuppressWarnings({ "unchecked", "rawtypes" })
    public Predicate toPredicate(CriteriaBuilder cb, Root<T> root) {
      Expression path = toPath(root, name);
      switch (op) {
      case EQ:
        return cb.equal(path, args[0]);
      case LIKE:                                
        return cb.like(path, (String) args[0]);
      case MIN: 
        return cb.greaterThanOrEqualTo(path, Integer.parseInt((String) args[0]));
      default:
        return null;
      }
    }

    @SuppressWarnings("rawtypes")
    public Expression toPath(Root root, String name) {
      Path result = null;
      if (name.contains("_")) {
        for (String node: name.split("_")) {
          node = StringUtils.uncapitalize(node);                    
          if (result == null) {
            result = root.get(node);
          } else {
            result = result.get(node);
          }
        }
      } else {
        result = root.get(name);
      }
      return (Expression) result;
    }

  }     

}

Setiap kali pengguna mengetik (atau memilih) kriteria filter di tabel, setelah delay selama 1000 ms, method load() di LazyDataModel akan dipanggil. Method ini perlu melakukan query di database untuk memperoleh hasil sesuai kriteria yang diberikan. Untuk itu, saya bisa menggunakan FilterSpecification yang saya buat seperti pada kode program berikut ini:

FilterSpecification<Produk> filterSpecification = new FilterSpecification<>();
if (getFilter("kode") != null) {
  filterSpecification.and("kode", Operation.LIKE, getFilterAsSearchExpr("kode"));
}
if (getFilter("nama") != null) {
  filterSpecification.and("nama", Operation.LIKE, getFilterAsSearchExpr("nama"));
}
if (getFilter("jenis") != null) {
  filterSpecification.and("jenis_id", Operation.EQ, getFilter("jenis"));
}
if (getFilter("harga") != null) {
  filterSpecification.and("harga", Operation.MIN, (String) getFilter("harga"));
}
if (getFilter("qty") != null) {
  filterSpecification.and("qty", Operation.MIN, (String) getFilter("qty"));
}

Method and() akan mendaftarkan sebuah Filter baru yang berisi nama path, operasi dan nilai pencarian. Untuk mengakses path di dalam path lain, saya menggunakan tanda garis bawah. Kode program yang memungkinkan hal ini terjadi dapat dilihat di method FilterSpecification.toPath().

Method toPredicate() hanya akan dipanggil pada saat Specification ini dipakai oleh salah satu method di repository Spring Data JPA. Agar bisa memakai Specification di repository Spring Data JPA, saya perlu menurunkan interface repository dari JpaSpecificationExecutor seperti yang terlihat pada kode program berikut ini:

public interface ProdukRepository extends JpaRepository<Produk, Long>, JpaSpecificationExecutor<Produk> {}

Sekarang, saya dapat memakai salah satu method yang mendukung Specification di ProdukRepository. Sebagai contoh, saya bisa melakukan filtering dengan menggunakan Specification sekaligus memperoleh hasil per halaman dengan Pageable dengan memanggil repository seperti berikut ini:

produkRepository.findAll(filterSpecification, pageable);

Dengan kode program yang sederhana dan mudah dipahami ini, saya kini bisa melakukan pencarian untuk kombinasi kolom berbeda. Sebagai contoh, bila saya mencari nama produk yang mengandung kata RING KEH dengan jenis HGP dan harga minimal Rp 100.000, saya akan memperoleh hasil seperti pada gambar berikut ini:

Melakukan filtering pada tabel

Melakukan filtering pada tabel

Belajar Membuat Aplikasi Web Dengan Spring Framework

Banyak sekali pemula yang kewalahan dalam mempelajari pemograman web di Java karena kewalahan dengan sekian banyak pilihan yang ada. Walaupun Java EE 7 kini sudah semakin mirip dengan Spring, untuk saat ini ia masih perlu banyak belajar lagi agar semakin dewasa. Saat ini tidak semua server Java EE 7 memiliki perilaku yang sama. Sebagai contoh, unit testing yang melibatkan CDI dan EJB begitu mudah dilakukan di server GlassFish karena ia mendukung EJBContainer untuk menjalankan embeddable container. Server WildFly tampaknya tidak menganut filosofi serupa dan lebih menyarankan penggunaan Arquillian untuk keperluan pengujian. Masalah perbedaan akan lebih sering muncul bila memakai Java EE API yang jarang dibahas seperti JMS.

Bila Java EE 7 adalah jalur resmi, maka teknologi dari Spring adalah jalur tidak resmi yang masih tetap bertahan sampai sekarang. Boleh dibilang CDI dari Java EE 7 menawarkan banyak fasilitas yang bersaing dengan Spring Container. Lalu kenapa masih memakai Spring? Karena Spring memiliki ekosistem yang luas dan dewasa. Memakai Spring ibarat masuk ke sebuah jeratan dunia baru yang beda sendiri. Spring dapat dijalankan pada server apa saja asalkan mendukung servlet (misalnya server Tomcat yang lawas).

Pada kesempatan ini, saya akan membuat sebuah aplikasi web sederhana dengan menggunakan Spring dan beberapa API dari Java EE 7 yang di-deploy pada server Tomcat. Aplikasi ini tidak membutuhkan server khusus untuk Java EE 7. Kode program lengkap untuk aplikasi latihan ini dapat dijumpai di https://github.com/JockiHendry/basic-inventory-web. Hasil akhirnya dapat dijumpai di https://basicinventory-latihanjocki.rhcloud.com/app/home.

Aplikasi ini memakai Gradle untuk mengelola proses siklus hidup seperti building. Sebagai contoh, bila saya memberikan perintah gradle build, sebuah file bernama ROOT.war akan dihasilkan untuk di-deploy pada servlet container seperti Tomcat. Sebagai informasi, tidak seperti di PHP yang berbasis file, sebuah aplikasi web di Java berada dalam bentuk file tunggal yang berakhir *.war (web archive). File ini adalah sebuah file ZIP yang berisi seluruh file yang dibutuhkan untuk menjalankan aplikasi dan juga metadata seperti nama dan versi aplikasi. Gradle juga secara otomatis men-download JAR yang dibutuhkan. Pada latihan ini, seluruh JAR yang saya pakai dapat dilihat di build.gradle di bagian dependencies.

Di dunia ini tidak ada teknologi yang sempurna! Suatu hari nanti, saya mungkin perlu mengganti salah satu teknologi yang dipakai dengan yang berbeda. Saya mungkin suatu hari ini ingin beralih dari JSF ke GWT. Atau, mungkin saya ingin berpindah dari database relational menjadi database NoSQL seperti MongoDB. Tentu saja saya tidak ingin sampai harus membuat ulang aplikasi dari awal. Untuk itu, saya berjaga-jaga dengan menggunakan arsitektur layering dimana saya mengelompokkan kode program ke dalam layer yang berbeda sesuai dengan perannya. Sebagai contoh, saya membuat package seperti pada gambar berikut ini:

Struktur package di aplikasi web

Struktur package di aplikasi web

Seluruh kode program yang berkaitan dengan presentation layer terletak di package com.jocki.inventory.view. Sementara itu, seluruh kode program yang berkaitan dengan service layer berada di package com.jocki.inventory.service. Sisanya, kode program yang berkaitan dengan persistence layer (mengakses database secara langsung) berada di package com.jocki.inventory.repository.

Salah satu syarat dalam layering adalah layer yang satu hanya bisa mengakses layer sesudahnya atau sebelumnya. Pada contoh ini, presentation layer hanya bisa mengakses service layer secara langsung. Setelah itu, service layer mengakses persistence layer guna membaca data dari database. Walaupun demikian, saya juga tidak begitu kaku. Misalnya, karena memakai Spring Data JPA pada persistence layer, saya mengizinkan presentation layer untuk memanggil finders secara langsung dari persistence layer tanpa melalui service layer.

Package com.jocki.inventory.domain berisi domain class yang bisa diakses oleh layer mana saja. Isi dari package ini adalah JPA entity yang mewakili domain, misalnya Konsumen, Supplier dan Sales. Karena saya ingin mengakses domain class secara langsung termasuk di presentation layer, ada baiknya saya membuat seluruh domain class menjadi Serializable.

Sama seperti domain class, masalah validasi adalah persoalan cross cutting yang dibutuhkan semua layer. Saya menyerahkan validasi pada Bean Validation yang menggunakan annotation seperti @NotBlank, @Size dan @NotNull pada domain class. Dengan demikian, validasi yang sama dan konsisten dapat diterapkan mulai dari presentation layer hingga persistence layer. Validasi ini juga dipakai oleh JPA untuk menghasilkan tabel lengkap dengan konstrain sehingga data yang tidak valid tidak bisa disimpan di database.

Bahkan, berkat PrimeFaces yang menyediakan komponen tambahan bagi JSF, validasi ini juga akan dikerjakan oleh kode program JavaScript sebelum nilai dikirim ke server. PrimeFaces memungkinkan validasi client side bila saya menambahkan atribut validateClient="true" pada <p:commandButton>. Hasil validasi client side akan terlihat seperti pada gambar berikut ini:

Validasi secara otomatis

Validasi secara otomatis

Untuk menampilkan pesan kesalahan, saya menggunakan <p:message>. Agar label untuk setiap pesan kesalahan juga ikut menjadi berwarna merah, saya tidak menggunakan <h:outputLabel> biasa melainkan <p:outputLabel>. Secara default, pesan kesalahan berada dalam bahasa Inggris. Karena validasi dilakukan di sisi klien, pesan kesalahan di ValidationMessage.properties tidak dapat dipakai. Oleh sebab itu, saya perlu membuat file JavaScript yang berisi pesan kesalahan dalam bahasa Indonesia seperti:

PrimeFaces.locales['en_US'] = {
  ...
  messages: {
    ...
    "javax.validation.constraints.NotNull.message": "tidak boleh kosong",
    "javax.validation.constraints.Size.message": "harus diantara {0} dan {1}",
    "javax.faces.validator.BeanValidator.MESSAGE": "{0}"
    ...
  }
}

Package com.jocki.inventory.config berisi konfigurasi aplikasi web. Salah satu pergerakan yang terlihat di Spring adalah peralihan konfigurasi berbasis XML menjadi konfigurasi programmatis melalui annotation. Bukan hanya Spring, tapi juga di Java. Sejak Servlet 3.0 (bagian dari JEE 6), file konfigurasi web.xml tidak wajib ada. Sebagai informasi, dulunya, setiap servlet wajib didaftarkan di web.xml. Sebagai gantinya, developer bisa membuat file javax.servlet.ServletContainerInitializer di folder META-INF/services untuk memberikan servlet container agar mengerjakan sebuah class untuk mencari definisi servlet dan sebagainya. Beruntungnya, semua ini sudah diurus oleh Spring WebMVC sehingga saya tinggal membuat turunan AbstractAnnotationConfigDispatcherServletInitializer seperti berikut ini:

public class DispatcherServletInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

  @Override
  protected Class<?>[] getRootConfigClasses() {
    return new Class[] { RootConfig.class, WebMvcConfig.class, WebFlowConfig.class };
  }

  @Override
  protected Class<?>[] getServletConfigClasses() {
    return null;
  }

  @Override
  protected String[] getServletMappings() {
    return new String[] { "/app/*" };
  }

  @Override
  public void onStartup(ServletContext servletContext) throws ServletException {
    servletContext.setInitParameter("javax.faces.DEFAULT_SUFFIX", ".xhtml");
    servletContext.setInitParameter("primefaces.FONT_AWESOME", "true");
    servletContext.setInitParameter("primefaces.CLIENT_SIDE_VALIDATION", "true");
    // Tambahkan konfigurasi development bila sedang tidak di-deploy di OpenShift
    if (System.getenv("OPENSHIFT_APP_NAME") == null) {
      servletContext.setInitParameter("javax.faces.PROJECT_STAGE", "Development");
      servletContext.setInitParameter("javax.faces.FACELETS_REFRESH_PERIOD", "1");
    } else {
      servletContext.setInitParameter("javax.faces.PROJECT_STAGE", "Production");
    }
    servletContext.addListener(ConfigureListener.class);
    super.onStartup(servletContext);
  }

}

Pada class di atas, saya menginstruksikan agar Spring selanjutnya membaca konfigurasi yang ada di class RootConfig, WebMvcConfig dan WebFlowConfig. Karena getServletMappings() mengembalikan /app/*, maka URL untuk aplikasi ini harus memiliki pola serupa seperti http://localhost/app/konsumen. Saya juga melakukan konfigurasi JSF pada class ini.

Aplikasi web biasanya membutuhkan state padahal protokol HTTP yang dipakai oleh website bersifat stateless. Oleh sebab itu, saya menggunakan Spring Web Flow (SWF) untuk memuluskan transisi antar halaman. SWF juga secara otomatis menerapkan POST-REDIRECT-GET sehingga tombol Back di browser dapat digunakan secara baik dan aman. Sebenarnya JSF 2.2 telah dilengkapi dengan dukungan deklarasi flow seperti pada SWF. Walaupun demikian, karena ini adalah fitur baru, saya merasa flow pada JSF masih terbatas sehingga saya memutuskan untuk tetap menggunakan SWF. Konfigurasi SWF dapat dilihat pada class WebFlowConfig yang merupakan turunan dari AbstractFacesFlowConfiguration. Class ini secara otomatis mendeklarasi mapping untuk akses ke URL seperti /javax.faces.resources/** untuk keperluan JSF.

Sebagai informasi, JavaServer Faces (JSF) adalah bagian dari Java EE yang memungkinkan developer memakai facelet dalam bentuk tag XML untuk menghasilkan komponen UI. Tujuannya adalah agar developer tidak perlu berhubungan langsung dengan semua operasi tingkat rendah yang melibatkan JavaScript, Ajax dan CSS styling. Bila komponen UI yang disediakan oleh JSF tidak cukup, pengguna bisa membuat komponen baru sendiri. Atau, pengguna juga dapat memakai komponen dari pihak ketiga seperti PrimeFaces, RichFaces, ICEfaces dan sebagainya.

Pengguna yang terbiasa membuat situs dengan PHP yang berorientasi file mungkin akan bertanya dimana sesungguhnya lokasi file dan bagaimana pemetaan file pada aplikasi web di Java. Berbeda jauh dari situs berorientasi file, sebuah servlet Java akan menerima input berupa URL tujuan lalu mengembalikan output sesuai dengan URL tersebut. Yup! Apapun URL-nya hanya akan ditangani oleh 1 servlet. Pada contoh di atas, saya secara tidak langsung hanya mendaftarkan org.springframework.web.servlet.DispatcherServlet dari Spring dan javax.faces.webapp.FacesServlet (dari Mojarra, implementasi JSF yang saya pakai). Setiap request URL akan ditangani oleh salah satu servlet di atas, lebih tepatnya adalah oleh DispatcherServlet milik Spring karena FacesServlet hanya dibutuhkan untuk inisialisasi JSF. Method yang menangani request akan terlihat seperti void service(HttpServletRequest request, HttpServletResponse response). Apa yang dikirim oleh pengguna mulai dari URL, parameter hingga cookie bisa dibaca melalui request. Untuk menulis hasil yang dikirim kepada pengguna, baik HTML, gambar, JSON, dan sebagainya, cukup tulis byte per byte melalui response.

Terlihat rumit? Saya tidak perlu melakukan pemograman tingkat rendah sampai ke servlet karena semua ini sudah ditangani oleh Spring WebMVC. Pada konfigurasi WebMvcConfig, saya membuat kode program seperti berikut ini:

@Configuration
@EnableWebMvc
@ComponentScan
public class WebMvcConfig extends WebMvcConfigurerAdapter {

  @Inject
  private WebFlowConfig webFlowConfig;

  @Override
  public void addViewControllers(ViewControllerRegistry registry) {
    registry.addViewController("/error").setViewName("error");
  }

  @Bean
  public UrlBasedViewResolver faceletsViewResolver() {
    UrlBasedViewResolver resolver = new UrlBasedViewResolver();
    resolver.setViewClass(JsfView.class);
    resolver.setPrefix("/WEB-INF/views/");
    resolver.setSuffix(".xhtml");
    return resolver;
  }

  ...
}

Pada konfigurasi di atas, saya memetakan URL /error dengan sebuah view bernama error di addViewControllers(). Dengan demikian, bila pengguna membuka URL seperti http://localhost/app/error, maka view dengan nama error akan ditampilkan. UrlBasedViewResolver yang saya pakai akan mencari view error di lokasi /WEB-INF/views/error.xhtml yang harus dalam bentuk facelet (JSF). Bila facelet tidak ditemukan, maka Spring mencari file dengan nama yang sama persis di src/main/webapp. Bila file tidak juga ditemukan, pesan kesalahan 404 akan diberikan pada pengguna. Pengguna tidak akan pernah bisa mengakses isi folder WEB-INF secara langsung walaupun folder ini terletak di dalam src/main/webapp.

Konfigurasi pada class RootConfig berisi hal diluar presentation layer seperti berikut ini:

@Configuration
@EnableJpaRepositories(basePackages="com.jocki.inventory.repository")
@EnableTransactionManagement
@ComponentScan("com.jocki.inventory")
public class RootConfig {

  @Bean
  public LocalContainerEntityManagerFactoryBean entityManagerFactory() throws NamingException {
    LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
    emf.setDataSource(dataSource());
    emf.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
    emf.setPackagesToScan("com.jocki.inventory.domain");

    Map<String,? super Object> jpaProperties = new HashMap<>();
    jpaProperties.put("hibernate.dialect", "org.hibernate.dialect.MySQL5Dialect");
    emf.setJpaPropertyMap(jpaProperties);

    return emf;
  }

  @Bean
  public DataSource dataSource() throws NamingException {
    JndiTemplate jndiTemplate = new JndiTemplate();
    DataSource dataSource = (DataSource) jndiTemplate.lookup("java:comp/env/jdbc/inventory");
    return dataSource;
  }

  @Bean
  public JpaTransactionManager transactionManager(EntityManagerFactory emf) {
    JpaTransactionManager transactionManager = new JpaTransactionManager();
    transactionManager.setEntityManagerFactory(emf);
    return transactionManager;
  }

}

Pada konfigurasi di atas, saya melakukan pengaturan JPA dan data source yang dipakai. Saya menambahkan annotation @EnableTransactionManagement sehingga transaksi pada service layer dapat diaktifkan. Selain itu, saya menambahkan @EnableJpaRepositories agar Spring Data JPA bisa secara otomatis menghasilkan implementasi dari interface repository yang ada. Saya juga menggunakan JNDI sehingga saya tidak perlu mendeklarasikan lokasi, nama pengguna dan password untuk database secara langsung di dalam kode program. Sebagai gantinya, server yang menjalankan aplikasi ini harus mendeklarasikan resource JNDI dengan nama jdbc/inventory yang berisi DataSource yang mewakili database yang hendak dipakai. Pada Tomcat, deklarasi ini dapat dilakukan dengan menambahkan baris seperti berikut ini pada file context.xml di folder conf:

<Resource auth="Container" driverClassName="com.mysql.jdbc.Driver" name="jdbc/inventory"
password="12345" type="javax.sql.DataSource" url="jdbc:mysql://localhost/latihandb"
username="namauser" validationQuery="/* ping */ SELECT 1"/>

Pada server percobaan, saya membuat resource jdbc/inventory yang merujuk pada database lokal. Untuk live server, tentunya resource ini harus merujuk pada database di live server. Peralihan ke database yang berbeda ini dapat berlangsung secara transparan tanpa harus mengubah kode program karena informasi koneksi database tidak tersimpan di dalam aplikasi.

Walaupun idealnya konfigurasi web.xml bisa dibuang sepenuhnya, untuk saat ini, saya masih tidak bisa lepas dari web.xml. Sebagai contoh, deklarasi halaman kesalahanan dan referensi JNDI tetap dibutuhkan pada web.xml:

<error-page>
  <location>/app/error</location>
</error-page>
<resource-ref>
  <description>Default Datasource</description>
  <res-ref-name>jdbc/inventory</res-ref-name>
  <res-type>javax.sql.DataSource</res-type>
  <res-auth>Container</res-auth>
</resource-ref>

Pada deklarasi di atas, bila terjadi kesalahan pada aplikasi web, maka URL /app/error akan ditampilkan. Sejak Servlet 3.0, saya bisa menggunakan 1 halaman error generik untuk seluruh jenis kesalahan yang ada. Sesuai dengan konfigurasi yang saya lakukan pada WebMvcConfig, URL ini akan menampilkan facelet yang terletak di src/main/webapp/WEB-INF/views/error.xhtml. Pada facelet ini, saya bisa mengakses informasi lebih lengkap mengenai kesalahan yang terjadi melalui EL seperti #{request.getAttribute('javax.servlet.error.message')} atau #{request.getAttribute('javax.servlet.error.status_code')}.

Sebuah halaman web biasanya memiliki template yang konsisten, misalnya mengandung bagian header dan footer. Oleh sebab itu, daripada mengetik ulang bagian yang sama berkali-kali (dan mengubahnya berkali-kali bila salah!), saya akan menggunakan fasilitas template dari JSF. Untuk itu, saya mendeklarasikan sebuah template di src/main/webapp/WEB-INF/layouts/standard.xml yang isinya seperti berikut ini:

<html ...>
<f:view contentType="text/html">
    <h:head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> 
        <title>Basic Inventory Web Demo</title>
        <h:outputStylesheet name="css/basic.css" /> 
        <h:outputScript name="js/locales.js" /> 
        <ui:insert name="headIncludes" />   
    </h:head>
    <h:body>
        <p:layout fullPage="true" style="border: 0px;">
            <p:layoutUnit position="west" resizable="true" collapsible="true" styleClass="main-menu">
                ...
            </p:layoutUnit>
            <p:layoutUnit position="center" styleClass="main-panel">
                <h:form id="mainForm">
                    <ui:insert name="content" />
                </h:form>
            </p:layoutUnit>
            <p:layoutUnit position="south" styleClass="main-panel">
                ...
            </p:layoutUnit>                       
        </p:layout>               
    </h:body>
</f:view>
<p:ajaxStatus onerror="PF('dialogKesalahan').show();"/>                                                   
<p:dialog header="Terjadi Kesalahan" id="dialogKesalahan" widgetVar="dialogKesalahan" modal="true" resizable="false">   
    <p>Telah terjadi kesalahan saat berkomunikasi dengan server.</p>
    <p>Silahkan perbaharui halaman atau ulangi beberapa saat kemudian!</p>
</p:dialog>
...
</html>

Pada facelet di atas, selain layout, saya juga menambahkan p:ajaxStatus secara global untuk setiap halaman yang ada sehingga pesan kesalahan akan selalu muncul bila terjadi kesalahan Ajax pada halaman mana saja seperti yang terlihat pada gambar berikut ini:

Tampilan pesan kesalahan pada Ajax

Tampilan pesan kesalahan pada Ajax

Salah satu kasus dimana pesan kesalahan ini akan selalu muncul adalah bila pengguna membuka sebuah halaman JSF dan meninggalkannya untuk jangka waktu yang lama. JSF menyimpan informasi komponen view yang dihasilkannya ke dalam session. Secara default, session di Tomcat diakhiri bila tidak ada aktifitas dari pengguna setelah 30 menit. Hal ini perlu dilakukan untuk mengirit memori. Bila pengguna mencoba memakai komponen JSF setelah session berakhir, ia akan menemukan setiap tombol tidak akan bekerja. Solusi untuk masalah ini cukup sederhana: pengguna harus me-refresh halaman untuk memulai session baru. PrimeFaces 5 sebenarnya memiliki komponen <p:ajaxExceptionHandler> untuk menghasilkan informasi kesalahan yang lebih jelas tetapi ia membutuhkan deklarasi halaman kesalahan secara eksplisit di web.xml.

File yang memakai template perlu menggunakan ui:composition dan mengisi nilai atribut template sesuai dengan lokasi file template yang hendak dipakai. Selain itu, pengguna template juga perlu menggunakan ui:define untuk menyisipkan nilai pada ui:insert di template. Sebagai contoh, facelet untuk pesan kesalahan akan terlihat seperti berikut ini:

<ui:composition ...  template="/WEB-INF/layouts/standard.xhtml">
    <ui:define name="content">
        <div class="ui-message-error ui-widget ui-corner-all">          
            <div class="ui-message-error-detail">
                <p><strong>Telah terjadi kesalahan saat menampilkan halaman ini!</strong></p>               
                <p>Status kesalahan: <strong><h:outputText value="#{request.getAttribute('javax.servlet.error.status_code')}"/></strong></p>
                ...
            </div>
        </div>
    </ui:define>
</ui:composition>

Saya mendeklarasikan setiap flow SWF dalam folder masing-masing di src/main/webapp/WEB-INF/flows. Sebagai contoh, URL seperti app/konsumen akan mengakses flow yang didefinisikan di src/main/webapp/WEB-INF/flows/konsumen/konsumen-flow.xml. SWF mendukung flow inheritance sehingga saya dapat memakai ulang pola flow yang sama berkali-kali. Sebagai contoh, semua flow untuk operasi CRUD diturunkan dari src/main/webapp/WEB-INF/flows/crud/crud-flow.xml yang memiliki aliran seperti:

Contoh flow di SWF

Contoh flow di SWF

Selain flow inheritance, SWF juga mendukung embedded flow. Ini adalah solusi yang tepat untuk Ajax. Sebagai contoh, pada flow di atas, bila pengguna melakukan aksi edit atau tambah, maka flow src/main/webapp/WEB-INF/flows/crud/entry/entry-flow.xml akan dikerjakan:

<flow ... abstract="true">

    <input name="selectedEntity" />    

    <view-state id="entry">
        <on-render>
            <set name="viewScope.updateMode" value="(selectedEntity != null) and (selectedEntity.id != null)" />
        </on-render>  
        <transition on="simpan" />
        <transition on="kembali" to="kembali" bind="false" validate="false" />
        <transition on="update" to="updateAction" />
    </view-state>     

    <action-state id="updateAction">                                        
        <transition on="success" to="kembali" />
        <transition on="error" to="entry" />  
    </action-state>

    <end-state id="kembali" />

</flow>

Karena saya menambahkan atribut abstract dengan nilai true, flow di atas tidak akan pernah bisa dipanggil secara langsung dengan URL seperti /app/crud/entry. Bila dipakai sebagai embedded flow, setiap perpindahan view-state pada flow di atas tidak akan menyebabkan request halaman baru (tidak ada POST-REDIRECT-GET) sehingga semuanya bisa berlangsung tanpa merefresh halaman secara penuh.

Contoh flow yang mengimplementasikan crud/entry adlaah konsumen/entry yang definisinya terlihat seperti berikut ini:

<flow ... parent="crud/entry">

  <view-state id="entry">

    <on-entry>
      <evaluate expression="kotaRepository.findAll()" result="viewScope.daftarKota" />
    </on-entry>

    <transition on="simpan">
      <evaluate expression="konsumenAction.simpan(selectedEntity, flowRequestContext)" />
    </transition>

    <transition on="kembali" to="kembali" bind="false" validate="false" />

    <transition on="update" to="updateAction" />

  </view-state>       

  <action-state id="updateAction">
     <evaluate expression="konsumenAction.update(selectedEntity, flowRequestContext)" />        
  </action-state>

</flow>

Pada saat view entry pertama kali dikerjakan, method findAll() dari kotaRepository akan dikerjakan dan hasilnya akan ditampung sebagai variabel di daftarKota (dengan view scope). Secara default, id dari view-state dipakai untuk menentukan facelet yang hendak ditampilkan, dalam hal ini adalah src/main/webapp/WEB-INF/konsumen/entry/entry.xhtml yang isinya seperti berikut ini:

<ui:composition ... template="/WEB-INF/layouts/standard.xhtml">
    <ui:define name="content">              
        <p:growl for="pesanInformasi" globalOnly="true" showDetail="true" />
        <p:panel id="panel" columns="3" styleClass="entry" header="#{updateMode? 'Edit Konsumen': 'Tambah Konsumen'}">
            <p:focus />
            <h:panelGrid columns="3" cellpadding="5">

                <p:outputLabel for="id" value="Id:" rendered="#{updateMode}"/>
                <p:inputText id="id" size="50" value="#{selectedEntity.id}" disabled="true" rendered="#{updateMode}"/>
                <p:message for="id" display="text" rendered="#{updateMode}"/>

                <p:outputLabel for="nama" value="Nama:" />
                <p:inputText id="nama" size="50" value="#{selectedEntity.nama}">
                    <p:clientValidator event="keyup" />
                </p:inputText>
                <p:message for="nama" display="text" />

                <p:outputLabel for="alamat" value="Alamat:" />
                <p:inputTextarea id="alamat" rows="5" cols="50" value="#{selectedEntity.alamat}">
                    <p:clientValidator event="keyup" />
                </p:inputTextarea>                                                    
                <p:message for="alamat" display="text" />

                <p:outputLabel for="kota" value="Kota:" />
                <p:selectOneMenu id="kota" value="#{selectedEntity.kota}" filter="true" converter="#{kotaConverter}">
                    <f:selectItems value="#{daftarKota}" var="vKota" itemValue="#{vKota}"/>
                  </p:selectOneMenu>  
                <p:message for="kota" display="text" />           

            </h:panelGrid>
            <div class="buttonPanel">
                <p:commandButton id="simpan" value="Simpan" icon="fa fa-floppy-o" action="simpan" validateClient="true" rendered="#{!updateMode}" update="mainForm"/>
                <p:commandButton id="update" value="Update" icon="fa fa-edit" action="update" validateClient="true" rendered="#{updateMode}" update="mainForm"/>                    
                <p:commandButton id="kembali" value="Kembali" icon="fa fa-arrow-left" action="kembali" immediate="true" />
            </div>                                    
        </p:panel>                            
    </ui:define>  
</ui:composition>

Pada facelet di atas, saya melakukan binding dari setiap komponen input ke variabel selectedEntity di flow melalui atribut value. Saya menambahkan p:clientValidator agar validasi JavaScript langsung dilakukan pada saat saya mengetik di komponen input. Khusus untuk p:selectOneMenu yang menampilkan pilihan daftar kota, saya perlu memberikan variabel daftar kota yang sudah saya query sebelumnya. Selain itu, saya juga perlu memberikan sebuah converter yang bisa menerjemahkan id kota menjadi nama kota dan sebaliknya (dapat dijumpai di src/main/java/view/converter/KotaConverter). Bila saya menampilkan view ini di browser dan nilai selectedEntity adalah null, saya akan memperoleh tampilan seperti berikut ini:

Contoh tampilan data entry

Contoh tampilan data entry

Bila seandainya selectedEntity tidak bernilai null, maka saya akan memperoleh tampilan seperti berikut ini:

Contoh tampilan edit

Contoh tampilan edit

Pada <p:commandButton>, saya menambahkan atribut update dengan nilai mainForm supaya saat tombol di-klik, hanya panel ini saja yang perlu diperbaharui melalui Ajax. Nilai action seperti 'simpan', 'update' atau 'kembali' harus sesuai dengan yang saya pakai di &lt;transition&gt; di deklarasi flow. Khusus untuk tombol kembali, saya menambahkan atribute immediate dengan nilai true agar validasi dan binding tidak dilakukan.

Pada view display, saya menggunakan sebuah <p:dataTable> yang dilengkapi dengan halaman, filter, dan pengurutan seperti yang terlihat pada gambar berikut ini:

Contoh tampilan tampilan tabel

Contoh tampilan tampilan tabel

Untuk meningkatkan kinerja, daripada men-query isi tabel sekaligus, saya hanya men-query 10 record saja per halaman. Begitu pengguna men-klik nomor halaman yang berbeda, kode program akan men-query 10 record lain yang dibutuhkan (melalui Ajax). Hal ini dapat dicapai dengan memberikan nilai "true" pada atribut lazy. Selain itu, saya juga perlu sebuah turunan LazyDataModel. Pada class ini, terdapat method seperti:

List load(int first, int pageSize, String sortField, SortOrder sortOrder, Map<String, Object> filters)

yang perlu di-override. Beruntungnya, Spring Data JPA mendukung pembatasan query per halaman dengan menggunakan interface Pageable (bentuk konkrit-nya adalah PageRequest). Pageable juga dapat dipakai untuk menyimpan informasi pengurutan.

Untuk membuat persistence layer, saya cukup membuat interface seperti:

public interface KonsumenRepository extends JpaRepository<Konsumen, Long> {

  Page findByNamaLike(String nama, Pageable pageable);

  Page findByAlamatLike(String alamat, Pageable pageable);

  Page findByKota_NamaLike(String namaKota, Pageable pageable);

}

Spring Data JPA akan membuat implementasi dari interface ini secara otomatis. Saya hanya perlu menambahkan @Inject pada class lain yang perlu memakai interface ini. Seluruh method yang ada mengembalikan object Page. Object ini tidak hanya berisi daftar Konsumen yang di-query, tetapi juga informasi lain seperti jumlah seluruh konsumen yang ada. Informasi seperti ini dibutuhkan untuk membuat daftar halaman, menentukan apakah ini adalah halaman pertama atau halaman terakhir, dan sebagainya. Selain finders, JpaRepository sudah menyediakan method seperti save() dan delete() untuk melakukan operasi CRUD pada sebuah JPA entity.

Pada service layer, saya membuat class seperti:

@Service @Transactional
public class KonsumenService {

  @Inject
  private transient KonsumenRepository konsumenRepository;

  @Override
  public Konsumen simpan(Konsumen konsumen) {
    return konsumenRepository.save(konsumen);
  }

  @Override
  public Konsumen update(Konsumen konsumenBaru) {
    Konsumen konsumen = konsumenRepository.findOne(konsumenBaru.getId());
    konsumen.setNama(konsumenBaru.getNama());
    konsumen.setAlamat(konsumenBaru.getAlamat());
    konsumen.setKota(konsumenBaru.getKota());
    return konsumen;
  }

  @Override
  public void hapus(Konsumen konsumen) {
    konsumenRepository.delete(konsumen.getId());
  }

}

Saya menggunakan annotation @Service untuk memberi tanda bahwa class di atas adalah bagian dari service layer. Selain itu, saya juga memberikan annotation @Transactional agar setiap method di dalam class ini dikerjakan dalam sebuah transaksi database. Bila terjadi kesalahan selama eksekusi sebuah method yang diindikasi oleh sebuah Exception, maka proses rollback harus dilakukan sehingga tidak ada perubahan yang terjadi pada database. Saya tidak menggunakan save() pada method update() melainkan men-update satu per satu atribut yang ada karena pada domain class yang kompleks, sering kali method update() hanya boleh memperbaharui sebagian atribut saja.

Berkat Spring container, saya bisa memakai class service layer di presentation layer dengan menggunakan @Inject seperti pada:

@Component
public class KonsumenAction {

  @Inject
  private KonsumenService konsumenService;

  ...

}

Saya kemudian bisa memakainya seperti pada:

public String update(@NotNull Konsumen entity, RequestContext context) {
  try {
    konsumenService.update(entity);
    return "success";
  } catch (Exception ex) {
    addErrorMessage(ex.getMessage());
    return "error";
  }
}

public void addInfoMessage(String message) {
  FacesMessage facesMessage = new FacesMessage(FacesMessage.SEVERITY_INFO, "Sukses", message);
  FacesContext.getCurrentInstance().addMessage("pesanInformasi", facesMessage);
}

public void addErrorMessage(String message) {
  FacesMessage facesMessage = new FacesMessage(FacesMessage.SEVERITY_ERROR, "Terjadi kesalahan", message);
  FacesContext.getCurrentInstance().addMessage("pesanInformasi", facesMessage);
}

Method simpan() mengembalikan sebuah String yang mewakili transisi yang akan dilakukan pada SWF. Selain itu, saya juga bisa menambahkan pesan informasi dalam bentuk FacesMessage yang bisa ditampilkan oleh PrimeFaces melalui p:growl seperti yang terlihat pada gambar berikut ini:

Tampilan pesan informasi melalui growl

Tampilan pesan informasi melalui growl

Karena KonsumenAction memiliki annotation @Component, maka singleton-nya sudah disediakan oleh Spring sehingga saya bisa langsung memakainya di SWF seperti pada contoh berikut ini:

<action-state id="updateAction">
    <evaluate expression="konsumenAction.update(selectedEntity, flowRequestContext)" />
    <transition on="success" to="kembali" />
    <transition on="error" to="entry" />          
</action-state>

Sebagai langkah terakhir, saya perlu men-deploy aplikasi web ini ke sebuah server. Bila saya menjalankan perintah gradle build, saya akan memperoleh sebuah file bernama ROOT.war di folder webapps. Masalahnya adalah kemana saya harus meletakkan file ini? Salah satu solusi gratis yang bisa saya pakai adalah dengan menggunakan OpenShift (https://www.openshift.com/) yang menyediakan layanan PaaS. Saya mulai dengan membuat sebuah cartridge DIY (Do It Yourself) baru dan men-install Tomcat 8 pada cartridge tersebut. Saya juga menambahkan sebuah cartridge MySQL untuk memperoleh sebuah database MySQL. Setelah men-setup script di folder .openshift dan memindahkan kode program ke server OpenShift dengan menggunakan Git, aplikasi bisa di-deploy dan dijalankan.

Salah satu hal yang harus diperhatikan saat memakai OpenShift adalah hanya port 15000 – 35530 yang boleh digunakan. Port 8080 adalah satu-satunya yang diperbolehkan diluar port yang diizinkan. Setiap request HTTP (di port 80) akan di forward ke port 8080 di server virtual saya. Saya juga perlu mengubah context.xml di server Tomcat 8 yang saya install agar memiliki resource bernama jdbc/inventory yang merujuk pada database MySQL. Saya bisa memperoleh informasi mengenai alamat host, port, user dan ip dengan melihat isi environment variable seperti OPENSHIFT_MYSQL_DB_HOST, OPENSHIFT_MYSQL_DB_PORT, OPENSHIFT_MYSQL_DB_USERNAME dan OPENSHIFT_MYSQL_DB_PASSWORD. Selain itu, sebagai pengguna gratis, aplikasi yang saya deploy akan memasuki idling state bila tidak pernah diakses selama 24 jam. Ini akan menyebabkan pengguna yang pertama kali mengakses aplikasi setelah idling state harus mengalami sedikit delay.

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.

Ikuti

Get every new post delivered to your Inbox.

Bergabunglah dengan 47 pengikut lainnya.