Android 隐私 Codelab

1. 简介

学习内容

  • 为什么隐私对于用户来说变得越来越重要。
  • 过去几个版本中 Android 的隐私最佳实践。
  • 如何将隐私最佳实践集成到现有应用中,使其更注重隐私保护。

你将构建什么

在本 Codelab 中,您将从一个示例应用开始,该应用允许用户保存他们的照片回忆。

它将从以下屏幕开始

  • 权限屏幕 - 在继续进入主屏幕之前,要求用户授予所有权限的屏幕。
  • 主屏幕 - 显示所有用户现有照片日志的屏幕,还允许他们添加新的照片日志。
  • 添加日志屏幕 - 允许用户创建新的照片日志的屏幕。在这里,用户可以浏览其库中现有的照片,使用相机拍摄新照片,并将他们当前的城市添加到照片日志中。
  • 相机屏幕 - 允许用户拍摄照片并将其保存到照片日志的屏幕。

该应用正在运行,但它存在许多隐私缺陷,我们将一起改进它们!

随着您逐步完成 Codelab,您将

  • ...了解为什么隐私对您的应用很重要
  • ...了解 Android 的隐私功能和关键最佳实践。
  • ...了解如何在现有应用中实施这些最佳实践,方法如下
  • 在上下文中请求权限
  • 减少应用的位置访问
  • 使用照片选择器和其它存储改进
  • 使用数据访问审计 API

完成后,您将拥有一个应用,该应用

  • ...实现了上述隐私最佳实践。
  • ...注重隐私保护并通过小心处理用户的私人数据来保护用户,从而增强用户体验。

你需要什么

不错的话

2. 为什么隐私很重要?

研究表明,人们对自己的隐私持谨慎态度。 皮尤研究中心进行的一项调查 发现,84% 的美国人认为他们对公司和应用收集的自己数据的控制力很小或没有控制力。他们主要感到沮丧的是,不知道除了直接使用之外,他们的数据发生了什么。例如,他们担心数据被用于其他目的,例如创建用于目标广告的档案,甚至出售给其他方。一旦数据泄露,似乎就无法删除它。

这种对隐私的担忧已经对人们使用哪些服务或应用的决定产生了重大影响。事实上,同样的皮尤研究中心研究发现,超过一半(52%)的美国成年人因为隐私问题而决定不使用某种产品或服务,例如担心收集了多少关于他们的数据。

因此,增强和展示应用的隐私对于改善应用的用户体验至关重要,研究表明,它也可能有助于您扩大用户群。

在本 Codelab 中,我们将介绍的许多功能和最佳实践都与减少应用访问的数据量或增强用户对其私人数据的控制感直接相关。这两项增强功能都直接解决了我们在早期研究中看到的用户提出的担忧。

3. 设置您的环境

为了让您尽快开始,我们为您准备了一个入门项目供您构建。在此步骤中,您将下载整个 Codelab 的代码,包括入门项目,然后在您的模拟器或设备上运行入门应用。

如果您安装了 git,则只需运行以下命令即可。要检查是否安装了 git,请在终端或命令行中键入 git –version 并验证它是否正确执行。

git clone https://github.com/android/privacy-codelab

如果您没有安装 git,则可以点击 下载链接 下载此 Codelab 的所有代码。

设置 Codelab

  1. 在 Android Studio 中打开 PhotoLog_Start 目录中的项目。
  2. 在运行 Android 12(S)或更高版本的设备或模拟器上运行 PhotoLog_Start 运行配置。

d98ce953b749b2be.png

您应该会看到一个屏幕,要求您授予权限才能运行应用!这意味着您已成功设置了环境。

4. 最佳实践:在上下文中请求权限

你们中的许多人都知道运行时权限对于解锁许多对获得出色用户体验至关重要的关键功能至关重要,但您是否知道应用何时以及如何请求权限也会对用户体验产生重大影响?

让我们看看 PhotoLog_Start 应用如何请求权限,以向您展示为什么它没有最佳的权限模型

  1. 启动后立即,用户会立即收到权限提示,要求他们授予多个权限。这可能会让用户感到困惑,最糟糕的情况可能是导致他们失去对我们应用的信任或卸载应用!
  2. 应用不允许用户继续,除非所有权限都已授予。用户在启动时可能不够信任我们的应用,以授予访问所有这些敏感信息的权限。

如您所料,以上列表代表我们将一起进行的一系列改进,以改进应用的权限请求流程!让我们开始吧。

