This commit is contained in:
deepfal 2025-08-18 11:17:38 +08:00
commit d2a3e61b54
89 changed files with 6390 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -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

3
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# 默认忽略的文件
/shelf/
/workspace.xml

1
.idea/.name Normal file
View File

@ -0,0 +1 @@
BandTOTP-APP

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android Vitals" />
</component>
</project>

View File

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-18T01:23:46.284668500Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\deepf\.android\avd\Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=40ee665e" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

19
.idea/gradle.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,50 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

10
.idea/migrations.xml Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

106
REFACTOR_SUMMARY.md Normal file
View File

@ -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 3Google Authenticator风格
**功能完整**:扫码、手动、文件导入三种添加方式
**智能同步**:可视化状态管理,去重合并
**专业体验**:真实图标、品牌色彩、流畅动画
应用现已完成现代化重构,构建成功,可以直接使用和测试!🎉

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

128
app/build.gradle.kts Normal file
View File

@ -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)
}

Binary file not shown.

Binary file not shown.

BIN
app/keystore/keystore.jks Normal file

Binary file not shown.

Binary file not shown.

21
app/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -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)
}
}

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 小米可穿戴设备通信权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Android 12+ 蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 文件读取权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- 相机权限 - 二维码扫描 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BandTOTPAPP">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.BandTOTPAPP">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

File diff suppressed because it is too large Load Diff

View File

@ -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("添加")
}
}
}
}
}
}

View File

@ -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("取消")
}
}
)
}

View File

