news 2026/8/24 3:46:43

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

作者头像

张小明

前端开发工程师

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

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;}}

注意两点工程纪律:

  1. 平台守卫Capacitor.getPlatform() !== 'android'直接短路返回,因为 iOS 侧我们根本没实现(后面说为什么)。
  2. 异常吞掉返回 null。原生调用失败(权限被拒、系统无记录)在业务上不是致命错误,UI 降级即可,不要让它抛到 React 渲染层。

二、插件骨架:package.json 与目录约定

本地插件不需要发 npm,放在仓库plugins/下即可。关键是package.jsoncapacitor字段告诉 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,前端不用再算时区。

五、最难的不是读,是"号码对得上"

这是整篇最值钱的部分。你以为传个号码进去,系统里存的也是这个号码?太天真了

真实世界里的号码格式千奇百怪:

你业务侧存的系统通话记录里的差异
1380013800013800138000完全一致(少数)
13800138000+8613800138000多了+86国家码
13800138000013800138000多了区号前缀0
13800138000138 0013 8000有空格
010-1234567812345678固话区号带了-

所以插件里做了两层归一化 + 末位模糊匹配:

// 第一层:只保留数字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 填"打了多久"),而不是去读系统。这点千万别碰苹果红线。

七、构建与同步的坑

原生插件写完后,别忘了三步:

  1. npx cap sync android:把plugins/下的原生代码同步进主工程,否则你改了 Java,App 跑的还是旧代码——这个坑我踩过,改完不 sync,调试半小时以为逻辑写错了。
  2. build.gradlenamespace必须唯一:我们用com.example.app.calllog,不能和主工程或其他插件撞车。
  3. 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注解 + 权限 aliasCallLogPlugin.java
权限申请requestPermissionForAlias+@PermissionCallback异步回调同上
数据读取ContentResolverCallLog.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 优化校阅,转发请注明首发地址,谢谢大家!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/24 3:42:40

云端一键部署MiniMax-H3:48G显存搞定LoRA模型训练全攻略

最近在尝试用大模型做图像生成时&#xff0c;是不是总被复杂的本地部署、高昂的硬件成本和繁琐的命令行劝退&#xff1f;特别是想训练自己的LoRA模型&#xff0c;面对动辄上百G的显存需求和全英文的界面&#xff0c;更是让人望而却步。别担心&#xff0c;今天就来分享一个“懒人…

作者头像 李华
网站建设 2026/8/24 3:40:06

093-用AI辅助刻意练习编程与设计

刻意练习 093:用AI辅助刻意练习——编程与设计 智能工具如何加速技能提升(二) 如果说语言和写作是"人类表达"的刻意练习领域,那么编程和设计则可能是AI辅助效果最显著的领域——因为这两个领域的核心活动(编写代码和创作视觉作品)都有明确的输出物、清晰的评…

作者头像 李华
网站建设 2026/8/24 3:36:04

移动端AI Agent工程实践:从OpenClaw到Android的Harness Engineering

1. 项目概述&#xff1a;从实验室原型到移动端可用的跨越最近在AI工程化领域&#xff0c;一个话题讨论得挺热&#xff1a;如何把一个听起来很酷的实验室Agent框架&#xff0c;真正变成一个能在用户手机上稳定运行、解决实际问题的应用。标题里的“从OpenClaw到Android”&#x…

作者头像 李华
网站建设 2026/8/24 3:35:02

一笔作业如何走完 River 的一生?Go 后台任务生命周期的 6 个路标

一笔作业如何走完 River 的一生?Go 后台任务生命周期的 6 个路标 【免费下载链接】river Fast and reliable background jobs in Go 项目地址: https://gitcode.com/gh_mirrors/river/river River 是面向 Go 的后台作业处理系统,主打快和可靠。做后台任务最怕三个坑:进…

作者头像 李华
网站建设 2026/8/24 3:35:01

Qt折叠控件实现:从QToolBox到自定义动画抽屉的交互优化

1. 从界面臃肿到优雅折叠&#xff1a;一个被忽视的交互优化点 最近在重构一个老项目的配置界面&#xff0c;看着那密密麻麻、动辄需要滚动好几屏的选项面板&#xff0c;我意识到一个严重的问题&#xff1a;用户真的需要一次性看到所有东西吗&#xff1f;答案显然是否定的。很多…

作者头像 李华