我们可以看到,Android 的 最佳实践建议 指出,我们应该在用户第一次开始与功能交互时在上下文中请求权限。这是因为,如果应用请求权限以启用用户正在交互的功能,则该请求不会让用户感到意外。这带来了更好的用户体验。在 PhotoLog 应用中,我们应该等到用户第一次点击相机或位置按钮后再请求权限。

首先,让我们删除强制用户在进入主页之前批准所有权限的权限屏幕。此逻辑当前在 MainActivity.kt 中定义,因此让我们导航到那里

val startNavigation =
   if (permissionManager.hasAllPermissions) {
       Screens.Home.route
   } else {
       Screens.Permissions.route
   }

它会检查用户是否已授予所有权限,然后再允许他们转到主屏幕。如前所述,这并不符合我们关于用户体验的最佳实践。让我们将其更改为以下代码,使用户能够在未授予所有权限的情况下与我们的应用交互

val startNavigation = Screens.Home.route

现在我们不再需要权限屏幕了,我们还可以从 NavHost 中删除此行

composable(Screens.Permissions.route) { PermissionScreen(navController) }

接下来,从 Screens 类中删除此行

object Permissions : Screens("permissions")

最后,我们也可以删除 PermissionsScreen.kt 文件。

现在,删除并重新安装您的应用,这是一种重置先前授予的权限的方法!您现在应该能够立即转到主屏幕,但是当您在“添加日志”屏幕中按下相机或位置按钮时,什么也不会发生,因为应用不再具有向用户请求权限的逻辑。让我们修复它。

添加请求相机权限的逻辑

我们将从相机权限开始。根据我们在 请求权限文档 中看到的代码示例,我们将需要首先注册权限回调才能使用 RequestPermission() 合同。

让我们评估我们需要实现的逻辑

  • 如果用户接受权限,我们将需要使用 viewModel 注册权限,并且如果用户尚未达到已添加照片数量的限制,则导航到相机屏幕。
  • 如果用户拒绝权限,我们可以通知他们该功能无法使用,因为权限已被拒绝。

要执行此逻辑,我们可以将此代码块添加到: // TODO:步骤 1. 注册 ActivityResult 以请求相机权限

val requestCameraPermission =
   rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
       if (isGranted) {
           viewModel.onPermissionChange(CAMERA, isGranted)
           canAddPhoto {
               navController.navigate(Screens.Camera.route)
           }
       }
       else {
           coroutineScope.launch {
               snackbarHostState.showSnackbar("Camera currently disabled due to denied permission.")
           }
       }
   }

现在,我们希望在导航到相机屏幕之前验证应用是否具有相机权限,如果用户尚未授予权限,则请求权限。要实现此逻辑,我们可以将以下代码块添加到: // TODO:步骤 2. 在导航到相机屏幕之前检查和请求相机权限

canAddPhoto {
   when {
       state.hasCameraAccess -> navController.navigate(Screens.Camera.route)
       // TODO: Step 4. Trigger rationale screen for Camera if needed
       else -> requestCameraPermission.launch(CAMERA)
   }
}

现在,尝试再次运行应用,然后点击“添加日志”屏幕中的相机图标。您应该会看到一个对话框,请求相机权限。恭喜!这比在用户试用应用之前就要求他们批准所有权限要好得多,对吧?

但是,我们还能做得更好吗?是的!我们可以检查系统是否建议我们显示一个理由来解释为什么我们的应用需要访问相机。这有助于潜在地提高权限的同意率,并保留应用在更合适的时间再次请求权限的能力。

为此,让我们构建一个理由屏幕,解释为什么我们的应用需要访问用户的相机。通过将以下代码块添加到以下位置来执行此操作: // TODO:步骤 3. 添加相机权限的解释对话框

var showExplanationDialogForCameraPermission by remember { mutableStateOf(false) }
if (showExplanationDialogForCameraPermission) {
   CameraExplanationDialog(
       onConfirm = {
           requestCameraPermission.launch(CAMERA)
           showExplanationDialogForCameraPermission = false
       },
       onDismiss = { showExplanationDialogForCameraPermission = false },
   )
}

现在我们有了对话框本身,我们只需要检查是否应该在请求相机权限之前显示理由即可。我们通过调用 ActivityCompat 的 shouldShowRequestPermissionRationale() API 来做到这一点。如果它返回 true,我们只需要将 showExplanationDialogForCameraPermission 也设置为 true 以显示解释对话框即可。

让我们将以下代码块添加到 state.hasCameraAccess 案例和 else 案例之间,或者在说明中先前添加了以下 TODO 的位置: // TODO:步骤 4. 添加相机权限的解释对话框

ActivityCompat.shouldShowRequestPermissionRationale(context.getActivity(),
           CAMERA) -> showExplanationDialogForCameraPermission = true

