Android 隐私 Codelab

1. 简介

您将学到什么

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

您将构建什么

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

它将从以下屏幕开始

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

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

随着您完成 Codelab 的步骤,您将

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

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

  • ...实施了上面列出的隐私最佳实践。
  • ...注重隐私,并通过谨慎处理用户的私有数据来保护用户,从而增强用户体验。

您需要什么

建议

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))
}

就这样,我们完成了这个代码实验室的权限部分!继续尝试重置您的应用程序并查看结果。

我们如何改进用户体验以及对您应用程序的益处的总结

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

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

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

作为快速回顾,在 Android 12 中,我们为用户提供了更多关于位置的控制选项。现在,用户可以选择与应用程序共享不太准确的位置数据,方法是在应用程序请求位置访问权限时选择大约位置而不是精确位置

大约位置会为应用程序提供用户位置在 3 平方公里内的估计值,对于您应用程序的许多功能来说,这应该足够精确。我们鼓励所有需要位置访问权限的开发者检查您的用例,并且只有在用户积极参与需要其精确位置的功能时才请求ACCESS_FINE_LOCATION

ea5cc51fce3f219e.png

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

对于我们的 PhotoLog 应用程序来说,大约位置访问权限已经足够了,因为我们只需要用户的城市来提醒他们“记忆”来自哪里。但是,该应用程序目前正在向用户请求ACCESS_COARSE_LOCATIONACCESS_FINE_LOCATION 。让我们改变它。

首先,我们需要编辑位置的活动结果,并提供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))

... with

requestLocationPermissions.launch(ACCESS_COARSE_LOCATION)

PhotoLog 中有两个launch() 方法的实例需要我们更改,一个是LocationExplanationDialogonConfirm() 逻辑中的// 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" />

现在我们完成了代码实验室的位置部分!继续卸载/重新安装您的应用程序并查看结果!

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

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

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

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

首先,让我们在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 隐私信息中心