React 移动端实战 · Web 技术栈能读手机通话记录吗?手撸一个 Capacitor 原生插件给你看
各位看官,做移动端开发绕不开一个现实:有些系统能力,Web 标准就是碰不到。比如——读取本机通话记录。
你要做外勤拜访类、设备巡检类、或者任何"打了电话要回写到业务里"的 App,第一步往往是:用户刚拨完号,你希望在界面上自动带出"这次通话打了多久、几点打的、是主叫还是被叫"。纯 Web 做不到,浏览器不可能把通话记录交给你。但用 Capacitor 包一层原生插件,就能让 React 代码像调普通异步函数一样拿到这些数据。
这篇不聊别的,就聊怎么从零写一个 Capacitor 自定义原生插件,把 Android 系统的通话记录读出来,并且解决一个真实到骨子里的痛点:你存的号码和系统记录的号码,格式永远对不上。
一、为什么必须自己写插件
Capacitor 官方社区插件覆盖不了所有系统能力。官方有@capacitor/device、@capacitor/contacts(实验),但读通话记录这件事,官方没有,社区也没有一个好用的。原因很现实:
- 这是敏感权限(
READ_CALL_LOG),上架审核严,通用插件作者不愿意碰; - 各业务对"匹配哪条通话"的逻辑差异极大,无法做成通用方案。
所以这条路只有一条:自己写原生插件,自己定义 JS 接口。好消息是,Capacitor 的插件机制设计得相当干净——你只需在原生侧注册一个@CapacitorPlugin,JS 侧用registerPlugin一行桥接,中间那层 JNI/bridge 它全包了。
我们的目标接口很简单,JS 侧只要两个能力:
// src/mobile/callLog.ts —— 经过脱敏泛化的桥接层import{Capacitor,registerPlugin}from'@capacitor/core';exporttypeNativeCallLogEntry={number:string;// 对方号码(系统原始值)duration:number;// 通话时长(秒)startedAt:string;// 开始时间 ISO8601endedAt:string;// 结束时间 ISO8601type:number;// 1=呼入 2=呼出 3=未接 等};typeCallLogPlugin={requestPermissions:()=>Promise<{granted:boolean}>;getLatestForNumber:(options:{phone:string;since?:string;})=>Promise<{entry:NativeCallLogEntry|null}>;};constNativeCallLog=registerPlugin<CallLogPlugin>('CallLog');exportasyncfunctionrequestCallLogPermission(){if(Capacitor.getPlatform()!=='android')returnfalse;try{constresult=awaitNativeCallLog.requestPermissions();returnresult.granted;}catch{returnfalse;}}exportasyncfunctiongetLatestCallForNumber(phone:string,since?:string){if(Capacitor.getPlatform()!=='android')returnnull;try{constresult=awaitNativeCallLog.getLatestForNumber({phone,since});returnresult.entry;}catch{returnnull;}}注意两点工程纪律:
- 平台守卫。
Capacitor.getPlatform() !== 'android'直接短路返回,因为 iOS 侧我们根本没实现(后面说为什么)。 - 异常吞掉返回 null。原生调用失败(权限被拒、系统无记录)在业务上不是致命错误,UI 降级即可,不要让它抛到 React 渲染层。
二、插件骨架:package.json 与目录约定
本地插件不需要发 npm,放在仓库plugins/下即可。关键是package.json的capacitor字段告诉 CLI 原生代码在哪:
{"name":"@example/capacitor-call-log","version":"0.1.0","main":"dist/plugin.cjs.js","module":"dist/esm/index.js","types":"dist/esm/index.d.ts","private":true,"peerDependencies":{"@capacitor/core":">=8.0.0"},"capacitor":{"android":{"src":"android"}}}capacitor.android.src指向原生工程目录。Capacitor CLI 在npx cap sync时,会把android/拷进主工程的android/src/main/java/<namespace>/并完成注册。
TS 侧的入口和类型定义分离,这是官方推荐结构:
// src/definitions.tsexporttypeCallLogEntry={/* 同上 NativeCallLogEntry */};exporttypeCallLogPlugin={requestPermissions:()=>Promise<{granted:boolean}>;getLatestForNumber:(options:{phone:string;since?:string})=>Promise<{entry:CallLogEntry|null}>;};// src/index.tsimport{registerPlugin}from'@capacitor/core';importtype{CallLogPlugin}from'./definitions';constCallLog=registerPlugin<CallLogPlugin>('CallLog');export*from'./definitions';export{CallLog};registerPlugin('CallLog')里的字符串必须和原生侧@CapacitorPlugin(name = "CallLog")完全一致——这是桥接的唯一标识,拼错一个字母,JS 调原生就静默失败,排错能让你怀疑人生。
三、权限:Android 的运行时权限回调
读通话记录需要READ_CALL_LOG,这是个dangerous 权限,不能只在 Manifest 声明,还要运行时向用户申请。Capacitor 把权限回调封装得很优雅:
// android/src/main/java/com/example/app/calllog/CallLogPlugin.java@CapacitorPlugin(name="CallLog",permissions={@Permission(strings={Manifest.permission.READ_CALL_LOG},alias="callLog")})publicclassCallLogPluginextendsPlugin{@PluginMethodpublicvoidrequestPermissions(PluginCallcall){if(hasCallLogPermission()){JSObjectresult=newJSObject();result.put("granted",true);call.resolve(result);return;}// 关键:带 alias + 回调方法名,Capacitor 会自动弹系统授权框requestPermissionForAlias("callLog",call,"callLogPermissionCallback");}@PermissionCallbackprivatevoidcallLogPermissionCallback(PluginCallcall){JSObjectresult=newJSObject();result.put("granted",hasCallLogPermission());call.resolve(result);}privatebooleanhasCallLogPermission(){returnContextCompat.checkSelfPermission(getContext(),Manifest.permission.READ_CALL_LOG)==PackageManager.PERMISSION_GRANTED;}}这里有个新手必踩的坑:@PluginMethod默认是同步 resolve 的,但权限申请是异步的。如果你直接requestPermissions()不传回调名,结果根本回不到 JS。必须用requestPermissionForAlias(alias, call, callbackMethodName)三参数写法,并在@PermissionCallback标注的方法里call.resolve()。少了这个回调,Promise 永远 pending。
AndroidManifest.xml里也别忘了声明:
<uses-permissionandroid:name="android.permission.READ_CALL_LOG"/>四、核心:用 ContentResolver 查系统通话记录
Android 的通话记录是一个系统级 ContentProvider,路径在CallLog.Calls.CONTENT_URI。拿到权限后,用ContentResolver.query像查数据库一样查它:
@PluginMethodpublicvoidgetLatestForNumber(PluginCallcall){if(!hasCallLogPermission()){call.reject("READ_CALL_LOG permission is not granted");return;}Stringphone=normalize(call.getString("phone",""));Stringsince=call.getString("since",null);longsinceMillis=0L;if(since!=null&&!since.isEmpty()){sinceMillis=Math.max(0L,parseIso(since)-30000L);// 容差 30 秒}Uriuri=CallLog.Calls.CONTENT_URI;String[]projection={CallLog.Calls.NUMBER,// 号码CallLog.Calls.DATE,// 起始时间戳(ms)CallLog.Calls.DURATION,// 时长(秒)CallLog.Calls.TYPE// 1呼入 2呼出 3未接};Stringselection=sinceMillis>0?CallLog.Calls.DATE+">=?":null;String[]args=sinceMillis>0?newString[]{String.valueOf(sinceMillis)}:null;Stringsort=CallLog.Calls.DATE+" DESC";// 最新的排前面try(Cursorcursor=getContext().getContentResolver().query(uri,projection,selection,args,sort)){if(cursor==null){call.resolve(nullEntry());return;}while(cursor.moveToNext()){Stringnumber=cursor.getString(0);StringnormalizedNumber=normalize(number);if(!isLikelySameNumber(phone,normalizedNumber)){continue;// 不是目标号码,跳过}longstartedAtMillis=cursor.getLong(1);intduration=cursor.getInt(2);inttype=cursor.getInt(3);longendedAtMillis=startedAtMillis+duration*1000L;JSObjectentry=newJSObject();entry.put("number",number);entry.put("duration",Math.max(duration,0));entry.put("startedAt",iso(startedAtMillis));entry.put("endedAt",iso(endedAtMillis));entry.put("type",type);JSObjectresult=newJSObject();result.put("entry",entry);call.resolve(result);return;}call.resolve(nullEntry());}catch(Exceptione){call.reject("Failed to read call log",e);}}几个工程细节:
since参数带 30 秒容差:系统记录的通话开始时间和你业务侧记的时间可能有秒级偏差,减去 30 秒作为下界,避免"刚打完却查不到"。DATE DESC排序 + 命中即返回:我们要的是"最近一次通话",所以第一条命中的就是答案,不必遍历全表。try (Cursor ...):Cursor 必须关闭,用 try-with-resources 让 JVM 兜底,避免 ContentProvider 连接泄漏。iso()统一转 UTC:系统返回的是 epoch 毫秒,直接格式化成yyyy-MM-dd'T'HH:mm:ss.SSS'Z'回传 JS,前端不用再算时区。
五、最难的不是读,是"号码对得上"
这是整篇最值钱的部分。你以为传个号码进去,系统里存的也是这个号码?太天真了。
真实世界里的号码格式千奇百怪:
| 你业务侧存的 | 系统通话记录里的 | 差异 |
|---|---|---|
13800138000 | 13800138000 | 完全一致(少数) |
13800138000 | +8613800138000 | 多了+86国家码 |
13800138000 | 013800138000 | 多了区号前缀0 |
13800138000 | 138 0013 8000 | 有空格 |
010-12345678 | 12345678 | 固话区号带了- |
所以插件里做了两层归一化 + 末位模糊匹配:
// 第一层:只保留数字privateStringnormalize(Stringvalue){if(value==null)return"";returnvalue.replaceAll("[^0-9]","");}// 第二层:末位模糊匹配(核心算法)privatebooleanisLikelySameNumber(Stringexpected,Stringactual){if(expected.isEmpty()||actual.isEmpty())returnfalse;if(expected.equals(actual))returntrue;// 取两者较短者与 11 取小,作为比对长度,但最少 7 位才有意义intminLength=Math.min(Math.min(expected.length(),actual.length()),11);if(minLength<7)returnfalse;// 比末尾 minLength 位是否一致returnexpected.substring(expected.length()-minLength).equals(actual.substring(actual.length()-minLength));}为什么比末尾而不是开头?因为+86、区号0、分隔符都加在前面,末尾的手机号本体反而是最稳定的。取末 7~11 位比对,既能容忍国家码/区号差异,又不会因为只比后 4 位(容易误命中不同号段)而出错。
真实教训:最早我只比后 4 位,结果测试机上有两位同事号码后 4 位相同,匹配串了。改成动态末位(最短者长度,封顶 11、保底 7)后彻底干净。
TS 侧传入前也先normalizePhone一次,和原生保持一致:
// src/utils/format.tsexportfunctionnormalizePhone(phone:string){returnphone.replace(/[^\d+]/g,'');}注意 TS 这里保留了+,原生 Java 侧normalize把+也去掉了——没问题,因为到了比末位那一步,+86已经被剥离,比的是纯数字末尾。
六、iOS 怎么办?
这版只实现了 Android 端。原因很简单:READ_CALL_LOG在 iOS 上根本不存在——苹果从设计上就不允许任何 App 读取系统通话记录,这是平台红线,不是技术没做到。
所以 JS 桥接层里那句if (Capacitor.getPlatform() !== 'android') return null;不是偷懒,是刻意的平台边界声明:在 iOS 上,这个功能就是不可用,UI 要在此之前就给出"本机不支持"的提示,而不是调一个永远 reject 的插件。
如果你硬要在 iOS 上做类似能力,唯一合规路径是让用户手动输入通话结果(比如拨号后跳回 App 填"打了多久"),而不是去读系统。这点千万别碰苹果红线。
七、构建与同步的坑
原生插件写完后,别忘了三步:
npx cap sync android:把plugins/下的原生代码同步进主工程,否则你改了 Java,App 跑的还是旧代码——这个坑我踩过,改完不 sync,调试半小时以为逻辑写错了。build.gradle的namespace必须唯一:我们用com.example.app.calllog,不能和主工程或其他插件撞车。minSdk注意:READ_CALL_LOG在 API 16 就存在,但运行时权限模型从 API 23 才引入,所以minSdk 24安全;如果你要支持更低版本,得自己写兼容分支。
android { namespace = "com.example.app.calllog" compileSdk = 36 defaultConfig { minSdkVersion 24 targetSdkVersion 36 } }八、小结
回顾一下这一套 Capacitor 自定义原生插件的完整链路:
| 层 | 职责 | 关键文件 |
|---|---|---|
| JS 桥接 | 定义 TS 接口 +registerPlugin一行桥接 + 平台守卫 | src/mobile/callLog.ts |
| 插件定义 | TS 类型与入口分离 | plugins/.../src/index.ts+definitions.ts |
| 原生注册 | @CapacitorPlugin注解 + 权限 alias | CallLogPlugin.java |
| 权限申请 | requestPermissionForAlias+@PermissionCallback异步回调 | 同上 |
| 数据读取 | ContentResolver查CallLog.Calls | 同上 |
| 号码匹配 | normalize+isLikelySameNumber末位模糊比对 | 同上 |
Web 技术栈不是不能碰系统能力,而是碰到够不着的,就老老实实写一层原生桥。Capacitor 把桥接的脏活都干了,你只管写原生那几十行和 JS 那两行。真正费脑子的,从来不是"怎么读",而是"读出来怎么对得上"——那个末位模糊匹配,才是这篇值得你抄走的东西。
相关阅读:
- React 移动端实战 · 弱网下提交的数据说没就没?离线优先队列 + 客户端幂等,移动端补传一次说清
- React 移动端实战 · 弹层一滑,背后的列表跟着滚?移动端滚动穿透,我让 AI 改了三次才改对
- Node 后端实战 · 敏感数据防泄露 PII 脱敏与审计日志
- Node 后端实战 · JWT 双密钥轮转与 token 版本号
- Node 后端实战 · 多租户数据隔离
- React 管理后台实战 · 异步导出前端:别让用户干等,轮询还是 WebSocket?
- React 管理后台实战 · React Query 双 key 缓存:列表与详情如何互不污染
- Flutter Web token 存储陷阱:crypto.subtle 在非安全上下文失效排查实录
- Flutter Android 构建突发红字?一个跟通知无关的库,逼你开 core library desugaring
- Flutter 401 自动刷新拦截器并发死锁:_refreshQueue 死锁根治实录
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!