自分の歩いた経路をGCP上に保存するスマホアプリ - Kotlin

##はじめに
以下の概要の通り、ここではKotlinで作成する位置情報アプリについて記載します。
Androidアプリの作成は不慣れな部分もあり、バグなどあるかもしれません。あらかじめ、ご了承ください。

* [概要](https://qiita.com/potato-refrain/items/f23a3d2ca68fbe22a97d "概要")

* イメージ図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568280/527301d9-73cd-9196-80dd-5a97bc4fd9db.png)


* スマホアプリのイメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568280/6eb77e8a-470a-76c0-cf67-7ecf176b99b6.png)


##スマホアプリ概要
* Kotlinで位置情報を取得して、GCP上に保存します。
* 保存した位置情報は、Google Mapに表示させます。
* 履歴を一覧表示できて、タップするとGoogle Mapが表示する仕組みにします。
* 歩いている途中に画面ロックがかかったりアプリを切り替えたりした時も位置情報を送信できるように、バックグラウンドでの処理を実装します。
* 今回は登録のみとします。位置情報の削除、更新は実装しません。※自分専用のアプリであり、主目的はGCPの学習であるため
* 自分の端末以外での使用は想定していません。

## ソースコード(GitHub)
* [Kotlinソース](https://github.com/ozbone/GpsTestKotlin/tree/GpsTest_Ver1/app/src/main/java/com/example/gpstest "Kotlin関連のソース")

* [画面](https://github.com/ozbone/GpsTestKotlin/tree/GpsTest_Ver1/app/src/main/res/layout "画面関連")

* [AndroidManifest](https://github.com/ozbone/GpsTestKotlin/blob/GpsTest_Ver1/app/src/main/AndroidManifest.xml "AndroidManifest")

* [その他(gradleなど)](https://github.com/ozbone/GpsTestKotlin "その他")

## IDE
Android Studio(ver4.0)

## 画面(2つだけ)

* メイン画面(MainActivity)

 * 履歴ボタン・・履歴画面を表示します。
 * 開始ボタン・・位置情報取得を開始します。
 * 停止ボタン・・位置情報取得を終了します。
 * タイトル・・・後からわかるように、位置情報にタイトルをつけます。(必須)
 * 備考・・・・・補足事項です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568280/43b2779b-280c-63c9-0d09-0315e0cbd827.png)

* 履歴画面(LocationList)
 * 一覧・・・・・履歴を参照できます。クリックするとブラウザが開いてGoogleMapを表示します。ListViewを使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568280/b483cf3e-56f6-a69f-bff9-ba4feb762f1a.png)

##アプリ起動時の処理

* 位置情報の初期設定や権限確認など

  * Android端末の位置情報使用許可やアプリへの許可、バックグラウンド許可など
 位置情報の使用が許可されていないと、以下のようなメッセージが表示されるように実装します。ONにすると使えるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568280/104da8f9-bb4f-2704-ba37-3c2ffc23f0c3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568280/aee9ebda-a389-0ffd-be17-2bbc06cec8d1.png)

 * この部分は、色々調べて以下のように書くとできました。

```Kotlin:MainActivity
    // ----------------------------------------
    private fun createLocationRequest() {

        this.locationRequest = LocationRequest.create()

        // 位置情報に接続するために、位置情報リクエストを追加
        val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest!!)

        // 現在の設定が満たされているかチェックする
        val client: SettingsClient = LocationServices.getSettingsClient(this)
        val task: Task<LocationSettingsResponse> = client.checkLocationSettings(builder.build())
        task.addOnSuccessListener { locationSettingsResponse ->
            requestingLocationUpdates = true
        }

        // 以下のチェックは、Android端末の設定→位置情報がONになっていない場合にONにする設定。（アプリレベルの許可は別）
        // エラーが発生した場合でResolvableApiExceptionが発生した場合は位置情報サービスを有効にするか確認する
        task.addOnFailureListener { exception ->
            if (exception is ResolvableApiException) {
                try {
                    // Show the dialog by calling startResolutionForResult(),
                    // and check the result in onActivityResult().
                    exception.startResolutionForResult(
                        this@MainActivity,
                        REQUEST_CHECK_SETTINGS
                    )
                    requestingLocationUpdates = true
                } catch (sendEx: IntentSender.SendIntentException) {
                    // Ignore the error.
                }
            }
        }

        // アプリに位置情報の使用を許可する
        if (ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.ACCESS_FINE_LOCATION
            ) == PackageManager.PERMISSION_GRANTED
        ) {
            // ok→BackGroundが許可されているかチェック
            val backgroundLocationPermissionApproved =
                ActivityCompat.checkSelfPermission(
                    this, Manifest.permission.ACCESS_BACKGROUND_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
            // 許可されている
            if (backgroundLocationPermissionApproved) {
            }
            // 許可されていないのでバックグラウンド許可を求める
            else {
                ActivityCompat.requestPermissions(
                    this,
                    arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
                    REQUEST_BACKGROUND_SETTINGS
                )
            }
        }
        // 許可されていない場合は許可を求める
        else {
            ActivityCompat.requestPermissions(
                this,
                arrayOf(
                    Manifest.permission.ACCESS_FINE_LOCATION,
                    Manifest.permission.ACCESS_BACKGROUND_LOCATION
                ),
                REQUEST_ALL
            )
        }
    }
```

　また、以下のようにAndroidManifest.xmlを編集する必要がありました。

```xml:AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.gpstest">

    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
・・・・・
```

ACCESS_FINE_LOCATIONは、正確な位置情報を取得する必要がある場合に指定します。
これに対して、ACCESS_COARSE_LOCATIONの場合はおおよその位置情報を取得するようです。[ご参考]("https://developer.android.com/reference/android/Manifest.permission#ACCESS_FINE_LOCATION" "ACCESS_FINE_LOCATION")

今回は正確な位置情報が欲しいので、「ACCESS_FINE_LOCATION」を指定しました。

また、バックグラウンド処理をするので「ACCESS_BACKGROUND_LOCATION」を指定しました。

  
* 開始ボタン押下時の処理追加(setOnClickListener)
 * タイトル、開始時刻、備考をAndroid端末内のDBに登録します。SQLiteでダイレクトにSQLでやるのが楽と思いましたが、今回はRealmというO/Rマッパーで実装しました。(慣れるまで時間がかかりました)。Realmの処理は、[この参考書](https://www.amazon.co.jp/%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AEAndroid%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0-%E7%AC%AC4%E7%89%88-%E9%87%91%E7%94%B0-%E6%B5%A9%E6%98%8E/dp/4815601143 "はじめてのAndroidプログラミング")を参考にしました。
 * また、データを格納するモデルとしてRealmObjectを継承したLocationDataクラスを用意しました。ここには、DBに格納する項目とデータ型、初期値を設定します。

```Kotlin:LocationData
open class LocationData:RealmObject() {
    @PrimaryKey
    var id: Long = 0
    var startdate: String = ""
    var enddate:  String = ""
    var title:String = ""
    var note:String = ""
}
```

  Realmへの登録処理はLocationEditというKotlinクラスを別途作成して、共通化しました。MainActivityからは、メソッド呼び出しだけです。IDもこのメソッドで作成して、Returnするようにします。

```Kotlin:LocationEdit
    // Realm登録
    public fun UpdateRealm(inID : Long, inTitle : String, inNote : String
                           , inStartDate : String, inEndDate : String) : Long {
        var id = inID ?:-1L
        realm = Realm.getDefaultInstance()
        realm.executeTransaction {
            val locdata: LocationData?
            when(id) {
                // 追加処理の場合はIDを採番
                -1L -> {
                    val maxId = realm.where<LocationData>().max("id")
                    id = ((maxId?.toLong() ?: 0L) + 1)
                    locdata = realm.createObject<LocationData>(id)
                }
                else -> {
                    locdata = realm.where<LocationData>().equalTo("id",id).findFirst()
                }
            }
            Log.d("LocationEdit.kt", "StartDate=" + inStartDate)
            // 追加 or 更新
            if (locdata != null) {
                locdata.title=inTitle ?: ""
                locdata.note=inNote ?: ""
                locdata.startdate= inStartDate
                locdata.enddate=inEndDate
            }
        }

        return id
    }
```

 * 開始ボタンの処理は以下の通りです。

```Kotlin:MainActivity
        // 開始ボタン
        btn_start.setOnClickListener {
            val title = txt_title.text?.toString() ?: ""
            val note : String = txt_note.text?.toString() ?:""
・・・・・(省略)
                    // 追加
                    var le :LocationEdit = LocationEdit()
                    this.starttime = Common.getToday()
                    myid = le.UpdateRealm(
                        -1L,
                        txt_title.text.toString(),
                        txt_note.text.toString(),
                        this.starttime,
                        this.endtime
                    )
```

 　myidが設定されてたらDB登録完了です。位置情報取得処理の開始します(startService)。後続処理でAppEngineにIDを送信する必要があるので、putExtra("id", myid)でIDを渡してあげます。その他、ボタンの制御もします(ボタン無効化など)。

```Kotlin:MainActivity
                    // myidが設定されれば位置情報取得処理開始※設定されていなければエラーになっている
                    if (myid != -1L) {
                        // IDを渡してあげるよ！(GCPに送信する必要があるからね★)
                        intent3.putExtra("id", myid)

                        // サービススタート★★
                        startService(intent3)
                        setBtnEnabled(true)
                        txtStartTime.setText("開始時刻：" + starttime)
                        txtEndTime.setText("終了時刻：")
                    }
```

```Kotlin:ボタン無効化の処理
    private fun setBtnEnabled(flgStart: Boolean) {
        if (flgStart) {
            btn_start.isEnabled = false
            btn_stop.isEnabled = true
            txt_title.isEnabled = false
            txt_note.isEnabled = false
        } else {
            btn_start.isEnabled = true
            btn_stop.isEnabled = false
            txt_title.isEnabled = true
            txt_note.isEnabled = true
        }
    }
```
 
* 終了ボタン押下時の処理追加(setOnClickListener)
  * 位置情報取得処理を停止します。(stopService)
  * 開始ボタン押下時に登録済みのIDで、Realmを更新します(停止時間でUpdate)。ここでも、開始ボタンの時と同じメソッドをCALLします。

```Kotlin:MainActivity
        btn_stop.setOnClickListener {
            stopService(intent3)
            setBtnEnabled(false)
            this.endtime = Common.getToday()
            txtEndTime.setText("終了時刻：" + this.endtime)

            // 終了時にEndTimeを入れ込む
            var le :LocationEdit = LocationEdit()
            myid = le.UpdateRealm(
                myid,
                txt_title.text.toString(),
                txt_note.text.toString(),
                this.starttime,
                this.endtime
            )
```

* 履歴ボタン押下時の処理追加(setOnClickListener)
 * 履歴画面を呼び出します。
 
```Kotlin:MainActivity
        // 履歴参照
        btn_history.setOnClickListener(){
            startActivity<LocationList>()
        }
```

##位置情報取得処理(GpsBackgroundService2)

* バックグラウンド処理担当として、「GpsBackgroundService2」というサービスクラスを追加しました。ここで、位置情報を取得する処理を記述します。
* サービス追加なので、AndroidManifestに以下のようにserviceタグを追加しました。

```xml:AndroidManifest.xml
        <service
            android:name=".GpsBackgroundService2"
            android:exported="false"
            android:foregroundServiceType="location" />
    </application>

</manifest>
```

* 内容は以下になります。
* 初めにonStartCommandが実行されます。
* 通知チャネルを作ります。通信チャネルと作ると、画面上部に通知ができます。調べた結果、Android8.1以上は必須とのこと。([ご参考](https://developer.android.com/training/notify-user/channels?hl=ja))
* ThreadでstartGpsJobを実行します。startGpsJobでは、更新間隔を設定したり、ハンドラをスタートしたりします。
* 更新間隔は5～10秒にしました。更新のたびにAppEngineにデータを送信します。
* 更新はstartLocationUpdates()で行います。requestLocationUpdatesの中でthis.mainLooperを設定しないと、1回位置情報を取得したら終わりという悲しい結果になります。大変、ハマりました(1日くらい)。
* GCPへのデータ送信は、GAE(AppEngine)に位置情報をPostする形で実現しています。(setCallBack→sendGPS)直接Pub/Subにpublishすることも検討しましたが、keyが必要になりそうだったのでGAEを間に挟むことにしました。
* また、呼び出し画面(MainActivity)で停止ボタンが押下されると、onDestroyが呼び出されて位置情報取得処理が終了します。

```Kotlin:GpsBackgroundService2
package com.example.gpstest

import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.location.Location
import android.os.Build
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.github.kittinunf.fuel.httpPost
import com.google.android.gms.location.*
import com.google.android.gms.tasks.Task
import java.text.SimpleDateFormat
import java.util.*

class GpsBackgroundService2() : Service() {

    private lateinit var fusedLocationClient: FusedLocationProviderClient
    private lateinit var locationCallback: LocationCallback
    lateinit var settingsClient: SettingsClient

    private val GAE_URL :String = "<GAEのURL>/test2"
    private var locationRequest: LocationRequest?  = null

    private var myid : Long? = -1L

    override fun onBind(intent: Intent?): IBinder? {
        TODO("Not yet implemented")
    }
    @TargetApi(Build.VERSION_CODES.O)
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

        // Android8.1以降では独自の通知チャネルを作成する必要があるらしいので
        val channelId =
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                createNotificationChannel("gps_service","GPS Service")
            } else {
                ""
            }

        val notificationBuilder = Notification.Builder(this,channelId)
        val notification = notificationBuilder.setOngoing(true)
            .setSmallIcon(R.mipmap.ic_launcher)
            .setCategory(Notification.CATEGORY_SERVICE)
            .build()
        this.myid = intent?.getLongExtra("id",-1)

        // スレッドで開始する！
        Thread(
            Runnable {
                startGpsJob(intent)
            }).start()

        startForeground(1, notification)

        return START_STICKY
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun createNotificationChannel(channelId: String, channelName: String): String{
        val chan = NotificationChannel(channelId,channelName, NotificationManager.IMPORTANCE_NONE)
        chan.lightColor = Color.BLUE
        chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
        val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        service.createNotificationChannel(chan)
        return channelId
    }

    // -----------------------------------
    //  GPS処理開始
    // -----------------------------------
    private fun startGpsJob(intent: Intent?) {
        // 位置情報サービスクライアントを作成する
        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)

        // callbackの設定
        setCallBack()

        val dataString = intent?.dataString
        Log.d("GpsBackgroundService", "MSG: ${intent?.getStringExtra("MSG")}")

        this.locationRequest = LocationRequest.create()?.apply {
            // 更新間隔(アプリが現在地の更新情報を受信する頻度をミリ秒単位で設定)
            interval = 10000

            // 最短更新間隔(アプリが現在地の更新情報を処理できる最高頻度をミリ秒単位で設定。この例では5秒間隔。)
            fastestInterval = 5000

            // 優先度(精度を調整可能。大雑把なほうが省電力)
            priority = LocationRequest.PRIORITY_HIGH_ACCURACY

        }
        val builder = this.locationRequest?.let {
            LocationSettingsRequest.Builder().addLocationRequest(
                it
            )
        }

        settingsClient = LocationServices.getSettingsClient(this)
        val task: Task<LocationSettingsResponse> = settingsClient.checkLocationSettings(builder?.build())

        task.addOnSuccessListener { locationSettingsResponse ->
            fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper())
        }
        Log.d("GpsBackgroundService","location-interval="+this.locationRequest?.interval.toString())

        // ハンドラースタート
        startLocationUpdates()
    }

    private fun setCallBack() {
        locationCallback = object : LocationCallback() {
            override fun onLocationResult(locationResult: LocationResult?) {
                locationResult ?: return
                for (location in locationResult.locations){
                    Log.d("GpsBackgroundService", "onHandleIntent()-kousin : "
                            + myid.toString() + ","                // ID
                            + location?.longitude.toString() + "," // 経度
                            + location?.latitude.toString() + ","  // 緯度
                            + location?.altitude.toString() + ","  // 高度
                            + Common.getToday())
                    sendGPS(location)
                }
            }
        }
    }

    // ハンドラー
    private fun startLocationUpdates() {
        Log.d("GpsBackgroundService", "startLocationUpdates()-start")
        fusedLocationClient.requestLocationUpdates(this.locationRequest,
            locationCallback,
            this.mainLooper /* Looper */) // Looperを指定しないと一回で終わってしまうので注意。
        Log.d("GpsBackgroundService", "startLocationUpdates()-end")
    }

    private fun sendGPS(location : Location) {
        // ちゃんと取得できていればGAEにPOSTする。
        // PubSubのクライアントはGAE側で定義する
        if (location != null) {
            // Postで送信
            val response1 = GAE_URL.httpPost(
                listOf(
                    "id" to this.myid.toString(), // 経度
                    "longitude" to location?.longitude.toString(), // 経度
                    "latitude" to location?.latitude.toString(),   // 緯度
                    "altitude" to location?.altitude.toString(),   // 高度
                    "dt" to Common.getToday()
                )
            ).response { request, response, result ->
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d("GpsBackgroundService", "終了")
        fusedLocationClient.removeLocationUpdates(locationCallback);
    }
}
```


# 履歴画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568280/690c5265-1927-ffe6-997b-a1fb1084d683.png)

* ListViewコントロールを使って、Realmに登録済みのデータを一覧表示します。
* RealmとListViewコントロールは、RalmのAdapterを使って通信します。(LocationAdapter)
* 対象の履歴をタップすると、ブラウザが開いてGoogleMapを表示します。
* GoogleMapでは、BigQueryに登録済みの対象情報の位置情報を表示します。
* Mapの処理はデータ取得部分はPython、クライアントはJavaScriptで実装しています。※また別途記載します
* ソースは以下になります。

```kotlin:LocationList(履歴画面)
package com.example.gpstest

import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import io.realm.Realm
import io.realm.Sort
import io.realm.kotlin.where
import kotlinx.android.synthetic.main.activity_location_list.*

class LocationList : AppCompatActivity() {
    private lateinit var realm: Realm
    private val GAE_URL :String = "<GAEのURL>/showmap"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_location_list)
        realm = Realm.getDefaultInstance()
        var locdatas = realm.where<LocationData>().findAll()
        locdatas = locdatas.sort("id", Sort.DESCENDING)
        listView.adapter = LocationAdapter(locdatas)

        // listViewがクリックlされたときは地図を表示するよ！
        listView.setOnItemClickListener { parent,view,position,id ->
            val locdata:LocationData = parent.getItemAtPosition(position) as LocationData

            // 地図表示
            var uri = Uri.parse(GAE_URL + "?id=" + locdata.id)
            var intent_map : Intent = Intent(Intent.ACTION_VIEW,uri)
            startActivity(intent_map)
        }
    }
}
```

* 以下、Adapterの内容です。

```Kotlin:LocationAdapter
package com.example.gpstest

import android.text.format.DateFormat
import android.text.format.DateFormat.format
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import io.realm.OrderedRealmCollection
import io.realm.RealmBaseAdapter

class LocationAdapter(data: OrderedRealmCollection<LocationData>?) : RealmBaseAdapter<LocationData>(data) {
    inner class ViewHolder(cell: View) {
        val date = cell.findViewById<TextView>(android.R.id.text1)
        val title = cell.findViewById<TextView>(android.R.id.text2)
    }
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val view: View
        val viewHolder: ViewHolder

        when (convertView) {
            null ->{
                val inflater = LayoutInflater.from(parent?.context)
                view = inflater.inflate(android.R.layout.simple_list_item_2,parent,false)
                viewHolder = ViewHolder(view)
                view.tag = viewHolder
            }
            else -> {
                view = convertView
                viewHolder = view.tag as ViewHolder
            }
        }
        adapterData?.run {
            val locdata = get(position)
            viewHolder.date.text = locdata.startdate + "～" + locdata.enddate
            viewHolder.title.text = locdata.title
        }
        return view
    }
}
```

## 感想

* Android Studioがいい感じで補完してくれるのでコードを書くのは楽でした。
* 次はもう少し機能を増やしたいです。