Android TOTP生成器 - 带扫码功能
我将为Android TOTP生成器添加扫码功能,让用户可以方便地扫描二维码添加密钥。下面是完整的实现方案:
实现思路
- 添加ZXing库实现二维码扫描
- 创建扫码界面
- 解析TOTP二维码格式
- 将扫描结果添加到主界面
完整实现代码
1. 添加依赖 (app/build.gradle)
dependencies {
// 原有依赖
implementation 'commons-codec:commons-codec:1.16.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation "androidx.security:security-crypto:1.1.0-alpha06"
// 新增扫码依赖
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'androidx.activity:activity-ktx:1.8.0'
}
2. 扫码活动 (ScanActivity.kt)
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class ScanActivity : AppCompatActivity() {
private lateinit var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>
private lateinit var cameraExecutor: ExecutorService
private lateinit var barcodeScanner: BarcodeScanner
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_scan)
cameraExecutor = Executors.newSingleThreadExecutor()
// 配置条码扫描器
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
barcodeScanner = BarcodeScanning.getClient(options)
// 检查相机权限
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startCamera()
} else {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION)
}
}
private fun startCamera() {
cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// 设置预览
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(findViewById(R.id.previewView).surfaceProvider)
}
// 设置图像分析器
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(cameraExecutor) { imageProxy ->
processImage(imageProxy)
}
}
// 选择后置摄像头
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
// 解除绑定后绑定
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageAnalysis
)
} catch (e: Exception) {
Toast.makeText(this, "启动相机失败: ${e.message}", Toast.LENGTH_SHORT).show()
}
}, ContextCompat.getMainExecutor(this))
}
private fun processImage(imageProxy: ImageAnalysis.ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
barcodeScanner.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
barcode.rawValue?.let { value ->
if (value.startsWith("otpauth://totp/")) {
// 找到有效的TOTP二维码
val resultIntent = Intent()
resultIntent.putExtra("SCAN_RESULT", value)
setResult(RESULT_OK, resultIntent)
finish()
}
}
}
}
.addOnCompleteListener {
imageProxy.close()
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CAMERA_PERMISSION) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startCamera()
} else {
Toast.makeText(this, "需要相机权限才能扫码", Toast.LENGTH_SHORT).show()
finish()
}
}
}
override fun onDestroy() {
super.onDestroy()
barcodeScanner.close()
cameraExecutor.shutdown()
}
companion object {
private const val REQUEST_CAMERA_PERMISSION = 1001
}
}
3. 扫码界面布局 (activity_scan.xml)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<androidx.camera.view.PreviewView
android:id="@+id/previewView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<View
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_marginTop="100dp"
android:background="#2200ff00"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="将二维码放入框内扫描"
android:textColor="@android:color/white"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginBottom="100dp"/>
<Button
android:id="@+id/closeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="关闭"
android:layout_margin="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
4. 修改TOTP工具类 (TOTPGenerator.kt)
添加二维码解析方法:
// 在TOTPGenerator对象中添加
fun parseQRContent(content: String): Pair<String, String>? {
if (!content.startsWith("otpauth://totp/")) return null
try {
// 提取密钥和发行者
val secret = content.substringAfter("secret=").substringBefore("&")
val issuer = content.substringAfter("issuer=").substringBefore("&")
return Pair(secret, issuer)
} catch (e: Exception) {
Log.e(TAG, "解析二维码失败: ${e.message}")
return null
}
}
5. 修改主活动 (MainActivity.kt)
添加扫码功能和结果处理:
class MainActivity : AppCompatActivity() {
// ... 已有变量
// 注册扫码活动结果
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val data = result.data
val scanResult = data?.getStringExtra("SCAN_RESULT") ?: ""
TOTPGenerator.parseQRContent(scanResult)?.let { (secret, issuer) ->
secretInput.setText(secret)
SecureStorage.saveSecret(this, secret)
Toast.makeText(this, "已添加: $issuer", Toast.LENGTH_SHORT).show()
} ?: run {
Toast.makeText(this, "无效的TOTP二维码", Toast.LENGTH_SHORT).show()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ... 已有初始化
// 添加扫码按钮
val scanButton = findViewById<Button>(R.id.scanButton)
scanButton.setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startScan()
} else {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 101)
}
}
}
// 启动扫码
private fun startScan() {
val intent = Intent(this, ScanActivity::class.java)
scanLauncher.launch(intent)
}
// 处理权限请求结果
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 101) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startScan()
} else {
Toast.makeText(this, "需要相机权限才能扫码", Toast.LENGTH_SHORT).show()
}
}
}
// ... 其他已有代码
}
6. 修改主界面布局 (activity_main.xml)
添加扫码按钮和账户显示:
<!-- 在原有布局基础上修改 -->
<!-- 添加账户显示 -->
<TextView
android:id="@+id/accountText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/titleText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp"/>
<!-- 修改按钮布局 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/secretInput"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp">
<Button
android:id="@+id/scanButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="扫码添加"
android:drawableStart="@android:drawable/ic_menu_camera"
android:drawablePadding="8dp"
android:layout_marginEnd="8dp"/>
<Button
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="保存密钥"
android:layout_marginStart="8dp"/>
</LinearLayout>
7. 修改安全存储 (SecureStorage.kt)
添加账户信息存储:
// 在SecureStorage对象中添加
private const val ISSUER_KEY = "encrypted_issuer"
// 保存账户信息
fun saveAccount(context: Context, secret: String, issuer: String) {
// ... 已有加密存储初始化
prefs.edit()
.putString(SECRET_KEY, secret)
.putString(ISSUER_KEY, issuer)
.apply()
}
// 获取账户信息
fun getAccount(context: Context): Pair<String, String>? {
// ... 已有加密存储初始化
val secret = prefs.getString(SECRET_KEY, null)
val issuer = prefs.getString(ISSUER_KEY, null)
return if (secret != null && issuer != null) {
Pair(secret, issuer)
} else {
null
}
}
8. 在AndroidManifest.xml中添加权限和活动
<uses-permission android:name="android.permission.CAMERA"/>
<application>
<!-- 其他组件 -->
<activity
android:name=".ScanActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.NoActionBar"/>
</application>
功能说明
-
扫码添加密钥
- 使用CameraX和ML Kit实现高性能二维码扫描
- 支持标准TOTP二维码格式 (otpauth://totp/)
- 自动提取密钥和发行者信息
-
账户管理
- 显示当前账户名称(如Google、GitHub等)
- 加密存储账户信息
-
权限处理
- 动态请求相机权限
- 优雅处理权限拒绝情况
-
用户界面优化
- 添加扫码按钮
- 显示账户名称
- 扫码界面添加引导框
-
二维码解析
- 支持标准TOTP URI格式
- 提取secret和issuer参数
- 错误处理无效二维码
使用流程
- 用户点击"扫码添加"按钮
- 系统请求相机权限(如未授权)
- 打开扫码界面,显示相机预览
- 用户扫描TOTP二维码
- 解析二维码内容,提取密钥和发行者
- 保存账户信息并返回主界面
- 主界面显示账户名称和生成的验证码
安全增强
- 最小权限原则:仅在扫码时请求相机权限
- 数据加密:所有敏感信息使用AndroidX Security加密存储
- 输入验证:验证二维码格式有效性
- 权限检查:扫码前检查相机权限
- 资源释放:扫码完成后立即释放相机资源
这个实现提供了完整的扫码添加TOTP账户的功能,用户可以方便地通过扫描二维码添加新的验证账户,无需手动输入复杂的Base32密钥。