LoginSignup
18

More than 3 years have passed since last update.

【Android】Geofenceをキャッチアップする

Last updated at Posted at 2018-10-22

Geofence

これでぽけもんごを超えるアプリをリリースするのも目じゃねえなあ。

AndroidでGeofenceを実装しようと思いましたが、親切に解説してくれているサイトが割と少なく、また公式ドキュメントもなんだかわかり辛いので記事にすることにしました。

オフィシャルなドキュメントはこちら

本記事で作成するアプリはこんな感じ
geofence1.png
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クラスを登録しておきます。

AndroidManifest.xml
<?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.レイアウトファイル

ここは読み飛ばしてもらって結構です。

main_fragment.xml
<?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の登録を行います。

GeofenceService.kt
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へ通知しています。

GeofenceTransitionsIntentService.kt
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!!!という文字列を表示させています。

MainFragment.kt
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で判定される。というような記述がされているサイトがありましたが詳しく検証できていません。

以上です。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
18