AndroidアプリでForeground Serviceを使って、画面スリープ状態でも位置情報を定期取得する

Androidアプリがバックグラウンドいるとき(スリープ状態のとき)でも位置情報を取得するためには、Foreground Serviceを使う必要があります。 これを実際に試してみました。
2020.03.24

Android 8.0から、バックグラウンドアプリが位置情報を取得する回数に制限が制限されています。 アプリがフォアグラウンドにいる場合は問題ありませんが、この制限を回避する方法の一つは、Foreground Serviceを使うことです。

アプリがバックグラウンドいるとき(スリープ状態のとき)でも位置情報を取得したくなったため、サンプルアプリを作って試してみました。

環境

  • Android Studio 3.6
  • Pixcel 3a (Android 10)

サンプルアプリを作ってみる

プロジェクトの新規作成

Empty Activityを選択して作成します。

Empty Activityでプロジェクトを新規作成する

Google Play Serviceライブラリの追加

app/build.gradlecom.google.android.gms:play-services-location:17.0.0を追加します。

build.gradle

dependencies {
    ...

    implementation 'com.google.android.gms:play-services-location:17.0.0'
}

Google Play Servicesの最新バージョンは下記で確認できます。

パーミッションの付与

AndroidManifest.xmlに下記3つのパーミッションを追加します。

  • ACCESS_COARSE_LOCATION
  • ACCESS_FINE_LOCATION
  • ACCESS_BACKGROUND_LOCATION

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="jp.test.tryforegroundservicesample">

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

    <application
        ...
    </application>
</manifest>

位置情報のパーミッションをリクエストする

ユーザに対して、アプリが位置情報を利用する旨を確認するため、requestPermission()などを追加します。

MainActivity.kt

class MainActivity : AppCompatActivity() {
    companion object {
        private const val PERMISSION_REQUEST_CODE = 1234
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        requestPermission()
    }

    private fun requestPermission() {
        val permissionAccessCoarseLocationApproved =
            ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
                    PackageManager.PERMISSION_GRANTED &&
                    ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
                    PackageManager.PERMISSION_GRANTED

        if (permissionAccessCoarseLocationApproved) {
            val backgroundLocationPermissionApproved = ActivityCompat
                .checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
                    PackageManager.PERMISSION_GRANTED

            if (backgroundLocationPermissionApproved) {
                // フォアグラウンドとバックグランドのバーミッションがある
            } else {
                // フォアグラウンドのみOKなので、バックグラウンドの許可を求める
                ActivityCompat.requestPermissions(this,
                    arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
                    PERMISSION_REQUEST_CODE
                )
            }
        } else {
            // 位置情報の権限が無いため、許可を求める
            ActivityCompat.requestPermissions(this,
                arrayOf(
                    Manifest.permission.ACCESS_COARSE_LOCATION,
                    Manifest.permission.ACCESS_FINE_LOCATION,
                    Manifest.permission.ACCESS_BACKGROUND_LOCATION
                ),
                PERMISSION_REQUEST_CODE
            )
        }
    }
}

Foreground Serviceとして動くServiceを実装

ForegroundServiceとして動くLocationService.ktを新規作成します。サービスをフォアグラウンドで実行するstartForeground()を呼んでいます。その際に通知情報を渡す必要があるため、通知情報も作成しています。

LocationService.kt

import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.*

class LocationService : Service() {
    companion object {
        const val CHANNEL_ID = "777"
    }

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

    override fun onCreate() {
        super.onCreate()
        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        var updatedCount = 0
        locationCallback = object : LocationCallback() {
            override fun onLocationResult(locationResult: LocationResult?) {
                locationResult ?: return
                for (location in locationResult.locations){
                    updatedCount++
                    Log.d(this.javaClass.name, "[${updatedCount}] ${location.latitude} , ${location.longitude}")
                }
            }
        }

        val openIntent = Intent(this, MainActivity::class.java).let {
            PendingIntent.getActivity(this, 0, it, 0)
        }
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle("位置情報テスト")
            .setContentText("位置情報を取得しています...")
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setContentIntent(openIntent)
            .build()

        startForeground(9999, notification)

        startLocationUpdates()

        return START_STICKY
    }


