Android 隐私保护 Codelab

1. 简介

学习内容

  • 为什么隐私保护对用户越来越重要?
  • 前几个版本的 Android 隐私保护最佳实践。
  • 如何将隐私保护最佳实践集成到现有应用中以提高隐私保护力度?

构建内容

在此 Codelab 中,您将从一个示例应用开始构建,该应用可供用户保存照片,留住美好回忆。

该应用已有如下屏幕:

  • 权限屏幕 - 该屏幕要求用户授予所有权限后才能转到主屏幕。
  • 主屏幕 - 该屏幕显示用户现有的所有照片日志,还可供用户添加新的照片日志。
  • 添加日志屏幕 - 该屏幕可供用户创建新的照片日志。在此屏幕中,用户可以浏览照片库中的现有照片,使用相机拍摄新照片,以及将当前所在城市添加到照片日志中。
  • 相机屏幕 - 该屏幕可供用户拍摄照片并将其保存到照片日志中。

该应用可以正常运行,但是在隐私保护方面存在许多不足,我们接下来将一起改进这些不足之处!

学完此 Codelab 后,您将:

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

完成后,您将得到一款具备以下特征的应用:

  • …实施了上述隐私保护最佳实践。
  • …可保护隐私并通过谨慎处理用户私密数据来保护用户,从而提升用户体验。

所需条件

建议条件

2. 为什么隐私保护很重要?

研究表明,人们对自己的隐私很谨慎。Pew Research Institute 进行的一项调查发现,84% 的美国人认为他们无法控制或几乎无法控制公司和应用收集的有关他们的数据。他们感到懊恼的主要是不知道自己的数据会发生什么(除了直接使用之外)。例如,他们担心数据会被用于其他用途,比如创建个人资料供定位广告使用,甚至是出售给其他方。而且,数据一旦被收集后,似乎就无法移除了。

这种对隐私的担忧已经严重影响到人们对于使用哪些服务或应用所做的决定。事实上,Pew Research Institute 的同一项调研还发现,超过半数 (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 文件。

现在,删除并重新安装应用,您可以通过此方式重置先前授予的权限!您现在应该能够立即转到主屏幕,但是在您按下“Add Log”屏幕中的相机或位置信息按钮时应用会毫无反应,因为应用已经没有向用户请求权限的逻辑。让我们解决这个问题。

添加用于请求相机权限的逻辑

我们首先请求相机权限。根据我们在请求权限文档中看到的代码示例,我们需要先注册权限回调,然后才能使用 RequestPermission() 协定。

我们评估一下所需的逻辑:

  • 如果用户同意授予权限,我们就需要向 viewModel 注册权限,并在用户尚未达到已添加照片数上限的情况下转到相机屏幕。
  • 如果用户拒绝授予权限,我们可以通知用户,由于权限请求遭拒,相应功能无法正常运行。

为了执行此逻辑,我们可以将以下代码块添加到 // TODO: Step 1. Register ActivityResult to request Camera permission

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: Step 2. Check & request for Camera permission before navigating to the camera screen

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

现在,再次尝试运行应用,并点击“Add Log”屏幕中的相机图标。您应该会看到一个请求相机权限的对话框。祝贺您!这比在用户甚至还没试过应用之前就要求他们批准所有权限要好得多,对吧?

但是,我们能不能做得更好?当然能!我们可以检查系统是否建议我们显示一条理由,用于说明应用需要访问相机的原因。这样做可能有助于提高用户对授予该权限的选择接受率,同时还能让应用在更合适的时机再次请求该权限。

要做到这一点,我们要构建一个理由屏幕,说明应用需要访问用户相机的原因。为此,请将以下代码块添加到 // TODO: Step 3. Add explanation dialog for Camera permission

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

现在,对话框本身已经有了,我们只需检查是否应在请求相机权限前显示原因即可。我们调用 ActivityCompat 的 shouldShowRequestPermissionRationale() API 来进行此检查。如果该 API 返回 true,那么只需将 showExplanationDialogForCameraPermission 也设置为 true 即可显示说明原因的对话框。

我们要将以下代码块添加到 state.hasCameraAccess 情况与 else 情况之间,或者添加到之前在说明中添加 TODO 代码 // TODO: Step 4. Add explanation dialog for Camera permission 之处:

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 最佳实践完成了对相机权限的处理!接下来,再次删除并重新安装应用,然后尝试按“Add Log”页面中的相机按钮。如果您拒绝授予权限,应用不会阻止您使用打开相册等其他功能。

不过,在拒绝授予权限后,当您下次点击相机图标时,应该会看到我们刚刚添加的说明提示!*请注意,系统权限提示只有在用户点击说明提示上的“continue”后才会显示。如果用户点击“not now”,我们要允许用户使用应用而不出现任何中断。这可以帮助应用避免用户拒绝授予其他权限的情况,让我们能够改日再次请求权限,届时用户也许会更愿意授予相应权限。

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

添加用于请求位置信息权限的逻辑

接下来,我们对位置信息执行相同的操作。我们可以先为位置信息权限注册 ActivityResult,方法是将以下代码块添加到 // TODO: Step 5. Register ActivityResult to request Location permissions

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(
       
           // TODO: Step 10. Change location request to only request COARSE location.
           requestLocationPermissions.launch(arrayOf(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION))
           showExplanationDialogForLocationPermission = false
       },
        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 权限。让我们来修改一下。

首先,我们需要修改位置信息的 activity 结果,并提供 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 表示,另一个在“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. 建议:在调试 build 中使用数据访问审核 API

您是否有一个大型应用,包含大量功能并有许多协作者(或者您预计它将来会这样!),导致您难以跟踪应用访问的用户数据类型?您知道吗?即使数据访问来自一度使用过但现在只是在应用中停留的 API 或 SDK,您的应用仍会对数据访问负责。

我们深知,想要跟踪应用访问私密数据的所有位置,包括其中包含的所有 SDK 和其他依赖项,是一件困难的事情。因此,为了让应用及其依赖项访问用户私密数据的过程更加透明,Android 11 引入了数据访问审核功能。借助此 API,开发者可以在每次发生以下任一事件时执行特定操作,例如向日志文件执行输出操作:

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

首先,我们来回顾一下有关 Android 中的数据访问审核 API 工作原理的基础知识。为了采用数据访问审核功能,我们需要注册一个 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 的隐私保护力度并提升了该应用的用户体验,在此过程中还学习了许多概念,希望您学习愉快!

如需查找参考代码(可选),请执行以下操作:

如果您尚未查看此 Codelab 的解决方案代码,可以在 PhotoLog_End 文件夹中找到。如果您严格按照此 Codelab 的说明进行操作,那么 PhotoLog_Start 文件夹中的代码应该与 PhotoLog_End 文件夹中的代码相同。

了解更多内容

大功告成!如需详细了解前文所述的最佳实践,请参阅 Android 隐私保护着陆页