您现在相机按钮的完整逻辑如下所示

canAddPhoto {
   when {
       state.hasCameraAccess -> navController.navigate(Screens.Camera.route)
       ActivityCompat.shouldShowRequestPermissionRationale(context.getActivity(),
           CAMERA) -> showExplanationDialogForCameraPermission = true
       else -> requestCameraPermission.launch(CAMERA)
   }
}

恭喜!我们已经完成了在遵循所有 Android 最佳实践的同时处理相机权限的操作!继续,再次删除并重新安装应用,然后尝试从“添加日志”页面按下相机按钮。如果您拒绝权限,应用不会阻止您使用打开照片专辑等其他功能。

但是,下次您在拒绝权限后点击相机图标时,您应该会看到我们刚刚添加的解释提示!* 请注意,系统权限提示仅在用户在解释提示上点击“继续”后才会显示,如果用户点击“现在不”,我们将允许他们使用应用,而不会出现任何进一步的中断。这有助于应用避免用户额外的权限拒绝,并保留我们在不同时间再次请求权限的能力,届时用户可能更有可能授予权限。

  • 注意:shouldShowRequestPermissionRationale() API 的确切行为是内部实现细节,可能会发生变化。

添加请求位置权限的逻辑

现在,让我们对位置权限也做同样的处理。我们可以首先通过将以下代码块添加到 // TODO: Step 5. Register ActivityResult to request Location permissions 中来注册位置权限的 ActivityResult。

val requestLocationPermissions =
   rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
       if (isGranted) {
           viewModel.onPermissionChange(ACCESS_COARSE_LOCATION, isGranted)
           viewModel.onPermissionChange(ACCESS_FINE_LOCATION, isGranted)
           viewModel.fetchLocation()
       }
       else {
           coroutineScope.launch {
               snackbarHostState.showSnackbar("Location currently disabled due to denied permission.")
           }
       }
   }

之后,我们可以通过将以下代码块添加到 // TODO: Step 6. Add explanation dialog for Location permissions 中来添加位置权限的说明对话框。

var showExplanationDialogForLocationPermission by remember { mutableStateOf(false) }
if (showExplanationDialogForLocationPermission) {
   LocationExplanationDialog(
       onConfirm = {
           // TODO: Step 10. Change location request to only request COARSE location.
           requestLocationPermissions.launch(arrayOf(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION))
           showExplanationDialogForLocationPermission = false
       },
       onDismiss = { showExplanationDialogForLocationPermission = false },
   )
}

接下来,让我们继续检查、解释(如有必要)并请求位置权限。如果权限被授予,我们可以获取位置并填充照片日志。让我们将以下代码块添加到 // TODO: Step 7. Check, request, and explain Location permissions 中。

when {
   state.hasLocationAccess -> viewModel.fetchLocation()
   ActivityCompat.shouldShowRequestPermissionRationale(context.getActivity(),
       ACCESS_COARSE_LOCATION) ||    
   ActivityCompat.shouldShowRequestPermissionRationale(
       context.getActivity(), ACCESS_FINE_LOCATION) ->
       showExplanationDialogForLocationPermission = true
   else -> requestLocationPermissions.launch(arrayOf(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION))
}

就是这样,我们完成了本 Codelab 的权限部分!请尝试重置您的应用并查看结果。

我们如何改进用户体验以及应用优势的总结

  • 在用户与功能交互时(而不是在应用启动后立即)请求权限 → 减少困惑和用户流失。
  • 创建说明屏幕向用户解释我们的应用为何需要访问权限 → 提高用户透明度。
  • 利用 shouldShowRequestPermissionRationale() API 来确定系统何时认为您的应用应该显示说明屏幕 → 提高权限接受率并降低永久拒绝权限的可能性。

5. 最佳实践:减少应用的位置访问

位置是最敏感的权限之一,这就是 Android 在 隐私信息中心 中突出显示它的原因。

简单回顾一下,在 Android 12 中,我们为用户提供了更多关于位置的控制选项。当应用请求位置访问权限时,用户现在可以选择共享不太精确的位置数据,即选择**近似位置**而不是**精确位置**,从而做出明确的选择。

近似位置为应用提供用户位置在 3 平方公里范围内的估计值,对于许多应用的功能来说,这种精度应该足够了。我们鼓励所有需要位置访问权限的应用开发者审查其用例,并且仅在用户积极参与需要其精确位置的功能时才请求 ACCESS_FINE_LOCATION

ea5cc51fce3f219e.png

以加利福尼亚州洛杉矶市中心为中心的粗略位置估计范围的可视化图形。