    override fun onBind(p0: Intent?): IBinder? {
        return null
    }

    override fun stopService(name: Intent?): Boolean {
        return super.stopService(name)
        stopLocationUpdates()
    }

    override fun onDestroy() {
        super.onDestroy()
        stopLocationUpdates()
        stopSelf()
    }

    private fun startLocationUpdates() {
        val locationRequest = createLocationRequest() ?: return
        fusedLocationClient.requestLocationUpdates(
            locationRequest,
            locationCallback,
            null)
    }

    private fun stopLocationUpdates() {
        fusedLocationClient.removeLocationUpdates(locationCallback)
    }

    private fun createLocationRequest(): LocationRequest? {
        return LocationRequest.create()?.apply {
            interval = 10000
            fastestInterval = 5000
            priority = LocationRequest.PRIORITY_HIGH_ACCURACY
        }
    }
}

AndroidManifestファイルにForeground Service権限とサービスクラス名を追加

AndroidManifest.xmlに下記を追加します。

  • FOREGROUND_SERVICEの権限(APIレベル28以上で必要)
  • 作成したサービスクラス名

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="jp.test.tryforegroundservicesample">

    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <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" />

    <application
        <activity android:name=".MainActivity">
            ...
        </activity>

        <service android:name=".LocationService" />
    </application>
</manifest>

通知先チャンネルを作成

Foreground Serviceが動いていることをユーザに示すための通知を表示しなければなりません。この通知先チャンネルを作成します。(APIレベル26以上で必要)

MainActivity.ktcreateNotificationChannel()を実装し、onCreate()から呼び出します。

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    requestPermission()

    createNotificationChannel()
}

private fun requestPermission() {
    ...
}

private fun createNotificationChannel() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val channel = NotificationChannel(
            LocationService.CHANNEL_ID,
            "お知らせ",
            NotificationManager.IMPORTANCE_DEFAULT).apply {
            description = "お知らせを通知します。"
        }
        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.createNotificationChannel(channel)
    }
}

開始ボタンと終了ボタンを作成

activity_main.xmlに開始ボタンと終了ボタンを作成します。

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/startButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Start"
        app:layout_constraintBottom_toTopOf="@id/finishButton"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    <Button
        android:id="@+id/finishButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Finish"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/startButton" />

</androidx.constraintlayout.widget.ConstraintLayout>

開始&終了ボタンの選択時にForeground Serviceを開始&終了する

MainActivity.ktに開始ボタンと終了ボタン選択時の動作を追加します。

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    requestPermission()

    createNotificationChannel()

    startButton.setOnClickListener {
        val intent = Intent(this, LocationService::class.java)
        startForegroundService(intent)
    }

    finishButton.setOnClickListener {
        val intent = Intent(this, LocationService::class.java)
        stopService(intent)
    }
}

動作確認

ビルド&起動

アプリをビルドして起動します。起動すると位置情報を取得するか聞かれるため、「常に許可」を選択します。

アプリに位置情報へのアクセス権限を許可する

作成した「START」ボタンと「FINISH」が表示されています。

STARTボタンとFINISHボタンが表示されている

位置情報の定期取得を開始する

「START」ボタンを押して、Foreground Serviceで位置情報の定期取得を開始します。 通知画面には、位置情報を取得している旨の通知が常時表示されています。

通知画面の様子

そして、次のようなログがバッチリ出力されています!

[1] 35.xxxx , 139.yyyy
[2] 35.xxxx , 139.yyyy
[3] 35.xxxx , 139.yyyy
...
[30] 35.xxxx , 139.yyyy
[31] 35.xxxx , 139.yyyy
[32] 35.xxxx , 139.yyyy

Androidアプリを次の状態にしても、ログは定期的に表示されていました。

  • 画面に作成したアプリが表示されている状態
  • 画面に別のアプリが表示されている状態
  • 画面スリープ状態

位置情報の定期取得を終了する

「FINISH」ボタンを押すだけです。通知画面から表示も消えます。

さいごに

Foreground Serviceの作り方が分かりました。 公式ドキュメントの読解方法もなんとなく掴めてきたような気がします……。 どなたかの参考になれば幸いです。

参考