commit d2a3e61b54e4cf7b4b561f7959fa2570cff43ce5 Author: deepfal Date: Mon Aug 18 11:17:38 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..359bb53 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..886b16c --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +BandTOTP-APP \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..6bbe2ae --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..41096b2 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,26 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/REFACTOR_SUMMARY.md b/REFACTOR_SUMMARY.md new file mode 100644 index 0000000..bfb17e7 --- /dev/null +++ b/REFACTOR_SUMMARY.md @@ -0,0 +1,106 @@ +# 🎯 现代化TOTP验证器应用重构完成 + +## ✨ 重构成果 + +已成功将原有的TOTP验证器应用重构为现代化、优雅的身份验证器应用,完全符合您的设计要求。 + +### 🎨 界面重构亮点 + +#### **主界面设计** +- **极简风格**:去除复杂的手环状态显示,专注于验证码展示 +- **Google Authenticator风格**:采用Material Design 3设计语言 +- **现代化图标**:使用Material Icons替代emoji,确保跨设备一致性 +- **优雅动画**:流畅的页面切换和列表动画效果 + +#### **TOTP卡片设计** +- **服务图标**:智能识别服务并显示对应图标和品牌色彩 +- **实时倒计时**:圆形进度条,剩余5秒时变红警告 +- **验证码显示**:等宽字体,分组显示,支持Steam特殊格式 +- **类型标签**:清晰标识TOTP/Steam类型 + +### 🚀 功能增强 + +#### **添加方式升级** +- **📱 浮动按钮**:右下角双FAB设计,符合Material规范 +- **📷 二维码扫描**:集成CameraX + MLKit,专业扫描界面 +- **📁 文件导入**:保留原有功能,现代化界面 +- **✏️ 手动添加**:完整参数配置,支持高级选项 + +#### **同步功能重构** +- **🔄 智能同步界面**:统一列表显示,清晰状态标识 +- **📊 三种状态**: + - 📱 仅手机(蓝色) + - ⌚ 仅手环(橙色) + - ✅ 已同步(绿色) +- **🎯 批量操作**:支持全部同步到手机/手环 +- **📈 统计面板**:实时显示各状态账户数量 + +### 🛠️ 技术升级 + +#### **现代化依赖** +```kotlin +// Material Icons扩展包 +implementation("androidx.compose.material:material-icons-extended:1.6.0") + +// 相机和扫码 +implementation("androidx.camera:camera-camera2:1.3.1") +implementation("com.google.mlkit:barcode-scanning:17.2.0") + +// 权限处理 +implementation("com.google.accompanist:accompanist-permissions:0.32.0") +``` + +#### **架构优化** +- **组件化设计**:功能模块独立,便于维护 +- **状态管理**:使用Compose State进行响应式UI更新 +- **现代API**:CameraX + MLKit + Material Design 3 + +### 📁 项目结构 + +``` +app/src/main/java/cn/deepfal/band/TOTPauthenticator/ +├── MainActivity.kt # 主Activity - 现代化设计 +├── data/ +│ └── TOTPInfo.kt # TOTP数据模型 +├── components/ +│ ├── ModernTOTPCard.kt # 现代化验证码卡片 +│ ├── AddComponents.kt # 添加相关组件 +│ └── SyncScreen.kt # 同步管理界面 +├── scanner/ +│ └── QRCodeScanner.kt # 专业二维码扫描器 +├── sync/ +│ └── SyncManager.kt # 同步逻辑管理 +└── utils/ + ├── LocalAccountManager.kt # 本地存储管理 + └── TOTPGenerator.kt # TOTP算法实现 +``` + +### 🎯 用户体验提升 + +#### **主界面** +- 空状态友好提示 +- 流畅的账户列表滚动 +- 实时验证码更新 +- 智能色彩和图标识别 + +#### **添加流程** +- 直观的底部菜单 +- 专业的扫码体验 +- 完整的手动输入选项 +- 即时反馈和错误处理 + +#### **同步管理** +- 清晰的状态可视化 +- 一键批量操作 +- 实时连接状态监控 +- 友好的错误提示 + +## 🏆 设计目标达成 + +✅ **极简美观**:去除冗余元素,专注核心功能 +✅ **现代审美**:Material Design 3,Google Authenticator风格 +✅ **功能完整**:扫码、手动、文件导入三种添加方式 +✅ **智能同步**:可视化状态管理,去重合并 +✅ **专业体验**:真实图标、品牌色彩、流畅动画 + +应用现已完成现代化重构,构建成功,可以直接使用和测试!🎉 \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fbfcd15 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/app/keystore/band_keystore.jks b/app/keystore/band_keystore.jks new file mode 100644 index 0000000..d86dd50 Binary files /dev/null and b/app/keystore/band_keystore.jks differ diff --git a/app/keystore/band_keystore.p12 b/app/keystore/band_keystore.p12 new file mode 100644 index 0000000..76df977 Binary files /dev/null and b/app/keystore/band_keystore.p12 differ diff --git a/app/keystore/keystore.jks b/app/keystore/keystore.jks new file mode 100644 index 0000000..eaacb35 Binary files /dev/null and b/app/keystore/keystore.jks differ diff --git a/app/libs/xms-wearable-lib_1.4_release.aar b/app/libs/xms-wearable-lib_1.4_release.aar new file mode 100644 index 0000000..3291866 Binary files /dev/null and b/app/libs/xms-wearable-lib_1.4_release.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/cn/deepfal/band/TOTPauthenticator/ExampleInstrumentedTest.kt b/app/src/androidTest/java/cn/deepfal/band/TOTPauthenticator/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..984401e --- /dev/null +++ b/app/src/androidTest/java/cn/deepfal/band/TOTPauthenticator/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..deaba07 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/MainActivity.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/MainActivity.kt new file mode 100644 index 0000000..1333b64 --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/MainActivity.kt @@ -0,0 +1,1543 @@ +package cn.deepfal.band.TOTPauthenticator + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.core.view.WindowCompat +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.OnBackPressedCallback +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +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.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import cn.deepfal.band.TOTPauthenticator.ui.theme.BandTOTPAPPTheme +import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo +import cn.deepfal.band.TOTPauthenticator.utils.LocalAccountManager +import cn.deepfal.band.TOTPauthenticator.sync.SyncManager +import cn.deepfal.band.TOTPauthenticator.scanner.QRCodeScanner +import cn.deepfal.band.TOTPauthenticator.components.ModernTOTPCard +import cn.deepfal.band.TOTPauthenticator.components.AddTokenMenu +import cn.deepfal.band.TOTPauthenticator.components.ManualAddDialog +import cn.deepfal.band.TOTPauthenticator.components.SyncScreen +import cn.deepfal.band.TOTPauthenticator.components.AccountDetailsDialog +import com.xiaomi.xms.wearable.Wearable +import com.xiaomi.xms.wearable.auth.Permission +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 androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import org.burnoutcrew.reorderable.* + +class MainActivity : ComponentActivity() { + + private lateinit var nodeApi: NodeApi + private lateinit var messageApi: MessageApi + private lateinit var localAccountManager: LocalAccountManager + private lateinit var syncManager: SyncManager + private val handler = Handler(Looper.getMainLooper()) + + // 手环连接相关 + private lateinit var nodeId: String + private lateinit var curNode: Node + + // 状态管理 + private var accountsState = mutableStateOf>(emptyList()) + private var bandAccountsState = mutableStateOf>(emptyList()) + private var showSyncScreen = mutableStateOf(false) + private var showAddMenu = mutableStateOf(false) + private var showQRScanner = mutableStateOf(false) + private var showManualAdd = mutableStateOf(false) + private var connectionStatusState = mutableStateOf("设备未连接") + private var isConnectedState = mutableStateOf(false) + private var syncLogsState = mutableStateOf>(emptyList()) + private var showAccountDetails = mutableStateOf(false) + private var selectedAccount = mutableStateOf(null) + private var isReorderMode = mutableStateOf(false) + + // 消息监听器状态 + private var isListenerRegistered = false + + // 消息监听器 + private val messageListener = OnMessageReceivedListener { deviceId, message -> + try { + val jsonString = String(message) + log("📨 收到手环消息: ${jsonString.take(100)}...") + + val jsonObject = JSONObject(jsonString) + if (jsonObject.has("list")) { + val accountsList = mutableListOf() + val jsonArray = jsonObject.getJSONArray("list") + + for (i in 0 until jsonArray.length()) { + val accountJson = jsonArray.getJSONObject(i) + val totpInfo = TOTPInfo( + name = accountJson.getString("name"), + usr = accountJson.getString("usr"), + key = accountJson.getString("key"), + algorithm = accountJson.optString("algorithm", "SHA1"), + digits = accountJson.optInt("digits", 6), + period = accountJson.optInt("period", 30), + type = accountJson.optString("type", "totp") + ) + accountsList.add(totpInfo) + } + + bandAccountsState.value = accountsList + log("✅ 成功接收到 ${accountsList.size} 个手环账户") + } + + // 处理删除确认 + if (jsonObject.has("cmd") && jsonObject.getString("cmd") == "deleteConfirmation") { + val deletedKey = jsonObject.getString("key") + val success = jsonObject.getBoolean("success") + + if (success) { + log("✅ 手环账户删除成功: ${deletedKey.take(8)}...") + Toast.makeText(this@MainActivity, "手环账户删除成功", Toast.LENGTH_SHORT).show() + + // 立即从bandAccountsState中移除删除的账户,提供即时UI反馈 + val currentBandAccounts = bandAccountsState.value.toMutableList() + val removed = currentBandAccounts.removeAll { it.key == deletedKey } + if (removed) { + bandAccountsState.value = currentBandAccounts + log("🔄 已立即更新UI,移除删除的账户") + } + + // 延迟重新请求手环账户列表以确保数据同步 + handler.postDelayed({ requestBandAccounts() }, 1000) + } else { + log("❌ 手环账户删除失败: ${deletedKey.take(8)}...") + Toast.makeText(this@MainActivity, "手环账户删除失败", Toast.LENGTH_SHORT).show() + } + } + + // 处理重排序确认 + if (jsonObject.has("cmd") && jsonObject.getString("cmd") == "reorderConfirmation") { + val success = jsonObject.getBoolean("success") + + if (success) { + log("✅ 手环账户重排序成功") + Toast.makeText(this@MainActivity, "手环账户重排序成功", Toast.LENGTH_SHORT).show() + + // 立即请求更新的手环账户列表 + requestBandAccounts() + } else { + log("❌ 手环账户重排序失败") + Toast.makeText(this@MainActivity, "手环账户重排序失败", Toast.LENGTH_SHORT).show() + } + } + } catch (e: Exception) { + log("❌ 解析手环消息失败: ${e.message}") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // 配置边到边显示 + enableEdgeToEdge() + + nodeApi = Wearable.getNodeApi(this) + messageApi = Wearable.getMessageApi(this) + localAccountManager = LocalAccountManager(this) + syncManager = SyncManager(this, nodeApi, messageApi) + + loadAccounts() + initializeWearableConnection() + + // 处理返回键逻辑 + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // 检查当前显示的界面状态,按优先级处理 + when { + // 账户详情对话框 + showAccountDetails.value -> { + showAccountDetails.value = false + } + // 手动添加对话框 + showManualAdd.value -> { + showManualAdd.value = false + } + // 二维码扫描器 + showQRScanner.value -> { + showQRScanner.value = false + } + // 添加菜单 + showAddMenu.value -> { + showAddMenu.value = false + } + // 主界面的重排序模式 + isReorderMode.value && !showSyncScreen.value -> { + isReorderMode.value = false + } + // 同步界面(包括同步界面内的重排序模式) + showSyncScreen.value -> { + showSyncScreen.value = false + } + else -> { + // 如果在主界面,则退出应用 + finish() + } + } + } + }) + + setContent { + BandTOTPAPPTheme { + // 根据主题动态设置状态栏内容颜色 + val isDarkTheme = androidx.compose.foundation.isSystemInDarkTheme() + + LaunchedEffect(isDarkTheme) { + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = !isDarkTheme // 浅色主题用深色状态栏内容,深色主题用浅色状态栏内容 + } + } + + MainScreen() + } + } + } + + override fun onDestroy() { + super.onDestroy() + // 清理消息监听器 + if (isListenerRegistered && ::nodeId.isInitialized) { + messageApi.removeListener(nodeId) + isListenerRegistered = false + log("🔄 已清理消息监听器") + } + } + + private fun initializeWearableConnection() { + // 先查询已连接的设备 + queryConnectedDevices() + } + + private fun queryConnectedDevices() { + connectionStatusState.value = "正在连接..." + isConnectedState.value = false + + nodeApi.connectedNodes.addOnSuccessListener { nodes -> + if (nodes.isNotEmpty()) { + curNode = nodes[0] + nodeId = curNode.id + connectionStatusState.value = "已连接: ${curNode.name}" + isConnectedState.value = true + log("🔗 已连接到设备: ${curNode.name}") + checkAndRequestPermissions() + } else { + connectionStatusState.value = "未找到设备" + isConnectedState.value = false + log("❌ 未找到连接的设备") + } + }.addOnFailureListener { e -> + connectionStatusState.value = "连接失败: ${e.message}" + isConnectedState.value = false + log("❌ 查询设备失败: ${e.message}") + } + } + + private fun checkAndRequestPermissions() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.BLUETOOTH), 1001) + } + + val authApi = Wearable.getAuthApi(this) + if (::nodeId.isInitialized) { + val did = nodeId + authApi.checkPermission(did, Permission.DEVICE_MANAGER) + .addOnSuccessListener { granted -> + if (!granted) { + authApi.requestPermission(did, Permission.DEVICE_MANAGER) + .addOnSuccessListener { + log("✅ 权限已获取") + setupMessageListener() + } + .addOnFailureListener { e -> + log("❌ 权限获取失败: ${e.message}") + } + } else { + log("✅ 权限已存在") + setupMessageListener() + } + } + .addOnFailureListener { e -> + log("❌ 权限检查失败: ${e.message}") + } + } + } + + private fun setupMessageListener() { + if (::nodeId.isInitialized) { + // 如果已经注册过监听器,先移除 + if (isListenerRegistered) { + messageApi.removeListener(nodeId) + log("🔄 已移除旧的消息监听器") + isListenerRegistered = false + } + + messageApi.addListener(nodeId, messageListener) + .addOnSuccessListener { + log("✅ 消息监听器已注册") + isListenerRegistered = true + // 自动请求一次账户列表 + handler.postDelayed({ + requestBandAccounts() + }, 2000) + } + .addOnFailureListener { e -> + log("❌ 消息监听器注册失败: ${e.message}") + isListenerRegistered = false + } + } + } + + private fun openWearableApp() { + if (::nodeId.isInitialized) { + log("🔄 正在打开手环应用...") + nodeApi.isWearAppInstalled(nodeId) + .addOnSuccessListener { isInstalled -> + if (isInstalled) { + nodeApi.launchWearApp(nodeId, "pages/index") + .addOnSuccessListener { + log("✅ 手环应用打开成功") + // 延迟请求账户,确保应用已启动 + handler.postDelayed({ + requestBandAccounts() + }, 3000) + } + .addOnFailureListener { e -> + log("❌ 打开手环应用失败: ${e.message}") + // 即使打开失败也尝试请求账户 + requestBandAccounts() + } + } else { + log("⚠️ 手环未安装TOTP应用") + Toast.makeText(this, "手环未安装TOTP应用,请先安装", Toast.LENGTH_SHORT).show() + } + } + .addOnFailureListener { e -> + log("❌ 检查手环应用安装状态失败: ${e.message}") + Toast.makeText(this, "检查手环应用失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } else { + log("❌ 设备未连接") + Toast.makeText(this, "设备未连接", Toast.LENGTH_SHORT).show() + } + } + + private fun requestBandAccounts() { + if (::nodeId.isInitialized) { + val requestMessage = """{"cmd":"getAccounts"}""" + log("📥 请求手环账户列表...") + + messageApi.sendMessage(nodeId, requestMessage.toByteArray()) + .addOnSuccessListener { + log("✅ 账户列表请求已发送") + } + .addOnFailureListener { e -> + log("❌ 账户列表请求失败: ${e.message}") + } + } else { + log("❌ 设备未连接") + } + } + + private fun deleteBandAccount(account: TOTPInfo) { + if (::nodeId.isInitialized) { + val deleteMessage = """{"cmd":"deleteAccount","key":"${account.key}"}""" + log("🗑️ 请求删除手环账户: ${account.name}") + + messageApi.sendMessage(nodeId, deleteMessage.toByteArray()) + .addOnSuccessListener { + log("✅ 删除请求已发送") + } + .addOnFailureListener { e -> + log("❌ 删除请求失败: ${e.message}") + Toast.makeText(this, "删除请求失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } else { + log("❌ 设备未连接,无法删除") + Toast.makeText(this, "设备未连接", Toast.LENGTH_SHORT).show() + } + } + + private fun reorderBandAccounts(reorderedAccounts: List) { + if (::nodeId.isInitialized) { + val reorderMessage = """{"cmd":"reorderAccounts","accounts":${reorderedAccounts.joinToString(",", "[", "]") { it.toJsonString() }}}""" + log("🔄 发送手环账户重排序命令...") + + // 立即更新本地状态以提供即时UI反馈 + bandAccountsState.value = reorderedAccounts + log("🔄 已立即更新UI,新顺序已生效") + + messageApi.sendMessage(nodeId, reorderMessage.toByteArray()) + .addOnSuccessListener { + log("✅ 重排序命令已发送") + Toast.makeText(this, "重排序命令已发送到手环", Toast.LENGTH_SHORT).show() + // 不需要延迟请求,手环确认后会自动刷新 + } + .addOnFailureListener { e -> + log("❌ 重排序命令失败: ${e.message}") + Toast.makeText(this, "重排序失败: ${e.message}", Toast.LENGTH_SHORT).show() + // 如果发送失败,重新请求手环账户以恢复原始顺序 + requestBandAccounts() + } + } else { + log("❌ 设备未连接,无法重排序") + Toast.makeText(this, "设备未连接", Toast.LENGTH_SHORT).show() + } + } + + private fun loadAccounts() { + try { + log("📂 开始加载本地账户...") + val accounts = localAccountManager.loadAccounts() + accountsState.value = accounts + log("✅ 成功加载 ${accounts.size} 个本地账户") + } catch (e: Exception) { + log("❌ 加载账户失败: ${e.message}") + log("📍 加载账户错误堆栈: ${e.stackTraceToString()}") + Toast.makeText(this, "加载账户失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + private fun syncToWearable(accounts: List) { + if (::nodeId.isInitialized) { + val jsonMessage = """{"list":${accounts.joinToString(",", "[", "]") { it.toJsonString() }}}""" + log("📤 同步 ${accounts.size} 个账户到手环...") + + messageApi.sendMessage(nodeId, jsonMessage.toByteArray()) + .addOnSuccessListener { + log("✅ 账户同步成功") + Toast.makeText(this, "同步成功", Toast.LENGTH_SHORT).show() + // 重新请求手环账户验证同步结果 + handler.postDelayed({ requestBandAccounts() }, 1000) + } + .addOnFailureListener { e -> + log("❌ 账户同步失败: ${e.message}") + Toast.makeText(this, "同步失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } else { + log("❌ 设备未连接,无法同步") + Toast.makeText(this, "设备未连接", Toast.LENGTH_SHORT).show() + } + } + + private fun log(message: String) { + val timestamp = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date()) + val logEntry = "$timestamp: $message" + syncLogsState.value = syncLogsState.value + logEntry + // 保持最新100条日志 + if (syncLogsState.value.size > 100) { + syncLogsState.value = syncLogsState.value.takeLast(100) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun MainScreen() { + val accounts by accountsState + val showSync by showSyncScreen + val showAdd by showAddMenu + val showScanner by showQRScanner + val showManual by showManualAdd + + // 文件导入启动器 + val importFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + handleFileImport(it) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // 主界面与同步界面的转场动画 + AnimatedVisibility( + visible = !showSync, + enter = slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) + ) + fadeIn(animationSpec = tween(durationMillis = 300)), + exit = slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(durationMillis = 300)) + ) { + // 主界面 + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + ) { + // 顶部标题栏 + TopAppBar( + title = { + // 标题文字切换动画 + AnimatedContent( + targetState = isReorderMode.value, + transitionSpec = { + slideInVertically { height -> -height } + fadeIn() togetherWith + slideOutVertically { height -> height } + fadeOut() + }, + label = "title_animation" + ) { isReorder -> + Text( + text = if (isReorder) "拖拽排序" else "身份验证器", + fontSize = 24.sp, + fontWeight = FontWeight.Medium + ) + } + }, + actions = { + if (accounts.isNotEmpty()) { + // 排序按钮动画 + AnimatedVisibility( + visible = true, + enter = scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)), + exit = scaleOut() + ) { + IconButton( + onClick = { + isReorderMode.value = !isReorderMode.value + }, + modifier = Modifier.animateContentSize() + ) { + // 图标切换动画 + AnimatedContent( + targetState = isReorderMode.value, + transitionSpec = { + scaleIn() + fadeIn() togetherWith scaleOut() + fadeOut() + }, + label = "icon_animation" + ) { isReorder -> + Icon( + imageVector = if (isReorder) Icons.Default.Check else Icons.Default.Reorder, + contentDescription = if (isReorder) "完成排序" else "排序" + ) + } + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + + // 账户列表区域动画 + AnimatedContent( + targetState = accounts.isEmpty(), + transitionSpec = { + if (targetState) { + // 显示空状态 + fadeIn(animationSpec = tween(500)) togetherWith + fadeOut(animationSpec = tween(300)) + } else { + // 显示列表 + fadeIn(animationSpec = tween(500)) + + slideInVertically( + initialOffsetY = { it / 3 }, + animationSpec = tween(600, easing = EaseOutCubic) + ) togetherWith + fadeOut(animationSpec = tween(300)) + } + }, + label = "content_animation" + ) { isEmpty -> + if (isEmpty) { + EmptyState() + } else { + AccountsList( + accounts = accounts, + isReorderMode = isReorderMode.value, + onReorder = { reorderedList -> + accountsState.value = reorderedList + localAccountManager.saveAccounts(reorderedList) + log("📝 账户顺序已保存") + } + ) + } + } + } + } + + // 浮动操作按钮 - 移到外层Box中,只在主界面显示 + AnimatedVisibility( + visible = !showSync, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + fadeIn(), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(200) + ) + fadeOut(), + modifier = Modifier.align(Alignment.BottomEnd) + ) { + FloatingActionButtons( + onAddClick = { showAddMenu.value = true }, + onSyncClick = { + showSyncScreen.value = true + // 打开同步界面时自动打开手环应用 + openWearableApp() + } + ) + } + + // 同步界面转场动画 + AnimatedVisibility( + visible = showSync, + enter = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) + ) + fadeIn(animationSpec = tween(durationMillis = 300)), + exit = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(durationMillis = 300)) + ) { + SyncScreen( + onClose = { showSyncScreen.value = false }, + phoneAccounts = accounts, + watchAccounts = bandAccountsState.value, + connectionStatus = connectionStatusState.value, + isConnected = isConnectedState.value, + syncLogs = syncLogsState.value, + onSyncToWatch = { accountsToSync -> + syncToWearable(accountsToSync) + }, + onSyncToPhone = { accountsToSync -> + accountsToSync.forEach { account -> + localAccountManager.addAccount(account) + } + loadAccounts() + log("✅ 同步 ${accountsToSync.size} 个账户到手机") + Toast.makeText(this@MainActivity, "同步到手机成功", Toast.LENGTH_SHORT).show() + }, + onRequestBandAccounts = { + openWearableApp() + }, + onReconnect = { + queryConnectedDevices() + }, + onDeleteFromBand = { account -> + deleteBandAccount(account) + }, + onReorderBandAccounts = { reorderedAccounts -> + reorderBandAccounts(reorderedAccounts) + } + ) + } + + // 添加菜单 - 简洁的从下滑入动画 + AnimatedVisibility( + visible = showAdd, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + fadeIn(animationSpec = tween(durationMillis = 250)), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 200, easing = EaseInQuart) + ) + fadeOut(animationSpec = tween(durationMillis = 150)) + ) { + AddTokenMenu( + onDismiss = { showAddMenu.value = false }, + onScanQR = { + showAddMenu.value = false + showQRScanner.value = true + }, + onImportFile = { + showAddMenu.value = false + importFileLauncher.launch("text/plain") + }, + onManualAdd = { + showAddMenu.value = false + showManualAdd.value = true + } + ) + } + + // 二维码扫描器淡入动画 + AnimatedVisibility( + visible = showScanner, + enter = fadeIn(animationSpec = tween(300)) + scaleIn( + initialScale = 0.8f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ), + exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f) + ) { + QRCodeScanner( + onCodeScanned = { qrCode -> + showQRScanner.value = false + handleQRCode(qrCode) + }, + onClose = { showQRScanner.value = false } + ) + } + + // 对话框动画 + AnimatedVisibility( + visible = showManual, + enter = fadeIn() + scaleIn( + initialScale = 0.8f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ), + exit = fadeOut() + scaleOut(targetScale = 0.8f) + ) { + ManualAddDialog( + onDismiss = { showManualAdd.value = false }, + onAdd = { totpInfo -> + localAccountManager.addAccount(totpInfo) + loadAccounts() + showManualAdd.value = false + Toast.makeText(this@MainActivity, "账户添加成功", Toast.LENGTH_SHORT).show() + } + ) + } + + // 账户详情对话框 + AnimatedVisibility( + visible = showAccountDetails.value, + enter = fadeIn() + scaleIn( + initialScale = 0.8f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ), + exit = fadeOut() + scaleOut(targetScale = 0.8f) + ) { + selectedAccount.value?.let { account -> + AccountDetailsDialog( + account = account, + onDismiss = { showAccountDetails.value = false }, + onEdit = { newServiceName, newUsername -> + // 更新账户信息 + localAccountManager.updateAccount(account, newServiceName, newUsername) + loadAccounts() + log("✅ 账户编辑成功: ${account.name} -> $newServiceName") + Toast.makeText(this@MainActivity, "账户编辑成功", Toast.LENGTH_SHORT).show() + } + ) + } + } + } + } + + @Composable + fun EmptyState() { + // 浮动动画状态 + var isVisible by remember { mutableStateOf(false) } + + // 图标脉冲动画 + val pulseScale by animateFloatAsState( + targetValue = if (isVisible) 1.05f else 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2000, easing = EaseInOutSine), + repeatMode = RepeatMode.Reverse + ), + label = "pulse_scale" + ) + + // 启动动画 + LaunchedEffect(Unit) { + delay(200) + isVisible = true + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // 主图标 - 带脉冲和滑入动画 + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically( + initialOffsetY = { -it }, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + fadeIn(animationSpec = tween(800)) + ) { + Icon( + imageVector = Icons.Default.Security, + contentDescription = null, + modifier = Modifier + .size(120.dp) + .graphicsLayer( + scaleX = pulseScale, + scaleY = pulseScale + ), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 标题文字 - 从下方滑入 + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically( + initialOffsetY = { it / 2 }, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + fadeIn(animationSpec = tween(600)) + ) { + Text( + text = "还没有账户", + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 描述文字 - 最后出现 + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically( + initialOffsetY = { it / 2 }, + animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy) + ) + fadeIn(animationSpec = tween(800)) + ) { + Text( + text = "点击右下角的 + 按钮开始添加您的第一个验证器账户", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + lineHeight = 22.sp + ) + } + } + } + + @OptIn(ExperimentalAnimationApi::class) + @Composable + fun AccountsList( + accounts: List, + isReorderMode: Boolean = false, + onReorder: (List) -> Unit = {} + ) { + var reorderableAccounts by remember { mutableStateOf(accounts) } + + // 滑动状态管理 - 跟踪当前滑动的账户 + var currentSwipedAccount by remember { mutableStateOf(null) } + + // 动画状态 - 用于列表项的错开入场动画 + var listVisible by remember { mutableStateOf(false) } + + // 当accounts变化时更新reorderableAccounts + LaunchedEffect(accounts) { + reorderableAccounts = accounts + // 重置列表可见性以触发重新入场动画 + if (accounts != reorderableAccounts) { + listVisible = false + delay(50) + listVisible = true + } + } + + // 启动列表入场动画 + LaunchedEffect(Unit) { + delay(100) // 稍微延迟,让主界面先出现 + listVisible = true + } + + val reorderableState = rememberReorderableLazyListState( + onMove = { from, to -> + reorderableAccounts = reorderableAccounts.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + }, + onDragEnd = { _, _ -> + onReorder(reorderableAccounts) + } + ) + + LazyColumn( + state = reorderableState.listState, + modifier = Modifier + .fillMaxSize() + .reorderable(reorderableState), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemsIndexed(reorderableAccounts, key = { _, account -> account.key }) { index, account -> + // 为每个列表项添加错开的入场动画 + AnimatedVisibility( + visible = listVisible, + enter = slideInVertically( + initialOffsetY = { it / 2 }, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + fadeIn(animationSpec = tween(durationMillis = 400)), + exit = slideOutVertically( + targetOffsetY = { -it / 2 }, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + fadeOut(animationSpec = tween(durationMillis = 200)) + ) { + ReorderableItem(reorderableState, key = account.key) { isDragging -> + val elevation = animateDpAsState( + targetValue = if (isDragging) 12.dp else 2.dp, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "elevation" + ) + + // 拖拽时的缩放动画 - 与手环排序保持一致 + val dragScale by animateFloatAsState( + targetValue = if (isDragging) 1.03f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "drag_scale" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer( + scaleX = dragScale, + scaleY = dragScale + ) + .animateContentSize(), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors( + containerColor = if (isDragging) + MaterialTheme.colorScheme.surfaceContainerHighest + else + MaterialTheme.colorScheme.surface + ) + ) { + Row { + // 拖拽排序模式下的账户卡片 + if (isReorderMode) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + // 拖拽手柄 - 带动画 + AnimatedVisibility( + visible = true, + enter = slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + fadeIn() + ) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "拖拽排序", + modifier = Modifier + .padding(start = 16.dp) + .detectReorderAfterLongPress(reorderableState) + .animateContentSize(), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + ModernTOTPCard( + account = account, + modifier = Modifier.weight(1f), + onDelete = null, // 排序模式下禁用删除 + onViewDetails = null, // 排序模式下禁用详情 + currentSwipedAccount = null, // 排序模式下禁用滑动 + onSwipeStateChange = { _, _ -> } // 排序模式下禁用滑动 + ) + } + } else { + // 普通模式的账户卡片 - 支持二级左滑 + ModernTOTPCard( + account = account, + onDelete = { + localAccountManager.removeAccount(account) + loadAccounts() + // 删除后重置滑动状态 + currentSwipedAccount = null + }, + onViewDetails = { + selectedAccount.value = account + showAccountDetails.value = true + // 查看详情后重置滑动状态 + currentSwipedAccount = null + }, + currentSwipedAccount = currentSwipedAccount, + onSwipeStateChange = { swipedAccount, swipeState -> + currentSwipedAccount = swipedAccount + } + ) + } + } + } + } + } + } + + // 底部间距,避免被FAB遮挡 - 带动画 + item { + AnimatedVisibility( + visible = listVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 300)) + ) { + Spacer(modifier = Modifier.height(88.dp)) + } + } + } + } + + @Composable + fun FloatingActionButtons( + modifier: Modifier = Modifier, + onAddClick: () -> Unit, + onSyncClick: () -> Unit + ) { + // 按钮缩放状态 + var addButtonPressed by remember { mutableStateOf(false) } + var syncButtonPressed by remember { mutableStateOf(false) } + + // 协程scope + val coroutineScope = rememberCoroutineScope() + + // 按钮缩放动画 + val addButtonScale by animateFloatAsState( + targetValue = if (addButtonPressed) 0.95f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "add_button_scale" + ) + + val syncButtonScale by animateFloatAsState( + targetValue = if (syncButtonPressed) 0.95f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "sync_button_scale" + ) + + Column( + modifier = modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.End + ) { + // 同步按钮 + FloatingActionButton( + onClick = { + syncButtonPressed = true + // 延迟执行点击,让动画效果更明显 + coroutineScope.launch { + delay(50) + syncButtonPressed = false + onSyncClick() + } + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = CircleShape, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp + ), + modifier = Modifier + .graphicsLayer( + scaleX = syncButtonScale, + scaleY = syncButtonScale + ) + .animateContentSize() + ) { + // 图标旋转动画 + var rotationAngle by remember { mutableStateOf(0f) } + val rotation by animateFloatAsState( + targetValue = rotationAngle, + animationSpec = tween(durationMillis = 300), + label = "sync_rotation" + ) + + LaunchedEffect(syncButtonPressed) { + if (syncButtonPressed) { + rotationAngle += 180f + } + } + + Icon( + imageVector = Icons.Default.Sync, + contentDescription = "同步", + modifier = Modifier.graphicsLayer(rotationZ = rotation) + ) + } + + // 添加按钮 - 主要按钮 + FloatingActionButton( + onClick = { + addButtonPressed = true + coroutineScope.launch { + delay(50) + addButtonPressed = false + onAddClick() + } + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp + ), + modifier = Modifier + .graphicsLayer( + scaleX = addButtonScale, + scaleY = addButtonScale + ) + .animateContentSize() + ) { + // 简化图标动画 - 移除旋转,只保留缩放 + Icon( + imageVector = Icons.Default.Add, + contentDescription = "添加" + ) + } + } + } + + private fun handleFileImport(uri: Uri) { + try { + val contentResolver = contentResolver + val importedAccounts = mutableListOf() + + log("📁 开始解析导入文件...") + contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + val content = reader.readText() + log("📄 文件内容长度: ${content.length} 字符") + + // 智能提取所有otpauth URI + val extractedUris = extractOtpAuthUris(content) + log("🔍 找到 ${extractedUris.size} 个潜在的TOTP URI") + + extractedUris.forEachIndexed { index, uriString -> + try { + log("🔄 处理第 ${index + 1} 个URI...") + log("📝 URI内容: ${uriString.take(100)}...") + + // 尝试解析为Steam JSON格式 + val steamAccount = parseSteamJson(uriString) + if (steamAccount != null) { + log("🎮 识别为Steam账户") + + // 检查是否已存在相同账户(避免重复添加) + val existingAccount = importedAccounts.find { + it.name == steamAccount.name && it.key == steamAccount.key + } + if (existingAccount == null) { + importedAccounts.add(steamAccount) + log("✅ 成功添加Steam账户: ${steamAccount.name}") + } else { + log("⚠️ Steam账户已存在,跳过: ${steamAccount.name}") + } + } else { + // 尝试解析为标准TOTP URL格式 + val totpAccount = parseOtpAuthUrl(uriString) + if (totpAccount != null) { + log("🔐 识别为TOTP账户") + + // 检查是否已存在相同账户 + val existingAccount = importedAccounts.find { + it.name == totpAccount.name && it.key == totpAccount.key + } + if (existingAccount == null) { + importedAccounts.add(totpAccount) + log("✅ 成功添加TOTP账户: ${totpAccount.name}") + } else { + log("⚠️ TOTP账户已存在,跳过: ${totpAccount.name}") + } + } else { + log("❌ 无法解析URI: ${uriString.take(50)}...") + } + } + } catch (e: Exception) { + log("❌ 解析第 ${index + 1} 个URI时出错: ${e.message}") + } + } + } + } + + log("📊 解析完成,共找到 ${importedAccounts.size} 个有效账户") + + if (importedAccounts.isNotEmpty()) { + try { + // 保存到本地 + log("💾 开始保存账户到本地...") + var savedCount = 0 + importedAccounts.forEach { account -> + try { + localAccountManager.addAccount(account) + savedCount++ + log("✅ 账户保存成功: ${account.name} (${savedCount}/${importedAccounts.size})") + } catch (e: Exception) { + log("❌ 账户保存失败: ${account.name} - ${e.message}") + } + } + + log("🔄 重新加载账户列表...") + loadAccounts() + + log("📊 导入完成: 成功保存 ${savedCount}/${importedAccounts.size} 个账户") + Toast.makeText(this, "成功导入 ${savedCount} 个账户", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + log("❌ 保存账户时发生错误: ${e.message}") + Toast.makeText(this, "保存账户时发生错误: ${e.message}", Toast.LENGTH_SHORT).show() + } + } else { + log("❌ 文件中未找到有效的TOTP账户") + Toast.makeText(this, "文件中未找到有效的TOTP账户", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + log("❌ 文件导入失败: ${e.message}") + log("📍 错误堆栈: ${e.stackTraceToString()}") + Toast.makeText(this, "文件导入失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + /** + * 智能提取文本中所有的otpauth URI + * 支持各种编码格式和嵌套在JSON中的URI + */ + private fun extractOtpAuthUris(content: String): List { + val uris = mutableSetOf() // 使用Set避免重复 + + try { + log("🔍 开始智能URI提取...") + + // 优先检查是否为完整的Steam JSON格式 + if (content.trim().startsWith("{") && content.trim().endsWith("}")) { + try { + log("📄 检测到JSON格式,验证是否为Steam JSON...") + val jsonObject = JSONObject(content.trim()) + if (jsonObject.has("shared_secret") && jsonObject.has("account_name")) { + // 这是一个完整的Steam JSON,直接添加原始内容 + uris.add(content.trim()) + log("🎮 识别为完整Steam JSON格式,跳过其他URI提取方式") + return uris.toList() // 直接返回,避免重复识别 + } else { + log("⚠️ JSON格式但非Steam格式,继续其他提取方式") + } + } catch (e: Exception) { + // 不是有效的JSON,继续其他方式解析 + log("⚠️ JSON解析失败,尝试其他格式: ${e.message}") + } + } + + log("🔗 开始提取标准otpauth URI...") + + // 1. 直接查找otpauth://开头的完整URI(更宽松的匹配) + val directUriPattern = Regex("otpauth://totp/[^\\s\\n\\r\"'{}\\[\\]]+", RegexOption.IGNORE_CASE) + directUriPattern.findAll(content).forEach { match -> + val rawUri = match.value + val cleanUri = cleanAndDecodeUri(rawUri) + if (cleanUri.isNotEmpty() && isValidOtpAuthUri(cleanUri)) { + uris.add(cleanUri) + log("🔗 直接找到URI: ${cleanUri.take(50)}...") + } + } + + // 2. 查找JSON中的uri字段(只有在不是Steam JSON的情况下) + val jsonUriPattern = Regex("\"uri\"\\s*:\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE) + jsonUriPattern.findAll(content).forEach { match -> + val rawUri = match.groupValues[1] + val cleanUri = cleanAndDecodeUri(rawUri) + if (cleanUri.startsWith("otpauth://totp/", ignoreCase = true) && isValidOtpAuthUri(cleanUri)) { + uris.add(cleanUri) + log("🔗 JSON中找到URI: ${cleanUri.take(50)}...") + } + } + + // 3. 查找引号包围的otpauth URI + val quotedUriPattern = Regex("[\"']otpauth://totp/[^\"']+[\"']", RegexOption.IGNORE_CASE) + quotedUriPattern.findAll(content).forEach { match -> + val rawUri = match.value.trim('"', '\'') + val cleanUri = cleanAndDecodeUri(rawUri) + if (cleanUri.isNotEmpty() && isValidOtpAuthUri(cleanUri)) { + uris.add(cleanUri) + log("🔗 引号中找到URI: ${cleanUri.take(50)}...") + } + } + + } catch (e: Exception) { + log("❌ 提取URI时出错: ${e.message}") + } + + log("🔍 URI提取完成,总共找到 ${uris.size} 个不重复的有效URI") + return uris.toList() + } + + /** + * 验证是否为有效的otpauth URI + */ + private fun isValidOtpAuthUri(uri: String): Boolean { + return try { + val parsedUri = Uri.parse(uri) + parsedUri.scheme?.equals("otpauth", ignoreCase = true) == true && + parsedUri.host?.equals("totp", ignoreCase = true) == true && + !parsedUri.getQueryParameter("secret").isNullOrEmpty() + } catch (e: Exception) { + false + } + } + + /** + * 清理和解码URI,处理各种编码格式 + */ + private fun cleanAndDecodeUri(rawUri: String): String { + return try { + var cleanUri = rawUri.trim() + + // 处理Unicode转义序列 (如 \u0026) + cleanUri = cleanUri.replace("\\u0026", "&") + .replace("\\u003D", "=") + .replace("\\u003F", "?") + .replace("\\u0023", "#") + .replace("\\u002F", "/") + .replace("\\u003A", ":") + + // 处理URL编码 + cleanUri = java.net.URLDecoder.decode(cleanUri, "UTF-8") + + // 移除可能的引号 + cleanUri = cleanUri.removePrefix("\"").removeSuffix("\"") + .removePrefix("'").removeSuffix("'") + + // 验证是否为有效的otpauth URI + if (cleanUri.startsWith("otpauth://", ignoreCase = true)) { + cleanUri + } else { + "" + } + } catch (e: Exception) { + log("⚠️ URI解码失败: ${e.message}") + rawUri // 返回原始URI作为后备 + } + } + + private fun parseSteamJson(jsonString: String): TOTPInfo? { + try { + log("🎮 开始解析Steam JSON...") + + if (!jsonString.startsWith("{") || !jsonString.endsWith("}")) { + log("⚠️ 不是JSON格式,跳过Steam解析") + return null // 不是JSON格式 + } + + log("📄 尝试解析JSON对象...") + val jsonObject = JSONObject(jsonString) + log("✅ JSON解析成功") + + // 检查是否包含Steam相关字段 + if (!jsonObject.has("shared_secret") || !jsonObject.has("account_name")) { + log("⚠️ 缺少Steam必需字段 (shared_secret 或 account_name),跳过") + return null + } + + val accountName = jsonObject.getString("account_name") + log("📝 账户名: ${accountName}") + + // 🔄 关键修复:优先从URI字段中提取Base32编码的secret + var finalSecret: String? = null + var serviceName = "Steam" + var userName = accountName + + if (jsonObject.has("uri")) { + log("🔗 找到URI字段,从中提取secret...") + val uri = jsonObject.getString("uri") + log("📝 URI内容: $uri") + + try { + val parsedUri = Uri.parse(uri) + + // 从URI中提取secret参数(Base32编码) + val secretFromUri = parsedUri.getQueryParameter("secret") + if (!secretFromUri.isNullOrEmpty()) { + finalSecret = secretFromUri + log("🔑 从URI提取到secret: ${secretFromUri.take(8)}...") + } + + // 从URI中提取服务名和用户名 + val path = parsedUri.path?.removePrefix("/") + if (path != null && path.contains(":")) { + val parts = path.split(":", limit = 2) + serviceName = parts[0].trim() + userName = parts[1].trim() + log("📝 从URI解析到服务名: ${serviceName}, 用户名: ${userName}") + } + + } catch (e: Exception) { + log("⚠️ URI解析失败: ${e.message}") + } + } + + // 如果URI中没有找到secret,才使用shared_secret作为后备 + if (finalSecret.isNullOrEmpty()) { + log("⚠️ URI中未找到secret,尝试使用shared_secret作为后备") + val sharedSecret = jsonObject.getString("shared_secret") + + // 检查shared_secret是否看起来像Base64(包含+/=字符) + if (sharedSecret.contains("+") || sharedSecret.contains("/") || sharedSecret.contains("=")) { + log("🔐 shared_secret似乎是Base64格式,但Steam通常使用Base32 secret") + // 在这种情况下,我们可能需要特殊处理,但通常Steam JSON应该包含URI + return null + } else { + finalSecret = sharedSecret + log("🔑 使用shared_secret作为secret: ${sharedSecret.take(8)}...") + } + } + + // 验证最终的secret + if (finalSecret.isNullOrBlank()) { + log("❌ 未找到有效的secret,无法创建Steam账户") + return null + } + + log("📝 最终解析结果: 服务=${serviceName}, 用户=${userName}, secret长度=${finalSecret.length}") + + val totpInfo = TOTPInfo( + name = serviceName, + usr = userName, + key = finalSecret, // 使用从URI中提取的Base32编码secret + algorithm = "SHA1", // Steam使用SHA1 + digits = 5, // Steam使用5位验证码 + period = 30, // Steam使用30秒周期 + type = "steam" // 标记为Steam类型 + ) + + log("✅ Steam账户创建成功") + return totpInfo + + } catch (e: Exception) { + log("❌ 解析Steam JSON失败: ${e.message}") + log("📍 Steam JSON解析错误堆栈: ${e.stackTraceToString()}") + return null + } + } + + private fun handleQRCode(qrCode: String) { + try { + val totpInfo = parseOtpAuthUrl(qrCode) + if (totpInfo != null) { + localAccountManager.addAccount(totpInfo) + loadAccounts() + log("✅ 扫码添加账户: ${totpInfo.name}") + Toast.makeText(this, "账户添加成功", Toast.LENGTH_SHORT).show() + } else { + log("❌ 无效的二维码") + Toast.makeText(this, "无效的二维码", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + log("❌ 解析二维码失败: ${e.message}") + Toast.makeText(this, "解析二维码失败: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + private fun parseOtpAuthUrl(url: String): TOTPInfo? { + if (!url.startsWith("otpauth://totp/", ignoreCase = true)) return null + + try { + val uri = Uri.parse(url) + val path = uri.path?.removePrefix("/") ?: return null + val secret = uri.getQueryParameter("secret") ?: return null + + // 获取issuer参数(服务提供商) + val issuer = uri.getQueryParameter("issuer")?.trim() + + log("🔍 解析URI: path=$path, issuer=$issuer") + + // 解析服务名和用户名的逻辑 + val (serviceName, userName) = when { + // 情况1:path包含冒号,格式为 "服务名:用户名" + path.contains(":") -> { + val parts = path.split(":", limit = 2) + val pathService = parts[0].trim() + val pathUser = if (parts.size > 1) parts[1].trim() else "" + + // 如果有issuer参数,优先使用issuer作为服务名 + val finalService = if (!issuer.isNullOrEmpty()) issuer else pathService + Pair(finalService, pathUser) + } + + // 情况2:path不包含冒号,但有issuer参数 + !issuer.isNullOrEmpty() -> { + // path作为用户名,issuer作为服务名 + Pair(issuer, path.trim()) + } + + // 情况3:path不包含冒号,也没有issuer + else -> { + // path作为服务名,用户名为空 + Pair(path.trim(), "") + } + } + + // 后处理:避免重复信息 + val finalUserName = when { + // 如果服务名和用户名完全相同,清空用户名 + serviceName.equals(userName, ignoreCase = true) -> "" + // 如果用户名为空,保持为空 + userName.isEmpty() -> "" + else -> userName + } + + // 处理空服务名的情况 + val finalServiceName = if (serviceName.isEmpty()) "Unknown Service" else serviceName + + // 解析其他参数 + val algorithm = uri.getQueryParameter("algorithm") ?: "SHA1" + val digits = uri.getQueryParameter("digits")?.toIntOrNull() ?: 6 + val period = uri.getQueryParameter("period")?.toIntOrNull() ?: 30 + + log("📝 解析结果: 服务=${finalServiceName}, 用户=${finalUserName}, 算法=${algorithm}, 位数=${digits}") + + return TOTPInfo( + name = finalServiceName, + usr = finalUserName, + key = secret, + algorithm = algorithm, + digits = digits, + period = period, + type = if (finalServiceName.equals("Steam", ignoreCase = true)) "steam" else "totp" + ) + } catch (e: Exception) { + log("❌ 解析TOTP URL失败: ${e.message}") + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/AddComponents.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/AddComponents.kt new file mode 100644 index 0000000..f97807e --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/AddComponents.kt @@ -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("添加") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/ModernTOTPCard.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/ModernTOTPCard.kt new file mode 100644 index 0000000..f5a1f8d --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/ModernTOTPCard.kt @@ -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("取消") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/ReorderableBandAccountsList.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/ReorderableBandAccountsList.kt new file mode 100644 index 0000000..c3f4bbf --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/ReorderableBandAccountsList.kt @@ -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, + onReorder: (List) -> 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)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/SyncScreen.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/SyncScreen.kt new file mode 100644 index 0000000..38cf0b5 --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/components/SyncScreen.kt @@ -0,0 +1,1138 @@ +package cn.deepfal.band.TOTPauthenticator.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.automirrored.filled.ArrowBack +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cn.deepfal.band.TOTPauthenticator.sync.SyncAccount +import cn.deepfal.band.TOTPauthenticator.sync.SyncStatus +import org.burnoutcrew.reorderable.* +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Spring +import androidx.compose.animation.animateContentSize +import androidx.compose.ui.graphics.graphicsLayer +import cn.deepfal.band.TOTPauthenticator.components.ServiceIcon +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SyncScreen( + onClose: () -> Unit, + phoneAccounts: List = emptyList(), + watchAccounts: List = emptyList(), + connectionStatus: String = "未连接", + isConnected: Boolean = false, + syncLogs: List = emptyList(), + onSyncToWatch: (List) -> Unit = {}, + onSyncToPhone: (List) -> Unit = {}, + onRequestBandAccounts: () -> Unit = {}, + onReconnect: () -> Unit = {}, + onDeleteFromBand: (cn.deepfal.band.TOTPauthenticator.data.TOTPInfo) -> Unit = {}, + onReorderBandAccounts: (List) -> Unit = {} +) { + var showLogs by remember { mutableStateOf(false) } + var showSortDialog by remember { mutableStateOf(false) } + var isReorderMode by remember { mutableStateOf(false) } + + val context = LocalContext.current + + // 处理同步界面内的返回键 + DisposableEffect(Unit) { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when { + showSortDialog -> { + showSortDialog = false + } + showLogs -> { + showLogs = false + } + isReorderMode -> { + isReorderMode = false + } + else -> { + onClose() + } + } + } + } + + (context as ComponentActivity).onBackPressedDispatcher.addCallback(callback) + + onDispose { + callback.remove() + } + } + + // 合并去重算法 - 保持手环端的原始顺序 + val mergedAccounts = remember(phoneAccounts, watchAccounts) { + val phoneMap = phoneAccounts.associateBy { "${it.name}_${it.key}" } + val watchMap = watchAccounts.associateBy { "${it.name}_${it.key}" } + val merged = mutableListOf() + + // 首先按手环返回的顺序添加手环账户(保持手环端排序) + watchAccounts.forEach { account -> + val key = "${account.name}_${account.key}" + val status = if (phoneMap.containsKey(key)) { + SyncStatus.SYNCED // 手机和手环都有 + } else { + SyncStatus.WATCH_ONLY // 仅手环有 + } + merged.add(SyncAccount(account, status)) + } + + // 然后添加仅手机账户(这些账户不在手环上,所以顺序不重要) + phoneAccounts.forEach { account -> + val key = "${account.name}_${account.key}" + if (!watchMap.containsKey(key)) { + merged.add(SyncAccount(account, SyncStatus.PHONE_ONLY)) + } + } + + // 不再进行额外排序,完全按照手环顺序 + 仅手机账户的顺序 + merged + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + ) { + // 顶部标题栏 + TopAppBar( + title = { + Text( + text = if (isReorderMode) "拖拽排序手环账户" else "同步管理", + fontSize = 24.sp, + fontWeight = FontWeight.Medium + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + actions = { + // 重排序按钮 - 只有手环账户时才显示 + if (watchAccounts.isNotEmpty() && isConnected) { + IconButton( + onClick = { + isReorderMode = !isReorderMode + } + ) { + Icon( + imageVector = if (isReorderMode) Icons.Default.Check else Icons.Default.Reorder, + contentDescription = if (isReorderMode) "完成排序" else "重排序手环账户" + ) + } + } + + // 排序按钮 + if (!isReorderMode) { + IconButton(onClick = { showSortDialog = true }) { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = "排序" + ) + } + } + + // 日志按钮 + if (!isReorderMode) { + IconButton(onClick = { showLogs = !showLogs }) { + Icon( + imageVector = Icons.Default.List, + contentDescription = "查看日志" + ) + } + } + + // 刷新按钮 + if (!isReorderMode) { + IconButton(onClick = onRequestBandAccounts) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "刷新" + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) + + if (showLogs) { + // 日志界面 + LogsView( + logs = syncLogs, + onClose = { showLogs = false } + ) + } else { + // 连接状态卡片 + ConnectionStatusCard( + connectionStatus = connectionStatus, + isConnected = isConnected, + onReconnect = onReconnect + ) + + // 主要内容 + SyncContent( + syncAccounts = mergedAccounts, + onSyncToWatch = onSyncToWatch, + onSyncToPhone = onSyncToPhone, + onDeleteFromBand = onDeleteFromBand, + isReorderMode = isReorderMode, + onReorderBandAccounts = onReorderBandAccounts + ) + } + } + + // 排序选项对话框 + if (showSortDialog) { + SortOptionsDialog( + onDismiss = { showSortDialog = false }, + onSortByStatus = { + showSortDialog = false + // 已经在mergedAccounts中实现了按状态排序 + }, + onSortByName = { + showSortDialog = false + // 可以添加更多排序选项 + } + ) + } +} + +@Composable +fun LoadingState() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 4.dp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "正在连接手环...", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } +} + +@Composable +fun DisconnectedState() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Watch, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.error + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "手环未连接", + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "请确保手环已连接并开启蓝牙", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } +} + +@Composable +fun SyncContent( + syncAccounts: List, + onSyncToWatch: (List) -> Unit, + onSyncToPhone: (List) -> Unit, + onDeleteFromBand: (cn.deepfal.band.TOTPauthenticator.data.TOTPInfo) -> Unit, + isReorderMode: Boolean = false, + onReorderBandAccounts: (List) -> Unit = {} +) { + Column(modifier = Modifier.fillMaxSize()) { + // 统计信息 + if (!isReorderMode) { + SyncStatistics(syncAccounts = syncAccounts) + } + + // 账户列表 + if (isReorderMode) { + // 重排序模式:只显示手环账户并支持拖拽 + val bandAccounts = syncAccounts + .filter { it.status == SyncStatus.WATCH_ONLY || it.status == SyncStatus.SYNCED } + .map { it.account } + + ReorderableBandAccountsListImpl( + accounts = bandAccounts, + onReorder = onReorderBandAccounts + ) + } else { + // 普通模式:按类别分组显示 + GroupedAccountsList( + syncAccounts = syncAccounts, + onSyncToWatch = onSyncToWatch, + onSyncToPhone = onSyncToPhone, + onDeleteFromBand = onDeleteFromBand + ) + } + } +} + +@Composable +fun GroupedAccountsList( + syncAccounts: List, + onSyncToWatch: (List) -> Unit, + onSyncToPhone: (List) -> Unit, + onDeleteFromBand: (cn.deepfal.band.TOTPauthenticator.data.TOTPInfo) -> Unit +) { + // 按照当前顺序分组账户(手环账户在前,仅手机账户在后) + val bandAccounts = syncAccounts.filter { + it.status == SyncStatus.WATCH_ONLY || it.status == SyncStatus.SYNCED + } + val phoneOnlyAccounts = syncAccounts.filter { + it.status == SyncStatus.PHONE_ONLY + } + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // 手环账户组(按手环返回顺序显示) + if (bandAccounts.isNotEmpty()) { + item { + AccountGroupHeader( + title = "手环账户", + subtitle = "按手环顺序显示,可重排序", + icon = Icons.Default.Watch, + color = MaterialTheme.colorScheme.secondary, + count = bandAccounts.size + ) + } + + items(bandAccounts) { syncAccount -> + SyncAccountCard( + syncAccount = syncAccount, + onSyncToWatch = { onSyncToWatch(listOf(syncAccount.account)) }, + onSyncToPhone = { onSyncToPhone(listOf(syncAccount.account)) }, + onDeleteFromBand = { onDeleteFromBand(syncAccount.account) }, + showGroupIndicator = true + ) + } + + if (phoneOnlyAccounts.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + + // 仅手机账户组 + if (phoneOnlyAccounts.isNotEmpty()) { + item { + AccountGroupHeader( + title = "仅手机账户", + subtitle = "需同步到手环后才能参与排序", + icon = Icons.Default.PhoneAndroid, + color = MaterialTheme.colorScheme.primary, + count = phoneOnlyAccounts.size + ) + } + + items(phoneOnlyAccounts) { syncAccount -> + SyncAccountCard( + syncAccount = syncAccount, + onSyncToWatch = { onSyncToWatch(listOf(syncAccount.account)) }, + onSyncToPhone = { onSyncToPhone(listOf(syncAccount.account)) }, + onDeleteFromBand = null, + showGroupIndicator = true + ) + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + + // 底部批量操作按钮 + BottomSyncActions( + syncAccounts = syncAccounts, + onSyncAllToWatch = { phoneAccounts -> + onSyncToWatch(phoneAccounts) + }, + onSyncAllToPhone = { watchAccounts -> + onSyncToPhone(watchAccounts) + } + ) + } +} + +@Composable +fun AccountGroupHeader( + title: String, + subtitle: String, + icon: ImageVector, + color: Color, + count: Int +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.1f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(color.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = color + ) + Text( + text = subtitle, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + // 数量徽章 + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(color), + contentAlignment = Alignment.Center + ) { + Text( + text = count.toString(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.surface + ) + } + } + } +} + +@Composable +fun SyncStatistics(syncAccounts: List) { + val phoneOnly = syncAccounts.count { it.status == SyncStatus.PHONE_ONLY } + val watchOnly = syncAccounts.count { it.status == SyncStatus.WATCH_ONLY } + val synced = syncAccounts.count { it.status == SyncStatus.SYNCED } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + count = phoneOnly, + label = "仅手机", + color = MaterialTheme.colorScheme.primary + ) + + StatItem( + count = watchOnly, + label = "仅手环", + color = MaterialTheme.colorScheme.secondary + ) + + StatItem( + count = synced, + label = "已同步", + color = MaterialTheme.colorScheme.tertiary + ) + } + } +} + +@Composable +fun StatItem(count: Int, label: String, color: Color) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = count.toString(), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = label, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } +} + +@Composable +fun SyncAccountCard( + syncAccount: SyncAccount, + onSyncToWatch: () -> Unit, + onSyncToPhone: () -> Unit, + onDeleteFromBand: (() -> Unit)? = null, + showGroupIndicator: Boolean = false +) { + if (onDeleteFromBand != null) { + // 仅手环账户支持侧滑删除 + var showDeleteConfirmation by remember { mutableStateOf(false) } + val dismissState = rememberSwipeToDismissBoxState() + + // 处理滑动事件 + LaunchedEffect(dismissState.currentValue) { + when (dismissState.currentValue) { + SwipeToDismissBoxValue.EndToStart -> { + showDeleteConfirmation = true + dismissState.snapTo(SwipeToDismissBoxValue.Settled) + } + else -> {} + } + } + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + // 背景内容 - 显示删除提示 + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.error, RoundedCornerShape(12.dp)) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError + ) + Text( + text = "从手环删除", + color = MaterialTheme.colorScheme.onError, + fontWeight = FontWeight.Medium + ) + } + } + } + ) { + SyncAccountCardContent( + syncAccount = syncAccount, + onSyncToWatch = onSyncToWatch, + onSyncToPhone = onSyncToPhone, + showGroupIndicator = showGroupIndicator + ) + } + + // 删除确认对话框 + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { + Text(text = "确认删除") + }, + text = { + Text(text = "确定要从手环删除账户 \"${syncAccount.account.name}\" 吗?此操作不可撤销。") + }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirmation = false + onDeleteFromBand() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("删除") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text("取消") + } + } + ) + } + } else { + // 普通账户卡片(无删除功能) + SyncAccountCardContent( + syncAccount = syncAccount, + onSyncToWatch = onSyncToWatch, + onSyncToPhone = onSyncToPhone, + showGroupIndicator = showGroupIndicator + ) + } +} + +@Composable +fun SyncAccountCardContent( + syncAccount: SyncAccount, + onSyncToWatch: () -> Unit, + onSyncToPhone: () -> Unit, + showGroupIndicator: Boolean = false +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 状态指示器 + StatusIndicator(status = syncAccount.status) + + Spacer(modifier = Modifier.width(12.dp)) + + // 账户信息 + Column(modifier = Modifier.weight(1f)) { + Text( + text = syncAccount.account.name, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + if (syncAccount.account.usr.isNotEmpty()) { + Text( + text = syncAccount.account.usr, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + StatusText(status = syncAccount.status) + } + + // 同步按钮 + when (syncAccount.status) { + SyncStatus.PHONE_ONLY -> { + IconButton(onClick = onSyncToWatch) { + Icon( + imageVector = Icons.Default.East, + contentDescription = "同步到手环", + tint = MaterialTheme.colorScheme.primary + ) + } + } + SyncStatus.WATCH_ONLY -> { + IconButton(onClick = onSyncToPhone) { + Icon( + imageVector = Icons.Default.West, + contentDescription = "同步到手机", + tint = MaterialTheme.colorScheme.secondary + ) + } + } + SyncStatus.SYNCED -> { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "已同步", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} + +@Composable +fun StatusIndicator(status: SyncStatus) { + val (icon, color) = when (status) { + SyncStatus.PHONE_ONLY -> Icons.Default.PhoneAndroid to MaterialTheme.colorScheme.primary + SyncStatus.WATCH_ONLY -> Icons.Default.Watch to MaterialTheme.colorScheme.secondary + SyncStatus.SYNCED -> Icons.Default.Sync to MaterialTheme.colorScheme.tertiary + } + + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(color.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(20.dp) + ) + } +} + +@Composable +fun StatusText(status: SyncStatus) { + val (text, color) = when (status) { + SyncStatus.PHONE_ONLY -> "仅在手机" to MaterialTheme.colorScheme.primary + SyncStatus.WATCH_ONLY -> "仅在手环" to MaterialTheme.colorScheme.secondary + SyncStatus.SYNCED -> "已同步" to MaterialTheme.colorScheme.tertiary + } + + Text( + text = text, + fontSize = 12.sp, + color = color, + fontWeight = FontWeight.Medium + ) +} + +@Composable +fun BottomSyncActions( + syncAccounts: List, + onSyncAllToWatch: (List) -> Unit, + onSyncAllToPhone: (List) -> Unit +) { + val phoneOnlyAccounts = syncAccounts.filter { it.status == SyncStatus.PHONE_ONLY }.map { it.account } + val watchOnlyAccounts = syncAccounts.filter { it.status == SyncStatus.WATCH_ONLY }.map { it.account } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (phoneOnlyAccounts.isNotEmpty()) { + Button( + onClick = { onSyncAllToWatch(phoneOnlyAccounts) }, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.East, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("全部同步到手环") + } + } + + if (watchOnlyAccounts.isNotEmpty()) { + OutlinedButton( + onClick = { onSyncAllToPhone(watchOnlyAccounts) }, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.West, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("全部同步到手机") + } + } + } + } +} + +@Composable +fun ConnectionStatusCard( + connectionStatus: String, + isConnected: Boolean, + onReconnect: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = if (isConnected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isConnected) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled, + contentDescription = null, + tint = if (isConnected) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isConnected) "手环已连接" else "手环未连接", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = if (isConnected) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = connectionStatus, + fontSize = 14.sp, + color = if (isConnected) + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + else + MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f) + ) + } + + if (!isConnected) { + IconButton(onClick = onReconnect) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "重新连接", + tint = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } +} + +@Composable +fun LogsView( + logs: List, + onClose: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + // 标题栏 + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "通信日志", + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "关闭" + ) + } + } + } + + // 日志列表 + if (logs.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.Message, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "暂无通信日志", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + reverseLayout = true + ) { + items(logs.reversed()) { log -> + LogItem(log = log) + } + } + } + } +} + +@Composable +fun LogItem(log: String) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Text( + text = log, + modifier = Modifier.padding(12.dp), + fontSize = 14.sp, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +fun SortOptionsDialog( + onDismiss: () -> Unit, + onSortByStatus: () -> Unit, + onSortByName: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = "显示顺序说明") + }, + text = { + Column { + Text( + text = "当前显示方式:按手环实际顺序", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text("• 手环账户:按手环端的实际排序显示") + Text("• 仅手机账户:显示在手环账户下方") + Text("• 重排序:点击重排序按钮可拖拽调整手环账户顺序") + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("了解") + } + } + ) +} + +@Composable +fun ReorderableBandAccountsListImpl( + accounts: List, + onReorder: (List) -> 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) + } + ) + + LazyColumn( + state = reorderableState.listState, + modifier = Modifier + .fillMaxSize() + .reorderable(reorderableState), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemsIndexed(reorderableAccounts, key = { _, account -> account.key }) { index, account -> + ReorderableItem(reorderableState, key = account.key) { isDragging -> + val elevation = animateDpAsState( + targetValue = if (isDragging) 12.dp else 2.dp, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "elevation" + ) + + // 拖拽时的缩放动画 - 与首页保持一致 + val dragScale by animateFloatAsState( + targetValue = if (isDragging) 1.03f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "drag_scale" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer( + scaleX = dragScale, + scaleY = dragScale + ) + .animateContentSize(), + elevation = CardDefaults.cardElevation(defaultElevation = elevation.value), + colors = CardDefaults.cardColors( + containerColor = if (isDragging) + MaterialTheme.colorScheme.surfaceContainerHighest + else + MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 拖拽手柄 + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "拖拽排序", + modifier = Modifier + .detectReorderAfterLongPress(reorderableState) + .padding(end = 16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // 服务图标 + 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 + ) + if (account.usr.isNotEmpty()) { + Text( + text = account.usr, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + // 当前验证码 + Text( + text = if (account.type.lowercase() == "steam") { + account.generateCurrentCode() + } else { + val code = account.generateCurrentCode() + "${code.take(3)} ${code.drop(3)}" + }, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + + // 底部间距 + item { + Spacer(modifier = Modifier.height(88.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/data/TOTPInfo.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/data/TOTPInfo.kt new file mode 100644 index 0000000..86c7c8b --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/data/TOTPInfo.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/scanner/QRCodeScanner.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/scanner/QRCodeScanner.kt new file mode 100644 index 0000000..25e73d9 --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/scanner/QRCodeScanner.kt @@ -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(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) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/sync/SyncManager.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/sync/SyncManager.kt new file mode 100644 index 0000000..62b1cc8 --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/sync/SyncManager.kt @@ -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 { + return localAccountManager.loadAccounts() + } + + suspend fun getWatchAccounts(): List { + // 发送请求到手环获取账户列表 + // 这里需要等待异步响应 + return emptyList() // 临时返回空列表 + } + + fun getMergedAccounts(phoneAccounts: List, watchAccounts: List): List { + val merged = mutableListOf() + 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): 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) { + accounts.forEach { account -> + localAccountManager.addAccount(account) + } + } + + fun cleanup() { + nodeId?.let { id -> + messageApi.removeListener(id) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Color.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Color.kt new file mode 100644 index 0000000..254bd4f --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Theme.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Theme.kt new file mode 100644 index 0000000..3f48162 --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Type.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Type.kt new file mode 100644 index 0000000..2eabac2 --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/ui/theme/Type.kt @@ -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 + ) +) \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/utils/LocalAccountManager.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/utils/LocalAccountManager.kt new file mode 100644 index 0000000..ea5b63d --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/utils/LocalAccountManager.kt @@ -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) { + 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 { + return try { + val jsonString = sharedPrefs.getString(KEY_ACCOUNTS, null) ?: return emptyList() + val jsonArray = JSONArray(jsonString) + val accounts = mutableListOf() + + 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) { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/deepfal/band/TOTPauthenticator/utils/TOTPGenerator.kt b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/utils/TOTPGenerator.kt new file mode 100644 index 0000000..816f8ce --- /dev/null +++ b/app/src/main/java/cn/deepfal/band/TOTPauthenticator/utils/TOTPGenerator.kt @@ -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() + + 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() + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..af4a905 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c48b06b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e466b6a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..dbbb63d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..de593ea Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b5cd46e --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.png b/app/src/main/res/drawable/ic_launcher_foreground.png new file mode 100644 index 0000000..c48b06b Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_foreground.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..16313e0 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml new file mode 100644 index 0000000..e416e1c --- /dev/null +++ b/app/src/main/res/layout/content_main.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_first.xml b/app/src/main/res/layout/fragment_first.xml new file mode 100644 index 0000000..44baecd --- /dev/null +++ b/app/src/main/res/layout/fragment_first.xml @@ -0,0 +1,35 @@ + + + + + +