近似位置访问对于我们的 PhotoLog 应用来说绝对足够了,因为我们只需要用户的城市来提醒他们“记忆”来自哪里。但是,该应用目前正在向用户请求 ACCESS_COARSE_LOCATIONACCESS_FINE_LOCATION。让我们改变这一点。

首先,我们需要编辑位置的 ActivityResult,并提供 ActivityResultContracts.RequestPermission() 函数作为参数,而不是 ActivityResultContracts.RequestMultiplePermissions(),以反映我们只将请求 ACCESS_COARSE_LOCATION 的事实。

让我们用以下代码块替换当前的 requestLocationsPermissions 对象(由 // TODO: Step 8. Change activity result to only request Coarse Location 表示)。

val requestLocationPermissions =
   rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
       if (isGranted) {
           viewModel.onPermissionChange(ACCESS_COARSE_LOCATION, isGranted)
       }
   }

接下来,我们将更改 launch() 方法,使其仅请求 ACCESS_COARSE_LOCATION 而不是两个位置权限。

让我们替换

requestLocationPermissions.launch(arrayOf(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION))

…为

requestLocationPermissions.launch(ACCESS_COARSE_LOCATION)

在 PhotoLog 中,我们需要更改 launch() 方法的两个实例,一个是在 LocationExplanationDialog 中的 onConfirm() 逻辑中(由 // TODO: Step 9. Change location request to only request COARSE location 表示),另一个是在“位置”列表项中(由 // TODO: Step 10. Change location request to only request COARSE location 表示)。

最后,既然我们不再为 PhotoLog 请求 ACCESS_FINE_LOCATION 权限,让我们继续从 AddLogViewModel.kt 中的 onPermissionChange() 方法中删除此部分。

Manifest.permission.ACCESS_FINE_LOCATION -> {
   uiState = uiState.copy(hasLocationAccess = isGranted)
}

并且不要忘记从应用的清单中删除 ACCESS_FINE_LOCATION

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

现在我们完成了 Codelab 的位置部分!请尝试卸载/重新安装您的应用并查看结果!

6. 最佳实践:最大程度减少存储权限的使用

应用通常会处理设备上存储的照片。为了让用户选择所需的图像和视频,这些应用通常会实现自己的文件选择器,这需要应用请求广泛的存储访问权限。用户不喜欢授予对其所有图片的访问权限,开发者也不喜欢维护独立的文件选择器。

Android 13 引入了照片选择器:一种允许用户选择媒体文件而无需授予应用访问其整个媒体库权限的工具。借助 Google Play 系统更新,它也被反向移植到 Android 11 和 12。

对于 PhotoLog 应用中的功能,我们将使用 PickMultipleVisualMedia ActivityResultContract。它将在设备上存在 Android 照片选择器时使用它,并在较旧的设备上依赖于 ACTION_OPEN_DOCUMENT intent。

首先,让我们在 AddLogScreen 文件中注册我们的 ActivityResultContract。为此,请在以下行之后添加以下代码块: // TODO: Step 11. Register ActivityResult to launch the Photo Picker

val pickImage = rememberLauncherForActivityResult(
   PickMultipleVisualMedia(MAX_LOG_PHOTOS_LIMIT),
    viewModel::onPhotoPickerSelect
)

注意:此处的 MAX_LOG_PHOTOS_LIMIT 表示我们在将照片添加到日志时选择的最大照片限制(在本例中为 3)。

现在我们需要用 Android 照片选择器替换应用中的内部选择器。在以下代码块之后添加以下代码: // TODO: Step 12. Replace the below line showing our internal UI by launching the Android Photo Picker instead

pickImage.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))

通过添加这两行代码,我们现在获得了一种无需权限即可访问设备照片的方式,它提供了更好的用户体验,并且不需要代码维护!

由于 PhotoLog 不再需要用于访问照片的旧版照片网格和存储权限,因此我们现在应该删除所有包含旧版照片网格的代码,从清单中的存储权限条目到其背后的逻辑,因为它们在我们的应用中不再需要了。

7. 建议:在调试版本中使用数据访问审计 API

您的应用是否很大,有很多功能和协作者(或者您预计将来会如此),这使得难以跟踪应用正在访问哪种用户数据?您是否知道,即使数据访问来自曾经使用过但现在仅停留在应用中的 API 或 SDK,您的应用仍然需要对数据访问负责?

我们了解跟踪应用访问私有数据的所有位置(包括所有包含的 SDK 和其他依赖项)很困难。因此,为了帮助您更透明地了解您的应用及其依赖项如何访问用户的私有数据,Android 11 引入了数据访问审计。此 API 允许开发人员在以下事件之一发生时执行特定操作(例如打印到日志文件)

  • 应用的代码访问私有数据。
  • 依赖库或 SDK 中的代码访问私有数据。

