PhoneGuard 开源系列第六篇。前五篇我们聊了应用锁、ADB 取证、图标伪装、设备管理器自动锁机和 SIM 哨兵,这一篇补上防盗闭环里最解气的一环:手机被入侵时,让手机自己往外发短信报案。
一、传统防盗的三板斧,全都盲
手机丢了,常规操作就三招:远程锁定、定位追踪、挂失 SIM 卡。
但仔细想想,这三招有个共同的死穴——全都依赖"你还能连上那台手机" 。
小偷不是傻子。断网、拔卡、关机、进 Recovery 双清,三板斧瞬间全瞎。你看着查找手机页面上最后出现在某小区的定位点,然后就没有然后了。
换个角度想:小偷拿到手机后,一定会做某些动作——换自己的 SIM 卡、连电脑导数据、连猜密码。这些动作拦不住,但它们本身就是"活信号":能不能让手机在被动的瞬间,主动把消息送出去?
这就是 Avast、Lookout 这类老牌防盗软件的 SIM Change Alert 思路,也是我这篇要讲的实现:换卡告警。
二、最妙的一环:告警短信是"用小偷的卡"发出去的
先说清楚这个设计的精妙之处。
手机被换卡后,机里插的已经是入侵者的 SIM 卡。此时程序发出一条告警短信,这条短信走的是对方的号码、记在对方的运营商账单上、出现在对方的实名信息里。
也就是说:收件人只要收到这条短信,顺手回拨或截图,入侵者的手机号码就到手了。 这不是定位,是自投罗网。
在 PhoneGuard 里,这个能力做成了三个触发源共用一套告警管道:
表格
触发源 监听方式 场景
SIM 卡被换 SIM_STATE_CHANGED 广播 + ICCID 绑定对比 小偷拔卡换卡
连接电脑/USB USB 设备接入广播 + ADB 状态 小偷连电脑想导数据
密码连猜 设备管理器失败计数回调 小偷暴力试密码
任何一个触发,都会向机主预设的"紧急号码"发一条告警短信。下面拆开讲实现。
三、第一步:监听"换卡"这个动作
Android 提供了系统广播 android.intent.action.SIM_STATE_CHANGED,SIM 卡每次插拔、上电、加载都会广播。但注意,它是个状态机,不能一收到广播就喊"换卡了"——正常开机也会触发。
关键在 ss 这个 extra 里的两个状态:
kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SimChangeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "android.intent.action.SIM_STATE_CHANGED") {
val state = intent.getStringExtra("ss")
if (state == "ABSENT" || state == "LOADED") {
// ABSENT = 卡被拔出;LOADED = 新卡加载完成
// LOADED 时启动前台服务做进一步比对
if (state == "LOADED") {
val serviceIntent = Intent(context, SimGuardService::class.java)
context.startForegroundService(serviceIntent)
}
}
}
}
}
LOADED 意味着新卡已经就绪,这时候才轮到第二步:这张卡是不是原来那张?
四、第二步:ICCID 绑定对比——这里有个 API 坑
思路很简单:第一次使用时把当前卡的 ICCID(SIM 卡唯一序列号)存下来,之后每次 LOADED 就拿新卡的 ICCID 和存档比对,不一致 = 换卡。
但这里埋着一个很多文章不提的坑:从 Android 10(Q)开始,普通应用调 getSimSerialNumber() 已经拿不到 ICCID 了——没有特权权限只会返回 null 或直接抛 SecurityException。
我的处理是按系统版本降级到 simCarrierId(不需要特殊权限):
kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private fun getCurrentIccid(): String {
return try {
val tm = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
tm.simCarrierId.toString() // Q+ 的替代方案
} else {
@Suppress("DEPRECATION")
tm.simSerialNumber ?: ""
}
} catch (e: SecurityException) {
""
}
}
carrierId 是运营商分配的数字 ID,同一张卡值不变,做"是否换卡"的比对够用了。防御式写法上,任何异常都返回空串,比对逻辑只在两边都非空时才判定,避免权限异常导致误报。
五、第三步:把短信发出去——SmsManager 的三个细节
确认换卡后,就是发短信。SmsManager 的调用本身只有几行,但有三个细节决定这功能是"能用"还是"稳用":
细节一:API 31 之后的获取方式变了。 getDefault() 已废弃,Android 12 起推荐 getSystemService(SmsManager::class.java),且可能返回 null(无 SIM 模块设备),要接住。
细节二:短信超长要手动拆。 告警正文带机型、时间、行动指引,很容易超 70 个汉字的单条上限。divideMessage() + sendMultipartTextMessage() 是标配:
kotlin
1
2
3
4
5
6
7
8
9
10
11
12
val sm: SmsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
getSystemService(SmsManager::class.java) ?: return
} else {
@Suppress("DEPRECATION") SmsManager.getDefault()
}
val parts = sm.divideMessage(body)
if (parts.size <= 1) {
sm.sendTextMessage(phone, null, body, null, null)
} else {
sm.sendMultipartTextMessage(phone, null, parts, null, null)
}
细节三:SEND_SMS 是危险权限,必须静默降级。 用户没点授权时,程序绝不能崩,也不能反复弹窗骚扰——我的做法是检查权限,未授权就写一条日志提示"请在 App 内授权发短信",流程安静地结束:
kotlin
1
2
3
4
5
6
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED) {
logger.logEvent(..., message = "未授权短信权限,告警未发出")
return
}
发送动作放在子线程,sendTextMessage 本身不保证同步返回结果,失败要捕获异常记录日志,方便用户在 App 内的日志页看到"为什么没收到短信"。
六、防轰炸:告警短信不能变成骚扰短信
轮询式检测有个天然问题:换卡后"新卡 ≠ 绑定卡"这个状态是持续成立的,如果每次轮询命中都发短信,小偷兜里那台手机会以 10 秒一条的频率轰炸紧急号码——电话费烧穿,告警反而被当成垃圾短信拉黑。
所以两个节流层是必须的:
SimGuard 服务层:10 秒轮询 + 30 分钟告警窗口,窗口内重复命中直接跳过;
通用告警层:五件套共用的 SmsAlertHelper 内置 10 分钟节流,同一个 Helper 无论被几个触发源调用,短信都不会刷屏。
kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
object SmsAlertHelper {
private var lastSmsAt = 0L
private const val SMS_INTERVAL_MS = 10 * 60 * 1000L
fun sendIfNeeded(context: Context, phone: String, body: String, ...) {
if (phone.isBlank()) return
if (未授权) { onResult("未授权"); return }
val now = System.currentTimeMillis()
if (now - lastSmsAt < SMS_INTERVAL_MS) return // 节流
lastSmsAt = now
Thread { ...发送... }.start()
}
}
七、短信正文怎么写?它是发给"未来的你"的
正文不是越长越好,是越可用越好。收到短信的人可能是机主自己(手机还在附近)、家人、或者捡到手机的好心人。我的模板:
plaintext
1
2
3
【PhoneGuard】您的手机SIM卡已被更换!设备:Pixel 6,时间:10-10 02:30。
若非本人操作,手机可能已被盗,请立即远程锁定、挂失SIM卡并报警。
三个要素:机型(多设备用户一眼认出是哪台)、时间(锁定案发窗口)、行动指引(不用临场想接下来干嘛)。
八、说两句边界
这套东西有用,但别神化:
它提供的是线索,不是证据。 短信暴露的号码是运营商实名信息,最终认定要靠警察,程序员不要把自己当执法者。
SEND_SMS 权限在国内应用商店上架会被严格审查,所以 PhoneGuard 的定位一直是侧载/个人使用——对个人用户来说,权限自己批,功能自己控,反而干净。
换卡告警依赖"小偷会换卡使用"这个行为假设,配合前几篇讲的设备管理器自动锁机(让刷机变难)和 ADB 取证(连电脑就留证据)一起用,才是完整的防御纵深。
九、完整源码
三个触发源 + 告警管道的全部实现都在这个开源仓库里,Kotlin + Jetpack Compose,六个独立工程按需取用:
GitHub:https://github.com/hnpdsyn/PhoneGuard1
apps/SimGuard:换卡告警(本文主角)
apps/AdbGuard:USB/ADB 接入告警
apps/Intrusion:暴力破解检测告警
apps/LoginGuard:登录失败退避 + 静默取证
apps/AppLock:应用锁 + 隐私保险箱
apps/SecurityLog:六件套统一安全日志
下一篇拆六件套的 "黑匣子"——SecurityLog 统一安全日志:怎么用一个 JSONL 文件记下全部入侵事件,既不拖慢前台,又能事后还原案发全程。
觉得有用的话点个收藏,仓库点个 Star,我们下篇见。