🐷

Jetpack Compose でアプリ・デバイスの位置情報権限を扱う方法を考える

2022/11/26に公開

はじめに

この記事は AccompanistJetpack Compose Permissions を使って位置情報権限の Runtime permissionsデバイスの位置情報の権限を扱う方法についてまとめています.

位置情報の権限許諾は Android 12 以降では おおよその位置情報 もシステムのダイアログで表示されるようになりました.
既存の rememberLauncherForActivityResult を用いて実装を行うと少し複雑になりますが Accompanist の Jetpack Compose Permissions を使うとシンプルに記述できます.

しかし位置情報は Runtime permissions に加えデバイスの位置情報も考慮する必要があり, 今回は Jetpack Compose Permissions をラップする State クラスを作成することでデバイスの位置情報にも対応する方法について考えてみました.

また本記事ではフォアグラウンドの位置情報の権限許諾についてのみに触れているので位置情報の権限許諾を用いた位置情報の取得などの操作については Request location permissions 等の公式のドキュメントを参考にしてください.

ソースコード

今回紹介する実装のソースコードは次のリポジトリで公開しています.

https://github.com/mona-apk/ComposeLocationPermissionSample

今回の実装例ではアプリとデバイスの位置情報の許諾状況に応じてテキスト・ボタンを押した時の動きが変わるアプリになっています.

環境

検証時のライブラリのバージョンを表にまとめておきます.

Jetpack Compose Permissions は Accompanist の一種であるのでもれなく @ExperimentalPermissionsApi が必要で将来的に実装が変更される可能性があります.
(特に Jetpack Compose Permissions はバージョンによって実装が大きく異なるので注意が必要です.)

com.google.android.gms:play-services-location はデバイスの位置情報をよしなに扱うために導入します.

ライブラリ バージョン
com.google.accompanist:accompanist-permissions 0.27.1
com.google.android.gms:play-services-location 21.0.1
build.gradle.kts
dependencies {

    implementation 'com.google.accompanist:accompanist-permissions:0.27.1'
    implementation 'com.google.android.gms:play-services-location:21.0.1'
}

位置情報の権限を Compose で扱う

位置情報の権限許諾をCompose で行う実装について紹介していきます.

公式のドキュメントでは Android 12 以上ではおおよそな位置情報のみの権限をリクエストすることが推奨 されていますが正確な位置情報をリクエストすることを想定した実装について考えます.

Manifest ファイルの記述

最初に AndroidManifest.xml に権限を記述します.

位置情報の権限は

の2つが用意されているので次の通りに AndroidManifest.xml に permission を追加します.

AndroidManifest.xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
補足

補足ですが, 先述した通りAndroid 12 以降ではユーザーは位置情報の権限許諾の時に おおよそな位置情報のみを許諾できる ようになっておりユーザーのプライバシーを尊重しておおよそな位置情報のみのリクエストをすることが強く推奨されています.

そのため Android 12 以降で正確な位置情報を扱う場合アプリ側でおおよその位置情報を扱いたくない場合でも 正確 (ACCESS_FINE_LOCATION) に加えて おおよそ (ACCESS_COARSE_LOCATION) の権限を設定する必要があります.

アプリ側でおおよそな位置情報でもユースケースとして問題ない場合は次のように おおよそ (ACCESS_COARSE_LOCATION) のみを設定することもできます.

AndroidManifest.xml
<!-- OK: アプリ側での位置情報のユースケースがおおよその位置情報でも問題ない場合は ACCESS_COARSE_LOCATION のみを追加する -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
AndroidManifest.xml
<!-- NG: ACCESS_FINE_LOCATION は ACCESS_COARSE_LOCATION も設定する必要がある -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

また Android 11 以下ではおおよその位置情報を利用できない (厳密には Google 位置情報の精度を下げることはできますが...) ので おおよそ (ACCESS_COARSE_LOCATION) のみが AndroidManifest.xml に記載・許諾されている状態でも正確な位置情報を扱うことができます.

LocationPermissionState

具体的に Jetpack Compose Permissions を使って位置情報の権限を管理する方法について紹介していきます.

Accompanist の方でも RequestLocationPermissionsSample.kt にサンプルが紹介されているので参考にしてください.

今回はデバイスの位置情報もよしなに扱いたいので Jetpack Compose Permissions をラップし位置情報周りの実装をまとめた State クラスである LocationPermissionState を作成してみます.