@ -0,0 +1,175 @@
package cn.deepfal.band.TOTPauthenticator.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.burnoutcrew.reorderable.*
@Composable
fun ReorderableBandAccountsList(
accounts: List<cn.deepfal.band.TOTPauthenticator.data.TOTPInfo>,
onReorder: (List<cn.deepfal.band.TOTPauthenticator.data.TOTPInfo>) -> Unit
) {
var reorderableAccounts by remember { mutableStateOf(accounts) }
// 当accounts变化时更新reorderableAccounts
LaunchedEffect(accounts) {
reorderableAccounts = accounts
}
val reorderableState = rememberReorderableLazyListState(
onMove = { from, to ->
reorderableAccounts = reorderableAccounts.toMutableList().apply {
add(to.index, removeAt(from.index))
}
},
onDragEnd = { _, _ ->
onReorder(reorderableAccounts)
}
)
Column(modifier = Modifier.fillMaxSize()) {
// 重排序提示
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "拖拽账户卡片可重新排序手环上的账户顺序",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
// 可重排序的账户列表
LazyColumn(
state = reorderableState.listState,
modifier = Modifier
.weight(1f)
.reorderable(reorderableState),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(reorderableAccounts, key = { _, account -> account.key }) { index, account ->
ReorderableItem(reorderableState, key = account.key) { isDragging ->
val elevation = animateDpAsState(
targetValue = if (isDragging) 16.dp else 2.dp,
label = "elevation"
)
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = elevation.value),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 拖拽手柄
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "拖拽排序",
modifier = Modifier
.detectReorderAfterLongPress(reorderableState)
.padding(end = 12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
// 状态指示器
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Watch,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
// 账户信息
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.name,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
if (account.usr.isNotEmpty()) {
Text(
text = account.usr,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "手环账户",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Medium
)
}
// 序号显示
Text(
text = "#${index + 1}",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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)
}
}

View File

@ -0,0 +1,370 @@
@file:OptIn(ExperimentalPermissionsApi::class)
package cn.deepfal.band.TOTPauthenticator.scanner
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun QRCodeScanner(
onCodeScanned: (String) -> Unit,
onClose: () -> Unit
) {
val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
Box(modifier = Modifier.fillMaxSize()) {
when {
cameraPermissionState.status.isGranted -> {
CameraPreview(
onCodeScanned = onCodeScanned,
onClose = onClose
)
}
else -> {
CameraPermissionRequest(
permissionState = cameraPermissionState,
onClose = onClose
)
}
}
}
}
@Composable
fun CameraPermissionRequest(
permissionState: PermissionState,
onClose: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
// 关闭按钮
IconButton(
onClick = onClose,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "关闭",
tint = Color.White
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "需要相机权限",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "扫描二维码需要使用相机功能",
fontSize = 14.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { permissionState.launchPermissionRequest() },
modifier = Modifier.fillMaxWidth()
) {
Text("授予权限")
}
}
}
}
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun CameraPreview(
onCodeScanned: (String) -> Unit,
onClose: () -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var flashEnabled by remember { mutableStateOf(false) }
var camera by remember { mutableStateOf<Camera?>(null) }
val preview = remember { Preview.Builder().build() }
val selector = remember { CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() }
val imageAnalysis = remember {
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
}
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
LaunchedEffect(Unit) {
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
val barcodeScanner = BarcodeScanning.getClient()
imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
val inputImage = InputImage.fromMediaImage(
imageProxy.image!!,
imageProxy.imageInfo.rotationDegrees
)
barcodeScanner.process(inputImage)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
when (barcode.valueType) {
Barcode.TYPE_TEXT -> {
val rawValue = barcode.rawValue
if (rawValue != null && rawValue.startsWith("otpauth://")) {
onCodeScanned(rawValue)
return@addOnSuccessListener
}
}
}
}
}
.addOnCompleteListener {
imageProxy.close()
}
}
try {
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(
lifecycleOwner,
selector,
preview,
imageAnalysis
)
} catch (e: Exception) {
e.printStackTrace()
}
}
DisposableEffect(Unit) {
onDispose {
cameraExecutor.shutdown()
}
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
PreviewView(context).apply {
this.scaleType = PreviewView.ScaleType.FILL_CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
},
modifier = Modifier.fillMaxSize(),
update = { previewView ->
preview.setSurfaceProvider(previewView.surfaceProvider)
}
)
// 扫描框覆盖层
ScanOverlay()
// 顶部控制栏
Row(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "关闭",
tint = Color.White
)
}
IconButton(
onClick = {
camera?.let {
if (it.cameraInfo.hasFlashUnit()) {
it.cameraControl.enableTorch(!flashEnabled)
flashEnabled = !flashEnabled
}
}
}
) {
Icon(
imageVector = if (flashEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff,
contentDescription = if (flashEnabled) "关闭闪光灯" else "打开闪光灯",
tint = Color.White
)
}
}
// 底部说明文字
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
)
) {
Text(
text = "将二维码对准扫描框",
modifier = Modifier.padding(16.dp),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 16.sp
)
}
}
}
@Composable
fun ScanOverlay() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
) {
// 扫描框
Box(
modifier = Modifier
.size(280.dp)
.align(Alignment.Center)
.border(
width = 3.dp,
color = Color.White,
shape = RoundedCornerShape(16.dp)
)
.clip(RoundedCornerShape(16.dp))
)
// 四个角的装饰
val cornerSize = 24.dp
val cornerThickness = 4.dp
val boxSize = 280.dp
val offsetFromCenter = (boxSize - cornerSize) / 2
// 左上角
Box(
modifier = Modifier
.align(Alignment.Center)
.offset(x = -offsetFromCenter, y = -offsetFromCenter)
.size(cornerSize)
.border(
width = cornerThickness,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(topStart = 8.dp)
)
)
// 右上角
Box(
modifier = Modifier
.align(Alignment.Center)
.offset(x = offsetFromCenter, y = -offsetFromCenter)
.size(cornerSize)
.border(
width = cornerThickness,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(topEnd = 8.dp)
)
)
// 左下角
Box(
modifier = Modifier
.align(Alignment.Center)
.offset(x = -offsetFromCenter, y = offsetFromCenter)
.size(cornerSize)
.border(
width = cornerThickness,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(bottomStart = 8.dp)
)
)
// 右下角
Box(
modifier = Modifier
.align(Alignment.Center)
.offset(x = offsetFromCenter, y = offsetFromCenter)
.size(cornerSize)
.border(
width = cornerThickness,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(bottomEnd = 8.dp)
)
)
}
}

