我最近完成了一个小型的原生 Android TOTP 身份验证器。它具备以下功能和特点:
- 可以管理多个账户,通过设置密钥、
otpauth://URI 或二维码导入账户。 - 支持 SHA-1、SHA-256、SHA-512、6 位和 8 位验证码以及自定义周期。
- 使用 Room、Android Keystore 和 AndroidX Biometric 完成本地持久化、密钥保护和应用锁。
- 项目最终构建为签名 APK,通过 GitHub Releases 发布。
从第一个项目脚手架到 v1.0.0,提交记录跨越了七个自然日。这个速度并不完全来自我对 Android API 的熟练掌握。项目从需求分析、技术选型、增量实现到代码审查和测试,都有 AI Agent 参与。
本文会从这个项目出发,记录我如何与 AI Agent 协作完成一个真实的 Android 应用,也会介绍其中的 Android、Jetpack Compose、安全实现和发布流程,以及几个比预想中更容易踩坑的地方。
项目仓库:origin-coding/totp-android
为什么选择 TOTP
我希望找一个规模不大、完成标准明确,但又不只是界面练习的 Android 项目。TOTP 身份验证器正好符合这些条件。
从表面看,它似乎只是"每 30 秒计算一次六位数字"。但真正做成一个可以安装和使用的应用,需要同时接触不少现代 Android 开发中的核心问题:
- 使用 Kotlin 实现可以独立验证的协议逻辑;
- 使用 Jetpack Compose 构建有状态界面;
- 使用 Navigation 3 管理页面和返回栈;
- 使用 Room、Flow 和 ViewModel 组织持久化数据;
- 使用 Android Keystore 保护敏感数据;
- 使用 CameraX 和 ML Kit 扫描二维码;
- 使用 AndroidX Biometric 实现应用锁;
- 编写 JVM、Repository、ViewModel 和 Compose 测试;
- 通过 GitHub Actions 构建、签名和发布 APK。
与此同时,它又有很清楚的完成点:Android 12 及以上用户能够下载、安装、添加账户、获得正确验证码,并让密钥只保存在设备本地。达到这些目标以后,项目就可以进入维护状态,而不是无限生长。
这一点对 AI 协作尤其重要。如果项目没有明确的边界,Agent 很容易根据常见"最佳实践"继续扩展:增加更多模块、引入依赖注入框架、建立复杂分层,甚至开始考虑云同步、多端支持和账号系统。这些方案未必错误,但都不属于这个项目。
所以在项目开始之前,确定好项目目标、可选支持功能,以及完全不会做的事情,这一点至关重要。
在写代码之前,先把边界写下来
项目根目录有一份 AGENTS.md。它不是产品宣传,也不是给用户看的使用说明,是一份长期有效的协作约束。
其中既包含功能目标,也包含明确的非目标。例如,这个应用支持多个标准 TOTP 账户、二维码导入、安全存储和应用锁,但不做云同步、密码管理、Passkey、推送认证、桌面端或 Google Play 分发;默认使用构造函数注入,不预先引入 Hilt 或 Koin;核心协议逻辑不依赖第三方 TOTP 运行时库;测试使用官方 Android 生态和手写 Fake,除非出现具体问题,否则不引入 MockK 或 Mockito。
这份文件解决了两个问题。
第一,它减少了重复沟通。我不需要在每个任务里重新说明"不要增加新模块"或者"不要引入一整套 Clean Architecture"。Agent 在检查仓库时就能获得稳定的项目上下文。
第二,它让审查有了依据。当某个方案看起来很完整,却超出了项目范围时,我不必凭感觉拒绝,而可以回到已经确定的目标:这项复杂度解决了什么已经存在的问题?如果没有,就不应该加入。
后来项目确实从最初的 :app 和 :core 演进成了 :app、:core、:data 三个模块,但这是因为 Room、Keystore 和 DataStore 已经形成了一组明确的 Android 数据基础设施,需要与界面层分开,而不是因为"三层架构看起来更专业"。最终也停在了三个模块,没有继续拆分 feature、domain、usecase 等层次。
这成为整个项目里最重要的一条原则:
只有当复杂度能够解决已经出现的正确性、维护性或平台边界问题时,才引入它。
AI Agent 到底做了什么
在这个项目里,AI Agent 不是单纯的代码补全工具。它参与的工作主要包括:
- 根据目标拆分开发阶段和验收条件;
- 检查 Android 官方方案和现有依赖,比较 API 选择;
- 阅读现有代码后完成局部实现;
- 为协议逻辑、Repository 和 ViewModel 补充测试;
- 运行测试、Lint 和构建,根据真实输出修复问题;
- 检查模块职责、冗余代码和发布配置;
- 整理 README、签名说明和 GitHub Actions 工作流。
人的职责则没有消失,反而变得更加集中:确定范围、决定安全取舍、判断是否接受依赖、检查 Agent 的结论、确认界面行为,以及决定什么时候可以发布。
我逐渐形成了一种更准确的分工:
| 工作 | AI Agent 更擅长 | 人必须负责 |
|---|---|---|
| 探索 | 搜索代码、列出方案、定位相关 API | 判断哪些问题值得解决 |
| 实现 | 完成边界清楚的局部修改 | 确认行为符合产品意图 |
| 验证 | 运行测试、Lint、构建并分析输出 | 判断测试是否验证了正确的事情 |
| 架构 | 发现依赖和职责问题 | 控制复杂度与项目范围 |
| 安全 | 检查常见风险和平台能力 | 接受风险并对发布结果负责 |
如果把 Agent 当成"自动写代码的人",很容易只关注它生成了多少文件;如果把它当成协作者,更值得关注的是每次修改有没有明确输入、可观察结果和验证闭环。
开发顺序:先证明核心正确,再连接 Android
项目首先从算法层面,也就是 :core 模块开始,它是纯 Kotlin/JVM,不依赖 Android,负责:
- Base32 编码和解码;
- HOTP 和 TOTP 生成;
- SHA-1、SHA-256 和 SHA-512 算法选择;
- 6 位和 8 位验证码;
otpauth://URI 的解析、格式化和校验。
这个顺序非常适合协议类功能。验证码是否正确,不应该通过"装到手机上看起来能用"来判断,而应该先用 RFC 测试向量在 JVM 上快速、确定地验证。
TOTP 本身并不复杂。它先根据当前 Unix 时间和周期计算计数器,再调用 HOTP:
text
counter = floor(unixTimeSeconds / periodSeconds)
TOTP = HOTP(secret, counter)
HOTP 使用 JCA 提供的 Mac 和 SecretKeySpec 计算 HMAC,然后按照 RFC 4226 做动态截断,最后对 10^digits 取模并补足前导零。项目没有自行实现 HMAC,也没有引入第三方 TOTP 库。
这里有一个很重要的协作经验:对于标准协议,我不会让 AI 以"它记得大概怎么实现"为完成标准。实现必须回到 RFC 测试向量。项目中的测试覆盖 RFC 4226 HOTP 向量、RFC 6238 在三种 HMAC 算法下的 TOTP 向量,以及周期边界、自定义周期、无效输入等行为。
AI 可以快速写出看起来合理的算法,但只有权威测试向量能够回答"它是否真的兼容标准"。
第一个坑:otpauth:// 不只是一次普通的 URL 解析
二维码扫描最终得到的通常是一段 otpauth:// URI,例如:
text
otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
一开始很容易把它理解成"读几个 query 参数"。实际上,兼容性细节主要藏在 URI 语义里:
- 类型位于 host,可能是
totp或hotp; - label 是单个路径段,可能同时包含 issuer 和账户名;
- label 中的 issuer 与 query 中的 issuer 同时存在时应保持一致;
- Base32 密钥可能使用小写,也可能带或不带填充;
- 算法名称可能大小写不同,但格式化时应输出规范形式;
%xx需要按照 UTF-8 严格解码;- 普通 URI query 中的
+是字面加号,不应该像 HTML 表单那样自动变成空格; - 重复参数、非法转义、错误位数、非正周期和不匹配的 issuer 都应该被拒绝。
项目最终没有直接套用表单语义的 query 解码器,转而实现了严格的百分号编码和解码,并为"保留字面加号""Unicode 往返""重复参数""非法 URI 结构"等情况建立测试。
这类问题很适合让 AI 帮助列边界条件,却不适合只让它给出一个"常见写法"。更有效的沟通方式是:
请根据 RFC 3986 和常见 Key URI Format 检查解析器,列出输入规范、默认值、拒绝条件和规范化规则;先扩展现有测试,再修改实现。
这样,任务从"写一个解析器"变成了"满足一组可以审查的兼容性约束"。
数据层:Room 里保存的不是明文密钥
完成协议核心后,项目增加了 :data 模块。界面只接触 TotpAccountRepository,Repository 负责协调 Room 和密钥保护器。
账户名称、issuer、算法、位数和周期可以直接持久化,但 TOTP 密钥不会以明文进入数据库。保存账户时,密钥先经过 AES-GCM 加密;Room 中保存的是密文和对应的随机 IV。需要生成验证码时,Repository 临时解密密钥并调用 :core。
加密密钥由 Android Keystore 生成并持有,应用只能通过系统提供的密码学操作使用它,不能把原始密钥导出。这样即使有人只取得数据库文件,也不能仅凭数据库还原 TOTP 密钥。
这里有一个容易混淆的概念:
- TOTP 密钥是从服务提供方获得、用于计算验证码的业务秘密;
- Keystore 中的 AES 密钥是设备本地生成、用于加密 TOTP 密钥的保护密钥。
两者不是同一个东西。把 TOTP 密钥"存进 Keystore"也不是一个可以无限扩展的通用数据库方案;本项目选择使用一个不可导出的 Keystore AES 密钥,加密任意数量的账户密钥,再把密文交给 Room 管理。
应用同时禁用了 Android 备份。这不是可有可无的配置:Room 数据库中的密文和 Keystore 中的设备绑定密钥必须作为一个整体考虑。如果只把密文备份到另一台设备,却没有原来的保护密钥,恢复出来的数据也无法解密。项目本身没有云同步和跨设备恢复目标,因此禁用备份是更清晰的选择。
需要强调的是,这仍然只是一个学习项目,并没有经过独立安全审计。使用平台 API、避免明文和减少权限能够缩小风险,但不能把"采用了 Keystore"直接等同于"已经绝对安全"。
Compose 中真正麻烦的是状态和生命周期
界面使用 Jetpack Compose 和 Material 3,导航使用 Navigation 3。路由被建模为实现 NavKey 的可序列化类型, NavDisplay 根据返回栈创建账户列表、新增、编辑、更换密钥、二维码扫描和设置等页面。
Compose 本身让 UI 声明变得直接,但验证码页面并不是一个完全静态的列表。每个账户都有周期倒计时,验证码会在周期边界变化。如果简单地为列表中所有账户启动持续刷新任务,不仅浪费资源,也会让状态和生命周期更难管理。
项目最后采用了更小的行为模型:只有用户展开的账户才生成并刷新验证码;切换或收起账户时,前一个刷新协程会被取消。刷新循环每次读取当前时间,使用 floorDiv 判断 TOTP counter 是否变化,只有进入新周期才重新计算验证码;剩余秒数则可以按 tick 更新。
这段实现对应的测试名称也直接描述了目标行为:only expanded account generates and refreshes a code 。测试没有检查内部用了几个协程,而是检查可观察结果------只有展开的账户会生成和刷新验证码。
这是我在与 AI 协作时反复使用的一种表达方式:不要规定内部必须调用某个私有函数,而是说明用户能够看到什么、什么时候发生、什么不应该发生。这样的测试对重构更稳定,也更接近真实需求。
Navigation 3 也带来了类似的思考。页面对应的 ViewModel 需要跟随导航条目保存状态,而不是在每次重组时被随意创建。项目为导航条目配置了可保存状态和 ViewModel Store 装饰器,让页面和状态的生命周期与返回栈保持一致。
二维码扫描不是"接上相机"就结束了
二维码导入使用 CameraX、LifecycleCameraController、MlKitAnalyzer 和 ML Kit Barcode Scanning。扫描到内容后,应用只接受 otpauth://totp/,再将 URI 交给已有的账户编辑流程解析和确认,而不是在扫描页面复制一套保存逻辑。
识别二维码的逻辑比较简单,复杂的是与之有关的周边状态:
- 设备可能没有摄像头,因此相机特性不能声明为必需;
- 权限可能首次被拒绝,也可能在系统设置中重新开启;
- 从设置页返回后需要重新检查权限;
- Analyzer、Scanner 和 CameraController 都需要跟随 Compose 生命周期正确释放;
- 同一个二维码可能连续出现在多帧中,成功结果只能处理一次;
- 扫到普通网址或其他二维码时,应给出明确错误,而不是把内容直接送入账户保存流程。
实现中通过 DisposableEffect 绑定和解绑相机、清理 Analyzer,并在销毁时关闭 Scanner;通过生命周期观察在 ON_RESUME 时重新检查权限;通过状态位阻止成功二维码被重复处理。
这说明"实现二维码扫描"不是一个足够精确的任务。后来我更倾向于把提示写成:
使用现有账户导入流程增加二维码入口,同时处理无摄像头、首次授权、拒绝后打开设置、从设置返回、重复帧和非 TOTP 二维码;相机资源必须随生命周期释放。
任务写得更具体,并不是替 AI 设计所有代码,而是把容易被忽略的产品行为提前变成验收条件。
应用锁与密钥加密是两层不同的保护
项目还使用 AndroidX Biometric 增加了可选应用锁,允许强生物识别或设备屏幕锁验证。
它和 Room 密钥加密解决的是不同问题:
- Keystore 加密保护静态存储中的 TOTP 密钥;
- 应用锁阻止他人在拿到已解锁设备时直接打开应用查看验证码。
应用锁设置保存在 DataStore。启用时需要先完成一次认证,成功后才写入设置;已启用的应用从后台停留超过 30 秒后会重新锁定。AppLockViewModel 把是否启用、是否锁定、是否正在认证和错误状态集中建模,并为"启用后当前会话保持解锁""后台超过 30 秒重新锁定"编写了协程测试。
这里同样没有把生物识别直接塞进 Repository。BiometricPrompt 依赖 Activity 和用户交互,属于 Android UI 边界;设置持久化在数据层;锁定状态和超时逻辑放在 ViewModel。各部分职责很少,但足以让核心状态逻辑脱离真实传感器进行测试。
测试不是 AI 工作结束后的补丁
如果 AI 只负责生成代码,人最后再补测试,很容易出现两个问题:测试被拖到最后,或者测试只是重复当前实现。
这个项目更接近增量验证:协议层完成后立即加入 RFC 向量和边界测试;Repository 使用手写 Fake DAO 和 Fake SecretProtector 验证"保存时加密、生成时解密、更新元数据时不替换密钥";ViewModel 使用可控时间和测试调度器验证刷新、保存、删除和应用锁行为;Compose 测试只覆盖关键交互入口。
项目没有追求把同一行为在每一层重复一遍。例如 TOTP 算法的所有边界都集中在 :core,账户界面的测试不再重新验证每一个 RFC 向量。Room 与 Android Keystore 的平台集成适合在设备或模拟器上检查,但 Repository 的业务协调可以在 JVM 上用 Fake 快速验证。
常规验证保持得很简单:
bash
./gradlew test
./gradlew lint
./gradlew assembleDebug
重要发布前再连接模拟器或设备运行 Instrumented Test,并手动检查账户管理、二维码和应用锁等关键流程。
对 AI Agent 来说,命令成功不是唯一目标。还需要检查:
- 测试是否验证了规范或用户可见行为;
- 是否为了让测试通过而放宽了正确的校验;
- 是否新增了与现有测试重复的文件;
- Lint 警告反映的是兼容性问题、冗余代码,还是确实需要保留的行为;
- 构建成功的产物是不是实际要发布的那个变体。
我会要求 Agent 在修改前先搜索"这个行为由哪套测试负责",尽量扩展已有测试套件,而不是每个 bug 新建一个回归测试文件。这样可以避免测试目录随着 AI 的高产而迅速碎片化。
最后一公里:本地成功不等于能够发布
完成应用功能后,项目增加了两条 GitHub Actions 工作流:
- CI 在 push 和 pull request 中运行单元测试、Android Lint 和 Debug APK 构建;
- Release 在
v*标签上恢复签名材料,重新执行验证,构建 Release APK,校验签名证书,生成 SHA-256 文件并发布到 GitHub Releases。
这一阶段贡献了最具体的几个坑,也最能说明为什么必须让 Agent 根据真实输出迭代。
sdkmanager 不一定直接位于 PATH
工作流最初直接调用 sdkmanager,但 GitHub Runner 的实际环境不能保证这种假设。后来改为先读取 ANDROID_HOME 或 ANDROID_SDK_ROOT,优先寻找 cmdline-tools/latest/bin/sdkmanager,找不到时再从已安装的 Command-line Tools 版本中选择。
Android SDK 包名不一定是想当然的格式
项目使用 API 37,工作流最初尝试安装 platforms;android-37,实际需要的包标识是 platforms;android-37.0。这类问题仅凭对过去版本的经验很难保证正确,Runner 输出才是事实。
Gradle Wrapper 在 Linux Runner 上需要可执行权限
Windows 本地一直通过 gradlew.bat 构建,不会暴露 gradlew 缺少可执行位的问题。到了 Ubuntu Runner, ./gradlew 才直接失败。最终需要把 Wrapper 的执行权限正确记录进 Git。
不要对工具输出格式做过窄的假设
发布工作流使用 apksigner verify --print-certs 检查 APK 的签名证书。最初的解析逻辑假设输出行严格以特定文本开头,实际输出与假设存在差异,导致明明签名正确却取不到摘要。后来改为查找包含 certificate SHA-256 digest: 的行并读取最后一个字段。
这几个修复都不复杂,却有共同点:初版脚本在逻辑上看起来合理,本地静态检查也很难证明它一定失败,只有放到真实环境里执行,才能发现隐含假设。
所以我不会把"Agent 已经写完工作流"视为完成,而会把 GitHub Actions 的实际绿色结果、签名 APK 的安装验证和证书指纹比对视为完成。
我如何向 AI 描述任务
这次项目让我明显感受到,提示词最重要的不是礼貌、长度或某种固定模板,而是信息结构。
一个有效任务通常包含五部分:
- 目标:用户最终能够做什么;
- 上下文:现有模块、代码所有者和相关约束;
- 边界:明确不做什么,不允许引入什么;
- 验收:哪些测试、命令或实际行为可以证明完成;
- 工作方式:先检查现有实现,再修改,并报告关键取舍。
例如,下面这种表达信息不足:
给应用加上安全存储。
更可执行的表达是:
在
:data中实现 TOTP 密钥的本地保护。使用 Android Keystore 持有不可导出的 AES-GCM 密钥,Room 只保存密文和 IV;账户元数据保持可查询。不要引入第三方密码学库或新的模块。为 Repository 增加测试,验证新增账户会加密密钥、生成验证码时会解密、编辑元数据不会意外替换密钥。最后运行相关测试、Lint 和 Debug 构建。
第二种写法并没有指定每个类名,却定义了安全边界和可观察结果。Agent 仍然有实现空间,但不容易把任务理解成"把字符串 Base64 一下"或者"再搭建一套安全框架"。
我也会根据任务类型改变沟通方式。
面对协议和安全逻辑:先要证据
我会要求引用标准、官方平台能力或现有测试,并明确哪些结论是规范要求、哪些只是设计选择。对于 TOTP,RFC 测试向量是权威证据;对于 Android Keystore、Biometric 和应用签名,平台 API 与真实设备行为比 Agent 的记忆更可靠。
面对界面:描述状态转换
与其说"做一个好看的账户列表",不如描述空状态、展开状态、加载状态、错误状态和点击行为。Compose UI 的大多数问题最终不是组件不会画,而是状态由谁持有、何时更新、离开页面后是否继续运行。
面对故障:提供完整输出
只告诉 Agent"CI 挂了",它只能猜。提供失败步骤、命令、错误输出和最近修改后,它才可以定位环境假设。对于工具链问题,我会让它根据输出提出最小修复,再重新运行,而不是一次改动多处配置。
面对架构建议:追问它解决什么具体问题
AI 很容易给出业界常见方案,但"常见"不等于适合当前项目。我会追问:如果不增加这个抽象,现在会出现什么实际问题?现有构造函数注入是否已经不足?这项依赖是否能够替代现有代码,还是只增加另一种写法?
这类追问经常能把一个宏大的重构建议缩小成几行更直接的修改。
AI 最有价值和最危险的地方
在这个项目里,AI Agent 最有价值的地方是降低了上下文切换成本。它可以连续阅读协议实现、Gradle 配置、Compose 状态和 CI 脚本,快速找出相关文件,执行重复验证,并把错误输出带回下一轮修改。对于一个学习项目,这意味着我可以把更多精力放在理解取舍,而不是在文档、命令和样板代码之间来回搬运。
它最危险的地方也来自同一种能力:它能够非常快地产生一个完整、合理、甚至测试通过的方案。完整感会让人降低警惕。
常见风险包括:
- 把记忆中的 API 版本或环境行为当作当前事实;
- 为了"架构完整"增加项目不需要的层次;
- 只覆盖成功路径,忽略权限、生命周期和恢复场景;
- 写出与实现紧密绑定、却没有证明用户行为的测试;
- 把采用安全 API 等同于整体已经安全;
- 在一个任务里顺手修改无关代码,扩大审查范围。
对这些风险最有效的控制不是一句"请认真一点",而是工程约束:小步提交、明确范围、现有测试归属、权威测试向量、真实命令输出、Lint、构建、设备验证和最终人工审查。
如果重新开始,我会更早做的几件事
回看整个过程,有几件事值得更早确定。
第一,尽早写出项目完成标准和非目标。它们不仅帮助 AI,也帮助我抵抗开发过程中的功能冲动。TOTP 应用尤其容易被扩展成密码管理器或同步服务,但那会把一个可以完成的学习项目变成长期平台工程。
第二,在协议实现之前就整理测试来源。知道 RFC 测试向量是最终裁判后,Agent 的实现路径会更稳,也减少"用另一个库的输出验证自己的库"这种循环依赖。
第三,从第一天就区分本地开发环境和 CI 环境。这个项目本地使用 China-hosted Gradle 和 Maven 镜像,而 GitHub Actions 使用官方仓库和 Gradle 分发。把差异显式写进配置,比发布前临时切换更可靠。
第四,更早安排设备验证。JVM 测试和 Compose 测试可以覆盖大量逻辑,但相机权限、BiometricPrompt、系统设置往返、应用后台超时和 APK 更新签名最终仍要在 Android 设备或模拟器上观察。
第五,在每个阶段结束时做一次"删除性审查":哪些类只是转发调用?哪些参数永远固定?哪些测试重复?哪些依赖已经不再使用?AI 很擅长增加内容,也应该被明确要求帮助减少内容。
结语:AI 提高的是迭代速度,工程判断仍然属于人
这个项目最终达到了预定的完成状态:它能够生成符合标准的 TOTP 验证码,管理和扫描多个账户,使用 Room 与 Android Keystore 在本地保护密钥,使用设备认证保护应用访问,并通过自动化流程发布签名 APK。
AI Agent 对这个过程帮助很大。它缩短了查找资料、实现功能、补充测试和处理工具链问题的时间,也让我能够在一个较短周期内接触现代 Android 开发的多个关键部分。
真正决定项目质量的,依然是那些并不新鲜的工程动作:定义问题、控制范围、建立可验证的标准、认真阅读失败输出、检查边界情况、理解安全模型,并对最终交付的产物负责。
现阶段,AI Agent 更适合作为一个执行力强、上下文容量大的工程协作者。工程判断与最终责任,仍然掌握在开发者手中。与它高效协作,也无需寻找某句神奇的提示词,关键在于清楚地表达工程意图:要解决什么问题,为什么选择这种方案,哪些事情明确不做,以及用什么证据判断工作已经完成。
AI 会放大执行力,也会放大意图中的模糊。缺少清晰的目标、边界和验收标准,它只会让项目更快抵达"可用";这些前提足够明确时,它才能把人的工程判断更快地落实为可靠、可验证、值得交付的结果。