简介一套完整双端 APP 信息系统全开源项目涵盖通讯录读取、相册访问、短信管理、定位获取及已安装应用信息查询等模块适合有 APP 开发基础的学习者研究移动端信息采集与权限管理设计。压缩包共 2000 个文件、约 38.24MB以 1298 个 js 文件承载核心逻辑配合 170 个 html/htm 页面、128 个 css 样式、133 个 json 配置、181 个 md 文档及 2 个 sql 数据库脚本形成前端页面、后端接口、数据字典与部署文档并存的完整工程结构。前端可导入 HBuilder X 打包安卓与苹果双端应用后端基于 ThinkPHP 框架运行目录、伪静态、环境配置及管理后台入口均已具备部署路线完整清晰。已有 210 人学习下载源码全部开放涵盖安装、配置到后台管理的完整链路便于理解客户端与服务端的协作方式。同时以源码形式呈现适合深入拆解数据表设计、权限接口调用及双端通信逻辑作为实战型学习样本价值较高。1. 双端获取 TXL、相册、短信、定位、已安装 APP 信息系统源码全开源先把它放到合法场景里再动手当你拿到“双端获取 TXL、相册、短信、定位、已安装 APP 信息系统源码 全开源”这类源码包时第一件事不是急着部署而是先想清楚一件事它本质上是一套移动端数据采集系统。客户端负责读取手机本地通讯录TXL、相册、短信、位置和已安装应用列表服务端负责接收、存储和查询。它最常见的合法用途是备份自己的手机数据、家长经授权监护未成年子女设备、企业内部设备台账以及测试和取证。前提条件就一条设备使用者明确授权。如果你只有别人的手机号没有设备权限那这套系统对你来说就是不能用、也不该碰的东西。下面的内容全部以“设备归自己所有或已取得授权”为前提展开。这套源码按 Android 和 iOS 双端分别拆解新手能照着跑通熟手能直接看到权限、增量同步和合规这几个最大的坑。2. 双端采集系统的架构与数据链路TXL、相册、短信、定位、已安装 App 怎么统一回传拿到全开源双端源码后先别急着编译。先花半小时把它的模块划分看清楚否则后面改起来会不停返工。2.1 为什么“原生双端 统一 API”是这类源码最常见的形态常见做法是 Android 端用 Kotlin 或 JavaiOS 端用 Swift服务端用 Node.js、PHP 或 Java 写一套 REST API外加一个简单的 Web 管理后台。你用 Flutter 或 uni-app 这类跨平台框架也能写但通讯录、短信、后台定位这几个能力一旦涉及系统权限弹窗和厂商 ROM 的差异化行为跨平台封装往往会漏掉一半细节。比如 uni-app 里plus.geolocation.watchPosition在前台能拿到定位切到后台后很多国产 ROM 会直接掐掉回调你被迫回头写原生插件。所以拿到这套系统源码后先确认三件事Android 端是不是原生工程、iOS 端是不是原生工程、服务端有没有独立的数据库脚本。只要这三样齐了后面加采集项、加字段都只是纵向扩展。如果服务端和客户端是强耦合在一个进程里的“演示版”那你要有心理准备它大概率跑通没问题但稍微改一下上报协议就要动两头。2.2 数据模型与会话协议先把五类数据统一成一张表双端采集的字段五花八门但上报到服务端时可以统一成一个结构设备 ID、数据类型、业务 JSON、采集时间、客户端签名。下面是我常用的采集项字段设计也是判断源码包“有没有用心写”的标尺。采集项核心字段增量依据去重口径TXL通讯录contactId、name、phone、updatedAt按联系人修改时间增量contactId phone 唯一短信address、body、date、type按 date 增量取最近 N 天date address body 哈希相册mediaId、displayName、path、dateAdded、size按 dateAdded 增量mediaId dateAdded 唯一定位lat、lng、accuracy、provider、time前台实时 后台定期time deviceId 唯一已安装 APPpackageName、label、versionName、firstInstallTime全量扫描后 diffpackageName firstInstallTime服务端接口更简单两个就够POST /api/v1/upload接收采集数据返回code和下一次允许上报的时间GET /api/v1/config下发采集开关、间隔和增量窗口。别在这里设计复杂的消息队列双端本来就不是高并发场景一台低配服务器完全扛得住搞 Kafka 反而让全开源源码的门槛变得没必要地高。2.3 上报策略增量、去重、重试少一个都跑不稳最容易被忽略的是增量同步。通讯录和相册全量上传一次还可以短信如果每次都全量扫数据量会越来越大服务端的去重压力也会跟着涨。常见做法是客户端本地建一张sync_offset表记录每个采集项上次成功上报的时间点或 ID每次上传只取增量。服务端收到数据后用SHA256(device_id data_type payload_digest)生成指纹字段加唯一索引重复上报直接忽略。还有一个长期跑下来才能发现的细节客户端如果一次性把相册几百张照片塞进一个 JSON 上报服务端大概率会超时。我一般会把大 payload 按条拆分每批最多 50 条上传失败时退避重试指数退避从 5 秒起步最多到 5 分钟。如果源码包里没有这个退避逻辑建议自己补上否则弱网环境下服务端会被重试请求打穿。3. Android 端实现通讯录、短信、已安装 App、定位与相册的最小可跑代码Android 端的采集逻辑不复杂难的是权限适配和厂商 ROM。这章给一段能直接放进MainActivity的最小实现配合注释说明。3.1 先过权限关Android 6、10、11、13 的权限矩阵把下面的权限声明加到AndroidManifest.xml。别全申请按你实际要用的采集项裁剪。READ_SMS和QUERY_ALL_PACKAGES属于敏感权限申请时会直接影响应用审核测试机无所谓正式上架前要逐个核对。uses-permission android:nameandroid.permission.READ_CONTACTS / uses-permission android:nameandroid.permission.READ_SMS / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_BACKGROUND_LOCATION / uses-permission android:nameandroid.permission.QUERY_ALL_PACKAGES / uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES /运行时申请用ActivityCompat.requestPermissions按组申请一次申请一组弹窗会连续出现。Android 13 上如果把READ_EXTERNAL_STORAGE和READ_MEDIA_IMAGES混在一起申请系统会把后者直接忽略Android 10 之后的定位弹窗多了一个“仅本次运行”选项用户如果选了它你的后台定位代码就收不到数据。这就是热词里“android只有打开app才能获取定位”的典型来源。3.2 通讯录和短信读取TXL 去重与短信增量通讯录读取建议用ContactsContract.CommonDataKinds.Phone它直接返回展示名和电话号码不用再联查 RawContacts。fun readContacts(): ListMapString, String { val list mutableListOfMapString, String() val projection arrayOf( ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER ) contentResolver.query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, projection, null, null, null )?.use { cursor - val idIdx cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) val nameIdx cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) val numIdx cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) while (cursor.moveToNext()) { // 同一个联系人可能有多个号码用 CONTACT_ID 关联 list.add(mapOf( contactId to cursor.getString(idIdx), name to cursor.getString(nameIdx), phone to cursor.getString(numIdx) )) } } return list }注意同一个联系人名下可能有好几个号码去重时用contactId phone拼接做唯一键只看号码不看 contactId会把同名不同号的人合并成一条脏数据。短信读取同理只查收件箱Telephony.Sms.Inbox按date DESC排序过滤最近 7 天的增量。fun readRecentSms(daysAgo: Long 7): ListMapString, String { val list mutableListOfMapString, String() val threshold System.currentTimeMillis() - daysAgo * 24 * 60 * 60 * 1000 val projection arrayOf(_id, address, body, date) contentResolver.query( Telephony.Sms.Inbox.CONTENT_URI, projection, date ?, arrayOf(threshold.toString()), date DESC )?.use { cursor - val bodyIdx cursor.getColumnIndex(body) val dateIdx cursor.getColumnIndex(date) val addrIdx cursor.getColumnIndex(address) while (cursor.moveToNext()) { list.add(mapOf( address to cursor.getString(addrIdx), body to cursor.getString(bodyIdx), date to cursor.getString(dateIdx) )) } } return list }READ_SMS在小米、vivo 这类 ROM 上有额外的一道“短信权限”开关经常出现应用商店夜了权限但读出来的列表一直是空的。这类机型只能用Settings.ACTION_APPLICATION_DETAILS_SETTINGS引导用户去手动打开没有代码层面的后门。3.3 已安装 App 列表与相册权限收缩后的两种做法Android 11 之后PackageManager默认只返回部分应用必须配合 manifest 里的queries声明或QUERY_ALL_PACKAGES权限才能看到全量列表。下面是遍历应用的核心代码fun readInstalledApps(): ListMapString, Any { val pm packageManager val apps pm.getInstalledApplications(PackageManager.GET_META_DATA) return apps.map { app - val label pm.getApplicationLabel(app).toString() val packageInfo pm.getPackageInfo(app.packageName, 0) mapOf( packageName to app.packageName, label to label, versionName to packageInfo.versionName ?: , firstInstallTime to packageInfo.firstInstallTime ) } }相册读取用MediaStore.Images.Media按DATE_ADDED倒序取增量。要注意的是MediaStore查出来的DATA字段在高版本 Android 上已经不可靠不要拿它当唯一路径直接用_ID和DATE_ADDED做业务键。fun readRecentImages(daysAgo: Long 7): ListMapString, Any { val list mutableListOfMapString, Any() val threshold System.currentTimeMillis() / 1000 - daysAgo * 24 * 60 * 60 val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media.SIZE ) contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, ${MediaStore.Images.Media.DATE_ADDED} ?, arrayOf(threshold.toString()), ${MediaStore.Images.Media.DATE_ADDED} DESC )?.use { cursor - while (cursor.moveToNext()) { list.add(mapOf( mediaId to cursor.getLong(0), name to cursor.getString(1), dateAdded to cursor.getLong(2), size to cursor.getLong(3) )) } } return list }这段代码在 Android 13 上要换成READ_MEDIA_IMAGES权限在 Android 12 及以下用READ_EXTERNAL_STORAGE。同一套源码如果只声明了旧权限Android 13 的真机上相册会永远是空列表这是全开源源码包里最常见的陈旧代码问题。3.4 定位前台实时拿后台靠前台服务兜底定位是最容易“看上去能用跑起来没数据”的模块。前台用LocationManager就够了后台必须配合前台服务否则 Android 10 会在一分钟内杀掉定位回调。val locationManager getSystemService(LOCATION_SERVICE) as LocationManager if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) PackageManager.PERMISSION_GRANTED) { locationManager.requestLocationUpdates( LocationManager.GPS_PROVIDER, 60_000L, // minTime单位毫秒建议 60000 以上省电 30f, // minDistance单位米30 米以下频繁触发没意义 object : LocationListener { override fun onLocationChanged(location: Location) { val payload mapOf( lat to location.latitude, lng to location.longitude, accuracy to location.accuracy, provider to location.provider, time to location.time ) // 把 payload 写入本地 SQLite等待上报 } } ) }minTime和minDistance是省电的玄学。实测中 60 秒 30 米的组合在城市通勤场景下能拿到足够的轨迹点又不会把电量消耗得很明显。如果你做的是室内定位或高精度定位场景可以把minTime降到 5 秒但一定要在服务端限制上报频率否则定位数据会占掉整个数据库的 80% 容量。4. iOS 端实现定位和相册能拿但短信这条路从根上就不同iOS 端的核心问题是权限模型和 App 生命周期代码逻辑不是最难的。4.1 Info.plist 权限声明少一个字符串就闪退iOS 上权限弹窗的文案不写清楚系统会在第一次调用时直接让应用崩溃。下面这四行是通讯录、定位、相册三件套的基本配置。keyNSContactsUsageDescription/key string需要访问通讯录用于授权设备的数据备份/string keyNSLocationWhenInUseUsageDescription/key string需要定位权限用于设备轨迹采集/string keyNSLocationAlwaysAndWhenInUseUsageDescription/key string需要在后台持续获取位置/string keyNSPhotoLibraryUsageDescription/key string需要访问相册用于图片备份/string如果你在源码包里看到这些键存在但文案是空字符串弹窗时系统会拒绝授权用户侧表现为“点了允许但读取还是空”。这是非常隐蔽的翻车点。4.2 iOS 短信采集第三方 App 根本读不到短信这是无知不接触过 iOS 的人最容易踩的坑。系统允许第三方 App 读取通讯录、相册和定位但把短信封装在MessageUI和短信通知扩展里普通 App 没有直接读取SMS Inbox的公开 API。因此一个声称“双端都能获取短信”的全开源项目iOS 端通常只有三种实现一是只做短信转发通知用户在收到新短信时通过UNUserNotificationCenter拿到通知内容二是通过企业 MDM 下发的描述文件拿到受管理设备的数据通道三是干脆只返回一个空数组。你拿到源码后先搜 iOS 工程里有没有UNNotification或MDM关键词有就是走了通知截获没有就是在占位。这里要奉劝一句通知截获只能在用户把应用的通知权限开启后拿到新短信的推送内容既读不了历史短信也拿不到短信发送者号码的完整格式别把它当成“读取短信”的替代品。做这一步前必须拿到设备使用者的明确同意否则合规上根本过不了关。4.3 定位用 Significant Location Change 代替持续回调iOS 的持续后台定位会被系统频繁挂起实际工程里我会优先用startMonitoringSignificantLocationChanges它只在用户移动了约 500 米或更换基站时才回调耗电极低也躲开大多数后台被杀的问题。import CoreLocation class DeviceLocationManager: NSObject, CLLocationManagerDelegate { private let manager CLLocationManager() func start() { manager.delegate self manager.requestAlwaysAuthorization() manager.startMonitoringSignificantLocationChanges() } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { if manager.authorizationStatus .authorizedAlways { manager.startMonitoringSignificantLocationChanges() } } }如果业务上确实需要间隔几秒采一次点那就必须在 Info.plist 里声明UIBackgroundModes [location]并且全程保持“前台无犯”状态没有这个声明后台采集会在一段时间后被系统冻结。这一条在 iOS 上没有例外别指望用后台任务绕过。4.4 相册iOS 14 之后的 Limited 权限是增量采集的绊脚石iOS 14 后用户可以选择“仅允许访问部分照片”你拿到的PHAsset只会是用户选中的那几张。下面这段是最小的相册读取代码注意判断authorizationStatus是否为.limitedimport Photos func readRecentAssets() { PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in let options PHFetchOptions() options.sortDescriptors [NSSortDescriptor(key: creationDate, ascending: false)] // 只取最近 7 天 options.predicate NSPredicate(format: creationDate %, Date(timeIntervalSinceNow: -7 * 86400) as CVarArg) let result PHAsset.fetchAssets(with: options) result.enumerateObjects { asset, _, _ in var name let resources PHAssetResource.assetResources(for: asset) if let res resources.first { name res.originalFilename } // 这里把 asset.localIdentifier 和 name 写入本地待上报列表 } } }拍摄时间超过 7 天的照片在增量窗口外不会出现在这个结果集里。服务端必须保存客户端上次上报的localIdentifier最大值下次从那个点继续增量相册才能持续补全。另外.limited状态下用户后续在相册里追加照片你的 App 不会自动收到通知需要在下次启动时再做一次全量扫描把新增的localIdentifier追加上报。5. 服务端接收与数据落库的合规避坑全开源源码部署后最容易翻车的 5 个位置服务端是把“能跑”变成“可信赖”的分水岭。很多双端源码的客户端写得有模有样服务端只提供一个小脚本跑起来全是问题。这章除了补服务端的最小实现还把常见问题按“现象 → 原因 → 解决”列出。5.1 上传接口一个轻量 Express 接收点与指纹去重用 Node.js Express 写一个接收接口非常快重点是入库前的指纹判断。const crypto require(crypto); const express require(express); const app express(); app.use(express.json({ limit: 10mb })); app.post(/api/v1/upload, async (req, res) { const { deviceId, dataType, payload, clientTime } req.body; // 指纹 设备 类型 服务端可见的 payload 前 64 字节用来去重 const digest crypto.createHash(sha256) .update(${deviceId}|${dataType}|${JSON.stringify(payload).slice(0, 64)}) .digest(hex); const fingerprint crypto.createHash(sha256) .update(${deviceId}|${clientTime}|${digest}).digest(hex); // 先查指纹再写库 const exists await db.query(SELECT id FROM event_log WHERE fingerprint ?, [fingerprint]); if (exists.length 0) { return res.json({ code: 0, duplic: true }); } await db.query( INSERT INTO event_log (device_id, data_type, payload, fingerprint, created_at) VALUES (?, ?, ?, ?, NOW()), [deviceId, dataType, JSON.stringify(payload), fingerprint] ); res.json({ code: 0, duplic: false }); });这段代码里的limit: 10mb很重要相册一次性上传超大 JSON 时会因为默认 100kb 限制直接返回 413。指纹字段加唯一索引后重复请求会被数据库自身拦截即使并发请求同时进来也不会写重。5.2 数据落库一张通用事件表的建表语句五类采集数据没必要建五套表一张带data_type的宽表足够支撑后续所有查询场景。推荐建表语句如下CREATE TABLE event_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, device_id VARCHAR(64) NOT NULL, data_type VARCHAR(20) NOT NULL, -- contact / sms / photo / location / app payload JSON NOT NULL, fingerprint VARCHAR(64) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_device_type_time (device_id, data_type, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;payload用 JSON 类型在 MySQL 5.7 上可以直接payload-$.lat做条件查询不用拆列。索引按device_id data_type created_at建立查询“某台设备最近的定位轨迹”是这条索引最典型的应用。千万别再把短信和定位分别存到两张表后续想按时间线串联事件时会非常痛苦。5.3 通信安全HTTPS 与签名是两回事全开源源码如果自带的是 HTTP 明文接口部署时必须换成 HTTPS否则短信和通讯录会在链路上被抓包。更稳的做法是每个请求加一个签名参数签名算法用HMAC-SHA256(ts, secret)secret 内置在客户端代码里并做混淆。签名的作用是防止数据包被中间人篡改但不能防止客户端本身被盗用。一旦有人在某个设备上脱壳拿到 secret就能伪造成你设备的deviceId上报假数据。所以服务端后台要加一个“设备活跃度”视图精确到小时级如果某台设备每天只上报一次定位却大量上报短信这就要注意是否有人伪造了这个设备的请求。5.4 合规避坑清单5 条一线运行中真实遇到的坑坑一后台定期上传不工作只有打开 App 才能拿到定位。现象Android 设备灭屏后定位数据停止增长亮屏后立刻补传几条。原因Android 10 对后台定位做了双重限制ACCESS_FINE_LOCATION只代表前台后台还需要ACCESS_BACKGROUND_LOCATION且用户必须在系统设置里把权限改成“始终允许”。解决在应用内引导用户进入定位权限设置页同时用前台服务包裹定位采集逻辑。坑二Android 13 上通讯录读出来是空列表但权限明明给了。现象华为、小米、Pixel 都有反馈代码在 Android 12 正常升级后失联。原因Android 13 对READ_CONTACTS引入新的模糊授权流程部分用户误选了“仅本次运行”进程被杀后权限自动回收。解决每次启动时主动查checkSelfPermission拿到未授权就重新弹窗并明确提示“拒绝后只能读到空通讯录”。坑三短信表越涨越快一个月就几百 MB。现象数据库文件不断膨胀定位和通讯录数据量正常。原因客户端每次全量扫描短信服务端指纹去重逻辑有一定概率漏掉同一短信在两次上报中内容前缀相同的情况。解决客户端改为按日期增量最近 7 天之外的短信不再扫服务端把指纹字段改成date address body的哈希减少碰撞。坑四iOS 相册授权拿到.limited导致只看到 6 张照片。现象系统弹窗里用户选了“部分允许”后面相册数据再也不增长。原因iOS 14 的新权限模型部分允许状态下只有用户勾选的照片能进PHAsset。解决检测到.limited时调用PHPhotoLibrary的presentLimitedLibraryPicker引导用户追加照片并把每次追加的localIdentifier存到本地增量表。坑五QUERY_ALL_PACKAGES在国产 ROM 上被当成风险权限扫码安装直接被拦截。现象APK 在原生 Android 上装得好好的在小米、华为上安装时提示“检测到风险权限”。原因应用列表权限被厂商风险控制标记。解决如果只需要判断某个应用是否安装改用queries声明目标包名不要申请全局权限现场装测试包时在厂商 ROM 里关闭“纯净模式”或“安全检测”。6. 验证方法与调试技巧用模拟位置和测试数据把双端链路完整走一遍整套系统跑通之后不要直接拿真机上的真实数据开始测试。先搭一套隔离的测试环境把五类数据全部注入一遍再用服务端对账确认链路完整。我的常规做法是三步走。第一步注入测试数据。短信用 Android 模拟器的控制台直接发送adb emu sms send 13800138000 test比手动造数据快得多。通讯录在模拟器里手动添加两个联系人即可。相册用adb push test.jpg /sdcard/Pictures/推一张图然后发送广播让媒体库扫描。已安装 App 列表不用额外造数据模拟器里的系统应用就是现成的样本。定位数据用开发者选项里的“选择模拟位置信息应用”装一个市面上常见的模拟位置 App把坐标设到你熟悉的办公楼附近观察上报点是否连续。第二步盯着服务端对账。跑完一轮采集后用一条 SQL 看每个设备每种类型的数据量SELECT device_id, data_type, COUNT(*) AS cnt, MAX(created_at) AS last_time FROM event_log GROUP BY device_id, data_type ORDER BY cnt DESC;如果服务端显示sms有 2 条而模拟器实际只发了 1 条说明指纹去重失效如果location只有一条且时间点集中在锁屏前说明后台定位没生效。拿设备和类型做交叉验证比单纯看客户端 Toast 里的“上传成功”靠谱得多。第三步做一次从风控到恢复的演练。我把服务端接口故意停掉十分钟让客户端积压一批数据再恢复服务看它能不能在退避策略下自动续传。全开源源码一般不会自带这一套所以这个演练特别能检验你前面对重试逻辑的改动是否正确。至少要在弱网和高并发两个场景下各跑一遍后者可以用模拟器同时开三个实例来模拟三台设备并发上报。最后说一个我的习惯这类采集系统上线后前两周每天都要看一次服务端的日志和数据库增长曲线而不是等到出了问题才查。我在第一次部署时就是没设指纹唯一索引一周后库里堆了两千条重复短信删数据比写代码痛苦得多。搞这种系统多想想“数据到了服务端之后还安不安全”比多刷几个采集技巧更有用。希望帮到你。本文还有配套的精品资源点击获取