init
No files matched your search
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,128 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
// 加载签名配置 - 参考官方demo
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cn.deepfal.band.TOTPauthenticator"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cn.deepfal.band.TOTPauthenticator"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
// 签名配置 - 参考官方demo
|
||||
signingConfigs {
|
||||
getByName("debug") {
|
||||
if (keystoreProperties.isNotEmpty()) {
|
||||
keyAlias = keystoreProperties["debug.key.alias"].toString()
|
||||
keyPassword = keystoreProperties["debug.key.password"].toString()
|
||||
storeFile = file(keystoreProperties["debug.store.file"].toString())
|
||||
storePassword = keystoreProperties["debug.store.password"].toString()
|
||||
}
|
||||
}
|
||||
create("release") {
|
||||
if (keystoreProperties.isNotEmpty()) {
|
||||
keyAlias = keystoreProperties["release.key.alias"].toString()
|
||||
keyPassword = keystoreProperties["release.key.password"].toString()
|
||||
storeFile = file(keystoreProperties["release.store.file"].toString())
|
||||
storePassword = keystoreProperties["release.store.password"].toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.navigation.fragment.ktx)
|
||||
implementation(libs.androidx.navigation.ui.ktx)
|
||||
|
||||
// 小米可穿戴SDK
|
||||
implementation(files("libs/xms-wearable-lib_1.4_release.aar"))
|
||||
|
||||
// Compose相关依赖
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Material Icons - 现代化图标
|
||||
implementation("androidx.compose.material:material-icons-extended:1.6.0")
|
||||
|
||||
// 相机和二维码扫描
|
||||
implementation("androidx.camera:camera-camera2:1.3.1")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||
implementation("androidx.camera:camera-view:1.3.1")
|
||||
implementation("com.google.mlkit:barcode-scanning:17.2.0")
|
||||
|
||||
// 权限处理
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||
|
||||
// 文件选择器
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// 拖拽排序
|
||||
implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6")
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
// Gson for JSON
|
||||
implementation(libs.gson)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.deepfal.band.TOTPauthenticator
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("cn.deepfal.band.TOTPauthenticator", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- 小米可穿戴设备通信权限 -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<!-- Android 12+ 蓝牙权限 -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- 文件读取权限 -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
<!-- 相机权限 - 二维码扫描 -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BandTOTPAPP">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.BandTOTPAPP">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,353 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
|
||||
|
||||
@Composable
|
||||
fun AddTokenMenu(
|
||||
onDismiss: () -> Unit,
|
||||
onScanQR: () -> Unit,
|
||||
onImportFile: () -> Unit,
|
||||
onManualAdd: () -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 背景遮罩 - 简洁淡入
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f))
|
||||
.clickable { onDismiss() }
|
||||
)
|
||||
|
||||
// 菜单内容 - 不再重复动画,由外层控制
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.clickable(enabled = false) { }, // 阻止点击传播
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) // 移除阴影
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
// 标题
|
||||
Text(
|
||||
text = "添加账户",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
// 菜单选项 - 简洁展示,无额外动画
|
||||
AddMenuOption(
|
||||
icon = Icons.Default.QrCodeScanner,
|
||||
title = "扫描二维码",
|
||||
subtitle = "扫描服务提供的二维码",
|
||||
onClick = onScanQR
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
AddMenuOption(
|
||||
icon = Icons.Default.FileOpen,
|
||||
title = "导入文件",
|
||||
subtitle = "从备份文件中导入账户",
|
||||
onClick = onImportFile
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
AddMenuOption(
|
||||
icon = Icons.Default.Edit,
|
||||
title = "手动输入",
|
||||
subtitle = "手动输入账户信息",
|
||||
onClick = onManualAdd
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 取消按钮
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "取消",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddMenuOption(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onClick() },
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ManualAddDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (TOTPInfo) -> Unit
|
||||
) {
|
||||
var serviceName by remember { mutableStateOf("") }
|
||||
var accountName by remember { mutableStateOf("") }
|
||||
var secretKey by remember { mutableStateOf("") }
|
||||
var selectedType by remember { mutableStateOf("totp") }
|
||||
var algorithm by remember { mutableStateOf("SHA1") }
|
||||
var digits by remember { mutableStateOf("6") }
|
||||
var period by remember { mutableStateOf("30") }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
// 标题
|
||||
Text(
|
||||
text = "手动添加账户",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
// 服务名称
|
||||
OutlinedTextField(
|
||||
value = serviceName,
|
||||
onValueChange = { serviceName = it },
|
||||
label = { Text("服务名称") },
|
||||
placeholder = { Text("例如: Google") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 账户名称
|
||||
OutlinedTextField(
|
||||
value = accountName,
|
||||
onValueChange = { accountName = it },
|
||||
label = { Text("账户名称") },
|
||||
placeholder = { Text("例如: user@example.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 密钥
|
||||
OutlinedTextField(
|
||||
value = secretKey,
|
||||
onValueChange = { secretKey = it },
|
||||
label = { Text("密钥") },
|
||||
placeholder = { Text("Base32格式的密钥") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 类型选择
|
||||
Text(
|
||||
text = "类型",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
onClick = { selectedType = "totp" },
|
||||
label = { Text("TOTP") },
|
||||
selected = selectedType == "totp"
|
||||
)
|
||||
FilterChip(
|
||||
onClick = { selectedType = "steam" },
|
||||
label = { Text("Steam") },
|
||||
selected = selectedType == "steam"
|
||||
)
|
||||
}
|
||||
|
||||
// 高级选项(仅TOTP)
|
||||
if (selectedType == "totp") {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "高级选项",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// 位数
|
||||
OutlinedTextField(
|
||||
value = digits,
|
||||
onValueChange = { digits = it },
|
||||
label = { Text("位数") },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// 周期
|
||||
OutlinedTextField(
|
||||
value = period,
|
||||
onValueChange = { period = it },
|
||||
label = { Text("周期(秒)") },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 算法选择
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
onClick = { algorithm = "SHA1" },
|
||||
label = { Text("SHA1") },
|
||||
selected = algorithm == "SHA1"
|
||||
)
|
||||
FilterChip(
|
||||
onClick = { algorithm = "SHA256" },
|
||||
label = { Text("SHA256") },
|
||||
selected = algorithm == "SHA256"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (serviceName.isNotBlank() && secretKey.isNotBlank()) {
|
||||
val totpInfo = TOTPInfo(
|
||||
name = serviceName.trim(),
|
||||
usr = accountName.trim(),
|
||||
key = secretKey.trim(),
|
||||
algorithm = if (selectedType == "steam") "SHA1" else algorithm,
|
||||
digits = if (selectedType == "steam") 5 else (digits.toIntOrNull() ?: 6),
|
||||
period = if (selectedType == "steam") 30 else (period.toIntOrNull() ?: 30),
|
||||
type = selectedType
|
||||
)
|
||||
onAdd(totpInfo)
|
||||
}
|
||||
},
|
||||
enabled = serviceName.isNotBlank() && secretKey.isNotBlank()
|
||||
) {
|
||||
Text("添加")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// 滑动状态枚举
|
||||
enum class SwipeState {
|
||||
NONE, // 未滑动
|
||||
DETAILS, // 第一级:显示详情按钮
|
||||
DELETE // 第二级:显示删除按钮
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModernTOTPCard(
|
||||
account: TOTPInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
onViewDetails: (() -> Unit)? = null,
|
||||
currentSwipedAccount: TOTPInfo? = null,
|
||||
onSwipeStateChange: (TOTPInfo?, SwipeState) -> Unit = { _, _ -> }
|
||||
) {
|
||||
// 实时更新验证码
|
||||
var currentCode by remember { mutableStateOf(account.generateCurrentCode()) }
|
||||
var remainingSeconds by remember { mutableStateOf(account.getRemainingSeconds()) }
|
||||
var progress by remember { mutableStateOf(account.getProgress()) }
|
||||
|
||||
// 滑动状态
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var swipeState by remember { mutableStateOf(SwipeState.NONE) }
|
||||
|
||||
// 删除确认对话框状态
|
||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
// 验证码更新动画状态
|
||||
var codeUpdateTrigger by remember { mutableStateOf(false) }
|
||||
|
||||
// 复制状态和动画
|
||||
var showCopySuccess by remember { mutableStateOf(false) }
|
||||
var copyAnimationTrigger by remember { mutableStateOf(false) }
|
||||
|
||||
// 获取剪贴板管理器和上下文
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val density = LocalDensity.current
|
||||
val detailsOffset = with(density) { 80.dp.toPx() }
|
||||
val deleteOffset = with(density) { 160.dp.toPx() }
|
||||
|
||||
// 卡片入场动画
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(50) // 错开动画时间
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
// 验证码更新时的脉冲效果
|
||||
val codeScale by animateFloatAsState(
|
||||
targetValue = if (codeUpdateTrigger) 1.05f else 1f,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "code_pulse"
|
||||
)
|
||||
|
||||
// 复制动画效果 - 简化
|
||||
val copyScale by animateFloatAsState(
|
||||
targetValue = if (copyAnimationTrigger) 1.1f else 1f,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "copy_scale"
|
||||
)
|
||||
|
||||
// 复制功能
|
||||
fun copyCodeToClipboard() {
|
||||
clipboardManager.setText(AnnotatedString(currentCode))
|
||||
copyAnimationTrigger = true
|
||||
showCopySuccess = true
|
||||
}
|
||||
|
||||
// 复制动画重置
|
||||
LaunchedEffect(copyAnimationTrigger) {
|
||||
if (copyAnimationTrigger) {
|
||||
delay(150)
|
||||
copyAnimationTrigger = false
|
||||
}
|
||||
}
|
||||
|
||||
// 复制成功提示自动隐藏
|
||||
LaunchedEffect(showCopySuccess) {
|
||||
if (showCopySuccess) {
|
||||
delay(1500)
|
||||
showCopySuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前滑动账户变化
|
||||
LaunchedEffect(currentSwipedAccount) {
|
||||
if (currentSwipedAccount != null && currentSwipedAccount != account) {
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
// 每秒更新验证码 - 带动画效果
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
val newCode = account.generateCurrentCode()
|
||||
if (newCode != currentCode) {
|
||||
// 触发验证码更新动画
|
||||
codeUpdateTrigger = true
|
||||
delay(100)
|
||||
currentCode = newCode
|
||||
codeUpdateTrigger = false
|
||||
}
|
||||
remainingSeconds = account.getRemainingSeconds()
|
||||
progress = account.getProgress()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 紧急状态的颜色 - 移除闪烁效果,保持平稳显示
|
||||
val urgentColorAlpha by animateFloatAsState(
|
||||
targetValue = if (remainingSeconds <= 5) 0.15f else 0f,
|
||||
animationSpec = tween(durationMillis = 300, easing = EaseOutCubic),
|
||||
label = "urgent_background"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { it / 2 },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy)
|
||||
) + fadeIn(animationSpec = tween(400)),
|
||||
exit = slideOutVertically(
|
||||
targetOffsetY = { -it / 2 },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeOut(animationSpec = tween(200))
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
// 背景层:操作按钮
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 详情按钮
|
||||
AnimatedVisibility(
|
||||
visible = swipeState == SwipeState.DETAILS || swipeState == SwipeState.DELETE,
|
||||
enter = fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)) +
|
||||
scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)),
|
||||
exit = fadeOut(animationSpec = tween(200)) + scaleOut(animationSpec = tween(200))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
onViewDetails?.invoke()
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
onSwipeStateChange(null, SwipeState.NONE)
|
||||
}
|
||||
.animateContentSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "查看详情",
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 删除按钮
|
||||
AnimatedVisibility(
|
||||
visible = swipeState == SwipeState.DELETE,
|
||||
enter = fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)) +
|
||||
scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)),
|
||||
exit = fadeOut(animationSpec = tween(200)) + scaleOut(animationSpec = tween(200))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.clickable {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
.animateContentSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "删除",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 前景层:彻底修复圆角问题 - 使用Box+背景+clip的方式
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset { IntOffset(offsetX.roundToInt(), 0) }
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (urgentColorAlpha > 0f) {
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = urgentColorAlpha)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
val newOffset = offsetX + delta
|
||||
if (newOffset <= 0) {
|
||||
offsetX = newOffset.coerceAtLeast(-deleteOffset)
|
||||
}
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
val targetState = when {
|
||||
velocity < -300 && offsetX < -20f -> {
|
||||
when (swipeState) {
|
||||
SwipeState.NONE -> SwipeState.DETAILS
|
||||
SwipeState.DETAILS -> SwipeState.DELETE
|
||||
SwipeState.DELETE -> SwipeState.DELETE
|
||||
}
|
||||
}
|
||||
offsetX < -deleteOffset * 0.5f -> SwipeState.DELETE
|
||||
offsetX < -detailsOffset * 0.3f -> SwipeState.DETAILS
|
||||
else -> SwipeState.NONE
|
||||
}
|
||||
|
||||
when (targetState) {
|
||||
SwipeState.DETAILS -> {
|
||||
offsetX = -detailsOffset
|
||||
swipeState = SwipeState.DETAILS
|
||||
onSwipeStateChange(account, SwipeState.DETAILS)
|
||||
}
|
||||
SwipeState.DELETE -> {
|
||||
offsetX = -deleteOffset
|
||||
swipeState = SwipeState.DELETE
|
||||
onSwipeStateChange(account, SwipeState.DELETE)
|
||||
}
|
||||
SwipeState.NONE -> {
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
onSwipeStateChange(null, SwipeState.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左侧:服务图标 - 带旋转动画
|
||||
ServiceIcon(serviceName = account.name)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 中间:账户信息
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = account.name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
if (account.usr.isNotEmpty()) {
|
||||
Text(
|
||||
text = account.usr,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 右侧:验证码和倒计时 - 带动画效果和点击复制
|
||||
Box {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
copyCodeToClipboard()
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
// 验证码 - 带缩放和复制动画
|
||||
Text(
|
||||
text = if (account.type.lowercase() == "steam") {
|
||||
currentCode
|
||||
} else {
|
||||
"${currentCode.take(3)} ${currentCode.drop(3)}"
|
||||
},
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = getCodeColor(remainingSeconds),
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier.graphicsLayer(
|
||||
scaleX = codeScale * copyScale,
|
||||
scaleY = codeScale * copyScale
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 倒计时指示器 - 带动画
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${remainingSeconds}s",
|
||||
fontSize = 12.sp,
|
||||
color = getCodeColor(remainingSeconds).copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
// 脉冲指示器
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (remainingSeconds <= 10) {
|
||||
getCodeColor(remainingSeconds).copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
}
|
||||
)
|
||||
.animateContentSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 简化的复制成功提示
|
||||
if (showCopySuccess) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.offset(y = 20.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "✓ 已复制",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除确认对话框 - 带动画
|
||||
if (showDeleteConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showDeleteConfirmation = false
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "确认删除",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "确定要删除账户 \"${account.name}\" 吗?",
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (account.usr.isNotEmpty()) {
|
||||
Text(
|
||||
text = "用户:${account.usr}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "此操作不可撤销。",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirmation = false
|
||||
onDelete?.invoke()
|
||||
// 重置滑动状态
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
onSwipeStateChange(null, SwipeState.NONE)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "删除",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServiceIcon(serviceName: String) {
|
||||
val icon = when (serviceName.lowercase()) {
|
||||
"google" -> Icons.Default.AccountCircle
|
||||
"github" -> Icons.Default.Code
|
||||
"microsoft" -> Icons.Default.Business
|
||||
"apple" -> Icons.Default.Phone
|
||||
"steam" -> Icons.Default.Games
|
||||
"discord" -> Icons.Default.Chat
|
||||
"twitter", "x" -> Icons.Default.Share
|
||||
"facebook" -> Icons.Default.Person
|
||||
"amazon" -> Icons.Default.ShoppingCart
|
||||
"dropbox" -> Icons.Default.Cloud
|
||||
"linkedin" -> Icons.Default.Work
|
||||
else -> Icons.Default.Security
|
||||
}
|
||||
|
||||
val color = when (serviceName.lowercase()) {
|
||||
"google" -> androidx.compose.ui.graphics.Color(0xFF4285F4)
|
||||
"github" -> androidx.compose.ui.graphics.Color(0xFF181717)
|
||||
"microsoft" -> androidx.compose.ui.graphics.Color(0xFF00A1F1)
|
||||
"apple" -> androidx.compose.ui.graphics.Color(0xFF000000)
|
||||
"steam" -> androidx.compose.ui.graphics.Color(0xFF171A21)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = serviceName,
|
||||
tint = color,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getCodeColor(remainingSeconds: Int): androidx.compose.ui.graphics.Color {
|
||||
return when {
|
||||
remainingSeconds <= 5 -> MaterialTheme.colorScheme.error
|
||||
remainingSeconds <= 10 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CountdownCircle(
|
||||
progress: Float,
|
||||
remainingSeconds: Int
|
||||
) {
|
||||
val isUrgent = remainingSeconds <= 5
|
||||
val color = if (isUrgent) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 背景圆环
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f },
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
|
||||
// 进度圆环
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f - progress },
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = color,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
|
||||
// 剩余秒数
|
||||
Text(
|
||||
text = remainingSeconds.toString(),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TypeChip(type: String) {
|
||||
val chipColor = when (type.lowercase()) {
|
||||
"steam" -> androidx.compose.ui.graphics.Color(0xFF171A21)
|
||||
else -> MaterialTheme.colorScheme.secondary
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = chipColor.copy(alpha = 0.1f),
|
||||
modifier = Modifier.padding(0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = type.uppercase(),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = chipColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountActionDialog(
|
||||
accountName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onViewDetails: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(text = "确认删除")
|
||||
},
|
||||
text = {
|
||||
Text(text = "确定要删除账户 \"$accountName\" 吗?此操作不可撤销。")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onDelete,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(text = accountName)
|
||||
},
|
||||
text = {
|
||||
Text(text = "选择要执行的操作")
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TextButton(onClick = onViewDetails) {
|
||||
Text("查看详情")
|
||||
}
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmation = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountDetailsDialog(
|
||||
account: TOTPInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: ((String, String) -> Unit)? = null // 回调函数:(newServiceName, newUsername) -> Unit
|
||||
) {
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showEditDialog) {
|
||||
EditAccountDialog(
|
||||
account = account,
|
||||
onDismiss = { showEditDialog = false },
|
||||
onSave = { newServiceName, newUsername ->
|
||||
onEdit?.invoke(newServiceName, newUsername)
|
||||
showEditDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ServiceIcon(serviceName = account.name)
|
||||
Text(text = "账户详情")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
DetailItem(label = "服务名称", value = account.name)
|
||||
DetailItem(label = "用户名", value = account.usr)
|
||||
DetailItem(label = "算法", value = account.algorithm)
|
||||
DetailItem(label = "位数", value = "${account.digits} 位")
|
||||
DetailItem(label = "周期", value = "${account.period} 秒")
|
||||
DetailItem(label = "类型", value = account.type.uppercase())
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (onEdit != null) {
|
||||
TextButton(
|
||||
onClick = { showEditDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("编辑")
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailItem(label: String, value: String) {
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditAccountDialog(
|
||||
account: TOTPInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String) -> Unit
|
||||
) {
|
||||
var serviceName by remember { mutableStateOf(account.name) }
|
||||
var username by remember { mutableStateOf(account.usr) }
|
||||
val isValid = serviceName.trim().isNotEmpty()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(text = "编辑账户")
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 服务名称输入框
|
||||
Column {
|
||||
Text(
|
||||
text = "服务名称 *",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = serviceName,
|
||||
onValueChange = { serviceName = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("例如:Google") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 用户名输入框
|
||||
Column {
|
||||
Text(
|
||||
text = "用户名",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("例如:user@example.com") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 提示信息
|
||||
Text(
|
||||
text = "* 必填字段",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
onSave(serviceName.trim(), username.trim())
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = if (isValid) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
) {
|
||||
Text("保存")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.components
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.burnoutcrew.reorderable.*
|
||||
|
||||
@Composable
|
||||
fun ReorderableBandAccountsList(
|
||||
accounts: List<cn.deepfal.band.TOTPauthenticator.data.TOTPInfo>,
|
||||
onReorder: (List<cn.deepfal.band.TOTPauthenticator.data.TOTPInfo>) -> Unit
|
||||
) {
|
||||
var reorderableAccounts by remember { mutableStateOf(accounts) }
|
||||
|
||||
// 当accounts变化时更新reorderableAccounts
|
||||
LaunchedEffect(accounts) {
|
||||
reorderableAccounts = accounts
|
||||
}
|
||||
|
||||
val reorderableState = rememberReorderableLazyListState(
|
||||
onMove = { from, to ->
|
||||
reorderableAccounts = reorderableAccounts.toMutableList().apply {
|
||||
add(to.index, removeAt(from.index))
|
||||
}
|
||||
},
|
||||
onDragEnd = { _, _ ->
|
||||
onReorder(reorderableAccounts)
|
||||
}
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 重排序提示
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "拖拽账户卡片可重新排序手环上的账户顺序",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 可重排序的账户列表
|
||||
LazyColumn(
|
||||
state = reorderableState.listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.reorderable(reorderableState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(reorderableAccounts, key = { _, account -> account.key }) { index, account ->
|
||||
ReorderableItem(reorderableState, key = account.key) { isDragging ->
|
||||
val elevation = animateDpAsState(
|
||||
targetValue = if (isDragging) 16.dp else 2.dp,
|
||||
label = "elevation"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = elevation.value),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 拖拽手柄
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = "拖拽排序",
|
||||
modifier = Modifier
|
||||
.detectReorderAfterLongPress(reorderableState)
|
||||
.padding(end = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// 状态指示器
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Watch,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 账户信息
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = account.name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (account.usr.isNotEmpty()) {
|
||||
Text(
|
||||
text = account.usr,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "手环账户",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
// 序号显示
|
||||
Text(
|
||||
text = "#${index + 1}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.data
|
||||
|
||||
import cn.deepfal.band.TOTPauthenticator.utils.TOTPGenerator
|
||||
import cn.deepfal.band.TOTPauthenticator.utils.SteamTOTPGenerator
|
||||
|
||||
/**
|
||||
* TOTP账户信息数据类
|
||||
*/
|
||||
data class TOTPInfo(
|
||||
val name: String,
|
||||
val usr: String,
|
||||
val key: String,
|
||||
val algorithm: String = "SHA1",
|
||||
val digits: Int = 6,
|
||||
val period: Int = 30,
|
||||
val type: String = "totp"
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return """{"name": "$name", "usr": "$usr", "key": "$key", "algorithm": "$algorithm", "digits": $digits, "period": $period, "type": "$type"}"""
|
||||
}
|
||||
|
||||
// 生成当前验证码
|
||||
fun generateCurrentCode(): String {
|
||||
return if (type.lowercase() == "steam") {
|
||||
SteamTOTPGenerator.generateSteamCode(key)
|
||||
} else {
|
||||
TOTPGenerator.generateTOTP(key, algorithm, digits, period)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取剩余时间(秒)
|
||||
fun getRemainingSeconds(): Int {
|
||||
return TOTPGenerator.getRemainingSeconds(period)
|
||||
}
|
||||
|
||||
// 获取进度(0.0-1.0)
|
||||
fun getProgress(): Float {
|
||||
return TOTPGenerator.getProgress(period)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package cn.deepfal.band.TOTPauthenticator.scanner
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun QRCodeScanner(
|
||||
onCodeScanned: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
cameraPermissionState.status.isGranted -> {
|
||||
CameraPreview(
|
||||
onCodeScanned = onCodeScanned,
|
||||
onClose = onClose
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
CameraPermissionRequest(
|
||||
permissionState = cameraPermissionState,
|
||||
onClose = onClose
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraPermissionRequest(
|
||||
permissionState: PermissionState,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "需要相机权限",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "扫描二维码需要使用相机功能",
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { permissionState.launchPermissionRequest() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("授予权限")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
onCodeScanned: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var flashEnabled by remember { mutableStateOf(false) }
|
||||
var camera by remember { mutableStateOf<Camera?>(null) }
|
||||
|
||||
val preview = remember { Preview.Builder().build() }
|
||||
val selector = remember { CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() }
|
||||
|
||||
val imageAnalysis = remember {
|
||||
ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
}
|
||||
|
||||
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
|
||||
|
||||
val barcodeScanner = BarcodeScanning.getClient()
|
||||
|
||||
imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
|
||||
val inputImage = InputImage.fromMediaImage(
|
||||
imageProxy.image!!,
|
||||
imageProxy.imageInfo.rotationDegrees
|
||||
)
|
||||
|
||||
barcodeScanner.process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
for (barcode in barcodes) {
|
||||
when (barcode.valueType) {
|
||||
Barcode.TYPE_TEXT -> {
|
||||
val rawValue = barcode.rawValue
|
||||
if (rawValue != null && rawValue.startsWith("otpauth://")) {
|
||||
onCodeScanned(rawValue)
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
camera = cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
selector,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
PreviewView(context).apply {
|
||||
this.scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { previewView ->
|
||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
)
|
||||
|
||||
// 扫描框覆盖层
|
||||
ScanOverlay()
|
||||
|
||||
// 顶部控制栏
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
camera?.let {
|
||||
if (it.cameraInfo.hasFlashUnit()) {
|
||||
it.cameraControl.enableTorch(!flashEnabled)
|
||||
flashEnabled = !flashEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (flashEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff,
|
||||
contentDescription = if (flashEnabled) "关闭闪光灯" else "打开闪光灯",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 底部说明文字
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "将二维码对准扫描框",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScanOverlay() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
) {
|
||||
// 扫描框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(280.dp)
|
||||
.align(Alignment.Center)
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
)
|
||||
|
||||
// 四个角的装饰
|
||||
val cornerSize = 24.dp
|
||||
val cornerThickness = 4.dp
|
||||
val boxSize = 280.dp
|
||||
val offsetFromCenter = (boxSize - cornerSize) / 2
|
||||
|
||||
// 左上角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = -offsetFromCenter, y = -offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(topStart = 8.dp)
|
||||
)
|
||||
)
|
||||
|
||||
// 右上角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = offsetFromCenter, y = -offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(topEnd = 8.dp)
|
||||
)
|
||||
)
|
||||
|
||||
// 左下角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = -offsetFromCenter, y = offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(bottomStart = 8.dp)
|
||||
)
|
||||
)
|
||||
|
||||
// 右下角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = offsetFromCenter, y = offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(bottomEnd = 8.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
|
||||
import cn.deepfal.band.TOTPauthenticator.utils.LocalAccountManager
|
||||
import com.xiaomi.xms.wearable.message.MessageApi
|
||||
import com.xiaomi.xms.wearable.message.OnMessageReceivedListener
|
||||
import com.xiaomi.xms.wearable.node.Node
|
||||
import com.xiaomi.xms.wearable.node.NodeApi
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SyncAccount(
|
||||
val account: TOTPInfo,
|
||||
val status: SyncStatus
|
||||
)
|
||||
|
||||
enum class SyncStatus {
|
||||
PHONE_ONLY, // 仅在手机
|
||||
WATCH_ONLY, // 仅在手环
|
||||
SYNCED // 已同步
|
||||
}
|
||||
|
||||
class SyncManager(
|
||||
private val context: Context,
|
||||
private val nodeApi: NodeApi,
|
||||
private val messageApi: MessageApi
|
||||
) {
|
||||
private val localAccountManager = LocalAccountManager(context)
|
||||
private var nodeId: String? = null
|
||||
private var currentNode: Node? = null
|
||||
|
||||
// 消息监听器
|
||||
private val messageListener = OnMessageReceivedListener { deviceId, message ->
|
||||
try {
|
||||
val jsonString = String(message)
|
||||
val jsonObject = JSONObject(jsonString)
|
||||
// 处理来自手环的消息
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initializeConnection(): Boolean {
|
||||
return try {
|
||||
val task = nodeApi.connectedNodes
|
||||
task.addOnSuccessListener { nodes ->
|
||||
if (nodes.isNotEmpty()) {
|
||||
currentNode = nodes[0]
|
||||
nodeId = currentNode?.id
|
||||
setupMessageListener()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMessageListener() {
|
||||
nodeId?.let { id ->
|
||||
messageApi.addListener(id, messageListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhoneAccounts(): List<TOTPInfo> {
|
||||
return localAccountManager.loadAccounts()
|
||||
}
|
||||
|
||||
suspend fun getWatchAccounts(): List<TOTPInfo> {
|
||||
// 发送请求到手环获取账户列表
|
||||
// 这里需要等待异步响应
|
||||
return emptyList() // 临时返回空列表
|
||||
}
|
||||
|
||||
fun getMergedAccounts(phoneAccounts: List<TOTPInfo>, watchAccounts: List<TOTPInfo>): List<SyncAccount> {
|
||||
val merged = mutableListOf<SyncAccount>()
|
||||
val phoneMap = phoneAccounts.associateBy { "${it.name}_${it.key}" }
|
||||
val watchMap = watchAccounts.associateBy { "${it.name}_${it.key}" }
|
||||
|
||||
// 添加手机账户
|
||||
phoneAccounts.forEach { account ->
|
||||
val key = "${account.name}_${account.key}"
|
||||
val status = if (watchMap.containsKey(key)) {
|
||||
SyncStatus.SYNCED
|
||||
} else {
|
||||
SyncStatus.PHONE_ONLY
|
||||
}
|
||||
merged.add(SyncAccount(account, status))
|
||||
}
|
||||
|
||||
// 添加仅手环账户
|
||||
watchAccounts.forEach { account ->
|
||||
val key = "${account.name}_${account.key}"
|
||||
if (!phoneMap.containsKey(key)) {
|
||||
merged.add(SyncAccount(account, SyncStatus.WATCH_ONLY))
|
||||
}
|
||||
}
|
||||
|
||||
return merged.sortedBy { it.account.name }
|
||||
}
|
||||
|
||||
suspend fun syncToWatch(accounts: List<TOTPInfo>): Boolean {
|
||||
return try {
|
||||
nodeId?.let { id ->
|
||||
val jsonMessage = """{"list":${accounts.joinToString(",", "[", "]") { it.toJsonString() }}}"""
|
||||
val task = messageApi.sendMessage(id, jsonMessage.toByteArray())
|
||||
task.addOnSuccessListener {
|
||||
// 同步成功
|
||||
}
|
||||
true
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun syncToPhone(accounts: List<TOTPInfo>) {
|
||||
accounts.forEach { account ->
|
||||
localAccountManager.addAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
nodeId?.let { id ->
|
||||
messageApi.removeListener(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,59 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BandTOTPAPPTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 本地账户存储管理器
|
||||
* 使用SharedPreferences存储TOTP账户信息
|
||||
*/
|
||||
class LocalAccountManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "totp_accounts"
|
||||
private const val KEY_ACCOUNTS = "accounts"
|
||||
}
|
||||
|
||||
private val sharedPrefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* 保存账户列表
|
||||
*/
|
||||
fun saveAccounts(accounts: List<TOTPInfo>) {
|
||||
try {
|
||||
val jsonArray = JSONArray()
|
||||
accounts.forEach { account ->
|
||||
val jsonObject = JSONObject().apply {
|
||||
put("name", account.name)
|
||||
put("usr", account.usr)
|
||||
put("key", account.key)
|
||||
put("algorithm", account.algorithm)
|
||||
put("digits", account.digits)
|
||||
put("period", account.period)
|
||||
put("type", account.type)
|
||||
}
|
||||
jsonArray.put(jsonObject)
|
||||
}
|
||||
|
||||
sharedPrefs.edit()
|
||||
.putString(KEY_ACCOUNTS, jsonArray.toString())
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载账户列表
|
||||
*/
|
||||
fun loadAccounts(): List<TOTPInfo> {
|
||||
return try {
|
||||
val jsonString = sharedPrefs.getString(KEY_ACCOUNTS, null) ?: return emptyList()
|
||||
val jsonArray = JSONArray(jsonString)
|
||||
val accounts = mutableListOf<TOTPInfo>()
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(i)
|
||||
val account = TOTPInfo(
|
||||
name = jsonObject.getString("name"),
|
||||
usr = jsonObject.getString("usr"),
|
||||
key = jsonObject.getString("key"),
|
||||
algorithm = jsonObject.optString("algorithm", "SHA1"),
|
||||
digits = jsonObject.optInt("digits", 6),
|
||||
period = jsonObject.optInt("period", 30),
|
||||
type = jsonObject.optString("type", "totp")
|
||||
)
|
||||
accounts.add(account)
|
||||
}
|
||||
|
||||
accounts
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户
|
||||
*/
|
||||
fun addAccount(account: TOTPInfo) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
|
||||
// 检查是否已存在相同的账户(基于secret和name)
|
||||
val existingIndex = currentAccounts.indexOfFirst {
|
||||
it.key == account.key && it.name == account.name
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新现有账户
|
||||
currentAccounts[existingIndex] = account
|
||||
} else {
|
||||
// 添加新账户
|
||||
currentAccounts.add(account)
|
||||
}
|
||||
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
fun removeAccount(account: TOTPInfo) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
currentAccounts.removeAll {
|
||||
it.key == account.key && it.name == account.name
|
||||
}
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息(支持修改服务名和用户名)
|
||||
*/
|
||||
fun updateAccount(originalAccount: TOTPInfo, newServiceName: String, newUsername: String) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
val targetIndex = currentAccounts.indexOfFirst {
|
||||
it.key == originalAccount.key && it.name == originalAccount.name && it.usr == originalAccount.usr
|
||||
}
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
// 创建更新后的账户信息
|
||||
val updatedAccount = originalAccount.copy(
|
||||
name = newServiceName,
|
||||
usr = newUsername
|
||||
)
|
||||
currentAccounts[targetIndex] = updatedAccount
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有账户
|
||||
*/
|
||||
fun clearAllAccounts() {
|
||||
sharedPrefs.edit().remove(KEY_ACCOUNTS).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加账户
|
||||
*/
|
||||
fun addAccounts(newAccounts: List<TOTPInfo>) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
|
||||
newAccounts.forEach { newAccount ->
|
||||
val existingIndex = currentAccounts.indexOfFirst {
|
||||
it.key == newAccount.key && it.name == newAccount.name
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
currentAccounts[existingIndex] = newAccount
|
||||
} else {
|
||||
currentAccounts.add(newAccount)
|
||||
}
|
||||
}
|
||||
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.utils
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) 生成器
|
||||
* 基于RFC 6238标准实现
|
||||
*/
|
||||
object TOTPGenerator {
|
||||
|
||||
/**
|
||||
* 生成TOTP验证码
|
||||
* @param secret Base32编码的密钥
|
||||
* @param algorithm 算法类型 (SHA1, SHA256, SHA512)
|
||||
* @param digits 验证码位数 (通常是6位)
|
||||
* @param period 时间周期 (通常是30秒)
|
||||
* @return 生成的验证码
|
||||
*/
|
||||
fun generateTOTP(
|
||||
secret: String,
|
||||
algorithm: String = "SHA1",
|
||||
digits: Int = 6,
|
||||
period: Int = 30
|
||||
): String {
|
||||
return try {
|
||||
val key = decodeBase32(secret)
|
||||
val timeCounter = System.currentTimeMillis() / 1000 / period
|
||||
val hmac = generateHMAC(key, timeCounter, algorithm)
|
||||
val code = truncate(hmac, digits)
|
||||
code.toString().padStart(digits, '0')
|
||||
} catch (e: Exception) {
|
||||
"000000" // 出错时返回默认值
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间步长内剩余的秒数
|
||||
* @param period 时间周期
|
||||
* @return 剩余秒数
|
||||
*/
|
||||
fun getRemainingSeconds(period: Int = 30): Int {
|
||||
val currentTime = System.currentTimeMillis() / 1000
|
||||
return period - (currentTime % period).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间步长的进度百分比
|
||||
* @param period 时间周期
|
||||
* @return 进度百分比 (0.0 - 1.0)
|
||||
*/
|
||||
fun getProgress(period: Int = 30): Float {
|
||||
val currentTime = System.currentTimeMillis() / 1000
|
||||
val elapsed = (currentTime % period).toInt()
|
||||
return elapsed.toFloat() / period.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32解码
|
||||
*/
|
||||
private fun decodeBase32(base32: String): ByteArray {
|
||||
val cleanInput = base32.replace(" ", "").replace("=", "").uppercase()
|
||||
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
val output = mutableListOf<Byte>()
|
||||
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
|
||||
for (char in cleanInput) {
|
||||
val index = alphabet.indexOf(char)
|
||||
if (index == -1) continue
|
||||
|
||||
buffer = (buffer shl 5) or index
|
||||
bitsLeft += 5
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
output.add((buffer shr (bitsLeft - 8)).toByte())
|
||||
bitsLeft -= 8
|
||||
}
|
||||
}
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成HMAC
|
||||
*/
|
||||
private fun generateHMAC(key: ByteArray, counter: Long, algorithm: String): ByteArray {
|
||||
val algorithmName = when (algorithm.uppercase()) {
|
||||
"SHA1" -> "HmacSHA1"
|
||||
"SHA256" -> "HmacSHA256"
|
||||
"SHA512" -> "HmacSHA512"
|
||||
else -> "HmacSHA1"
|
||||
}
|
||||
|
||||
val mac = Mac.getInstance(algorithmName)
|
||||
val keySpec = SecretKeySpec(key, algorithmName)
|
||||
mac.init(keySpec)
|
||||
|
||||
val counterBytes = ByteBuffer.allocate(8).putLong(counter).array()
|
||||
return mac.doFinal(counterBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态截取
|
||||
*/
|
||||
private fun truncate(hmac: ByteArray, digits: Int): Int {
|
||||
val offset = (hmac[hmac.size - 1].toInt() and 0x0F)
|
||||
|
||||
val code = ((hmac[offset].toInt() and 0x7F) shl 24) or
|
||||
((hmac[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((hmac[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(hmac[offset + 3].toInt() and 0xFF)
|
||||
|
||||
return code % (10.0.pow(digits).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam TOTP 生成器
|
||||
* Steam使用特殊的字符集和算法,密钥为Base32编码(从URI中提取)
|
||||
*/
|
||||
object SteamTOTPGenerator {
|
||||
private const val STEAM_CHARS = "23456789BCDFGHJKMNPQRTVWXY"
|
||||
|
||||
fun generateSteamCode(secret: String): String {
|
||||
return try {
|
||||
// 🔄 修复:现在Steam使用URI中的Base32编码的secret
|
||||
val key = decodeBase32(secret)
|
||||
|
||||
val timeDiv30 = System.currentTimeMillis() / 1000 / 30
|
||||
|
||||
// 创建8字节的时间计数器
|
||||
val timeBytes = ByteArray(8)
|
||||
timeBytes[4] = ((timeDiv30 shr 24) and 0xFF).toByte()
|
||||
timeBytes[5] = ((timeDiv30 shr 16) and 0xFF).toByte()
|
||||
timeBytes[6] = ((timeDiv30 shr 8) and 0xFF).toByte()
|
||||
timeBytes[7] = (timeDiv30 and 0xFF).toByte()
|
||||
|
||||
// 生成HMAC-SHA1
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
val keySpec = SecretKeySpec(key, "HmacSHA1")
|
||||
mac.init(keySpec)
|
||||
val hmacBytes = mac.doFinal(timeBytes)
|
||||
|
||||
// 获取偏移量 (最后一个字节的低4位)
|
||||
val offset = (hmacBytes[19].toInt() and 0x0F)
|
||||
|
||||
// 截取4字节并转换为32位整数
|
||||
var fullcode = ((hmacBytes[offset].toInt() and 0xFF) shl 24) or
|
||||
((hmacBytes[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((hmacBytes[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(hmacBytes[offset + 3].toInt() and 0xFF)
|
||||
|
||||
// 取31位 (去掉符号位)
|
||||
fullcode = fullcode and 0x7FFFFFFF
|
||||
|
||||
// 生成5位Steam验证码
|
||||
val result = StringBuilder()
|
||||
for (i in 0 until 5) {
|
||||
result.append(STEAM_CHARS[fullcode % STEAM_CHARS.length])
|
||||
fullcode /= STEAM_CHARS.length
|
||||
}
|
||||
|
||||
result.toString()
|
||||
} catch (e: Exception) {
|
||||
"23456" // Steam默认5位字符
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBase32(base32: String): ByteArray {
|
||||
val cleanInput = base32.replace(" ", "").replace("=", "").uppercase()
|
||||
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
val output = mutableListOf<Byte>()
|
||||
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
|
||||
for (char in cleanInput) {
|
||||
val index = alphabet.indexOf(char)
|
||||
if (index == -1) continue
|
||||
|
||||
buffer = (buffer shl 5) or index
|
||||
bitsLeft += 5
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
output.add((buffer shr (bitsLeft - 8)).toByte())
|
||||
bitsLeft -= 8
|
||||
}
|
||||
}
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<include layout="@layout/content_main" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="@dimen/fab_margin"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:srcCompat="@android:drawable/ic_dialog_email" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_host_fragment_content_main"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:defaultNavHost="true"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navGraph="@navigation/nav_graph" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".FirstFragment">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_first"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/next"
|
||||
app:layout_constraintBottom_toTopOf="@id/textview_first"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textview_first"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/lorem_ipsum"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_first" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".SecondFragment">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_second"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/previous"
|
||||
app:layout_constraintBottom_toTopOf="@id/textview_second"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textview_second"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/lorem_ipsum"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/button_second" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,10 @@
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:context="cn.deepfal.band.TOTPauthenticator.MainActivity">
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:orderInCategory="100"
|
||||
android:title="@string/action_settings"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/nav_graph"
|
||||
app:startDestination="@id/FirstFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/FirstFragment"
|
||||
android:name="cn.deepfal.band.TOTPauthenticator.FirstFragment"
|
||||
android:label="@string/first_fragment_label"
|
||||
tools:layout="@layout/fragment_first">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_FirstFragment_to_SecondFragment"
|
||||
app:destination="@id/SecondFragment" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/SecondFragment"
|
||||
android:name="cn.deepfal.band.TOTPauthenticator.SecondFragment"
|
||||
android:label="@string/second_fragment_label"
|
||||
tools:layout="@layout/fragment_second">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_SecondFragment_to_FirstFragment"
|
||||
app:destination="@id/FirstFragment" />
|
||||
</fragment>
|
||||
</navigation>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.BandTOTPAPP" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.BandTOTPAPP" parent="Base.Theme.BandTOTPAPP">
|
||||
<!-- Transparent system bars for edge-to-edge. -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">200dp</dimen>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">16dp</dimen>
|
||||
</resources>
|
||||
@@ -0,0 +1,46 @@
|
||||
<resources>
|
||||
<string name="app_name">BandTOTP-APP</string>
|
||||
<string name="action_settings">Settings</string>
|
||||
<!-- Strings used for fragments for navigation -->
|
||||
<string name="first_fragment_label">First Fragment</string>
|
||||
<string name="second_fragment_label">Second Fragment</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="previous">Previous</string>
|
||||
|
||||
<string name="lorem_ipsum">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris
|
||||
volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus
|
||||
dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad
|
||||
litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend
|
||||
diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a,
|
||||
ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum tellus.\n\n
|
||||
Suspendisse interdum ornare ante. Aliquam nec cursus lorem. Morbi id magna felis. Vivamus
|
||||
egestas, est a condimentum egestas, turpis nisl iaculis ipsum, in dictum tellus dolor sed
|
||||
neque. Morbi tellus erat, dapibus ut sem a, iaculis tincidunt dui. Interdum et malesuada
|
||||
fames ac ante ipsum primis in faucibus. Curabitur et eros porttitor, ultricies urna vitae,
|
||||
molestie nibh. Phasellus at commodo eros, non aliquet metus. Sed maximus nisl nec dolor
|
||||
bibendum, vel congue leo egestas.\n\n
|
||||
Sed interdum tortor nibh, in sagittis risus mollis quis. Curabitur mi odio, condimentum sit
|
||||
amet auctor at, mollis non turpis. Nullam pretium libero vestibulum, finibus orci vel,
|
||||
molestie quam. Fusce blandit tincidunt nulla, quis sollicitudin libero facilisis et. Integer
|
||||
interdum nunc ligula, et fermentum metus hendrerit id. Vestibulum lectus felis, dictum at
|
||||
lacinia sit amet, tristique id quam. Cras eu consequat dui. Suspendisse sodales nunc ligula,
|
||||
in lobortis sem porta sed. Integer id ultrices magna, in luctus elit. Sed a pellentesque
|
||||
est.\n\n
|
||||
Aenean nunc velit, lacinia sed dolor sed, ultrices viverra nulla. Etiam a venenatis nibh.
|
||||
Morbi laoreet, tortor sed facilisis varius, nibh orci rhoncus nulla, id elementum leo dui
|
||||
non lorem. Nam mollis ipsum quis auctor varius. Quisque elementum eu libero sed commodo. In
|
||||
eros nisl, imperdiet vel imperdiet et, scelerisque a mauris. Pellentesque varius ex nunc,
|
||||
quis imperdiet eros placerat ac. Duis finibus orci et est auctor tincidunt. Sed non viverra
|
||||
ipsum. Nunc quis augue egestas, cursus lorem at, molestie sem. Morbi a consectetur ipsum, a
|
||||
placerat diam. Etiam vulputate dignissim convallis. Integer faucibus mauris sit amet finibus
|
||||
convallis.\n\n
|
||||
Phasellus in aliquet mi. Pellentesque habitant morbi tristique senectus et netus et
|
||||
malesuada fames ac turpis egestas. In volutpat arcu ut felis sagittis, in finibus massa
|
||||
gravida. Pellentesque id tellus orci. Integer dictum, lorem sed efficitur ullamcorper,
|
||||
libero justo consectetur ipsum, in mollis nisl ex sed nisl. Donec maximus ullamcorper
|
||||
sodales. Praesent bibendum rhoncus tellus nec feugiat. In a ornare nulla. Donec rhoncus
|
||||
libero vel nunc consequat, quis tincidunt nisl eleifend. Cras bibendum enim a justo luctus
|
||||
vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim.
|
||||
</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.BandTOTPAPP" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.BandTOTPAPP" parent="Base.Theme.BandTOTPAPP" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.deepfal.band.TOTPauthenticator
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||