LocationPermissionState.kt のコード
LocationPermissionState.kt

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun rememberLocationPermissionState(
    multiplePermissionsState: MultiplePermissionsState = rememberMultiplePermissionsState(
        listOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION,
        ),
    ),
    onResult: (ActivityResult) -> Unit = {},
): LocationPermissionState {
    val context = LocalContext.current
    val windowManager = LocalWindowInfo.current

    val locationPermissionState = remember {
        MutableLocationPermissionState(
            context = context,
            multiplePermissionsState = multiplePermissionsState,
        )
    }

    LaunchedEffect(windowManager.isWindowFocused) {
        if (!windowManager.isWindowFocused) return@LaunchedEffect
        locationPermissionState.refreshDeviceLocation()
    }

    val activityResultLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartIntentSenderForResult(),
    ) { activityResult ->
        locationPermissionState.refreshDeviceLocation()
        onResult(activityResult)
    }

    DisposableEffect(locationPermissionState, activityResultLauncher) {
        locationPermissionState.activityResultLauncher = activityResultLauncher
        onDispose {
            locationPermissionState.activityResultLauncher = null
        }
    }

    return locationPermissionState
}

@Stable
interface LocationPermissionState {
    fun requestLocationPermission()
    val isLocationGranted: Boolean
    val isDeviceLocationEnabled: Boolean
    val shouldShowRationale: Boolean
    val shouldOpenLocationRequestDialog: Boolean
    fun onDismissRequest()
}

@OptIn(ExperimentalPermissionsApi::class)
@Stable
internal class MutableLocationPermissionState constructor(
    private val context: Context,
    private val multiplePermissionsState: MultiplePermissionsState,
) : LocationPermissionState {
    internal var activityResultLauncher: ActivityResultLauncher<IntentSenderRequest>? = null

    override fun requestLocationPermission() {
        when {
            isFineLocationGranted() || isCoarseLocationGranted() -> {
                if (isLocationEnabled()) return

                showDeviceLocationRequestDialog()
            }
            multiplePermissionsState.shouldShowRationale -> {
                shouldOpenLocationRequestDialog = true
            }
            else -> {
                multiplePermissionsState.launchMultiplePermissionRequest()
            }
        }
    }

    override val isLocationGranted by derivedStateOf {
        multiplePermissionsState.permissions.any { permissionState ->
            permissionState.status.isGranted
        } || multiplePermissionsState.revokedPermissions.isEmpty()
    }

    override var isDeviceLocationEnabled: Boolean by mutableStateOf(isLocationEnabled())

    internal fun refreshDeviceLocation() {
        isDeviceLocationEnabled = isLocationEnabled()
    }

    override val shouldShowRationale
        get() = multiplePermissionsState.shouldShowRationale

    override var shouldOpenLocationRequestDialog: Boolean by mutableStateOf(false)

    override fun onDismissRequest() {
        shouldOpenLocationRequestDialog = false
    }

    private fun isFineLocationGranted(): Boolean {
        return ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    }

    private fun isCoarseLocationGranted(): Boolean {
        return ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.ACCESS_COARSE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
    }

    private fun isLocationEnabled(): Boolean {
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
        return LocationManagerCompat.isLocationEnabled(locationManager)
    }

    private fun showDeviceLocationRequestDialog() {
        val builder = LocationSettingsRequest.Builder().addLocationRequest(
            LocationRequest.Builder(0L).build()
        )

        val client: SettingsClient = LocationServices.getSettingsClient(context)
        val task: Task<LocationSettingsResponse> = client.checkLocationSettings(builder.build())

        task.addOnFailureListener { exception ->
            if (exception is ResolvableApiException) {
                try {
                    val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
                    activityResultLauncher?.launch(intentSenderRequest) ?: error("ActivityResultLauncher cannot be null")
                } catch (_: IntentSender.SendIntentException) {
                }
            }
        }
    }
}

呼び出し側の Composable では rememberLocationPermissionState() によって LocationPermissionState を取得し, LocationPermissionState の各種の位置情報の権限を表す State を監視することで位置情報の権限の状態に応じて UI や処理のハンドリングを行うことができます.

全体的にコードを簡単に説明していきます.

LocationPermissionState inerface

LocationPermissionState のコード
LocationPermissionState

@Stable
interface LocationPermissionState {
    fun requestLocationPermission()
    val isLocationGranted: Boolean
    val isDeviceLocationEnabled: Boolean
    val shouldShowRationale: Boolean
    val shouldOpenLocationRequestDialog: Boolean
    fun onDismissRequest()
}

