Android Runtime Permissions 權限處理

在 Android 的安全模型中,權限管理是保護使用者隱私的核心。從 Android 6.0 (API 23) 開始,系統導入了執行時期權限 (Runtime Permissions) 機制。這意味著「危險權限」不僅要在專案中宣告,還必須在 App 運行時,於真正需要該功能的那一刻向使用者請求授權。

權限的類別

Android 將權限分為幾個主要類別,這決定了你是否需要向使用者顯示授權視窗:

1. 一般權限 (Normal Permissions)

這類權限對使用者隱私風險較低。你只需在 Manifest 宣告,系統會在安裝時自動授予。

  • 範例INTERNET (網路存取), ACCESS_NETWORK_STATE (檢查連線狀態), VIBRATE (震動)。

2. 危險權限 (Dangerous Permissions)

這類權限涉及使用者私密資料或裝置核心功能。必須在執行時動態請求授權。

  • 範例CAMERA (相機), READ_CONTACTS (讀取聯絡人), ACCESS_FINE_LOCATION (精確定位)。

3. 特殊權限 (Special Permissions)

這類權限通常用於高度敏感的操作,使用者必須到「系統設定」頁面手動開啟,無法透過簡單的對話框請求。

  • 範例SYSTEM_ALERT_WINDOW (顯示在其他應用程式上層), WRITE_SETTINGS (修改系統設定)。

第一步:在 Manifest 宣告權限

無論是什麼類別的權限,第一步永遠是在 AndroidManifest.xml 中使用 <uses-permission> 標籤宣告:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 一般權限:宣告後自動獲得 -->
    <uses-permission android:name="android.permission.INTERNET" />
    
    <!-- 危險權限:宣告後仍需動態請求 -->
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    
    <application ...>
        ...
    </application>
</manifest>

第二步:請求單一權限 (Jetpack Compose)

在現代的 Compose 開發中,我們使用 rememberLauncherForActivityResult 配合 ActivityResultContracts.RequestPermission 契約來處理請求。

@Composable
fun CameraPermissionScreen() {
    val context = LocalContext.current
    
    // 1. 宣告權限請求的 Launcher
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { isGranted: Boolean ->
        if (isGranted) {
            // 使用者同意了,可執行拍照邏輯
        } else {
            // 使用者拒絕了
        }
    }

    Button(onClick = {
        // 2. 檢查目前是否已擁有權限
        val permissionCheck = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
        
        if (permissionCheck == PackageManager.PERMISSION_GRANTED) {
            // 已擁有權限,直接執行
        } else {
            // 尚未擁有,啟動請求對話框
            launcher.launch(Manifest.permission.CAMERA)
        }
    }) {
        Text("請求相機權限")
    }
}

第三步:同時請求多項權限

有時你的功能需要多個權限(例如地圖功能需要粗略定位與精確定位)。使用 RequestMultiplePermissions

val multiplePermissionsLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissionsMap ->
    val areAllGranted = permissionsMap.values.all { it }
    if (areAllGranted) {
        // 全部權限都拿到了
    } else {
        // 有部分權限被拒絕
    }
}

// 啟動方式
multiplePermissionsLauncher.launch(
    arrayOf(
        Manifest.permission.ACCESS_FINE_LOCATION,
        Manifest.permission.ACCESS_COARSE_LOCATION
    )
)

第四步:處理 Rationale (解釋理由)

根據 Google 指南,如果使用者曾經拒絕過權限,你在第二次請求前應該顯示一段「說明理由」的 UI。這時我們需要檢查 shouldShowRequestPermissionRationale

val activity = context as Activity
// 檢查是否應該向使用者解釋為什麼需要這個權限
val shouldShowRationale = activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)

if (shouldShowRationale) {
    // TODO: 顯示你自己的對話框告知使用者:
    // 「我們需要相機來掃描 QR Code,請在接下來的視窗點選同意。」
}

第五步:處理「不再詢問」與永久拒絕

如果使用者多次拒絕或勾選了「不再詢問」,系統視窗將不再出現。此時你唯一的選擇是引導使用者前往 App 系統設定頁面手動開啟。

fun openAppSettings(context: Context) {
    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
        data = Uri.fromParts("package", context.packageName, null)
    }
    context.startActivity(intent)
}

現代 Android (API 33+) 的重大變更

隨著 Android 版本演進,權限變得更加精細:

  • 通知權限 (Android 13):新增了 POST_NOTIFICATIONS 危險權限。
  • 媒體存取 (Android 13):原本的 READ_EXTERNAL_STORAGE 被拆分為更細的權限:
    • READ_MEDIA_IMAGES
    • READ_MEDIA_VIDEO
    • READ_MEDIA_AUDIO
  • 相片挑選器 (Photo Picker):對於單純選圖需求,現在建議使用 PickVisualMedia 契約,它不需要任何權限就能安全地選取相片。

UX 最佳實踐

  1. Contextual Request:不要在 App 一啟動就噴出三四個權限請求。應該在使用者點擊「拍照」或「定位」那一刻才請求。
  2. 解釋價值:如果權限是非必備的(如通知),請明確告訴使用者開啟後能得到什麼好處。
  3. 優雅降級:如果使用者堅持不給權限,App 應該能在不具備該功能的情況下繼續執行,而不是直接閃退或無法使用。
對於複雜的權限需求,目前社群仍廣泛使用 Google 的 Accompanist Permissions 庫,雖然它已進入維護模式,但其提供的宣告式 API 在 Compose 中仍然非常方便。