Android 10 工业手持机蓝牙扫描失败:为什么需要定位权限
本文记录一次 GP 蓝牙打印机在 Android 10 工业手持机上无法扫描的排查过程。最终结论是:Android 10 的标准蓝牙权限模型要求 App 在主动扫描设备时获得位置权限;现场授予“位置”权限后,扫描立即恢复。Android 12+ 的“附近的设备”权限属于另一套权限模型,需要分开讨论。
一、问题现象
现场的 GP-Print 页面显示:
扫描失败
无法启动蓝牙扫描
当时已经确认:
- 手持机蓝牙已打开;
- 系统蓝牙设置页能搜索到附近设备;
- 打印机已开机且处于可发现状态;
- 同一版本的 App 在开发人员的普通 Android 设备上无法复现,扫描速度正常;
- 杀掉 App 进程并重新打开后,问题仍然存在。
最终,现场将 App 的“位置”权限打开后,蓝牙扫描立即恢复正常。
二、报错实际发生在哪里
该项目使用 Android 经典蓝牙发现来查找打印机。原生模块会先检查蓝牙适配器和权限,然后调用:
val started = bluetoothAdapter?.startDiscovery() == true
当 startDiscovery() 返回 false 时,App 显示“无法启动蓝牙扫描”。这里的含义不是“没有找到打印机”,而是“扫描未能启动”。蓝牙状态、权限、正在进行的配对、已有排队请求或蓝牙栈异常都可能导致启动失败,不能仅凭这个返回值断定具体原因。
相关代码位于:
android/app/src/main/java/com/matsuokaphone/gprinter/GPrinterModule.ktsrc/pages/me/PrinterSettingsPage.tsx
三、为什么蓝牙扫描历史上会和定位权限有关
蓝牙扫描本身不会直接读取 GPS 坐标,但扫描结果会暴露周围设备的标识和信号强度。如果将这些信息与已知位置的蓝牙信标、商场设备或其他数据库结合,理论上可以推断用户所在位置。
因此,在 Android 11(API 30)及以下版本中,扫描蓝牙设备需要 ACCESS_FINE_LOCATION 运行时权限。Android 10 对应 API 29,正好适用这项标准要求,并不是工业手持机特有的兼容行为。Android 官方蓝牙权限文档也明确说明,旧版 Android 要求该权限,是因为蓝牙扫描可能被用于收集位置信息。
四、Android 12 后的标准权限模型
Android 12(API 31)开始引入了专门的附近设备权限:
| 权限 | 用途 |
|---|---|
BLUETOOTH_SCAN | 搜索附近的蓝牙设备 |
BLUETOOTH_CONNECT | 连接或读取已配对蓝牙设备的信息 |
BLUETOOTH_ADVERTISE | 让当前设备对外广播 |
这些都是运行时权限,不能只写在 AndroidManifest.xml 中,App 还必须在运行时向用户申请。Android 会将其展示为“附近的设备”权限。Android 12 行为变更文档也将这套权限作为新的蓝牙访问模型。
按照标准 Android 规则,如果 App 不使用蓝牙扫描结果推导用户位置,可以在 BLUETOOTH_SCAN 上声明:
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
在符合标准的 Android 12+ 设备上,这种场景通常只需要“附近的设备”权限,不应再强制要求定位权限。需要注意的是,Android 官方同时说明,使用 neverForLocation 可能导致某些 BLE 信标被过滤。
五、为什么这台 Android 10 手持机需要位置权限
这里必须先根据系统版本判断适用哪套权限模型。现场手持机运行 Android 10,不存在 Android 12 才引入的“附近的设备”运行时权限。
本次现场结果是:
| 权限状态 | 手持机实际结果 |
|---|---|
| 蓝牙开启,App 未获得位置权限 | startDiscovery() 失败 |
| 蓝牙开启,App 获得精确位置权限 | 扫描立即恢复 |
这个结果符合 Android 10 的标准权限模型:
BLUETOOTH / BLUETOOTH_ADMIN(Manifest 普通权限)
+
ACCESS_FINE_LOCATION(运行时权限)
↓
允许扫描蓝牙设备
因此,本次事件本身不能证明该手持机使用了厂商定制的“混合权限模型”,也不能证明 Android 12+ 工业手持机必然需要额外的位置权限。如果要得出后一结论,需要在 Android 12+ 设备上单独做权限对照试验。
Android 10 中相关权限分别解决什么问题
BLUETOOTH/BLUETOOTH_ADMIN:允许 App 使用传统蓝牙并启动设备发现;在 Android 10 上属于 Manifest 权限,不会显示为 Android 12 的“附近的设备”运行时授权框。ACCESS_FINE_LOCATION:满足 Android 10 对蓝牙扫描的隐私保护要求。App 获得该权限不代表业务实际读取 GPS 坐标或收集用户位置。
六、为什么系统设置能搜到,App 却搜不到
系统蓝牙设置页是系统级应用,它可以通过系统签名或特权直接访问蓝牙服务。普通 App 则运行在独立沙箱中,必须通过 Manifest 声明、运行时授权和系统的 AppOps 检查。
所以,“系统设置可以搜到”只能证明:
- 蓝牙硬件基本正常;
- 打印机正在广播且距离合适。
它不能证明普通 App 的权限和扫描调用一定正常。
七、为什么重启 App 没有作用
权限状态由 Android 系统持久保存,不会因为杀掉 App 进程而改变。同样,异常的蓝牙系统服务状态也不一定会随 App 退出而重置。
所以排查时应该区分:
- 权限问题:到“设置 → 应用 → 当前 App → 权限”中修改;
- 蓝牙栈状态问题:关闭蓝牙,等待数秒后重新打开,必要时重启整台手持机;
- App 进程问题:只有当 App 内部保留了错误状态时,重启 App 才可能有效。
八、项目当前实现及版本边界
1. Manifest 声明
项目已在 AndroidManifest.xml 中同时声明旧版和新版蓝牙权限:
<!-- Android 11 及以下 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Android 12+ -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
neverForLocation 表示业务不使用蓝牙扫描结果推导物理位置。按照 Android 官方的最小权限建议,如果 Android 12+ 不需要位置权限,旧版权限可以增加 android:maxSdkVersion="30":
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
项目当前没有给 ACCESS_FINE_LOCATION 设置版本上限,并在 Android 12+ 也申请它。这是一项偏保守的兼容实现,但本次 Android 10 现场事件不能作为其必要性的证据。后续应在目标 Android 12+ 工业设备上验证后,决定保留、按厂商/型号申请,或限制到 API 30。
2. 扫描前动态申请权限
项目当前按系统版本申请权限:Android 11 及以下只动态申请精确位置;Android 12+ 申请附近设备权限,并额外申请精确位置作为兼容措施。
const permissions = Number(Platform.Version) >= 31
? [
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
]
: [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
const results = await PermissionsAndroid.requestMultiple(permissions);
如果用户选择“不再询问”,App 会提供“前往设置”入口,避免用户只看到扫描失败却不知道如何恢复。
3. 原生层保留防御性检查
即使 React Native 页面已经申请权限,原生模块仍应在调用蓝牙 API 前检查:
if (ActivityCompat.checkSelfPermission(
ctx,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
promise.reject(
"PERMISSION_DENIED",
"缺少定位权限,无法扫描蓝牙设备"
)
return
}
这样可以避免页面遗漏申请、权限在运行期被收回,或其他页面直接调用原生模块时出现不明确的扫描失败。需要注意,当前原生层会在所有 Android 版本上强制检查精确位置权限;如果后续决定在标准 Android 12+ 设备上采用最小权限方案,这里也应同步改为按系统版本或设备兼容策略检查。
4. 兼容取消扫描的异步时序
部分手持机上,cancelDiscovery() 不会同步释放蓝牙扫描状态。如果紧接着再次调用 startDiscovery(),也可能返回 false。
本次同时增加了两层保护:
- JavaScript 层停止扫描后等待 600ms;
- 原生层启动失败时等待 700ms 并自动重试一次。
这个时序修复不是本次“定位权限”问题的最终触发原因,但可以减少工业设备上连续扫描导致的偶发失败。
5. 权限提示支持多语言
新增的权限和扫描错误提示已接入项目 i18n,支持简体中文、英文、日文、越南文和孟加拉文。“确定”、“取消”、“前往设置”等按钮继续复用 common 命名空间,避免在蓝牙模块重复维护通用文案。
九、现场排查清单
遇到类似问题时,建议按以下顺序排查:
- 确认打印机已开机并处于可发现状态。
- 确认手持机系统蓝牙已打开。
- Android 12+:检查 App 的“附近的设备”权限。
- Android 11 及以下:检查 App 的“位置”权限;Android 10 应授予精确位置权限。
- 如果权限已授予仍无法扫描,确认系统“定位”总开关已打开;不同 Android 版本和厂商 ROM 的实际限制可能不同。
- 关闭蓝牙,等待 5–10 秒后重新打开。
- 如果系统可以发现打印机,可先在系统中完成配对,再回到 App 连接。
- 仍然失败时,采集
GPrinter、BluetoothAdapter和ReactNativeJS日志。
可使用以下命令检查现场设备:
adb shell getprop ro.build.version.release
adb shell getprop ro.build.version.sdk
adb shell dumpsys package com.matsuokaphone \
| grep -E "BLUETOOTH_SCAN|BLUETOOTH_CONNECT|ACCESS_FINE_LOCATION"
adb logcat -c
adb logcat -v time GPrinter:D BluetoothAdapter:D BluetoothManagerService:D ReactNativeJS:V '*:S'
十、方案边界与后续优化
对于 Android 10,主动扫描蓝牙设备时申请精确位置权限是系统标准要求。对于严格遵循 Android 12+ 规范的设备,如果已经声明 neverForLocation 且业务不使用扫描结果推导位置,仅为连接打印机而申请位置权限通常不是最小权限方案。
项目当前在 Android 12+ 同时申请“附近的设备”和位置权限,但应将它视为待验证的兼容策略,而不是本次 Android 10 事件已经证实的结论。
如果未来需要发布到更广泛的消费设备,可进一步考虑:
- 根据设备厂商、型号或系统版本按需申请定位权限;
- 优先展示系统已配对的打印机,减少主动扫描;
- 评估 Android
CompanionDeviceManager,由系统代理伴生设备配对; - 将“权限被拒绝”、“系统定位未开启”和“蓝牙栈拒绝扫描”拆分为不同错误码。
结论
本次问题不是打印机故障,也不是手持机蓝牙硬件损坏。现场手持机运行 Android 10,而 Android 10 的标准权限模型要求 App 获得精确位置权限后才能正常扫描蓝牙设备。
一句话概括:
Android 10 扫描蓝牙设备需要位置权限;Android 12+ 才使用“附近的设备”权限。两者是不同 Android 版本下的标准权限模型,不能混为同一次厂商兼容问题。