View File

@ -0,0 +1,129 @@
package cn.deepfal.band.TOTPauthenticator.sync
import android.content.Context
import android.widget.Toast
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
import cn.deepfal.band.TOTPauthenticator.utils.LocalAccountManager
import com.xiaomi.xms.wearable.message.MessageApi
import com.xiaomi.xms.wearable.message.OnMessageReceivedListener
import com.xiaomi.xms.wearable.node.Node
import com.xiaomi.xms.wearable.node.NodeApi
import org.json.JSONObject
data class SyncAccount(
val account: TOTPInfo,
val status: SyncStatus
)
enum class SyncStatus {
PHONE_ONLY, // 仅在手机
WATCH_ONLY, // 仅在手环
SYNCED // 已同步
}
class SyncManager(
private val context: Context,
private val nodeApi: NodeApi,
private val messageApi: MessageApi
) {
private val localAccountManager = LocalAccountManager(context)
private var nodeId: String? = null
private var currentNode: Node? = null
// 消息监听器
private val messageListener = OnMessageReceivedListener { deviceId, message ->
try {
val jsonString = String(message)
val jsonObject = JSONObject(jsonString)
// 处理来自手环的消息
} catch (e: Exception) {
e.printStackTrace()
}
}
suspend fun initializeConnection(): Boolean {
return try {
val task = nodeApi.connectedNodes
task.addOnSuccessListener { nodes ->
if (nodes.isNotEmpty()) {
currentNode = nodes[0]
nodeId = currentNode?.id
setupMessageListener()
}
}
true
} catch (e: Exception) {
false
}
}
private fun setupMessageListener() {
nodeId?.let { id ->
messageApi.addListener(id, messageListener)
}
}
fun getPhoneAccounts(): List<TOTPInfo> {
return localAccountManager.loadAccounts()
}
suspend fun getWatchAccounts(): List<TOTPInfo> {
// 发送请求到手环获取账户列表
// 这里需要等待异步响应
return emptyList() // 临时返回空列表
}
fun getMergedAccounts(phoneAccounts: List<TOTPInfo>, watchAccounts: List<TOTPInfo>): List<SyncAccount> {
val merged = mutableListOf<SyncAccount>()
val phoneMap = phoneAccounts.associateBy { "${it.name}_${it.key}" }
val watchMap = watchAccounts.associateBy { "${it.name}_${it.key}" }
// 添加手机账户
phoneAccounts.forEach { account ->
val key = "${account.name}_${account.key}"
val status = if (watchMap.containsKey(key)) {
SyncStatus.SYNCED
} else {
SyncStatus.PHONE_ONLY
}
merged.add(SyncAccount(account, status))
}
// 添加仅手环账户
watchAccounts.forEach { account ->
val key = "${account.name}_${account.key}"
if (!phoneMap.containsKey(key)) {
merged.add(SyncAccount(account, SyncStatus.WATCH_ONLY))
}
}
return merged.sortedBy { it.account.name }
}
suspend fun syncToWatch(accounts: List<TOTPInfo>): Boolean {
return try {
nodeId?.let { id ->
val jsonMessage = """{"list":${accounts.joinToString(",", "[", "]") { it.toJsonString() }}}"""
val task = messageApi.sendMessage(id, jsonMessage.toByteArray())
task.addOnSuccessListener {
// 同步成功
}
true
} ?: false
} catch (e: Exception) {
false
}
}
fun syncToPhone(accounts: List<TOTPInfo>) {
accounts.forEach { account ->
localAccountManager.addAccount(account)
}
}
fun cleanup() {
nodeId?.let { id ->
messageApi.removeListener(id)
}
}
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -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
)
)

View File