WindowManagerActivityResultLauncher の準備処理などを踏まえてほとんどの条件において rememberLocationPermissionState() から取得してほしいため LocationPermissionState は public に interface として公開をします.

また同時に isDeviceLocationEnabled など MutableLocationPermissionState 内部では var として保持している値を外部から操作されたくないので簡易的に interface で val としています.

MutableLocationPermissionState

MutableLocationPermissionState のコード
MutableLocationPermissionState

@OptIn(ExperimentalPermissionsApi::class)
@Stable
internal class MutableLocationPermissionState constructor(
    private val context: Context,
    private val multiplePermissionsState: MultiplePermissionsState,
) : LocationPermissionState {
    internal var activityResultLauncher: ActivityResultLauncher<IntentSenderRequest>? = null

    // 1. 位置情報のリクエストダイアログを表示させる
    override fun requestLocationPermission() {
        when {
            isFineLocationGranted() || isCoarseLocationGranted() -> {
                if (isLocationEnabled()) return

                showDeviceLocationRequestDialog()
            }
            multiplePermissionsState.shouldShowRationale -> {
                shouldOpenLocationRequestDialog = true
            }
            else -> {
                multiplePermissionsState.launchMultiplePermissionRequest()
            }
        }
    }

    // 2. アプリの位置情報の権限許諾が許諾されているかどうかを表す State
    override val isLocationGranted by derivedStateOf {
        multiplePermissionsState.permissions.any { permissionState ->
            permissionState.status.isGranted
        } || multiplePermissionsState.revokedPermissions.isEmpty()
    }

    // 3. 端末の位置情報の権限許諾が許諾されているかどうかを表す State
    override var isDeviceLocationEnabled: Boolean by mutableStateOf(isLocationEnabled())

    internal fun refreshDeviceLocation() {
        isDeviceLocationEnabled = isLocationEnabled()
    }

    // 4. 位置情報許諾のカスタムダイアログを表示させるかどうかを判断する State
    override val shouldShowRationale
        get() = multiplePermissionsState.shouldShowRationale

    override var shouldOpenLocationRequestDialog: Boolean by mutableStateOf(false)

    override fun onDismissRequest() {
        shouldOpenLocationRequestDialog = false
    }

    // 正確な位置情報が許諾されているかを確認
    private fun isFineLocationGranted(): Boolean = { /* 省略 */ }

    // おおよそな位置情報が許諾されているかを確認
    private fun isCoarseLocationGranted(): Boolean { /* 省略 */ }

    // 端末の位置情報が許諾されているかを確認
    private fun isLocationEnabled(): Boolean { /* 省略 */ }

    // 端末の位置情報をリクエストするダイアログを表示する実装
    private fun showDeviceLocationRequestDialog() {
        val builder = LocationSettingsRequest.Builder().addLocationRequest(
            LocationRequest.Builder(0L).build()
        )

        val client: SettingsClient = LocationServices.getSettingsClient(context)
        val task: Task<LocationSettingsResponse> = client.checkLocationSettings(builder.build())

        task.addOnFailureListener { exception ->
            if (exception is ResolvableApiException) {
                try {
                    val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
                    activityResultLauncher?.launch(intentSenderRequest) ?: error("ActivityResultLauncher cannot be null")
                } catch (_: IntentSender.SendIntentException) {
                }
            }
        }
    }
}

MutableLocationPermissionState は LocationPermissionState を実装させ外部にあまり公開したくないので internal の可視性で定義します (サンプルリポジトリでは意味がありませんが...).

行数が多くなってしまったのでさらに分割して説明します.

1. 位置情報のリクエストダイアログを表示させる
MutableLocationPermissionState
internal class MutableLocationPermissionState constructor(
    private val context: Context,
    // Jetpack Compose Permissions で用意されている State オブジェクト
    private val multiplePermissionsState: MultiplePermissionsState,
) : LocationPermissionState {
  // 省略

  override fun requestLocationPermission() {
      when {
          isFineLocationGranted() || isCoarseLocationGranted() -> {
              if (isLocationEnabled()) return

              // 端末の位置情報が許諾されていなければ端末の位置情報のリクエストダイアログを表示
              showDeviceLocationRequestDialog()
          }
          multiplePermissionsState.shouldShowRationale -> {
              // カスタムダイアログを表示
              shouldOpenLocationRequestDialog = true
          }
          else -> {
              // システム側のデフォルトの位置情報のダイアログを表示
              multiplePermissionsState.launchMultiplePermissionRequest()
          }
      }
  }

  // 省略
}

