简介:一套双端信息采集系统源码,定位于面向移动端取证与数据管理场景的开发者及学习者,适合有一定前端与后端基础、希望快速获得可运行案例的软件开发人员,也可作为信息安全类课程的项目参考。基于混合开发工具与后端框架搭建,覆盖通讯录、相册、短信、定位以及已安装应用程序列表等常用模块,前后端分离,全开源交付。包体共两千个文件,以JavaScript脚本、Markdown说明、HTML页面、JSON配置与CSS样式为主,文件类型占比清晰,便于按需检索和二次修改;JavaScript与JSON支撑业务逻辑与数据配置,HTML与CSS构成管理后台界面,Markdown便于阅读理解,另附数据库文件可辅助快速建库,压缩包大小约三十八兆字节。已有两百一十人学习下载。整套源码开放完整工程,部署后能快速理清双端采集、接口交互与后台权限管理逻辑,包含前后端工程、数据库结构与环境配置说明,能减少从零搭建的时间成本,适合需要搭建同类信息系统的开发者参考。
1. 双端信息获取系统:先想清楚能做什么,再谈权限
移动端双端获取通讯录、相册、短信、定位、已安装APP信息的系统源码全开源,看到这个标题的开发者,第一反应基本都是“敏感权限怎么过”。这确实是整套东西的命门。把它放到合法场景里看,它其实是手机回收检测、企业设备盘点、家长守护工具里最常见的五类数据采集需求。适合谁?适合正在做工具类 App 或接了设备上报需求的一线开发,Android 和 iOS 两端要一起做,又不想从零设计权限链路的人。拆这套源码之前,先立一个规矩:所有数据必须在用户知情并主动授权的前提下采集,没有这个前提,代码写得再顺都是雷。
2. 权限模型与合规边界:Android 运行时权限与 iOS 隐私清单怎么配
拿到源码包,先别急着看读取逻辑,打开清单文件把权限体系吃透。移动端不同于服务端,每一类敏感数据都需要双重关卡:Android 靠运行时权限申请,iOS 靠 Info.plist 的用途描述加系统弹窗。两个平台的设计思路不一样,配错了轻则拿不到数据,重则直接被系统判死刑。
2.1 Android 端:运行时权限申请与 callback 处理
我一般会先翻 AndroidManifest.xml,把声明的权限列一遍,再对照代码里的运行时申请逻辑。很多半吊子开源项目只在 manifest 里写了权限,没有运行时请求,跑起来永远拿不到数据。正确做法是下面这段:
private static final String[] PERMISSIONS = { Manifest.permission.READ_CONTACTS, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.READ_SMS }; private void requestPermissionsIfNeeded() { List<String> denied = new ArrayList<>(); for (String p : PERMISSIONS) { if (ContextCompat.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) { denied.add(p); } } if (!denied.isEmpty()) { // REQ_CODE 是自己定义的请求码,回调时用来区分本次请求 ActivityCompat.requestPermissions(this, denied.toArray(new String[0]), REQ_CODE); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode != REQ_CODE) return; for (int i = 0; i < grantResults.length; i++) { if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { Log.e("Permission", "被拒绝: " + permissions[i]); // 不要直接放弃,而是引导用户到系统设置页里手动授权 } } }这段代码的逻辑是把需要的权限一次性申请,但实际项目里我强烈建议把 READ_SMS 单独拆出来,等用户真正触发短信相关功能时再申请。一次性弹四个权限,用户大概率会全部拒绝,尤其是短信这种极度敏感的权限。还有个常被忽略的点:API 33 之后,读取图片的权限从 READ_EXTERNAL_STORAGE 分化成了 READ_MEDIA_IMAGES,如果你把 targetSdkVersion 指到了 34,还沿用老的权限名,系统会直接无视你的申请。
2.2 iOS 端:Info.plist 隐私描述与框架选择
iOS 端没有运行时申请这套代码,系统只认 Info.plist 里的用途描述。描述文案写得不清楚,审核会以“未说明收集目的”驳回。常见的五类描述大概是这样的:
<key>NSContactsUsageDescription</key> <string>用于展示设备内联系人信息,并仅在你授权后读取。</string> <key>NSPhotoLibraryUsageDescription</key> <string>用于选择并备份你授权的照片。</string> <key>NSLocationWhenInUseUsageDescription</key> <string>用于记录你授权的定位信息。</string> <key>NSMessageUsageDescription</key> <string>用于对用户主动授权的短信内容进行本地解析。</string>这里有一个非常容易踩的坑:iOS 并没有公开 API 能直接读取用户短信。NSMessageUsageDescription 这个 key 只在调用系统 MessageUI 框架发送短信时才会触发,并不能让你枚举历史短信。如果你在源码包的 iOS 目录里看到“ReadSMS”之类的代码,十有八九是用了私有 API,这种代码一旦上应用商店就会被拒,甚至会被标记为违规采集。所以拆这套源码时,我通常直接把 iOS 端短信模块当成一个空壳来看。
相册和定位是 iOS 端真正能落地的部分。相册读取走 Photos 框架,定位走 CoreLocation,权限弹窗由系统根据 Info.plist 自动触发。iOS 14 之后,照片授权多了“选择照片”的选项,对应的 PHPhotoLibrary 授权状态是 PHAuthorizationStatusLimited,如果你的 App 不支持这种模式,用户选了“部分授权”后,你的代码可能一行数据都拿不到。
2.3 合规自检:短信与定位在哪些场景下才能碰
上面这层是技术配置,下面这层是业务边界。短信这块,真正能名正言顺用的场景只有一个:验证码自动填写。正规应用通过 SMS Retriever API 拿到单次验证码,而不是去读整个短信库。如果你在做的是手机回收检测、自助售货机设备管理、员工手机盘点之类的事,用户根本不会预期你用短信权限,直接把它从功能清单里划掉。
定位也一样。很多项目把定位想成“只要用户同意就能随便搞”,实际上还要区分前台和后台。只做门店打卡、设备定点盘点,在前台用 when-in-use 就够了;如果产品经理要求 App 在后台持续回传位置,Android 端除了 ACCESS_FINE_LOCATION 之外还要单独的 ACCESS_BACKGROUND_LOCATION,iOS 端要开 including location 的后台模式,并且审核时会出现“你的 App 为什么需要在后台获取位置”的必答问题。这块不是代码能不能写的问题,是产品逻辑站不站得住的问题。
所以,拆完权限模型我建议先做一次自检:把源码中涉及的每项权限写进一张表,标注业务用途、用户触发的具体功能、是否必须后台运行。表里有一项答不上来,就不要往设备上装。
3. 核心采集模块再拆:通讯录、相册、定位与已安装 App 的读取逻辑
权限打通后,看数据读取层。我把四个最关键的模块拆开讲,每个模块都标注了代码路径和容易忽略的参数。
3.1 通讯录读取:Cursor 遍历还是 ContactsContract 聚合
Android 通讯录读起来简单,但很多人死在“一个联系人有多个号码”的重复数据上。直接用 ContactsContract.CommonDataKinds.Phone.CONTENT_URI 查询,同一个名字会返回多行:
ContentResolver resolver = getContentResolver(); Cursor cursor = resolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, new String[]{ ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }, null, null, null); if (cursor != null) { try { while (cursor.moveToNext()) { long contactId = cursor.getLong(0); String name = cursor.getString(1); String number = cursor.getString(2); // 这里不要直接插入数据库,先合并到 Map<Long, Contact> } } finally { cursor.close(); } }关键的合并逻辑:遍历时把同一个 contactId 的号码拼成一个列表,最后再落库。这样一对多的关系在 SQLite 里能保持整洁。iOS 端读取通讯录走 Contacts 框架,代码链路差不多,但要注意 CNContactStore 的访问权限需要 await 一个授权状态,而且首次访问会弹系统窗,不要在自己的加载回调里做太多同步操作,否则容易卡线程。
3.2 相册读取:PHImageManager 的缩略图与原始图策略
iOS 相册读取最典型的坑是拿到的图特别糊。原因就是 PHImageManager 默认的 deliveryMode 是 opportunistic,系统会先用低质量版本回调,你拿到后就立刻显示了,等高清版本回调时没有刷新 UI。正确的枚举和请求方式是这样的:
let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] let assets = PHAsset.fetchAssets(with: options) assets.enumerateObjects { (asset, _, _) in let manager = PHImageManager.default() let requestOptions = PHImageRequestOptions() requestOptions.deliveryMode = .highQualityFormat requestOptions.isNetworkAccessAllowed = true manager.requestImageDataAndOrientation(for: asset, options: requestOptions) { data, _, _, _ in // data 此时是原始图片数据,可以直接写文件 } }requestImageDataAndOrientation 拿到的才是原始数据,适合做备份和导出。如果你只是想生成用户可预览的缩略图,用 requestImage 并把 targetSize 设成 400 左右比较省内存。注意 isNetworkAccessAllowed 会让 iCloud 上的照片也同步下来,但同时也会带来网络耗时,必须捕获下载进度和错误回调。
Android 端相册读取我一般用 MediaStore 查询,按 DATE_ADDED 倒序拿照片的 content Uri,再用 ContentResolver 的 openInputStream 读取。Android 13 及以上读取图片权限已经分开,如果源码只写了 READ_EXTERNAL_STORAGE 而 targetSdkVersion 很高,MediaStore 查询会返回空列表,这一点在后面的避坑章节会展开。
3.3 定位:GPS 与网络定位的回调选择
定位模块较容易出问题的不是拿不到权限,而是拿到权限后定位一直不返回。原因多半是只看 GPS_PROVIDER,不看网络定位:
LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { Criteria criteria = new Criteria(); criteria.setAccuracy(Criteria.ACCURACY_FINE); String provider = locationManager.getBestProvider(criteria, true); locationManager.requestLocationUpdates(provider, 30000, 50, locationListener); }getBestProvider 方法会综合 GPS、Wi-Fi、基站信息返回当前最优的 provider。两个核心参数:时间间隔 30000 毫秒,距离间隔 50 米。如果你需要单次定位而不是连续监听,就用 requestSingleUpdate。注意每次定位结果要检查 isFromMockProvider,防止模拟位置数据污染你的业务逻辑。设备盘点类应用很容易被这个坑害。
3.4 已安装 App 列表:PackageManager 与私有 API 的取舍
Android 端拿 App 列表靠 PackageManager,核心代码不复杂:
PackageManager pm = getPackageManager(); List<PackageInfo> installedPackages = pm.getInstalledPackages(0); for (PackageInfo info : installedPackages) { if ((info.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0) { String packageName = info.packageName; String appName = info.applicationInfo.loadLabel(pm).toString(); // 保存到列表 } }这段代码避开了系统应用,只保留用户安装的第三方 App。但要注意 API 30 之后,getInstalledPackages 默认只能返回“可见”的应用列表,想拿到全部应用必须声明 QUERY_ALL_PACKAGES 权限。这个权限在应用市场上是高风险权限,非核心场景很难过审。所以我一般建议,如果业务目标是“统计用户用了哪些 App”,用 UsageStatsManager 拿每日使用记录,比直接枚举安装列表合规得多。
iOS 端不存在真正意义的“已安装 App 列表”接口,越狱私有API不算数。公开开发的 App 连自己在文件系统里的存在都被沙盒隔离,更别提枚举别人了。所以这套源码如果兼容 iOS,App 列表这组功能大概率只能放在 Android 端,或者直接砍掉。拆源码时不要被双端字样骗了,不是所有模块都双端可用。
4. 数据落盘与上报链路:从 SQLite 表结构到服务端验签
采集完的数据如果直接往上送,一断网全丢。真正能落地的源码一定包含本地缓存、增量导出、服务端鉴权三段式设计。我们看数据在这套链路里怎么流转。
4.1 SQLite 表结构设计与字段约束
先看建表语句。通讯录、相册、定位、App 列表这四类数据的共性结构是:设备标识、业务字段、采集时间戳。设计合理的表结构长这样:
CREATE TABLE contact ( uid INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, contact_id TEXT, name TEXT, number TEXT, date_collected INTEGER NOT NULL ); CREATE TABLE app_installed ( uid INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, package_name TEXT, app_name TEXT, is_system INTEGER DEFAULT 0, date_collected INTEGER NOT NULL ); CREATE TABLE location_log ( uid INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, longitude REAL, latitude REAL, accuracy REAL, provider TEXT, date_collected INTEGER NOT NULL );关键字段是 device_id 和 date_collected。device_id 用设备唯一标识,上报时服务端要凭这个字段做数据幂等。date_collected 用 epoch 毫秒,方便增量查询。不要在表里直接存“采集时间”的字符串,排序和去重都会变得很麻烦。
4.2 批量导出与断点续传
数据量大了以后,一次性把全量数据转成 JSON 传上去是不现实的。我习惯按时间范围分片:
// 每次只导出上一次同步点之后的 500 条数据 long lastSyncTime = pref.getLong("last_sync_time", 0); List<Contact> contacts = db.queryContactsAfter(lastSyncTime, 500); JSONArray array = new JSONArray(); for (Contact c : contacts) { JSONObject obj = new JSONObject(); obj.put("name", c.name); obj.put("number", c.number); obj.put("device_id", c.deviceId); array.put(obj); } File outFile = new File(getExternalFilesDir(null), "export_" + System.currentTimeMillis() + ".json"); // 将 array.toString() 写入文件,然后上传分片的意义在于断网重传。上传成功后再更新 lastSyncTime,失败则保留文件等待重试。这里有个细节:JSON 序列化放在 Activity 里会导致主线程卡顿,一定要放到线程池或协程中执行。
4.3 服务端接收接口的鉴权与验签
数据到了服务端不能裸奔。常见的做法是设备端用密钥对请求体签名,服务端校验签名后发现非法请求直接拒绝。我写了个最小可验证的 Flask 接口:
import hmac import hashlib from flask import Flask, request, jsonify app = Flask(__name__) SECRET = b"your-secret-key" def verify_signature(body: bytes, received_sig: str) -> bool: expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received_sig) @app.route("/api/upload", methods=["POST"]) def upload(): if not verify_signature(request.data, request.headers.get("X-Sign")): return jsonify({"code": 401, "msg": "sign error"}), 401 # 解析 JSON 入库 return jsonify({"code": 0, "msg": "ok"})客户端请求时要带上 X-Sign 请求头,签名字段是对原始请求体做 HmacSHA256 得到的。服务器不需要存储明文密钥,只要保存同一个密钥副本就能校验。还有一个容易被忽视的点:同样的数据防止重复上报。服务端接口里应该把 device_id 和时间戳组合做唯一校验,重复数据幂等返回成功,否则客户端会不停重试。
5. 双端联调避坑排障:权限、相册、短信、定位与 App 列表的五条踩坑记录
这部分内容全是真机联调时容易翻车的点,按现象到原因再到解决写清楚,照着自检能省下大半天时间。
5.1 权限弹窗不出现,代码直接进 denied 分支
现象:在 Android 12 的测试机上运行,申请权限的弹窗一闪而过,结果数组里全是拒绝。
原因:最常见的是同一权限已经被永久拒绝,系统提示“不再询问”;也可能是 targetSdkVersion 太高,权限声明缺失导致弹窗背后直接返回拒绝。
解决:检查 AndroidManifest.xml 中是否声明了对应权限;在系统设置里找到应用,撤销“不再询问”标记;如果代码里对权限持续拒绝弹窗,要用 shouldShowRequestPermissionRationale 引导用户去设置页手动开。
5.2 相册取回的图片分辨率忽高忽低
现象:iOS 拿到同一张照片,有时缩略图有时原图,逻辑不稳定。
原因:PHImageRequestOptions 的 deliveryMode 没有显式设置,系统默认使用 opportunistic 品质。
解决:将 deliveryMode 改为 highQualityFormat,如果想要原始文件则改用 requestImageDataAndOrientation。同时设置 isNetworkAccessAllowed,不然 iCloud 上的图片会一直返回 nil。
5.3 Android 10 之后短信内容查出来永远是空
现象:权限已授权,ContentResolver 也能访问 SMS 表,但查询结果为空。代码里还总是没有任何异常。
原因:API 29 开始在权限判定上对读短信接口进行过滤,很多定制 ROM 干脆默认关闭第三方应用读短信;部分系统邮件和短信应用独占数据库,第三方查询被拦在外面。
解决:不要依赖直接读数据库。改用 SMS Retriever API 获取一次性验证码,这既合规又不依赖完整短信权限,业务目标基本都能达成。
5.4 后台定位回调彻底停止
现象:App 切后台 3 分钟后,onLocationChanged 不再触发;杀进程后更安静。
原因:系统进入 Doze 模式,GPS 和网络定位的定时回调被中断;另一个原因是只注册了 GPS_PROVIDER,室内 GPS 基本收不到星。
解决:定位逻辑放到前台服务里,或改用 WorkManager 做周期任务,WorkManager 的 minimumLatencyMillis 要拉开到 15 秒以上,否则会被系统按批量调度合并。
5.5 iOS 端拿不到“已安装 App 列表”
现象:从源码包里找到一个私有 API 调用,跑起来能拿到部分数据,但一打包上架就被拒绝。
原因:iOS 对 App 枚举能力封闭,任何通过 LSApplicationWorkspace 等私有接口枚举 App 的行为都属于严重违规。
解决:iOS 端直接去掉这个功能,只保留 Android 端枚举能力;iOS 端如果一定要做设备画像,可以改用 App 内可记录的用户操作行为来替代。
6. 进阶技巧:定时采集与电量优化,用日志完整验证设备画像
采集功能做出来只是开始,真机上的稳定性和电量消耗才是打磨重点。我习惯把整个采集任务交给 WorkManager,而不是自己开 AlertManager 闹钟。原因很简单:WorkManager 能感知系统用电策略,并把多个任务合并到同一次唤醒周期里执行。最小周期虽然可以设到 15 分钟,但真实业务没必要这么频繁,设备信息采集一天做一次就够。
任务拆成两组:一组在 Wi-Fi 充电状态下做全量备份,另一组在电量 30% 以上时只做增量更新。这样用户不会因为被频繁采集而卸载 App。代码实现上,WorkManager 的 PeriodicWorkRequest 可以这样写:
PeriodicWorkRequest backupWork = new PeriodicWorkRequest.Builder( BackupWorker.class, 24, TimeUnit.HOURS) .setConstraints(new Constraints.Builder() .setRequiresCharging(true) .setRequiredNetworkType(NetworkType.WIFI) .build()) .build(); WorkManager.getInstance(context).enqueueUniquePeriodicWork( "device_backup", ExistingPeriodicWorkPolicy.KEEP, backupWork);注意 setRequiresCharging 和 setRequiredNetworkType 两个约束条件。如果不想让用户等太久,可以把网络约束去掉,让它在蜂窝网络下也能跑,但要在日志里记录本次上传花费的流量,方便后续统计。
还有个常被忽略的环节是验证“采集状态机”。我每次升级采集逻辑后,都会在本地记录一条状态日志,字段包括:开始采集时间、各模块成功数、失败原因、结束时间、本次耗时。市面上很多源码只有一个“采集成功”的弹窗,内部有没有漏采完全不知道。你可以在数据库里加一张 collection_log 表,字段设计如下:
CREATE TABLE collection_log ( uid INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, module TEXT NOT NULL, success_count INTEGER DEFAULT 0, fail_count INTEGER DEFAULT 0, error_msg TEXT, started_at INTEGER, finished_at INTEGER );每次采集任务结束按模块写入记录。想排查问题时,一条 SQL 就能看出问题在哪,比如某个型号的手机通讯录读取失败率特别高,或某次更新后续短信模块不再工作。那一次我上线了新的定位回调逻辑,连续三天没收到线上日志,就是靠 collection_log 发现所有设备的 location_log 都为空,最后定位到是某厂商 ROM 把后台启动限制成了“设备重启后不启动”,所以从那以后我每次发版前都强制走一遍真实设备状态日志的离线比对流程,把每个模块的成功数、失败数、耗时三个数字核对完才敢放量。
希望帮到你。
本文还有配套的精品资源,点击获取