Geofence
これでぽけもんごを超えるアプリをリリースするのも目じゃねえなあ。
AndroidでGeofenceを実装しようと思いましたが、親切に解説してくれているサイトが割と少なく、また公式ドキュメントもなんだかわかり辛いので記事にすることにしました。
本記事で作成するアプリはこんな感じ
current latitude、current longitudeに自身の現在位置の緯度、経度を表示し、latitude、longitudeにはgeofenceとして登録する緯度、経度を入力します。radiusは半径です。
※黒い箇所は本来自身の座標、設定した座標が表示されますが私の住所がバレバレになってしまうので塗りつぶしています。
また、サンプルアプリはめんどくさかったので全く例外をハンドリングしていません。緯度、経度にテキトーな値を入力したりするとアプリが落ちます。まぁ愛嬌愛嬌…
アプリを動作させる際は設定アプリから権限の位置情報をONにしておいてください。
実装できた際は下記フローで操作してください。
1.latitude入力
2.longitude入力
3.radius入力
4.SERVICE STARTボタン押下
5.領域に入るまでウロウロ
1.AndroidManifest.xmlの設定
GPSで位置情報を取得するためパーミッションの設定が必要になります。
また、Geofenceの核部分及び領域侵入時の処理をServiceという形で実装するために、Serviceクラスを登録しておきます。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="jp.jackall.geofencesample">
<!-- Permissionの設定 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Serviceクラス -->
<service android:name=".ui.service.GeofenceService" />
<service android:name=".ui.service.GeofenceTransitionsIntentService" />
</application>
</manifest>
2.レイアウトファイル
ここは読み飛ばしてもらって結構です。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainFragment">
<TextView
android:id="@+id/result_view"
android:layout_width="match_parent"
android:layout_height="150dp"
android:gravity="center"
android:textSize="30sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/current_latitude_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/result_view" >
<TextView
android:id="@+id/current_latitude_label"
android:text="current latitude:"
android:textSize="20sp"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"/>
<TextView
android:id="@+id/current_latitude"
android:textSize="20sp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:id="@+id/current_longitude_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="5dp"
app:layout_constraintTop_toBottomOf="@id/current_latitude_layout" >
<TextView
android:id="@+id/current_longitude_label"
android:text="current longitude:"
android:textSize="20sp"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"/>
<TextView
android:id="@+id/current_longitude"
android:textSize="20sp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:id="@+id/latitude_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="5dp"
app:layout_constraintTop_toBottomOf="@id/current_longitude_layout">
<TextView
android:id="@+id/latitude_label"
android:text="latitude:"
android:textSize="20sp"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"/>
<EditText
android:id="@+id/latitude_edit"
android:textSize="20sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</LinearLayout>
<LinearLayout
android:id="@+id/longitude_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/latitude_layout">
<TextView
android:id="@+id/longitude_label"
android:text="longitude:"
android:textSize="20sp"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp" />
<EditText
android:id="@+id/longitude_edit"
android:textSize="20sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</LinearLayout>
<LinearLayout
android:id="@+id/radius_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/longitude_layout">
<TextView
android:id="@+id/radius_label"
android:text="radius:"
android:textSize="20sp"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp" />
<EditText
android:id="@+id/radius_edit"
android:textSize="20sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</LinearLayout>
<Button
android:id="@+id/start_button"
android:text="Service Start"
android:layout_width="150dp"
android:layout_height="wrap_content"
android:layout_marginTop="230dp"
android:layout_marginLeft="20dp"
app:layout_constraintLeft_toLeftOf="@id/current_latitude_layout"
app:layout_constraintTop_toBottomOf="@+id/current_latitude_layout" />
<Button
android:id="@+id/stop_button"
android:text="Service Stop"
android:layout_width="150dp"
android:layout_height="wrap_content"
android:layout_marginTop="230dp"
android:layout_marginRight="20dp"
app:layout_constraintRight_toRightOf="@id/current_latitude_layout"
app:layout_constraintTop_toBottomOf="@+id/current_latitude_layout" />
</android.support.constraint.ConstraintLayout>
3.緯度、経度取得およびGeofenceを登録するサービスの作成
本アプリの核部分1つめ
LocationService.APIを使用した緯度、経度の取得とGeofencingClientを使用したGeofenceの登録を行います。
package jp.jackall.geofencesample.ui.service
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.widget.Toast
import com.google.android.gms.common.api.GoogleApiClient
import com.google.android.gms.location.Geofence
import com.google.android.gms.location.GeofencingClient
import com.google.android.gms.location.GeofencingRequest
import com.google.android.gms.location.LocationServices
import jp.jackall.geofencesample.ui.main.MainFragment
class GeofenceService: Service(), GoogleApiClient.ConnectionCallbacks {
private lateinit var googleApiClient: GoogleApiClient
private lateinit var geofencingClient: GeofencingClient
private lateinit var pendingIntent: PendingIntent
private var latitude: Double = 0.0
private var longitude: Double = 0.0
private var radius: Float = 0F
companion object {
const val LATITUDE_KEY = "latitude"
const val LONGITUDE_KEY = "longitude"
const val RADIUS_KEY = "radius"
}
override fun onCreate() {
super.onCreate()
// LocationServices.APIを使用するためのGoogleApiClientインスタンスの生成
googleApiClient = GoogleApiClient.Builder(this)
.addApi(LocationServices.API)
.addConnectionCallbacks(this)
.build()
googleApiClient.connect()
// Geofenceを登録するためのGeofencingClientインスタンスの生成
geofencingClient = LocationServices.getGeofencingClient(this)
Toast.makeText(this, "start service", Toast.LENGTH_SHORT).show()
Log.d("GeofenceService", "GeofenceService Create")
}
override fun onDestroy() {
super.onDestroy()
// LocationService.APIを切断する
googleApiClient.disconnect()
// 登録したGeofenceを削除する
geofencingClient.removeGeofences(pendingIntent)?.run {
addOnSuccessListener {
Log.d("GeofenceService", "removeGeofences addOnSuccessListener")
}
addOnFailureListener {
Log.d("GeofenceService", "removeGeofences addOnFailureListener")
}
}
Toast.makeText(this, "done service", Toast.LENGTH_SHORT).show()
Log.d("GeofenceService", "GeofenceService Destroy")
}
// activity.startService(Intent)でこのメソッドが呼ばれる
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("GeofenceService", "onStartCommand")
// 入力した緯度、経度、半径をmainfragmentから受け取り、プロパティに設定する。
intent?.let {
this.latitude = it.getDoubleExtra(GeofenceService.LATITUDE_KEY, 0.0)
this.longitude = it.getDoubleExtra(GeofenceService.LONGITUDE_KEY, 0.0)
this.radius = it.getFloatExtra(GeofenceService.RADIUS_KEY, 0F)
}
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent?): IBinder {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
@SuppressLint("MissingPermission")
private fun addGeofence(latitude: Double, longitude: Double, radius: Float) {
// Geofence登録処理
Log.d("GeofenceService", latitude.toString())
Log.d("GeofenceService", longitude.toString())
Log.d("GeofenceService", radius.toString())
var builder = Geofence.Builder()
builder.apply {
setRequestId("geofence_sample")
setCircularRegion(latitude, longitude, radius)
setExpirationDuration(Geofence.NEVER_EXPIRE)
setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT)
}
val geofences = ArrayList<Geofence>()
geofences.add(builder.build())
// Geofenceに対するイベント処理をPendingIntentとして登録する。
val intent = Intent(this, GeofenceTransitionsIntentService::class.java)
intent.putExtra(GeofenceTransitionsIntentService.TAG, MainFragment.BROADCAST_ACTION)
val req = GeofencingRequest.Builder()
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
.addGeofences(geofences)
.build()
pendingIntent = PendingIntent.getService(this, 0, intent, PendingIntent. FLAG_UPDATE_CURRENT);
geofencingClient.addGeofences(req, pendingIntent)?.run {
addOnSuccessListener {
Log.d("GeofenceService", "addGeofences addOnSuccessListener")
}
addOnFailureListener {
Log.d("GeofenceService", "addGeofences addOnFailureListener")
it.printStackTrace()
}
}
}
// googleApiClient.connect()でこのメソッドも呼ばれる
override fun onConnected(p0: Bundle?) {
Log.d("GeofenceService", "GoogleApiClient Connect")
addGeofence(this.latitude, this.longitude, this.radius)
}
override fun onConnectionSuspended(p0: Int) {
Log.d("GeofenceService", "GoogleApiClient disConnect")
}
}
4.イベントをハンドリングするサービスを作成
本アプリの核部分2つめ
登録したGeofence内に入ったときをハンドリングし各々処理を行います。本アプリではMainFragmentへ通知しています。
package jp.jackall.geofencesample.ui.service
import android.app.IntentService
import android.content.Intent
import android.util.Log
import android.widget.Toast
import com.google.android.gms.location.Geofence
import com.google.android.gms.location.GeofencingEvent
class GeofenceTransitionsIntentService: IntentService("GeofenceTransitionsIntentService") {
companion object {
const val TAG = "TAG"
}
private lateinit var result: String
override fun onHandleIntent(intent: Intent?) {
val sendTarget = Intent(intent?.getStringExtra(TAG))
val geofenceingEvent = GeofencingEvent.fromIntent(intent)
val geofenceTransition = geofenceingEvent.geofenceTransition
when(geofenceTransition) {
// 入ったとき
Geofence.GEOFENCE_TRANSITION_ENTER -> {
Log.d("GeofenceService", "enter")
result = "enter"
// 通知
sendBroadcast(sendTarget)
}
// 出たとき
Geofence.GEOFENCE_TRANSITION_EXIT -> {
Log.d("GeofenceService", "exit")
result = "exit"
}
}
}
override fun onDestroy() {
Toast.makeText(this, this.result, Toast.LENGTH_SHORT).show()
super.onDestroy()
}
}
5.GeofenceTransitionsIntentServiceから送信される通知をMainFragmentで受け取り任意の処理を行う。
これまでの実装でせっかくGeofence内に入ったことを検出できるようになったのに、Toastを表示させるだけなんかじゃ全く役に立たないので、MainFragmentで通知を受け取り任意の処理を行います。
本アプリではresult_viewをQiita色に塗りつぶし、ENTER!!!という文字列を表示させています。
package jp.jackall.geofencesample.ui.main
import android.Manifest
import android.arch.lifecycle.ViewModelProviders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.LOCATION_SERVICE
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.content.PermissionChecker.checkSelfPermission
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import jp.jackall.geofencesample.R
import jp.jackall.geofencesample.ui.service.GeofenceService
import kotlinx.android.synthetic.main.main_fragment.*
class MainFragment : Fragment(), View.OnClickListener, LocationListener {
companion object {
fun newInstance() = MainFragment()
const val BROADCAST_ACTION = "jp.jackall.geofencesample.ui.main.broadcast"
}
private lateinit var viewModel: MainViewModel
private lateinit var rootView: View
// 通知受け取り
private val receiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
result_view.text = "ENTER!!!"
result_view.setBackgroundColor(Color.GREEN)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
val locationManager = activity?.getSystemService(LOCATION_SERVICE) as LocationManager
activity?.applicationContext?.let {
if (checkSelfPermission(it, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 1F, this)
}
}
return inflater.inflate(R.layout.main_fragment, container, false)
}
override fun onResume() {
super.onResume()
// レシーバの登録
activity?.registerReceiver(receiver, IntentFilter(BROADCAST_ACTION))
}
override fun onPause() {
super.onPause()
// レシーバの削除
activity?.unregisterReceiver(receiver)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
start_button.setOnClickListener(this)
stop_button.setOnClickListener(this)
}
override fun onClick(v: View?) {
v?.let {
when(it.id) {
R.id.start_button ->
this.activity?.let {
result_view.text = ""
result_view.setBackgroundColor(Color.GRAY)
val intent = Intent(this.context, GeofenceService::class.java)
intent.putExtra(GeofenceService.LATITUDE_KEY, latitude_edit.text.toString().toDouble())
intent.putExtra(GeofenceService.LONGITUDE_KEY, longitude_edit.text.toString().toDouble())
intent.putExtra(GeofenceService.RADIUS_KEY, radius_edit.text.toString().toFloat())
it.startService(intent)
}
R.id.stop_button ->
this.activity?.let {
it.stopService(Intent(this.context, GeofenceService::class.java))
}
else -> {
}
}
}
}
override fun onLocationChanged(location: Location?) {
location?.let {
current_latitude.text = it.latitude.toString()
current_longitude.text = it.longitude.toString()
}
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
}
override fun onProviderEnabled(provider: String?) {
}
override fun onProviderDisabled(provider: String?) {
}
}
余談
色々調べていると半径は最低でも100mまでしか設定できない。100m未満を設定しても100mで判定される。というような記述がされているサイトがありましたが詳しく検証できていません。
以上です。