@ -0,0 +1,160 @@
package cn.deepfal.band.TOTPauthenticator.utils
import android.content.Context
import android.content.SharedPreferences
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
import org.json.JSONArray
import org.json.JSONObject
/**
* 本地账户存储管理器
* 使用SharedPreferences存储TOTP账户信息
*/
class LocalAccountManager(private val context: Context) {
companion object {
private const val PREFS_NAME = "totp_accounts"
private const val KEY_ACCOUNTS = "accounts"
}
private val sharedPrefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/**
* 保存账户列表
*/
fun saveAccounts(accounts: List<TOTPInfo>) {
try {
val jsonArray = JSONArray()
accounts.forEach { account ->
val jsonObject = JSONObject().apply {
put("name", account.name)
put("usr", account.usr)
put("key", account.key)
put("algorithm", account.algorithm)
put("digits", account.digits)
put("period", account.period)
put("type", account.type)
}
jsonArray.put(jsonObject)
}
sharedPrefs.edit()
.putString(KEY_ACCOUNTS, jsonArray.toString())
.apply()
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 加载账户列表
*/
fun loadAccounts(): List<TOTPInfo> {
return try {
val jsonString = sharedPrefs.getString(KEY_ACCOUNTS, null) ?: return emptyList()
val jsonArray = JSONArray(jsonString)
val accounts = mutableListOf<TOTPInfo>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val account = TOTPInfo(
name = jsonObject.getString("name"),
usr = jsonObject.getString("usr"),
key = jsonObject.getString("key"),
algorithm = jsonObject.optString("algorithm", "SHA1"),
digits = jsonObject.optInt("digits", 6),
period = jsonObject.optInt("period", 30),
type = jsonObject.optString("type", "totp")
)
accounts.add(account)
}
accounts
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
/**
* 添加账户
*/
fun addAccount(account: TOTPInfo) {
val currentAccounts = loadAccounts().toMutableList()
// 检查是否已存在相同的账户基于secret和name
val existingIndex = currentAccounts.indexOfFirst {
it.key == account.key && it.name == account.name
}
if (existingIndex >= 0) {
// 更新现有账户
currentAccounts[existingIndex] = account
} else {
// 添加新账户
currentAccounts.add(account)
}
saveAccounts(currentAccounts)
}
/**
* 删除账户
*/
fun removeAccount(account: TOTPInfo) {
val currentAccounts = loadAccounts().toMutableList()
currentAccounts.removeAll {
it.key == account.key && it.name == account.name
}
saveAccounts(currentAccounts)
}
/**
* 更新账户信息支持修改服务名和用户名
*/
fun updateAccount(originalAccount: TOTPInfo, newServiceName: String, newUsername: String) {
val currentAccounts = loadAccounts().toMutableList()
val targetIndex = currentAccounts.indexOfFirst {
it.key == originalAccount.key && it.name == originalAccount.name && it.usr == originalAccount.usr
}
if (targetIndex >= 0) {
// 创建更新后的账户信息
val updatedAccount = originalAccount.copy(
name = newServiceName,
usr = newUsername
)
currentAccounts[targetIndex] = updatedAccount
saveAccounts(currentAccounts)
}
}
/**
* 清空所有账户
*/
fun clearAllAccounts() {
sharedPrefs.edit().remove(KEY_ACCOUNTS).apply()
}
/**
* 批量添加账户
*/
fun addAccounts(newAccounts: List<TOTPInfo>) {
val currentAccounts = loadAccounts().toMutableList()
newAccounts.forEach { newAccount ->
val existingIndex = currentAccounts.indexOfFirst {
it.key == newAccount.key && it.name == newAccount.name
}
if (existingIndex >= 0) {
currentAccounts[existingIndex] = newAccount
} else {
currentAccounts.add(newAccount)
}
}
saveAccounts(currentAccounts)
}
}

View File

@ -0,0 +1,198 @@
package cn.deepfal.band.TOTPauthenticator.utils
import java.nio.ByteBuffer
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.pow
/**
* TOTP (Time-based One-Time Password) 生成器
* 基于RFC 6238标准实现
*/
object TOTPGenerator {
/**
* 生成TOTP验证码
* @param secret Base32编码的密钥
* @param algorithm 算法类型 (SHA1, SHA256, SHA512)
* @param digits 验证码位数 (通常是6位)
* @param period 时间周期 (通常是30秒)
* @return 生成的验证码
*/
fun generateTOTP(
secret: String,
algorithm: String = "SHA1",
digits: Int = 6,
period: Int = 30
): String {
return try {
val key = decodeBase32(secret)
val timeCounter = System.currentTimeMillis() / 1000 / period
val hmac = generateHMAC(key, timeCounter, algorithm)
val code = truncate(hmac, digits)
code.toString().padStart(digits, '0')
} catch (e: Exception) {
"000000" // 出错时返回默认值
}
}
/**
* 获取当前时间步长内剩余的秒数
* @param period 时间周期
* @return 剩余秒数
*/
fun getRemainingSeconds(period: Int = 30): Int {
val currentTime = System.currentTimeMillis() / 1000
return period - (currentTime % period).toInt()
}
/**
* 获取当前时间步长的进度百分比
* @param period 时间周期
* @return 进度百分比 (0.0 - 1.0)
*/
fun getProgress(period: Int = 30): Float {
val currentTime = System.currentTimeMillis() / 1000
val elapsed = (currentTime % period).toInt()
return elapsed.toFloat() / period.toFloat()
}
/**
* Base32解码
*/
private fun decodeBase32(base32: String): ByteArray {
val cleanInput = base32.replace(" ", "").replace("=", "").uppercase()
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
val output = mutableListOf<Byte>()
var buffer = 0
var bitsLeft = 0
for (char in cleanInput) {
val index = alphabet.indexOf(char)
if (index == -1) continue
buffer = (buffer shl 5) or index
bitsLeft += 5
if (bitsLeft >= 8) {
output.add((buffer shr (bitsLeft - 8)).toByte())
bitsLeft -= 8
}
}
return output.toByteArray()
}
/**
* 生成HMAC
*/
private fun generateHMAC(key: ByteArray, counter: Long, algorithm: String): ByteArray {
val algorithmName = when (algorithm.uppercase()) {
"SHA1" -> "HmacSHA1"
"SHA256" -> "HmacSHA256"
"SHA512" -> "HmacSHA512"
else -> "HmacSHA1"
}
val mac = Mac.getInstance(algorithmName)
val keySpec = SecretKeySpec(key, algorithmName)
mac.init(keySpec)
val counterBytes = ByteBuffer.allocate(8).putLong(counter).array()
return mac.doFinal(counterBytes)
}
/**
* 动态截取
*/
private fun truncate(hmac: ByteArray, digits: Int): Int {
val offset = (hmac[hmac.size - 1].toInt() and 0x0F)
val code = ((hmac[offset].toInt() and 0x7F) shl 24) or
((hmac[offset + 1].toInt() and 0xFF) shl 16) or
((hmac[offset + 2].toInt() and 0xFF) shl 8) or
(hmac[offset + 3].toInt() and 0xFF)
return code % (10.0.pow(digits).toInt())
}
}
/**
* Steam TOTP 生成器
* Steam使用特殊的字符集和算法密钥为Base32编码从URI中提取
*/
object SteamTOTPGenerator {
private const val STEAM_CHARS = "23456789BCDFGHJKMNPQRTVWXY"
fun generateSteamCode(secret: String): String {
return try {
// 🔄 修复现在Steam使用URI中的Base32编码的secret
val key = decodeBase32(secret)
val timeDiv30 = System.currentTimeMillis() / 1000 / 30
// 创建8字节的时间计数器
val timeBytes = ByteArray(8)
timeBytes[4] = ((timeDiv30 shr 24) and 0xFF).toByte()
timeBytes[5] = ((timeDiv30 shr 16) and 0xFF).toByte()
timeBytes[6] = ((timeDiv30 shr 8) and 0xFF).toByte()
timeBytes[7] = (timeDiv30 and 0xFF).toByte()
// 生成HMAC-SHA1
val mac = Mac.getInstance("HmacSHA1")
val keySpec = SecretKeySpec(key, "HmacSHA1")
mac.init(keySpec)
val hmacBytes = mac.doFinal(timeBytes)
// 获取偏移量 (最后一个字节的低4位)
val offset = (hmacBytes[19].toInt() and 0x0F)
// 截取4字节并转换为32位整数
var fullcode = ((hmacBytes[offset].toInt() and 0xFF) shl 24) or
((hmacBytes[offset + 1].toInt() and 0xFF) shl 16) or
((hmacBytes[offset + 2].toInt() and 0xFF) shl 8) or
(hmacBytes[offset + 3].toInt() and 0xFF)
// 取31位 (去掉符号位)
fullcode = fullcode and 0x7FFFFFFF
// 生成5位Steam验证码
val result = StringBuilder()
for (i in 0 until 5) {
result.append(STEAM_CHARS[fullcode % STEAM_CHARS.length])
fullcode /= STEAM_CHARS.length
}
result.toString()
} catch (e: Exception) {
"23456" // Steam默认5位字符
}
}
private fun decodeBase32(base32: String): ByteArray {
val cleanInput = base32.replace(" ", "").replace("=", "").uppercase()
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
val output = mutableListOf<Byte>()
var buffer = 0
var bitsLeft = 0
for (char in cleanInput) {
val index = alphabet.indexOf(char)
if (index == -1) continue
buffer = (buffer shl 5) or index
bitsLeft += 5
if (bitsLeft >= 8) {
output.add((buffer shr (bitsLeft - 8)).toByte())
bitsLeft -= 8
}
}
return output.toByteArray()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_main" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="@dimen/fab_margin"
android:layout_marginBottom="16dp"
app:srcCompat="@android:drawable/ic_dialog_email" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<fragment
android:id="@+id/nav_host_fragment_content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FirstFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<Button
android:id="@+id/button_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/next"
app:layout_constraintBottom_toTopOf="@id/textview_first"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textview_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/lorem_ipsum"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_first" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".SecondFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<Button
android:id="@+id/button_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/previous"
app:layout_constraintBottom_toTopOf="@id/textview_second"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textview_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/lorem_ipsum"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_second" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,10 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="cn.deepfal.band.TOTPauthenticator.MainActivity">
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/action_settings"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/FirstFragment">
<fragment
android:id="@+id/FirstFragment"
android:name="cn.deepfal.band.TOTPauthenticator.FirstFragment"
android:label="@string/first_fragment_label"
tools:layout="@layout/fragment_first">
<action
android:id="@+id/action_FirstFragment_to_SecondFragment"
app:destination="@id/SecondFragment" />
</fragment>
<fragment
android:id="@+id/SecondFragment"
android:name="cn.deepfal.band.TOTPauthenticator.SecondFragment"
android:label="@string/second_fragment_label"
tools:layout="@layout/fragment_second">
<action
android:id="@+id/action_SecondFragment_to_FirstFragment"
app:destination="@id/FirstFragment" />
</fragment>
</navigation>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.BandTOTPAPP" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>

View File

@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.BandTOTPAPP" parent="Base.Theme.BandTOTPAPP">
<!-- Transparent system bars for edge-to-edge. -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
</style>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">200dp</dimen>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">16dp</dimen>
</resources>

View File

@ -0,0 +1,46 @@
<resources>
<string name="app_name">BandTOTP-APP</string>
<string name="action_settings">Settings</string>
<!-- Strings used for fragments for navigation -->
<string name="first_fragment_label">First Fragment</string>
<string name="second_fragment_label">Second Fragment</string>
<string name="next">Next</string>
<string name="previous">Previous</string>
<string name="lorem_ipsum">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris
volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus
dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad
litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend
diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a,
ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum tellus.\n\n
Suspendisse interdum ornare ante. Aliquam nec cursus lorem. Morbi id magna felis. Vivamus
egestas, est a condimentum egestas, turpis nisl iaculis ipsum, in dictum tellus dolor sed
neque. Morbi tellus erat, dapibus ut sem a, iaculis tincidunt dui. Interdum et malesuada
fames ac ante ipsum primis in faucibus. Curabitur et eros porttitor, ultricies urna vitae,
molestie nibh. Phasellus at commodo eros, non aliquet metus. Sed maximus nisl nec dolor
bibendum, vel congue leo egestas.\n\n
Sed interdum tortor nibh, in sagittis risus mollis quis. Curabitur mi odio, condimentum sit
amet auctor at, mollis non turpis. Nullam pretium libero vestibulum, finibus orci vel,
molestie quam. Fusce blandit tincidunt nulla, quis sollicitudin libero facilisis et. Integer
interdum nunc ligula, et fermentum metus hendrerit id. Vestibulum lectus felis, dictum at
lacinia sit amet, tristique id quam. Cras eu consequat dui. Suspendisse sodales nunc ligula,
in lobortis sem porta sed. Integer id ultrices magna, in luctus elit. Sed a pellentesque
est.\n\n
Aenean nunc velit, lacinia sed dolor sed, ultrices viverra nulla. Etiam a venenatis nibh.
Morbi laoreet, tortor sed facilisis varius, nibh orci rhoncus nulla, id elementum leo dui
non lorem. Nam mollis ipsum quis auctor varius. Quisque elementum eu libero sed commodo. In
eros nisl, imperdiet vel imperdiet et, scelerisque a mauris. Pellentesque varius ex nunc,
quis imperdiet eros placerat ac. Duis finibus orci et est auctor tincidunt. Sed non viverra
ipsum. Nunc quis augue egestas, cursus lorem at, molestie sem. Morbi a consectetur ipsum, a
placerat diam. Etiam vulputate dignissim convallis. Integer faucibus mauris sit amet finibus
convallis.\n\n
Phasellus in aliquet mi. Pellentesque habitant morbi tristique senectus et netus et
malesuada fames ac turpis egestas. In volutpat arcu ut felis sagittis, in finibus massa
gravida. Pellentesque id tellus orci. Integer dictum, lorem sed efficitur ullamcorper,
libero justo consectetur ipsum, in mollis nisl ex sed nisl. Donec maximus ullamcorper
sodales. Praesent bibendum rhoncus tellus nec feugiat. In a ornare nulla. Donec rhoncus
libero vel nunc consequat, quis tincidunt nisl eleifend. Cras bibendum enim a justo luctus
vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim.
</string>
</resources>

View File

@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.BandTOTPAPP" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.BandTOTPAPP" parent="Base.Theme.BandTOTPAPP" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,17 @@
package cn.deepfal.band.TOTPauthenticator
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

Binary file not shown.

5
build.gradle.kts Normal file
View File

@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
}

23
gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

56
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,56 @@
[versions]
agp = "8.12.0"
kotlin = "2.0.21"
composeCompiler = "1.5.8"
composeBom = "2024.12.01"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
appcompat = "1.7.1"
material = "1.12.0"
constraintlayout = "2.1.4"
navigationFragmentKtx = "2.6.0"
navigationUiKtx = "2.6.0"
activityCompose = "1.9.3"
lifecycleRuntimeKtx = "2.8.7"
lifecycleViewmodelCompose = "2.8.7"
coroutinesAndroid = "1.9.0"
gson = "2.10.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" }
androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" }
# Compose BOM and libraries
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutinesAndroid" }
# Gson for JSON serialization
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
# Compose testing
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Thu Aug 14 21:27:07 CST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Normal file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

11
keystore.properties Normal file
View File

@ -0,0 +1,11 @@
# debug sign - using band app certificate
debug.store.file=keystore/band_keystore.jks
debug.store.password=deepfal123
debug.key.alias=deepfal
debug.key.password=deepfal123
# release sign - using band app certificate
release.store.file=keystore/band_keystore.jks
release.store.password=deepfal123
release.key.alias=deepfal
release.key.password=deepfal123

BIN
keystore/keystore.jks Normal file

Binary file not shown.

BIN
keystoreband_keystore.jks Normal file

Binary file not shown.

BIN
keystoreband_keystore.p12 Normal file

Binary file not shown.

23
settings.gradle.kts Normal file
View File

@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "BandTOTP-APP"
include(":app")