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