首先,让我们了解一下数据访问审计 API 在 Android 上的工作原理。要采用数据访问审计,我们将注册 AppOpsManager.OnOpNotedCallback 的一个实例(需要针对 Android 11 及更高版本)。

我们还需要覆盖回调中的三个方法,当应用以不同的方式访问用户数据时,系统将调用这些方法。它们是

  • onNoted() - 当应用调用访问用户数据的**同步**(双向绑定)**API** 时调用。这些通常是不需要回调的 API 调用。
  • onAsyncNoted() - 当应用调用访问用户数据的**异步**(单向绑定)**API** 时调用。这些通常是需要回调的 API 调用,并且数据访问发生在调用回调时。
  • onSelfNoted() - 这种情况不太可能发生,当应用将其自身的 UID 传递到 noteOp() 中时发生,例如。

现在让我们确定 PhotoLog 应用的数据访问适用于哪种方法。PhotoLog 主要在两个地方访问用户数据,一次是在激活相机时,另一次是在访问用户位置时。这两种都是异步 API 调用,因为它们都比较占用资源,因此我们预计在访问相应用户数据时,系统会调用 onAsyncNoted()

让我们逐步了解如何在 PhotoLog 中采用数据访问审计 API!

首先,我们需要创建一个 AppOpsManager.OnOpNotedCallback() 的实例,并覆盖上述三个方法。

对于对象中的所有三个方法,让我们继续记录访问私有用户数据的特定操作。此操作将包含有关访问哪种用户数据的更多信息。此外,由于我们预计在应用访问相机和位置信息时会调用 onAsyncNoted(),因此让我们做一些特殊的事情,并为位置访问记录地图表情符号,为相机访问记录相机表情符号。为此,我们可以将以下代码块添加到: // TODO: Step 1. Create Data Access Audit Listener Object

@RequiresApi(Build.VERSION_CODES.R)
object DataAccessAuditListener : AppOpsManager.OnOpNotedCallback() {
   // For the purposes of this codelab, we are just logging to console,
   // but you can also integrate other logging and reporting systems here to track
   // your app's private data access.
   override fun onNoted(op: SyncNotedAppOp) {
       Log.d("DataAccessAuditListener","Sync Private Data Accessed: ${op.op}")
   }

   override fun onSelfNoted(op: SyncNotedAppOp) {
       Log.d("DataAccessAuditListener","Self Private Data accessed: ${op.op}")
   }

   override fun onAsyncNoted(asyncNotedAppOp: AsyncNotedAppOp) {
       var emoji = when (asyncNotedAppOp.op) {
           OPSTR_COARSE_LOCATION -> "\uD83D\uDDFA"
           OPSTR_CAMERA -> "\uD83D\uDCF8"
           else -> "?"
       }

       Log.d("DataAccessAuditListener", "Async Private Data ($emoji) Accessed: 
       ${asyncNotedAppOp.op}")
   }
}

然后,我们需要实现我们刚刚创建的回调逻辑。为了获得最佳效果,我们需要尽早做到这一点,因为系统只有在我们注册回调**之后**才会开始跟踪数据访问。要注册回调,我们可以将以下代码块添加到: // TODO: Step 2. Register Data Access Audit Callback.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
   val appOpsManager = getSystemService(AppOpsManager::class.java) as AppOpsManager
   appOpsManager.setOnOpNotedCallback(mainExecutor, DataAccessAuditListener)
}

8. 总结

让我们回顾一下我们所涵盖的内容!我们…

  • …探讨了隐私对应用的重要性。
  • …了解了 Android 的隐私功能。
  • …通过以下方式实现了许多应用的关键隐私最佳实践,
  • 在上下文中请求权限
  • 减少应用的位置访问
  • 使用照片选择器和其它存储改进
  • 使用数据访问审计 API
  • …在现有应用中实施了这些最佳实践以增强其隐私。

我们希望您在改进 PhotoLog 的隐私和用户体验并在此过程中学习了许多概念的过程中玩得开心!

查找我们的参考代码(可选)

如果您还没有,可以查看代码实验室中 PhotoLog_End 文件夹中的解决方案代码。如果您仔细按照此代码实验室的说明操作,则 PhotoLog_Start 文件夹中的代码应与 PhotoLog_End 文件夹中的代码相同。

了解更多

就是这样!要了解有关我们上面介绍的最佳实践的更多信息,请查看 Android 隐私登陆页面