requestLocationPermission() はシステム側のデフォルトの位置情報のダイアログ・カスタムダイアログ・端末の位置情報のリクエストダイアログを表示する実装をまとめています.

アプリの位置情報が許諾されていても端末の位置情報が許諾されていない場合がありますが, com.google.android.gms:play-services-location を使い端末の位置情報をアプリ内で変更できるようにしています.

少々厄介なのが一度システム側のデフォルトの位置情報のダイアログで「許可しない」がタップされた時の扱いです.

Android ではシステムの権限許諾の表示頻度に制限があり, 権限許諾が拒否されている状態だと multiplePermissionsState.launchMultiplePermissionRequest() を呼び出したとしてもシステム側のデフォルトの位置情報のダイアログが表示されないことがあります.

そこでユーザーが権限許諾を拒否した場合に MultiplePermissionsState.shouldShowRationale の値をみてカスタムダイアログを表示させるようにします.
shouldShowRationale は Jetpack Compose Permissions の実装のコメントで

When true, the user should be presented with a rationale.

と, ユーザーに権限許諾が必要な根拠を示す必要があるときに true になると記載されており具体的にはシステム側のデフォルトの位置情報のダイアログで拒否した場合に true になります.

少し前までの Jetpack Compose Permissions では一度拒否されたかどうかを表す State が用意されていたのですが最新のバージョンでは実装が削除されており, shouldShowRationale の値をみてカスタムダイアログを表示する方針で統一しておくことが権限許諾のベストプラクティスRequest app permissionsを踏まえても良さそうです.

そこで今回も MultiplePermissionsState.shouldShowRationale が true の場合はアプリの設定画面への導線として次のようにユーザーに権限が必要な根拠を示すダイアログを表示させています.


ダイアログで権限が必要な根拠を示す

2. アプリの位置情報の権限許諾が許諾されているかどうかを表す State
MutableLocationPermissionState

override val isLocationGranted by derivedStateOf {
    multiplePermissionsState.permissions.any { permissionState ->
        permissionState.status.isGranted
    } || multiplePermissionsState.revokedPermissions.isEmpty()
}

isLocationGranted() はアプリ側の権限が許諾されているかどうかを表す State です.
Jetpack Compose Permissions の MultiplePermissionsState には allPermissionsGranted が用意されていますが今回はおおよその位置情報のみが許諾されている場合を可とするので元の実装を参考に少し変更しています.

3. 端末の位置情報の権限許諾が許諾されているかどうかを表す State
MutableLocationPermissionState

override var isDeviceLocationEnabled: Boolean by mutableStateOf(isLocationEnabled())

internal fun refreshDeviceLocation() {
    isDeviceLocationEnabled = isLocationEnabled()
}

isDeviceLocationEnabled() は端末側の権限が許諾されているかどうかを表す State です.
Jetpack Compose Permissions では Runtime permissions の対応は行われているのですが端末の権限についての管理を行うことはできません.

そこで今回

  1. 端末の権限の許諾状態を表す isDeviceLocationEnabled を用意
  2. 端末の権限の許諾状態の監視を MutableLocationPermissionState と rememberLocationPermissionState で行う

ということをしています.

アプリの権限の監視に関しては Jetpack Compose Permissions が内部で行っているようにライフサイクルに応じて監視を行えば良いのですがデバイスの位置情報の変更に関してはライフサイクルイベントで検知できないことがあるので注意が必要です (具体的な対応については後述します).


Quick Settings での変更はライフサイクルイベントで検知できない

4. 位置情報許諾のカスタムダイアログを表示させるかどうかを判断する State
MutableLocationPermissionState

override val shouldShowRationale
    get() = multiplePermissionsState.shouldShowRationale

override var shouldOpenLocationRequestDialog: Boolean by mutableStateOf(false)

override fun onDismissRequest() {
    shouldOpenLocationRequestDialog = false
}

今回 shouldOpenLocationRequestDialog の値を UI 側で監視し位置情報のリクエストダイアログを表示するようにしています.

Navigating with Compose でも軽く言及されていますが if 文での出し分けではなく実プロジェクトでは NavGraphBuilder.dialog() を利用する方法も良さそうです.

rememberLocationPermissionState()

