Android使用SafetyNet检测Root设备重启后触发CANCELLED错误如何解决
问题描述
我需要在应用的登录流程启动前完成设备Root状态检测,参考相关技术方案后,我选择使用SafetyNet实现该功能,调整后的代码如下:
public class RootedDeviceCheckWithSafetyNetAttestation { public static AtomicInteger sendSafetyNetRequest(Activity context) { AtomicInteger rootCheck = new AtomicInteger(); if(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS) { Log.e(TAG, "The SafetyNet Attestation API is available"); // TODO(开发者): 调整nonce生成逻辑,加入自定义的一次性值,建议从远端服务器获取 String nonceData = "Safety Net Sample: " + System.currentTimeMillis(); ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); Random mRandom = new SecureRandom(); byte[] bytes = new byte[24]; mRandom.nextBytes(bytes); try { byteStream.write(bytes); byteStream.write(nonceData.getBytes()); } catch (IOException e) { e.printStackTrace(); } byte[] nonce = byteStream.toByteArray(); SafetyNetClient client = SafetyNet.getClient(context); Task<SafetyNetApi.AttestationResponse> task = client.attest(nonce, MY-API-KEY); task.addOnSuccessListener(context, attestationResponse -> { /* TODO(开发者): 将该结果和nonce一起发送到自己的服务端进行校验 也可以在本地解析JwsResult确认API返回了响应,先检查是否存在error字段,再考虑指数退避重试 注意:不要仅依赖本地客户端侧的校验结果,必须在远端服务端校验响应保障安全 */ String jwsResult = attestationResponse.getJwsResult(); Log.e(TAG, "Success! SafetyNet result:\n" + jwsResult + "\n"); if (jwsResult == null) { Log.e(TAG, "jwsResult Null"); } final String[] jwtParts = jwsResult.split("\\."); if (jwtParts.length == 3) { String decodedPayload = new String(Base64.decode(jwtParts[1], Base64.DEFAULT)); try { JSONObject jsonObject = new JSONObject(decodedPayload); //Toast.makeText(context, "ctsProfileMatch:" + jsonObject.getString("ctsProfileMatch")+","+"basicIntegrity:" + jsonObject.getString("basicIntegrity"), Toast.LENGTH_SHORT).show(); if((jsonObject.getString("ctsProfileMatch").equals("false")) && (jsonObject.getString("basicIntegrity").equals("false"))){ rootCheck.set(4); } else{ rootCheck.set(1); } } catch (JSONException e) { e.printStackTrace(); rootCheck.set(2); } //Toast.makeText(context, decodedPayload, Toast.LENGTH_SHORT).show(); //Log.e(TAG, "decodedPayload : " + decodedPayload); } }); task.addOnFailureListener(context, e -> { // 与服务通信时发生错误 String mResult = null; rootCheck.set(5); if (e instanceof ApiException) { // Google Play服务API错误,包含额外错误信息 ApiException apiException = (ApiException) e; Log.e(TAG, "Error: " + CommonStatusCodes.getStatusCodeString(apiException.getStatusCode()) + ": " + apiException.getStatusMessage()); Toast.makeText(context, "Error: " + CommonStatusCodes.getStatusCodeString(apiException.getStatusCode()) + ": " + apiException.getStatusMessage(), Toast.LENGTH_LONG).show(); } else { // 其他未知类型错误 Log.e(TAG, "ERROR! " + e.getMessage()); Toast.makeText(context, "ERROR! " + e.getMessage(), Toast.LENGTH_LONG).show(); } }); } else { Log.e(TAG, "提示用户更新Google Play服务"); rootCheck.set(3); } return rootCheck; } }
我在模拟器或者Root设备上测试时,rootCheck变量可以按预期被赋值为4。
但在真机上测试时发现:首次登录时Root检测功能正常,但将应用杀后台后重新打开,代码就会弹出“Error: CANCELLED: null”的Toast提示,rootCheck变量被赋值为5,说明请求进入了task.addOnFailureListener的if (e instanceof ApiException)分支,该问题在Root和未Root的真机上都稳定复现。
请问该问题的诱因是什么,该如何解决?
问题诱因
- 你在调用
addOnSuccessListener和addOnFailureListener时传入了当前Activity实例作为生命周期所有者。当应用被后台杀死后重新打开,旧的Activity实例已经被系统销毁,Google Play服务的Task框架会自动取消所有绑定了已销毁生命周期所有者的待执行请求,直接触发失败回调返回CANCELLED状态码。 - 部分真机的Google Play服务对短时间内重复发起的SafetyNet请求有频率限制,首次请求成功后短时间内再次发起请求,可能被服务端直接拒绝返回取消状态。
- 你当前使用的SafetyNet Attestation API已经正式废弃,官方已停止维护,部分新机型和新版本Google Play服务对旧API的适配存在兼容性问题,也会导致请求被取消。
解决方案
1. 调整监听器绑定逻辑
移除监听器的Activity生命周期绑定,改用无生命周期参数的监听器重载方法,避免请求被自动取消。
修改前:
task.addOnSuccessListener(context, attestationResponse -> { ... }) task.addOnFailureListener(context, e -> { ... })
修改后:
task.addOnSuccessListener(attestationResponse -> { ... }) task.addOnFailureListener(e -> { ... })
如果担心内存泄漏,可以在Activity的onDestroy生命周期中手动调用task.cancel(true)取消未完成的请求。
2. 增加请求重试机制
捕获到状态码为CANCELLED的ApiException时,增加指数退避重试逻辑,最多重试2-3次,避开服务端频率限制。
3. 修复异步结果读取逻辑
你当前用同步方法返回AtomicInteger的写法存在逻辑漏洞,SafetyNet请求是异步执行的,方法返回时回调大概率还未触发,拿到的会是初始值0。建议改用接口回调的方式传递检测结果,示例如下:
public interface RootCheckCallback { void onCheckResult(int resultCode); } public static void sendSafetyNetRequest(Activity context, RootCheckCallback callback) { // 原有逻辑 // 在回调的各个分支调用 callback.onCheckResult(对应code)即可 }
4. 替换为Play Integrity API
SafetyNet Attestation API已在2024年完全停用,官方建议替换为Play Integrity API,兼容性、稳定性和安全性都高于旧API,可彻底规避旧API的适配问题。
内容的提问来源于stack exchange,提问作者Wicaledon

