React 移动端实战 · Web 技术栈能读手机通话记录吗?手撸一个 Capacitor 原生插件给你看

📅 2026/8/24 3:46:51
React 移动端实战 · Web 技术栈能读手机通话记录吗?手撸一个 Capacitor 原生插件给你看
React 移动端实战 · Web 技术栈能读手机通话记录吗手撸一个 Capacitor 原生插件给你看各位看官做移动端开发绕不开一个现实有些系统能力Web 标准就是碰不到。比如——读取本机通话记录。你要做外勤拜访类、设备巡检类、或者任何打了电话要回写到业务里的 App第一步往往是用户刚拨完号你希望在界面上自动带出这次通话打了多久、几点打的、是主叫还是被叫。纯 Web 做不到浏览器不可能把通话记录交给你。但用 Capacitor 包一层原生插件就能让 React 代码像调普通异步函数一样拿到这些数据。这篇不聊别的就聊怎么从零写一个 Capacitor 自定义原生插件把 Android 系统的通话记录读出来并且解决一个真实到骨子里的痛点你存的号码和系统记录的号码格式永远对不上。一、为什么必须自己写插件Capacitor 官方社区插件覆盖不了所有系统能力。官方有capacitor/device、capacitor/contacts(实验)但读通话记录这件事官方没有社区也没有一个好用的。原因很现实这是敏感权限READ_CALL_LOG上架审核严通用插件作者不愿意碰各业务对匹配哪条通话的逻辑差异极大无法做成通用方案。所以这条路只有一条自己写原生插件自己定义 JS 接口。好消息是Capacitor 的插件机制设计得相当干净——你只需在原生侧注册一个CapacitorPluginJS 侧用registerPlugin一行桥接中间那层 JNI/bridge 它全包了。我们的目标接口很简单JS 侧只要两个能力// src/mobile/callLog.ts —— 经过脱敏泛化的桥接层import{Capacitor,registerPlugin}fromcapacitor/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};};constNativeCallLogregisterPluginCallLogPlugin(CallLog);exportasyncfunctionrequestCallLogPermission(){if(Capacitor.getPlatform()!android)returnfalse;try{constresultawaitNativeCallLog.requestPermissions();returnresult.granted;}catch{returnfalse;}}exportasyncfunctiongetLatestCallForNumber(phone:string,since?:string){if(Capacitor.getPlatform()!android)returnnull;try{constresultawaitNativeCallLog.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}fromcapacitor/core;importtype{CallLogPlugin}from./definitions;constCallLogregisterPluginCallLogPlugin(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.javaCapacitorPlugin(nameCallLog,permissions{Permission(strings{Manifest.permission.READ_CALL_LOG},aliascallLog)})publicclassCallLogPluginextendsPlugin{PluginMethodpublicvoidrequestPermissions(PluginCallcall){if(hasCallLogPermission()){JSObjectresultnewJSObject();result.put(granted,true);call.resolve(result);return;}// 关键带 alias 回调方法名Capacitor 会自动弹系统授权框requestPermissionForAlias(callLog,call,callLogPermissionCallback);}PermissionCallbackprivatevoidcallLogPermissionCallback(PluginCallcall){JSObjectresultnewJSObject();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:nameandroid.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;}Stringphonenormalize(call.getString(phone,));Stringsincecall.getString(since,null);longsinceMillis0L;if(since!null!since.isEmpty()){sinceMillisMath.max(0L,parseIso(since)-30000L);// 容差 30 秒}UriuriCallLog.Calls.CONTENT_URI;String[]projection{CallLog.Calls.NUMBER,// 号码CallLog.Calls.DATE,// 起始时间戳(ms)CallLog.Calls.DURATION,// 时长(秒)CallLog.Calls.TYPE// 1呼入 2呼出 3未接};StringselectionsinceMillis0?CallLog.Calls.DATE?:null;String[]argssinceMillis0?newString[]{String.valueOf(sinceMillis)}:null;StringsortCallLog.Calls.DATE DESC;// 最新的排前面try(CursorcursorgetContext().getContentResolver().query(uri,projection,selection,args,sort)){if(cursornull){call.resolve(nullEntry());return;}while(cursor.moveToNext()){Stringnumbercursor.getString(0);StringnormalizedNumbernormalize(number);if(!isLikelySameNumber(phone,normalizedNumber)){continue;// 不是目标号码跳过}longstartedAtMilliscursor.getLong(1);intdurationcursor.getInt(2);inttypecursor.getInt(3);longendedAtMillisstartedAtMillisduration*1000L;JSObjectentrynewJSObject();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);JSObjectresultnewJSObject();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-ddTHH:mm:ss.SSSZ回传 JS前端不用再算时区。五、最难的不是读是号码对得上这是整篇最值钱的部分。你以为传个号码进去系统里存的也是这个号码太天真了。真实世界里的号码格式千奇百怪你业务侧存的系统通话记录里的差异1380013800013800138000完全一致少数138001380008613800138000多了86国家码13800138000013800138000多了区号前缀013800138000138 0013 8000有空格010-1234567812345678固话区号带了-所以插件里做了两层归一化 末位模糊匹配// 第一层只保留数字privateStringnormalize(Stringvalue){if(valuenull)return;returnvalue.replaceAll([^0-9],);}// 第二层末位模糊匹配核心算法privatebooleanisLikelySameNumber(Stringexpected,Stringactual){if(expected.isEmpty()||actual.isEmpty())returnfalse;if(expected.equals(actual))returntrue;// 取两者较短者与 11 取小作为比对长度但最少 7 位才有意义intminLengthMath.min(Math.min(expected.length(),actual.length()),11);if(minLength7)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/下的原生代码同步进主工程否则你改了 JavaApp 跑的还是旧代码——这个坑我踩过改完不 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.tsdefinitions.ts原生注册CapacitorPlugin注解 权限 aliasCallLogPlugin.java权限申请requestPermissionForAliasPermissionCallback异步回调同上数据读取ContentResolver查CallLog.Calls同上号码匹配normalizeisLikelySameNumber末位模糊比对同上Web 技术栈不是不能碰系统能力而是碰到够不着的就老老实实写一层原生桥。Capacitor 把桥接的脏活都干了你只管写原生那几十行和 JS 那两行。真正费脑子的从来不是怎么读而是读出来怎么对得上——那个末位模糊匹配才是这篇值得你抄走的东西。相关阅读React 移动端实战 · 弱网下提交的数据说没就没离线优先队列 客户端幂等移动端补传一次说清React 移动端实战 · 弹层一滑背后的列表跟着滚移动端滚动穿透我让 AI 改了三次才改对Node 后端实战 · 敏感数据防泄露 PII 脱敏与审计日志Node 后端实战 · JWT 双密钥轮转与 token 版本号Node 后端实战 · 多租户数据隔离React 管理后台实战 · 异步导出前端别让用户干等轮询还是 WebSocketReact 管理后台实战 · React Query 双 key 缓存列表与详情如何互不污染Flutter Web token 存储陷阱crypto.subtle 在非安全上下文失效排查实录Flutter Android 构建突发红字一个跟通知无关的库逼你开 core library desugaringFlutter 401 自动刷新拦截器并发死锁_refreshQueue 死锁根治实录本文由 FungLeo 主导Deepseek 优化校阅转发请注明首发地址谢谢大家