次に rememberLocationPermissionState() について簡単に補足します.

rememberLocationPermissionState() のコード
rememberLocationPermissionState

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun rememberLocationPermissionState(
    multiplePermissionsState: MultiplePermissionsState = rememberMultiplePermissionsState(
        listOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION,
        ),
    ),
    onResult: (ActivityResult) -> Unit = {},
): LocationPermissionState {
    val context = LocalContext.current
    val windowManager = LocalWindowInfo.current

    val locationPermissionState = remember {
        MutableLocationPermissionState(
            context = context,
            multiplePermissionsState = multiplePermissionsState,
        )
    }

    LaunchedEffect(windowManager.isWindowFocused) {
        if (!windowManager.isWindowFocused) return@LaunchedEffect
        // バックグラウンドから復帰した場合も呼び出されるのでライフサイクルの監視を行う必要はない
        locationPermissionState.refreshDeviceLocation()
    }

    val activityResultLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartIntentSenderForResult(),
    ) { activityResult ->
        locationPermissionState.refreshDeviceLocation()
        onResult(activityResult)
    }

    DisposableEffect(locationPermissionState, activityResultLauncher) {
        locationPermissionState.activityResultLauncher = activityResultLauncher
        onDispose {
            locationPermissionState.activityResultLauncher = null
        }
    }

    return locationPermissionState
}

rememberLocationPermissionState() の主な役割は

  • MultiplePermissionsState の生成
  • MutableLocationPermissionState の生成
  • 端末の位置情報の監視

で特に 端末の位置情報の監視 の比重が大きいです.

端末の位置情報は

  • ライフサイクルイベントで検知できない Quick Settings 経由
  • com.google.android.gms:play-services-location のダイアログ経由

がありそれぞれ windowManager.isWindowFocusedactivityResultLauncher で値の更新を行っています.

Compose 側の実装

最後に Compose 側の実装について説明します.

ソースコード全体を確認したい場合はリポジトリを確認してください.


@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
fun LocationRequestScreen(modifier: Modifier = Modifier) {
    val context = LocalContext.current

    var locationStateText by remember {
        mutableStateOf("")
    }

    // 1. rememberLocationPermissionState() の呼び出し
    val locationPermissionState = rememberLocationPermissionState()

    if (locationPermissionState.shouldOpenLocationRequestDialog) {
        LocationRequestDialog(
            onConfirmClick = {
                // アプリの設定画面に遷移
                val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                val uri = Uri.fromParts("package", context.packageName, null)
                intent.data = uri
                context.startActivity(intent)
            },
            onDismissRequest = locationPermissionState::onDismissRequest,
        )
    }

    // 3. locationPermissionState の値を監視し許諾状態に応じてテキストを変更
    LaunchedEffect(
        locationPermissionState.isLocationGranted,
        locationPermissionState.shouldShowRationale,
        locationPermissionState.isDeviceLocationEnabled
    ) {
        locationStateText = when {
            locationPermissionState.isLocationGranted && locationPermissionState.isDeviceLocationEnabled -> {
                "App and device location permissions are granted."
            }
            locationPermissionState.isLocationGranted -> {
                "Only app location permission is granted."
            }
            locationPermissionState.shouldShowRationale -> {
                "The user should be presented with a rationale."
            }
            else -> {
                "TAP!"
            }
        }
    }

    Scaffold(
        modifier = modifier.systemBarsPadding(),
    ) { insetsPadding ->
        LocationRequestPage(
            locationStateText = locationStateText,
            onClick = {
                // 2. 権限許諾のリクエストダイアログを表示
                locationPermissionState.requestLocationPermission()
            },
            modifier = Modifier.padding(insetsPadding)
        )
    }
}

Compose 側では

  1. rememberLocationPermissionState() の呼び出し
  2. 権限許諾のリクエストダイアログを表示
  3. locationPermissionState の値を監視し許諾状態に応じて処理を行う

という実装が必要です.

位置情報の権限を監視することで位置情報が切り替わった時にコールバックを呼び出す必要がないのでシンプルに記述できる印象があります.

まとめ

今回は Jetpack Compose Permissions を利用してアプリと端末の位置情報の権限許諾を扱う方法について考えてみました.

Compose を実装する際に大変便利になる Accompanist ですが Accompanist 単体だと対応できない物事が多いように感じました.

Accompanist の上手い活用について Jetpack Compose をキャッチアップすると共に考えていきたいです.

参考文献

Discussion