简介:一份面向“仿谷歌身份验证器”场景的 Android 端开源工程,基于 Java 8 实现动态口令验证核心逻辑,适合需要了解 TOTP 机制、或准备为自有账号体系添加两步验证的移动端开发者。项目围绕“固定密钥 + 实时时间戳”生成每 30 秒刷新的 6 位验证码,源码中可以看到密钥保存、二维码/密钥串导入、验证码展示与后台校验等关键环节,也有助于理解无网状态下凭密钥恢复验证器的设计思路。下载包共 114 个文件,包含 35 个 Java 源码、31 个 XML 界面与配置、26 张 PNG 图示,还附带 3 个可直接安装的 APK、Gradle 构建脚本及签名文件,压缩后仅 5.72MB。目前已有 442 人学习下载。想快速跑通验证器全流程或改造成自有工具,可直接编译运行;需要深入原理时,可结合文档与演示 GIF 分析验证码刷新节奏、异常处理与密钥管理方式,对开发同类安全组件很有参考价值。1. 开场:一份可以自己掌控的验证器源码出差路上临时要登录系统后台,打开新手机却找不到动态口令,旧手机里的账号又导不过来,这种场景想必不少人都经历过。这份仿谷歌验证器的Android开源源码,正是为了解决「验证器绑定容易、迁移难」的痛点而存在的。它基于Java8编写,完整覆盖扫码添加账户、TOTP/HOTP动态口令生成、SQLite本地持久化、列表倒计时刷新四个核心环节,工程不大但五脏俱全。想给自己维护一套两因素认证工具,或者在Android端摸一遍基于时间的签名算法,这份源码比翻原理PPT直观得多。2. 架构设计:一个小而全的Java8客户端怎么分层这类工具型App最怕的是把逻辑全堆在Activity里,读起来像一本流水账,后续加云端同步、指纹解锁都无从下手。这份源码的包结构按职责切分得比较清楚,主界面、扫码、算法、数据库各占一块,下面从模块划分、技术选型、目录结构三个角度过一遍。2.1 模块划分与数据流整体是一个典型的三明治结构:UI层、算法层、数据层。MainListActivity负责账户列表和悬浮扫码按钮,ScanActivity封装ZXing扫码流程,扫码拿到的原始字符串交给OtpAuthUri解析成Account对象,再写入SQLite。TokenListAdapter订阅一个每秒触发的tick事件,实时计算当前窗口的动态码并刷新列表。数据流是单向的:扫码结果 -> URI解析 -> 入库 -> 刷新列表。单向流的好处是每个环节都能独立测试,算法不依赖数据库,URI解析也不依赖界面。比如想改成从本地相册选二维码图片解析,只要替换ScanActivity的数据来源,后面逻辑完全不用动。第一次读源码的开发者,建议从MainListActivity的onActivityResult开始跟踪,扫码页返回的那段回调就是整条链路的入口。2.2 技术选型:为什么坚持Java8而不是Kotlin项目标题把Java8写在最前面,说明这是有意为之。Java8自带的JCE里已经有HmacSHA1和SecretKeySpec,TOTP算法不需要额外引加密库;Stream和Lambda在处理账户列表排序、过滤时也很顺手。Android对Java8的支持靠Gradle的compileOptions开启desugar,构建时会自动把Lambda等语法翻译成兼容字节码。| 模块 | 选型 | 理由 | | 扫码 | ZXing core + embedded | 二维码识别的事实标准,只取条码扫描能力 | | 数据库 | SQLite + SQLiteOpenHelper | 单机账户数据量小,原生方案最轻 | | 列表 | RecyclerView | 每秒刷新一次,RecyclerView的局部刷新比ListView稳 | | 算法 | Java8 JCE内置HmacSHA1 | 避免第三方加密库的体积和安全审查负担 | | 构建 | Gradle + compileOptions | desugar机制让Java8语法在低版本Android上可用 |这里有个容易被新手忽略的点:desugar只解决语法层面的兼容,像java.time这种新API默认不提供,代码里如果用了LocalDateTime,在minSdk 21以下会崩。所以源码里时间戳几乎全是System.currentTimeMillis加手工计算,这不是偷懒,是为了兼容面更宽。有人可能问:怎么不用Room?引入Room要加注解处理器和KSP,构建链路会重不少。SQLiteOpenHelper手写SQL虽然原始,但胜在零依赖、易调试,数据库字段变了直接改onCreate迁移即可。研究型项目保持最小依赖,更容易把算法讲清楚。2.3 源码目录速览:从包名看职责边界解压后的工程结构大致如下,包名路径在不同发布版本里可能略有差异,但职责划分一致。| 包路径 | 职责 | 关键类 | | activity | 界面入口与扫码页 | MainListActivity、ScanActivity | | adapter | 列表项绑定与倒计时刷新 | TokenListAdapter | | model | 数据模型与otpauth URI解析 | Account、OtpAuthUri | | db | 账户表读写 | AuthDbHelper、AccountDao | | code | 核心算法封装 | TotpGenerator、Base32Decoder | | util | 时间窗口与格式化工具 | TimeUtils |建议阅读顺序从model开始,Account字段定下来后,code层生成算法、db层持久化、ui层展示都围绕同一个模型转。很多开发者上手直接看Activity,容易被界面逻辑带偏,其实这个项目的核心在code和model两个包里。2.4 数据模型:一张表装下一个账户的完整信息Account是贯穿全项目的核心对象,字段比预想的多一点:id、label、issuer、secret、type、counter、createdAt。label是显示在列表上的名称,issuer是颁发方,secret就是那条32位左右的Base32种子密钥,type标记TOTP还是HOTP,counter是HOTP专用的事件计数。这里要特别强调:secret是敏感数据,一旦泄露等于拿到了动态口令的种子。很多开源版本把数据库放在私有目录就直接存明文,自己二次开发时至少应该用Android Keystore做一次加密,后面第6章会给出具体改造示例。理解这个模型后再看整个项目的功能就很好归类了——扫码只是在批量创建Account,列表刷新是在把Account变成长得像的六位数字,备份恢复则是把数据库里的Account序列化到文件。3. 核心模块拆解:一条验证码从扫码到上屏的完整链路动态验证码看似是一个密码学黑匣子,实际上核心代码加起来不超过一百行。从扫码到上屏可以拆成三步:解析otpauth URI、生成TOTP、驱动列表刷新。下面按这个顺序逐段讲,每段代码都可以直接拿出来单独测试。3.1 TOTP生成:RFC 6238在Android上的落地方案TOTP本质上是用时间计数器替代事件计数器的HMAC签名。整段算法固定,参数也就几个:共享密钥、时间步长、输出位数。下面是去掉边界判断后的核心方法。public static String generateTotp(String base32Secret, long currentTimeMillis, int periodSeconds, int digits) { // 密钥清洗:去掉空格、转大写,很多扫码结果里的secret带空格或小写 String cleanedSecret = base32Secret.replace(" ", "").toUpperCase(Locale.US); byte[] keyBytes = Base32Decoder.decode(cleanedSecret); // 时间窗口计数器:Unix时间戳除以周期,再转成8字节大端整数 long counter = currentTimeMillis / 1000L / periodSeconds; byte[] counterBytes = ByteBuffer.allocate(8).putLong(counter).array(); // 用密钥对计数器做HMAC-SHA1签名,得到20字节摘要 Mac mac = Mac.getInstance("HmacSHA1"); mac.init(new SecretKeySpec(keyBytes, "HmacSHA1")); byte[] hash = mac.doFinal(counterBytes); // 动态截断:取摘要最后一位的低4位作为偏移,再拼出31位整数 int offset = hash[hash.length - 1] & 0x0F; int binary = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | ((hash[offset + 3] & 0xFF)); int otp = binary % (int) Math.pow(10, digits); return String.format(Locale.US, "%0" + digits + "d", otp); }这里把动态截断拆成了四行,很直观。hash[offset] 与 0x7F 是把最高位清零,避免符号位干扰,很多移植版本就是漏了这一步导致生成了负数。函数参数向外暴露currentTimeMillis,是为了单元测试时能注入固定时间,不需要真等30秒。生产环境调用时直接传System.currentTimeMillis()即可,periodSeconds通常传30,digits传6。如果服务器端有容差机制,可以在边界处前后各扩展一个窗口,那就是把counter分别减一、加一再各算一遍,这个细节很多二次开发教程不会点破。3.2 扫码解析:otpauth协议比想象中简单扫码结果不是普通字符串,而是otpauth协议。标准格式是otpauth://totp/Label?secret=XXX&issuer=YYY。解析时用Uri.parse比手写正则更安全,系统会处理URL编码和特殊字符。public Account parseUri(String rawText) { Uri uri = Uri.parse(rawText); // 只接受otpauth协议,且类型为totp if (!"otpauth".equals(uri.getScheme()) || !"totp".equals(uri.getHost())) { return null; } String secret = uri.getQueryParameter("secret"); if (TextUtils.isEmpty(secret)) { return null; } String issuer = uri.getQueryParameter("issuer"); // pathSegments第一段是label,个别服务端会把中文做二次编码,所以这里手动decode String label = uri.getPathSegments().isEmpty() ? "" : Uri.decode(uri.getPathSegments().get(0)); return new Account(label, issuer, secret); }注意这里返回null的情况要单独处理,不能直接空指针。issuer允许为空,很多个人网站搭建的TOTP服务不会填这个参数。保存进数据库前,secret还要过一次清洗,把换行符、空格都去掉,否则后续生成验证码时Base32解码就会报错。3.3 列表刷新:倒计时的线程状态管理验证码每30秒变一次,但列表上的进度条和剩余秒数需要每秒更新。常见做法是用Handler往主线程发送延迟消息,这里有个性能雷点:如果脑抽在子线程里直接调notifyDataSetChanged,RecyclerView会直接抛异常。正确姿势是让Handler跑在主线程,每次回调里重算剩余时间。private final Handler refreshHandler = new Handler(Looper.getMainLooper()); private final Runnable tickRunnable = new Runnable() { @Override public void run() { long now = System.currentTimeMillis(); adapter.setCurrentTime(now); adapter.notifyDataSetChanged(); // 算下一次刷新的延迟,让刷新点对齐秒边界 long delay = 1000 - (now % 1000); refreshHandler.postDelayed(this, delay); } }; @Override protected void onResume() { super.onResume(); refreshHandler.post(tickRunnable); } @Override protected void onPause() { super.onPause(); // 页面不可见时立刻移除回调,避免空转耗电 refreshHandler.removeCallbacksAndMessages(null); }delay的计算值得细看:如果不对齐秒边界,刷新时刻会逐渐漂移,最后出现秒数跳变。另外很多改版者会在onPause里忘记移除回调,导致页面关闭后Handler还在跑,这是隐蔽的耗电元凶。这套写法同样适用于所有需要秒级刷新的列表,比如时钟App、抢购倒计时页面。4. 编译与运行:从源码包到APK上机的完整步骤拿到源码包第一件事是别急着双击,先确认环境。这个项目本身不复杂,但Android生态的版本错位经常比代码bug更折腾人。把环境对齐后,一条命令就能出包。4.1 环境准备:JDK8是硬性要求| 工具 | 版本建议 | 说明 | | JDK | 1.8 | 项目标题就是java8,用新JDK编译老工程会碰到javac编码警告 | | Android SDK | API 26-30 | minSdk通常设置在21,targetSdk在28附近问题最少 | | Android Studio | 3.5以上 | 老版本Gradle同步会出现DSL弃用警告 | | Gradle | 5.1-5.5 | 与Android Gradle Plugin 3.4-3.5匹配度最好 |如果本机装了多个JDK版本,注意Android Studio的Gradle JVM配置要手动切到JDK8,否则sync阶段就会报Unsupported class file major version。这个报错和项目代码没关系,纯粹是工具链错位,血泪经验。4.2 导入Android Studio:先改gradle再sync打开工程后先别急着sync,看一眼根目录的build.gradle里的依赖版本。多数情况下,把compileSdkVersion调整为本机已安装的API级别即可。android { compileSdkVersion 28 defaultConfig { minSdkVersion 21 targetSdkVersion 28 } compileOptions { // 开启Java8语法支持,核心步骤 sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }sourceCompatibility和targetCompatibility两个字段缺一不可,只配前者可能在Lambda表达式上翻车。这里的28不是写死值,家里SDK装了30就改成30,dynamic version在部分插件版本上不兼容,显式写数字最稳。sync成功后,IDE会自动下载依赖,如果网络环境一般,可以先在工程根目录执行一次gradlew --offline试试。4.3 命令行构建:不用IDE也能产出Debug包Gradle同步完成后,构建反而是最简单的一步。打开终端进入工程根目录:# 构建debug包,产物在app/build/outputs/apk/debug/目录下 ./gradlew assembleDebug # 安装到已连接的真机或模拟器 ./gradlew installDebugassembleDebug只产包不安装,installDebug会先构建再调用adb install。如果设备没开启USB调试,installDebug会在最后一步报错,这时用数据线连上手机,在开发者选项里打开USB调试即可。生成的app-debug.apk可以直接发到手机上安装,不需要签名配置。4.4 真机调试要留意的细节装好后第一件事不是扫码,而是先把系统时间改成自动获取。TOTP算法对时间同步极度敏感,手机时间偏差超过30秒,动态码就会在服务端校验失败。这个项目是纯本地应用,不依赖网络,飞行模式下照样能生成验证码,测试时别误以为是App坏了。建议准备一个已知secret的测试二维码,用zxing官方demo生成一张,能稳定跑通扫码、解析、出码、倒计时整个链路。5. 常见问题避坑:移植与二次开发的五个翻车点开源项目拿来跑通不难,难的是改成自己的过程中踩坑。下面五条都是从实际移植场景里爬出来的高频问题,按现象、原因、解决三个步骤记录。5.1 验证码对不上:先从手机时间开刀现象:扫码添加账户成功后,输入的六位数字在服务端总校验失败。原因:手机系统时间不准,或者开启了手动时间而没有自动同步。TOTP依赖时间窗口,系统时间和真实时间偏差越大,越容易踩到窗口边界。解决:进入系统设置把日期时间改为自动获取,等时钟同步完成后再试。调试时可以把代码里的periodSeconds临时改成60,变相扩大验证窗口,快速定位是不是时间问题。5.2 扫码闪退:动态权限不是加一行就能过现象:点击扫码按钮直接闪退,LogCat里报SecurityException,或者相机界面黑屏后返回。原因:Android 6.0以上运行时权限,源码的Manifest里虽然声明了CAMERA,但ScanActivity没有在运行时申请权限。解决:在启动扫码前先检查ContextCompat.checkSelfPermission,没有授权就走requestPermissions回调,授权成功后再初始化ZXing的CaptureManager。这是个高频坑,任何Android扫码项目都绕不过。5.3 Base32解码抛IllegalArgumentException现象:添加部分账户时程序直接崩溃,异常指向Base32Decoder.decode。原因:otpauth URI里的secret参数可能带空格、小写字母,极少数服务端还会把secret转成包含换行的格式,解码器不认识这些脏字符。解决:解码前统一做一次字符串清洗,用replace去掉所有空白符再toUpperCase,清洗动作必须在decode之前,放进工具类里复用。顺手把parseUri里的TextUtils.isEmpty判断加上,能拦截一半这类崩溃。5.4 列表秒数跳变:别维护自己的计数器现象:列表倒计时不是每秒递减,偶尔从29跳到27,看起来像刷新卡顿。原因:适配器里用了自增计数器,每次tick减一,但Handler在系统休眠或主线程卡顿时会跳过消息,之后没有用真实时间校准。解决:每次刷新都用System.currentTimeMillis重新计算剩余秒数,显示值永远跟随真实时钟走。这行代码看起来只是改了一个计算方式,实际上是把显示逻辑从增量式改成自校正式,一劳永逸。5.5 Release包扫码白屏:混淆规则要跟上现象:Debug包扫码正常,打Release包后扫码页白屏或直接崩溃。原因:混淆阶段把ZXing里通过反射调用的类干掉了,或so库没有跟随打包。解决:在proguard-rules.pro里加keep规则,保留com.google.zxing.**全量类,并检查jniLibs是否有armeabi-v7a、arm64-v8a目录。这条经验建议提前写进模板工程,不然每次发版都要翻LogCat。6. 二次开发:给验证器加上加密备份与恢复原版验让器最大的痛点就是换手机时没法导出账户。既然手里有源码,第一优先级的改造应该是做一个带加密的备份恢复功能。备份文件本质上就是把Account列表序列化成JSON,再加密写进文件。6.1 改造思路不要用简单的AES固定密钥,那把密钥写死在代码里等于没有加密。Android自带的Android Keystore可以让密钥永不离开安全硬件,用AES/GCM模式做对称加密,GCM自带完整性校验,密文被篡改一字节都解不出来。文件格式上先存IV长度、IV、密文长度、密文,导入时按长度拆开读取,避免踩到流结束判断的坑。public void backupToFile(File target) throws Exception { List<Account> accounts = authDbDao.getAll(); JSONArray array = new JSONArray(); for (Account account : accounts) { array.put(account.toJson()); } String json = array.toString(); KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); keyStore.load(null); SecretKey secretKey = (SecretKey) keyStore.getKey("backup_key", null); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, secretKey); byte[] encrypted = cipher.doFinal(json.getBytes(StandardCharsets.UTF_8)); try (FileOutputStream fos = new FileOutputStream(target); DataOutputStream dos = new DataOutputStream(fos)) { dos.writeInt(cipher.getIV().length); dos.write(cipher.getIV()); dos.writeInt(encrypted.length); dos.write(encrypted); } }先写IV长度再写IV,不要直接writeAll,否则导入时你不知道到底读了多少字节才算完。数据库读取、JSON序列化、加密这三个环节各自独立,可以分开测试。导入逻辑是这段代码的逆向,读长度、创建对应数组、按序填充、解密、批量入库。6.2 验证与上线习惯验证方法很直接:先添加两个测试账户,导出备份文件,然后卸载App或清除数据,重新安装后导入备份,账户列表应原样恢复,动态码继续正常工作。这个流程建议做成自动化脚本,每次改动存储相关代码就跑一遍。从那以后我每次改完这类认证模块,都会先走一遍备份、卸载、恢复的完整演练,确认数据不丢才敢放出去。希望帮到你。本文还有配套的精品资源,点击获取
简介:一份面向“仿谷歌身份验证器”场景的 Android 端开源工程,基于 Java 8 实现动态口令验证核心逻辑,适合需要了解 TOTP 机制、或准备为自有账号体系添加两步验证的移动端开发者。项目围绕“固定密钥 + 实时时间戳”生成每 30 秒刷新的 6 位验证码,源码中可以看到密钥保存、二维码/密钥串导入、验证码展示与后台校验等关键环节,也有助于理解无网状态下凭密钥恢复验证器的设计思路。下载包共 114 个文件,包含 35 个 Java 源码、31 个 XML 界面与配置、26 张 PNG 图示,还附带 3 个可直接安装的 APK、Gradle 构建脚本及签名文件,压缩后仅 5.72MB。目前已有 442 人学习下载。想快速跑通验证器全流程或改造成自有工具,可直接编译运行;需要深入原理时,可结合文档与演示 GIF 分析验证码刷新节奏、异常处理与密钥管理方式,对开发同类安全组件很有参考价值。1. 开场:一份可以自己掌控的验证器源码出差路上临时要登录系统后台,打开新手机却找不到动态口令,旧手机里的账号又导不过来,这种场景想必不少人都经历过。这份仿谷歌验证器的Android开源源码,正是为了解决「验证器绑定容易、迁移难」的痛点而存在的。它基于Java8编写,完整覆盖扫码添加账户、TOTP/HOTP动态口令生成、SQLite本地持久化、列表倒计时刷新四个核心环节,工程不大但五脏俱全。想给自己维护一套两因素认证工具,或者在Android端摸一遍基于时间的签名算法,这份源码比翻原理PPT直观得多。2. 架构设计:一个小而全的Java8客户端怎么分层这类工具型App最怕的是把逻辑全堆在Activity里,读起来像一本流水账,后续加云端同步、指纹解锁都无从下手。这份源码的包结构按职责切分得比较清楚,主界面、扫码、算法、数据库各占一块,下面从模块划分、技术选型、目录结构三个角度过一遍。2.1 模块划分与数据流整体是一个典型的三明治结构:UI层、算法层、数据层。MainListActivity负责账户列表和悬浮扫码按钮,ScanActivity封装ZXing扫码流程,扫码拿到的原始字符串交给OtpAuthUri解析成Account对象,再写入SQLite。TokenListAdapter订阅一个每秒触发的tick事件,实时计算当前窗口的动态码并刷新列表。数据流是单向的:扫码结果 -> URI解析 -> 入库 -> 刷新列表。单向流的好处是每个环节都能独立测试,算法不依赖数据库,URI解析也不依赖界面。比如想改成从本地相册选二维码图片解析,只要替换ScanActivity的数据来源,后面逻辑完全不用动。第一次读源码的开发者,建议从MainListActivity的onActivityResult开始跟踪,扫码页返回的那段回调就是整条链路的入口。2.2 技术选型:为什么坚持Java8而不是Kotlin项目标题把Java8写在最前面,说明这是有意为之。Java8自带的JCE里已经有HmacSHA1和SecretKeySpec,TOTP算法不需要额外引加密库;Stream和Lambda在处理账户列表排序、过滤时也很顺手。Android对Java8的支持靠Gradle的compileOptions开启desugar,构建时会自动把Lambda等语法翻译成兼容字节码。| 模块 | 选型 | 理由 | | 扫码 | ZXing core + embedded | 二维码识别的事实标准,只取条码扫描能力 | | 数据库 | SQLite + SQLiteOpenHelper | 单机账户数据量小,原生方案最轻 | | 列表 | RecyclerView | 每秒刷新一次,RecyclerView的局部刷新比ListView稳 | | 算法 | Java8 JCE内置HmacSHA1 | 避免第三方加密库的体积和安全审查负担 | | 构建 | Gradle + compileOptions | desugar机制让Java8语法在低版本Android上可用 |这里有个容易被新手忽略的点:desugar只解决语法层面的兼容,像java.time这种新API默认不提供,代码里如果用了LocalDateTime,在minSdk 21以下会崩。所以源码里时间戳几乎全是System.currentTimeMillis加手工计算,这不是偷懒,是为了兼容面更宽。有人可能问:怎么不用Room?引入Room要加注解处理器和KSP,构建链路会重不少。SQLiteOpenHelper手写SQL虽然原始,但胜在零依赖、易调试,数据库字段变了直接改onCreate迁移即可。研究型项目保持最小依赖,更容易把算法讲清楚。2.3 源码目录速览:从包名看职责边界解压后的工程结构大致如下,包名路径在不同发布版本里可能略有差异,但职责划分一致。| 包路径 | 职责 | 关键类 | | activity | 界面入口与扫码页 | MainListActivity、ScanActivity | | adapter | 列表项绑定与倒计时刷新 | TokenListAdapter | | model | 数据模型与otpauth URI解析 | Account、OtpAuthUri | | db | 账户表读写 | AuthDbHelper、AccountDao | | code | 核心算法封装 | TotpGenerator、Base32Decoder | | util | 时间窗口与格式化工具 | TimeUtils |建议阅读顺序从model开始,Account字段定下来后,code层生成算法、db层持久化、ui层展示都围绕同一个模型转。很多开发者上手直接看Activity,容易被界面逻辑带偏,其实这个项目的核心在code和model两个包里。2.4 数据模型:一张表装下一个账户的完整信息Account是贯穿全项目的核心对象,字段比预想的多一点:id、label、issuer、secret、type、counter、createdAt。label是显示在列表上的名称,issuer是颁发方,secret就是那条32位左右的Base32种子密钥,type标记TOTP还是HOTP,counter是HOTP专用的事件计数。这里要特别强调:secret是敏感数据,一旦泄露等于拿到了动态口令的种子。很多开源版本把数据库放在私有目录就直接存明文,自己二次开发时至少应该用Android Keystore做一次加密,后面第6章会给出具体改造示例。理解这个模型后再看整个项目的功能就很好归类了——扫码只是在批量创建Account,列表刷新是在把Account变成长得像的六位数字,备份恢复则是把数据库里的Account序列化到文件。3. 核心模块拆解:一条验证码从扫码到上屏的完整链路动态验证码看似是一个密码学黑匣子,实际上核心代码加起来不超过一百行。从扫码到上屏可以拆成三步:解析otpauth URI、生成TOTP、驱动列表刷新。下面按这个顺序逐段讲,每段代码都可以直接拿出来单独测试。3.1 TOTP生成:RFC 6238在Android上的落地方案TOTP本质上是用时间计数器替代事件计数器的HMAC签名。整段算法固定,参数也就几个:共享密钥、时间步长、输出位数。下面是去掉边界判断后的核心方法。public static String generateTotp(String base32Secret, long currentTimeMillis, int periodSeconds, int digits) { // 密钥清洗:去掉空格、转大写,很多扫码结果里的secret带空格或小写 String cleanedSecret = base32Secret.replace(" ", "").toUpperCase(Locale.US); byte[] keyBytes = Base32Decoder.decode(cleanedSecret); // 时间窗口计数器:Unix时间戳除以周期,再转成8字节大端整数 long counter = currentTimeMillis / 1000L / periodSeconds; byte[] counterBytes = ByteBuffer.allocate(8).putLong(counter).array(); // 用密钥对计数器做HMAC-SHA1签名,得到20字节摘要 Mac mac = Mac.getInstance("HmacSHA1"); mac.init(new SecretKeySpec(keyBytes, "HmacSHA1")); byte[] hash = mac.doFinal(counterBytes); // 动态截断:取摘要最后一位的低4位作为偏移,再拼出31位整数 int offset = hash[hash.length - 1] & 0x0F; int binary = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | ((hash[offset + 3] & 0xFF)); int otp = binary % (int) Math.pow(10, digits); return String.format(Locale.US, "%0" + digits + "d", otp); }这里把动态截断拆成了四行,很直观。hash[offset] 与 0x7F 是把最高位清零,避免符号位干扰,很多移植版本就是漏了这一步导致生成了负数。函数参数向外暴露currentTimeMillis,是为了单元测试时能注入固定时间,不需要真等30秒。生产环境调用时直接传System.currentTimeMillis()即可,periodSeconds通常传30,digits传6。如果服务器端有容差机制,可以在边界处前后各扩展一个窗口,那就是把counter分别减一、加一再各算一遍,这个细节很多二次开发教程不会点破。3.2 扫码解析:otpauth协议比想象中简单扫码结果不是普通字符串,而是otpauth协议。标准格式是otpauth://totp/Label?secret=XXX&issuer=YYY。解析时用Uri.parse比手写正则更安全,系统会处理URL编码和特殊字符。public Account parseUri(String rawText) { Uri uri = Uri.parse(rawText); // 只接受otpauth协议,且类型为totp if (!"otpauth".equals(uri.getScheme()) || !"totp".equals(uri.getHost())) { return null; } String secret = uri.getQueryParameter("secret"); if (TextUtils.isEmpty(secret)) { return null; } String issuer = uri.getQueryParameter("issuer"); // pathSegments第一段是label,个别服务端会把中文做二次编码,所以这里手动decode String label = uri.getPathSegments().isEmpty() ? "" : Uri.decode(uri.getPathSegments().get(0)); return new Account(label, issuer, secret); }注意这里返回null的情况要单独处理,不能直接空指针。issuer允许为空,很多个人网站搭建的TOTP服务不会填这个参数。保存进数据库前,secret还要过一次清洗,把换行符、空格都去掉,否则后续生成验证码时Base32解码就会报错。3.3 列表刷新:倒计时的线程状态管理验证码每30秒变一次,但列表上的进度条和剩余秒数需要每秒更新。常见做法是用Handler往主线程发送延迟消息,这里有个性能雷点:如果脑抽在子线程里直接调notifyDataSetChanged,RecyclerView会直接抛异常。正确姿势是让Handler跑在主线程,每次回调里重算剩余时间。private final Handler refreshHandler = new Handler(Looper.getMainLooper()); private final Runnable tickRunnable = new Runnable() { @Override public void run() { long now = System.currentTimeMillis(); adapter.setCurrentTime(now); adapter.notifyDataSetChanged(); // 算下一次刷新的延迟,让刷新点对齐秒边界 long delay = 1000 - (now % 1000); refreshHandler.postDelayed(this, delay); } }; @Override protected void onResume() { super.onResume(); refreshHandler.post(tickRunnable); } @Override protected void onPause() { super.onPause(); // 页面不可见时立刻移除回调,避免空转耗电 refreshHandler.removeCallbacksAndMessages(null); }delay的计算值得细看:如果不对齐秒边界,刷新时刻会逐渐漂移,最后出现秒数跳变。另外很多改版者会在onPause里忘记移除回调,导致页面关闭后Handler还在跑,这是隐蔽的耗电元凶。这套写法同样适用于所有需要秒级刷新的列表,比如时钟App、抢购倒计时页面。4. 编译与运行:从源码包到APK上机的完整步骤拿到源码包第一件事是别急着双击,先确认环境。这个项目本身不复杂,但Android生态的版本错位经常比代码bug更折腾人。把环境对齐后,一条命令就能出包。4.1 环境准备:JDK8是硬性要求| 工具 | 版本建议 | 说明 | | JDK | 1.8 | 项目标题就是java8,用新JDK编译老工程会碰到javac编码警告 | | Android SDK | API 26-30 | minSdk通常设置在21,targetSdk在28附近问题最少 | | Android Studio | 3.5以上 | 老版本Gradle同步会出现DSL弃用警告 | | Gradle | 5.1-5.5 | 与Android Gradle Plugin 3.4-3.5匹配度最好 |如果本机装了多个JDK版本,注意Android Studio的Gradle JVM配置要手动切到JDK8,否则sync阶段就会报Unsupported class file major version。这个报错和项目代码没关系,纯粹是工具链错位,血泪经验。4.2 导入Android Studio:先改gradle再sync打开工程后先别急着sync,看一眼根目录的build.gradle里的依赖版本。多数情况下,把compileSdkVersion调整为本机已安装的API级别即可。android { compileSdkVersion 28 defaultConfig { minSdkVersion 21 targetSdkVersion 28 } compileOptions { // 开启Java8语法支持,核心步骤 sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }sourceCompatibility和targetCompatibility两个字段缺一不可,只配前者可能在Lambda表达式上翻车。这里的28不是写死值,家里SDK装了30就改成30,dynamic version在部分插件版本上不兼容,显式写数字最稳。sync成功后,IDE会自动下载依赖,如果网络环境一般,可以先在工程根目录执行一次gradlew --offline试试。4.3 命令行构建:不用IDE也能产出Debug包Gradle同步完成后,构建反而是最简单的一步。打开终端进入工程根目录:# 构建debug包,产物在app/build/outputs/apk/debug/目录下 ./gradlew assembleDebug # 安装到已连接的真机或模拟器 ./gradlew installDebugassembleDebug只产包不安装,installDebug会先构建再调用adb install。如果设备没开启USB调试,installDebug会在最后一步报错,这时用数据线连上手机,在开发者选项里打开USB调试即可。生成的app-debug.apk可以直接发到手机上安装,不需要签名配置。4.4 真机调试要留意的细节装好后第一件事不是扫码,而是先把系统时间改成自动获取。TOTP算法对时间同步极度敏感,手机时间偏差超过30秒,动态码就会在服务端校验失败。这个项目是纯本地应用,不依赖网络,飞行模式下照样能生成验证码,测试时别误以为是App坏了。建议准备一个已知secret的测试二维码,用zxing官方demo生成一张,能稳定跑通扫码、解析、出码、倒计时整个链路。5. 常见问题避坑:移植与二次开发的五个翻车点开源项目拿来跑通不难,难的是改成自己的过程中踩坑。下面五条都是从实际移植场景里爬出来的高频问题,按现象、原因、解决三个步骤记录。5.1 验证码对不上:先从手机时间开刀现象:扫码添加账户成功后,输入的六位数字在服务端总校验失败。原因:手机系统时间不准,或者开启了手动时间而没有自动同步。TOTP依赖时间窗口,系统时间和真实时间偏差越大,越容易踩到窗口边界。解决:进入系统设置把日期时间改为自动获取,等时钟同步完成后再试。调试时可以把代码里的periodSeconds临时改成60,变相扩大验证窗口,快速定位是不是时间问题。5.2 扫码闪退:动态权限不是加一行就能过现象:点击扫码按钮直接闪退,LogCat里报SecurityException,或者相机界面黑屏后返回。原因:Android 6.0以上运行时权限,源码的Manifest里虽然声明了CAMERA,但ScanActivity没有在运行时申请权限。解决:在启动扫码前先检查ContextCompat.checkSelfPermission,没有授权就走requestPermissions回调,授权成功后再初始化ZXing的CaptureManager。这是个高频坑,任何Android扫码项目都绕不过。5.3 Base32解码抛IllegalArgumentException现象:添加部分账户时程序直接崩溃,异常指向Base32Decoder.decode。原因:otpauth URI里的secret参数可能带空格、小写字母,极少数服务端还会把secret转成包含换行的格式,解码器不认识这些脏字符。解决:解码前统一做一次字符串清洗,用replace去掉所有空白符再toUpperCase,清洗动作必须在decode之前,放进工具类里复用。顺手把parseUri里的TextUtils.isEmpty判断加上,能拦截一半这类崩溃。5.4 列表秒数跳变:别维护自己的计数器现象:列表倒计时不是每秒递减,偶尔从29跳到27,看起来像刷新卡顿。原因:适配器里用了自增计数器,每次tick减一,但Handler在系统休眠或主线程卡顿时会跳过消息,之后没有用真实时间校准。解决:每次刷新都用System.currentTimeMillis重新计算剩余秒数,显示值永远跟随真实时钟走。这行代码看起来只是改了一个计算方式,实际上是把显示逻辑从增量式改成自校正式,一劳永逸。5.5 Release包扫码白屏:混淆规则要跟上现象:Debug包扫码正常,打Release包后扫码页白屏或直接崩溃。原因:混淆阶段把ZXing里通过反射调用的类干掉了,或so库没有跟随打包。解决:在proguard-rules.pro里加keep规则,保留com.google.zxing.**全量类,并检查jniLibs是否有armeabi-v7a、arm64-v8a目录。这条经验建议提前写进模板工程,不然每次发版都要翻LogCat。6. 二次开发:给验证器加上加密备份与恢复原版验让器最大的痛点就是换手机时没法导出账户。既然手里有源码,第一优先级的改造应该是做一个带加密的备份恢复功能。备份文件本质上就是把Account列表序列化成JSON,再加密写进文件。6.1 改造思路不要用简单的AES固定密钥,那把密钥写死在代码里等于没有加密。Android自带的Android Keystore可以让密钥永不离开安全硬件,用AES/GCM模式做对称加密,GCM自带完整性校验,密文被篡改一字节都解不出来。文件格式上先存IV长度、IV、密文长度、密文,导入时按长度拆开读取,避免踩到流结束判断的坑。public void backupToFile(File target) throws Exception { List<Account> accounts = authDbDao.getAll(); JSONArray array = new JSONArray(); for (Account account : accounts) { array.put(account.toJson()); } String json = array.toString(); KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); keyStore.load(null); SecretKey secretKey = (SecretKey) keyStore.getKey("backup_key", null); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, secretKey); byte[] encrypted = cipher.doFinal(json.getBytes(StandardCharsets.UTF_8)); try (FileOutputStream fos = new FileOutputStream(target); DataOutputStream dos = new DataOutputStream(fos)) { dos.writeInt(cipher.getIV().length); dos.write(cipher.getIV()); dos.writeInt(encrypted.length); dos.write(encrypted); } }先写IV长度再写IV,不要直接writeAll,否则导入时你不知道到底读了多少字节才算完。数据库读取、JSON序列化、加密这三个环节各自独立,可以分开测试。导入逻辑是这段代码的逆向,读长度、创建对应数组、按序填充、解密、批量入库。6.2 验证与上线习惯验证方法很直接:先添加两个测试账户,导出备份文件,然后卸载App或清除数据,重新安装后导入备份,账户列表应原样恢复,动态码继续正常工作。这个流程建议做成自动化脚本,每次改动存储相关代码就跑一遍。从那以后我每次改完这类认证模块,都会先走一遍备份、卸载、恢复的完整演练,确认数据不丢才敢放出去。希望帮到你。本文还有配套的精品资源,点击获取