init
15
.gitignore
vendored
Normal 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
@ -0,0 +1,3 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
.idea/.name
Normal file
@ -0,0 +1 @@
|
||||
BandTOTP-APP
|
||||
6
.idea/AndroidProjectSystem.xml
Normal 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>
|
||||
6
.idea/appInsightsSettings.xml
Normal 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>
|
||||
123
.idea/codeStyles/Project.xml
Normal 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>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
Normal 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
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
26
.idea/deploymentTargetSelector.xml
Normal 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
@ -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
@ -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>
|
||||
50
.idea/inspectionProfiles/Project_Default.xml
Normal 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
@ -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
@ -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>
|
||||
17
.idea/runConfigurations.xml
Normal 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
@ -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
@ -0,0 +1,106 @@
|
||||
# 🎯 现代化TOTP验证器应用重构完成
|
||||
|
||||
## ✨ 重构成果
|
||||
|
||||
已成功将原有的TOTP验证器应用重构为现代化、优雅的身份验证器应用,完全符合您的设计要求。
|
||||
|
||||
### 🎨 界面重构亮点
|
||||
|
||||
#### **主界面设计**
|
||||
- **极简风格**:去除复杂的手环状态显示,专注于验证码展示
|
||||
- **Google Authenticator风格**:采用Material Design 3设计语言
|
||||
- **现代化图标**:使用Material Icons替代emoji,确保跨设备一致性
|
||||
- **优雅动画**:流畅的页面切换和列表动画效果
|
||||
|
||||
#### **TOTP卡片设计**
|
||||
- **服务图标**:智能识别服务并显示对应图标和品牌色彩
|
||||
- **实时倒计时**:圆形进度条,剩余5秒时变红警告
|
||||
- **验证码显示**:等宽字体,分组显示,支持Steam特殊格式
|
||||
- **类型标签**:清晰标识TOTP/Steam类型
|
||||
|
||||
### 🚀 功能增强
|
||||
|
||||
#### **添加方式升级**
|
||||
- **📱 浮动按钮**:右下角双FAB设计,符合Material规范
|
||||
- **📷 二维码扫描**:集成CameraX + MLKit,专业扫描界面
|
||||
- **📁 文件导入**:保留原有功能,现代化界面
|
||||
- **✏️ 手动添加**:完整参数配置,支持高级选项
|
||||
|
||||
#### **同步功能重构**
|
||||
- **🔄 智能同步界面**:统一列表显示,清晰状态标识
|
||||
- **📊 三种状态**:
|
||||
- 📱 仅手机(蓝色)
|
||||
- ⌚ 仅手环(橙色)
|
||||
- ✅ 已同步(绿色)
|
||||
- **🎯 批量操作**:支持全部同步到手机/手环
|
||||
- **📈 统计面板**:实时显示各状态账户数量
|
||||
|
||||
### 🛠️ 技术升级
|
||||
|
||||
#### **现代化依赖**
|
||||
```kotlin
|
||||
// Material Icons扩展包
|
||||
implementation("androidx.compose.material:material-icons-extended:1.6.0")
|
||||
|
||||
// 相机和扫码
|
||||
implementation("androidx.camera:camera-camera2:1.3.1")
|
||||
implementation("com.google.mlkit:barcode-scanning:17.2.0")
|
||||
|
||||
// 权限处理
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||
```
|
||||
|
||||
#### **架构优化**
|
||||
- **组件化设计**:功能模块独立,便于维护
|
||||
- **状态管理**:使用Compose State进行响应式UI更新
|
||||
- **现代API**:CameraX + MLKit + Material Design 3
|
||||
|
||||
### 📁 项目结构
|
||||
|
||||
```
|
||||
app/src/main/java/cn/deepfal/band/TOTPauthenticator/
|
||||
├── MainActivity.kt # 主Activity - 现代化设计
|
||||
├── data/
|
||||
│ └── TOTPInfo.kt # TOTP数据模型
|
||||
├── components/
|
||||
│ ├── ModernTOTPCard.kt # 现代化验证码卡片
|
||||
│ ├── AddComponents.kt # 添加相关组件
|
||||
│ └── SyncScreen.kt # 同步管理界面
|
||||
├── scanner/
|
||||
│ └── QRCodeScanner.kt # 专业二维码扫描器
|
||||
├── sync/
|
||||
│ └── SyncManager.kt # 同步逻辑管理
|
||||
└── utils/
|
||||
├── LocalAccountManager.kt # 本地存储管理
|
||||
└── TOTPGenerator.kt # TOTP算法实现
|
||||
```
|
||||
|
||||
### 🎯 用户体验提升
|
||||
|
||||
#### **主界面**
|
||||
- 空状态友好提示
|
||||
- 流畅的账户列表滚动
|
||||
- 实时验证码更新
|
||||
- 智能色彩和图标识别
|
||||
|
||||
#### **添加流程**
|
||||
- 直观的底部菜单
|
||||
- 专业的扫码体验
|
||||
- 完整的手动输入选项
|
||||
- 即时反馈和错误处理
|
||||
|
||||
#### **同步管理**
|
||||
- 清晰的状态可视化
|
||||
- 一键批量操作
|
||||
- 实时连接状态监控
|
||||
- 友好的错误提示
|
||||
|
||||
## 🏆 设计目标达成
|
||||
|
||||
✅ **极简美观**:去除冗余元素,专注核心功能
|
||||
✅ **现代审美**:Material Design 3,Google Authenticator风格
|
||||
✅ **功能完整**:扫码、手动、文件导入三种添加方式
|
||||
✅ **智能同步**:可视化状态管理,去重合并
|
||||
✅ **专业体验**:真实图标、品牌色彩、流畅动画
|
||||
|
||||
应用现已完成现代化重构,构建成功,可以直接使用和测试!🎉
|
||||
1
app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
||||
128
app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
BIN
app/keystore/band_keystore.jks
Normal file
BIN
app/keystore/band_keystore.p12
Normal file
BIN
app/keystore/keystore.jks
Normal file
BIN
app/libs/xms-wearable-lib_1.4_release.aar
Normal file
21
app/proguard-rules.pro
vendored
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
44
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
1543
app/src/main/java/cn/deepfal/band/TOTPauthenticator/MainActivity.kt
Normal 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("添加")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,841 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// 滑动状态枚举
|
||||
enum class SwipeState {
|
||||
NONE, // 未滑动
|
||||
DETAILS, // 第一级:显示详情按钮
|
||||
DELETE // 第二级:显示删除按钮
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModernTOTPCard(
|
||||
account: TOTPInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
onViewDetails: (() -> Unit)? = null,
|
||||
currentSwipedAccount: TOTPInfo? = null,
|
||||
onSwipeStateChange: (TOTPInfo?, SwipeState) -> Unit = { _, _ -> }
|
||||
) {
|
||||
// 实时更新验证码
|
||||
var currentCode by remember { mutableStateOf(account.generateCurrentCode()) }
|
||||
var remainingSeconds by remember { mutableStateOf(account.getRemainingSeconds()) }
|
||||
var progress by remember { mutableStateOf(account.getProgress()) }
|
||||
|
||||
// 滑动状态
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var swipeState by remember { mutableStateOf(SwipeState.NONE) }
|
||||
|
||||
// 删除确认对话框状态
|
||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
// 验证码更新动画状态
|
||||
var codeUpdateTrigger by remember { mutableStateOf(false) }
|
||||
|
||||
// 复制状态和动画
|
||||
var showCopySuccess by remember { mutableStateOf(false) }
|
||||
var copyAnimationTrigger by remember { mutableStateOf(false) }
|
||||
|
||||
// 获取剪贴板管理器和上下文
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val density = LocalDensity.current
|
||||
val detailsOffset = with(density) { 80.dp.toPx() }
|
||||
val deleteOffset = with(density) { 160.dp.toPx() }
|
||||
|
||||
// 卡片入场动画
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(50) // 错开动画时间
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
// 验证码更新时的脉冲效果
|
||||
val codeScale by animateFloatAsState(
|
||||
targetValue = if (codeUpdateTrigger) 1.05f else 1f,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "code_pulse"
|
||||
)
|
||||
|
||||
// 复制动画效果 - 简化
|
||||
val copyScale by animateFloatAsState(
|
||||
targetValue = if (copyAnimationTrigger) 1.1f else 1f,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "copy_scale"
|
||||
)
|
||||
|
||||
// 复制功能
|
||||
fun copyCodeToClipboard() {
|
||||
clipboardManager.setText(AnnotatedString(currentCode))
|
||||
copyAnimationTrigger = true
|
||||
showCopySuccess = true
|
||||
}
|
||||
|
||||
// 复制动画重置
|
||||
LaunchedEffect(copyAnimationTrigger) {
|
||||
if (copyAnimationTrigger) {
|
||||
delay(150)
|
||||
copyAnimationTrigger = false
|
||||
}
|
||||
}
|
||||
|
||||
// 复制成功提示自动隐藏
|
||||
LaunchedEffect(showCopySuccess) {
|
||||
if (showCopySuccess) {
|
||||
delay(1500)
|
||||
showCopySuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前滑动账户变化
|
||||
LaunchedEffect(currentSwipedAccount) {
|
||||
if (currentSwipedAccount != null && currentSwipedAccount != account) {
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
// 每秒更新验证码 - 带动画效果
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
val newCode = account.generateCurrentCode()
|
||||
if (newCode != currentCode) {
|
||||
// 触发验证码更新动画
|
||||
codeUpdateTrigger = true
|
||||
delay(100)
|
||||
currentCode = newCode
|
||||
codeUpdateTrigger = false
|
||||
}
|
||||
remainingSeconds = account.getRemainingSeconds()
|
||||
progress = account.getProgress()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// 紧急状态的颜色 - 移除闪烁效果,保持平稳显示
|
||||
val urgentColorAlpha by animateFloatAsState(
|
||||
targetValue = if (remainingSeconds <= 5) 0.15f else 0f,
|
||||
animationSpec = tween(durationMillis = 300, easing = EaseOutCubic),
|
||||
label = "urgent_background"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { it / 2 },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy)
|
||||
) + fadeIn(animationSpec = tween(400)),
|
||||
exit = slideOutVertically(
|
||||
targetOffsetY = { -it / 2 },
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeOut(animationSpec = tween(200))
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
// 背景层:操作按钮
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 详情按钮
|
||||
AnimatedVisibility(
|
||||
visible = swipeState == SwipeState.DETAILS || swipeState == SwipeState.DELETE,
|
||||
enter = fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)) +
|
||||
scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)),
|
||||
exit = fadeOut(animationSpec = tween(200)) + scaleOut(animationSpec = tween(200))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
onViewDetails?.invoke()
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
onSwipeStateChange(null, SwipeState.NONE)
|
||||
}
|
||||
.animateContentSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "查看详情",
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 删除按钮
|
||||
AnimatedVisibility(
|
||||
visible = swipeState == SwipeState.DELETE,
|
||||
enter = fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)) +
|
||||
scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)),
|
||||
exit = fadeOut(animationSpec = tween(200)) + scaleOut(animationSpec = tween(200))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.clickable {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
.animateContentSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "删除",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 前景层:彻底修复圆角问题 - 使用Box+背景+clip的方式
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset { IntOffset(offsetX.roundToInt(), 0) }
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (urgentColorAlpha > 0f) {
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = urgentColorAlpha)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
val newOffset = offsetX + delta
|
||||
if (newOffset <= 0) {
|
||||
offsetX = newOffset.coerceAtLeast(-deleteOffset)
|
||||
}
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
val targetState = when {
|
||||
velocity < -300 && offsetX < -20f -> {
|
||||
when (swipeState) {
|
||||
SwipeState.NONE -> SwipeState.DETAILS
|
||||
SwipeState.DETAILS -> SwipeState.DELETE
|
||||
SwipeState.DELETE -> SwipeState.DELETE
|
||||
}
|
||||
}
|
||||
offsetX < -deleteOffset * 0.5f -> SwipeState.DELETE
|
||||
offsetX < -detailsOffset * 0.3f -> SwipeState.DETAILS
|
||||
else -> SwipeState.NONE
|
||||
}
|
||||
|
||||
when (targetState) {
|
||||
SwipeState.DETAILS -> {
|
||||
offsetX = -detailsOffset
|
||||
swipeState = SwipeState.DETAILS
|
||||
onSwipeStateChange(account, SwipeState.DETAILS)
|
||||
}
|
||||
SwipeState.DELETE -> {
|
||||
offsetX = -deleteOffset
|
||||
swipeState = SwipeState.DELETE
|
||||
onSwipeStateChange(account, SwipeState.DELETE)
|
||||
}
|
||||
SwipeState.NONE -> {
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
onSwipeStateChange(null, SwipeState.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左侧:服务图标 - 带旋转动画
|
||||
ServiceIcon(serviceName = account.name)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 中间:账户信息
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = account.name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
if (account.usr.isNotEmpty()) {
|
||||
Text(
|
||||
text = account.usr,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 右侧:验证码和倒计时 - 带动画效果和点击复制
|
||||
Box {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
copyCodeToClipboard()
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
// 验证码 - 带缩放和复制动画
|
||||
Text(
|
||||
text = if (account.type.lowercase() == "steam") {
|
||||
currentCode
|
||||
} else {
|
||||
"${currentCode.take(3)} ${currentCode.drop(3)}"
|
||||
},
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = getCodeColor(remainingSeconds),
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier.graphicsLayer(
|
||||
scaleX = codeScale * copyScale,
|
||||
scaleY = codeScale * copyScale
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 倒计时指示器 - 带动画
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${remainingSeconds}s",
|
||||
fontSize = 12.sp,
|
||||
color = getCodeColor(remainingSeconds).copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
// 脉冲指示器
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (remainingSeconds <= 10) {
|
||||
getCodeColor(remainingSeconds).copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
}
|
||||
)
|
||||
.animateContentSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 简化的复制成功提示
|
||||
if (showCopySuccess) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.offset(y = 20.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "✓ 已复制",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除确认对话框 - 带动画
|
||||
if (showDeleteConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showDeleteConfirmation = false
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "确认删除",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "确定要删除账户 \"${account.name}\" 吗?",
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (account.usr.isNotEmpty()) {
|
||||
Text(
|
||||
text = "用户:${account.usr}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "此操作不可撤销。",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirmation = false
|
||||
onDelete?.invoke()
|
||||
// 重置滑动状态
|
||||
offsetX = 0f
|
||||
swipeState = SwipeState.NONE
|
||||
onSwipeStateChange(null, SwipeState.NONE)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "删除",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServiceIcon(serviceName: String) {
|
||||
val icon = when (serviceName.lowercase()) {
|
||||
"google" -> Icons.Default.AccountCircle
|
||||
"github" -> Icons.Default.Code
|
||||
"microsoft" -> Icons.Default.Business
|
||||
"apple" -> Icons.Default.Phone
|
||||
"steam" -> Icons.Default.Games
|
||||
"discord" -> Icons.Default.Chat
|
||||
"twitter", "x" -> Icons.Default.Share
|
||||
"facebook" -> Icons.Default.Person
|
||||
"amazon" -> Icons.Default.ShoppingCart
|
||||
"dropbox" -> Icons.Default.Cloud
|
||||
"linkedin" -> Icons.Default.Work
|
||||
else -> Icons.Default.Security
|
||||
}
|
||||
|
||||
val color = when (serviceName.lowercase()) {
|
||||
"google" -> androidx.compose.ui.graphics.Color(0xFF4285F4)
|
||||
"github" -> androidx.compose.ui.graphics.Color(0xFF181717)
|
||||
"microsoft" -> androidx.compose.ui.graphics.Color(0xFF00A1F1)
|
||||
"apple" -> androidx.compose.ui.graphics.Color(0xFF000000)
|
||||
"steam" -> androidx.compose.ui.graphics.Color(0xFF171A21)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = serviceName,
|
||||
tint = color,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getCodeColor(remainingSeconds: Int): androidx.compose.ui.graphics.Color {
|
||||
return when {
|
||||
remainingSeconds <= 5 -> MaterialTheme.colorScheme.error
|
||||
remainingSeconds <= 10 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CountdownCircle(
|
||||
progress: Float,
|
||||
remainingSeconds: Int
|
||||
) {
|
||||
val isUrgent = remainingSeconds <= 5
|
||||
val color = if (isUrgent) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 背景圆环
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f },
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
|
||||
// 进度圆环
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f - progress },
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = color,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
|
||||
// 剩余秒数
|
||||
Text(
|
||||
text = remainingSeconds.toString(),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TypeChip(type: String) {
|
||||
val chipColor = when (type.lowercase()) {
|
||||
"steam" -> androidx.compose.ui.graphics.Color(0xFF171A21)
|
||||
else -> MaterialTheme.colorScheme.secondary
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = chipColor.copy(alpha = 0.1f),
|
||||
modifier = Modifier.padding(0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = type.uppercase(),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = chipColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountActionDialog(
|
||||
accountName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onViewDetails: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(text = "确认删除")
|
||||
},
|
||||
text = {
|
||||
Text(text = "确定要删除账户 \"$accountName\" 吗?此操作不可撤销。")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onDelete,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(text = accountName)
|
||||
},
|
||||
text = {
|
||||
Text(text = "选择要执行的操作")
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TextButton(onClick = onViewDetails) {
|
||||
Text("查看详情")
|
||||
}
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmation = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountDetailsDialog(
|
||||
account: TOTPInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: ((String, String) -> Unit)? = null // 回调函数:(newServiceName, newUsername) -> Unit
|
||||
) {
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showEditDialog) {
|
||||
EditAccountDialog(
|
||||
account = account,
|
||||
onDismiss = { showEditDialog = false },
|
||||
onSave = { newServiceName, newUsername ->
|
||||
onEdit?.invoke(newServiceName, newUsername)
|
||||
showEditDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ServiceIcon(serviceName = account.name)
|
||||
Text(text = "账户详情")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
DetailItem(label = "服务名称", value = account.name)
|
||||
DetailItem(label = "用户名", value = account.usr)
|
||||
DetailItem(label = "算法", value = account.algorithm)
|
||||
DetailItem(label = "位数", value = "${account.digits} 位")
|
||||
DetailItem(label = "周期", value = "${account.period} 秒")
|
||||
DetailItem(label = "类型", value = account.type.uppercase())
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (onEdit != null) {
|
||||
TextButton(
|
||||
onClick = { showEditDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("编辑")
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailItem(label: String, value: String) {
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditAccountDialog(
|
||||
account: TOTPInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String) -> Unit
|
||||
) {
|
||||
var serviceName by remember { mutableStateOf(account.name) }
|
||||
var username by remember { mutableStateOf(account.usr) }
|
||||
val isValid = serviceName.trim().isNotEmpty()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(text = "编辑账户")
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 服务名称输入框
|
||||
Column {
|
||||
Text(
|
||||
text = "服务名称 *",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = serviceName,
|
||||
onValueChange = { serviceName = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("例如:Google") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 用户名输入框
|
||||
Column {
|
||||
Text(
|
||||
text = "用户名",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("例如:user@example.com") },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 提示信息
|
||||
Text(
|
||||
text = "* 必填字段",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
onSave(serviceName.trim(), username.trim())
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = if (isValid) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
) {
|
||||
Text("保存")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.components
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.burnoutcrew.reorderable.*
|
||||
|
||||
@Composable
|
||||
fun ReorderableBandAccountsList(
|
||||
accounts: List<cn.deepfal.band.TOTPauthenticator.data.TOTPInfo>,
|
||||
onReorder: (List<cn.deepfal.band.TOTPauthenticator.data.TOTPInfo>) -> Unit
|
||||
) {
|
||||
var reorderableAccounts by remember { mutableStateOf(accounts) }
|
||||
|
||||
// 当accounts变化时更新reorderableAccounts
|
||||
LaunchedEffect(accounts) {
|
||||
reorderableAccounts = accounts
|
||||
}
|
||||
|
||||
val reorderableState = rememberReorderableLazyListState(
|
||||
onMove = { from, to ->
|
||||
reorderableAccounts = reorderableAccounts.toMutableList().apply {
|
||||
add(to.index, removeAt(from.index))
|
||||
}
|
||||
},
|
||||
onDragEnd = { _, _ ->
|
||||
onReorder(reorderableAccounts)
|
||||
}
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 重排序提示
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "拖拽账户卡片可重新排序手环上的账户顺序",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 可重排序的账户列表
|
||||
LazyColumn(
|
||||
state = reorderableState.listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.reorderable(reorderableState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(reorderableAccounts, key = { _, account -> account.key }) { index, account ->
|
||||
ReorderableItem(reorderableState, key = account.key) { isDragging ->
|
||||
val elevation = animateDpAsState(
|
||||
targetValue = if (isDragging) 16.dp else 2.dp,
|
||||
label = "elevation"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = elevation.value),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 拖拽手柄
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = "拖拽排序",
|
||||
modifier = Modifier
|
||||
.detectReorderAfterLongPress(reorderableState)
|
||||
.padding(end = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// 状态指示器
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Watch,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 账户信息
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = account.name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (account.usr.isNotEmpty()) {
|
||||
Text(
|
||||
text = account.usr,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "手环账户",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
// 序号显示
|
||||
Text(
|
||||
text = "#${index + 1}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.data
|
||||
|
||||
import cn.deepfal.band.TOTPauthenticator.utils.TOTPGenerator
|
||||
import cn.deepfal.band.TOTPauthenticator.utils.SteamTOTPGenerator
|
||||
|
||||
/**
|
||||
* TOTP账户信息数据类
|
||||
*/
|
||||
data class TOTPInfo(
|
||||
val name: String,
|
||||
val usr: String,
|
||||
val key: String,
|
||||
val algorithm: String = "SHA1",
|
||||
val digits: Int = 6,
|
||||
val period: Int = 30,
|
||||
val type: String = "totp"
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return """{"name": "$name", "usr": "$usr", "key": "$key", "algorithm": "$algorithm", "digits": $digits, "period": $period, "type": "$type"}"""
|
||||
}
|
||||
|
||||
// 生成当前验证码
|
||||
fun generateCurrentCode(): String {
|
||||
return if (type.lowercase() == "steam") {
|
||||
SteamTOTPGenerator.generateSteamCode(key)
|
||||
} else {
|
||||
TOTPGenerator.generateTOTP(key, algorithm, digits, period)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取剩余时间(秒)
|
||||
fun getRemainingSeconds(): Int {
|
||||
return TOTPGenerator.getRemainingSeconds(period)
|
||||
}
|
||||
|
||||
// 获取进度(0.0-1.0)
|
||||
fun getProgress(): Float {
|
||||
return TOTPGenerator.getProgress(period)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,370 @@
|
||||
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||
|
||||
package cn.deepfal.band.TOTPauthenticator.scanner
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun QRCodeScanner(
|
||||
onCodeScanned: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
cameraPermissionState.status.isGranted -> {
|
||||
CameraPreview(
|
||||
onCodeScanned = onCodeScanned,
|
||||
onClose = onClose
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
CameraPermissionRequest(
|
||||
permissionState = cameraPermissionState,
|
||||
onClose = onClose
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraPermissionRequest(
|
||||
permissionState: PermissionState,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "需要相机权限",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "扫描二维码需要使用相机功能",
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { permissionState.launchPermissionRequest() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("授予权限")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
onCodeScanned: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var flashEnabled by remember { mutableStateOf(false) }
|
||||
var camera by remember { mutableStateOf<Camera?>(null) }
|
||||
|
||||
val preview = remember { Preview.Builder().build() }
|
||||
val selector = remember { CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() }
|
||||
|
||||
val imageAnalysis = remember {
|
||||
ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
}
|
||||
|
||||
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
|
||||
|
||||
val barcodeScanner = BarcodeScanning.getClient()
|
||||
|
||||
imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
|
||||
val inputImage = InputImage.fromMediaImage(
|
||||
imageProxy.image!!,
|
||||
imageProxy.imageInfo.rotationDegrees
|
||||
)
|
||||
|
||||
barcodeScanner.process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
for (barcode in barcodes) {
|
||||
when (barcode.valueType) {
|
||||
Barcode.TYPE_TEXT -> {
|
||||
val rawValue = barcode.rawValue
|
||||
if (rawValue != null && rawValue.startsWith("otpauth://")) {
|
||||
onCodeScanned(rawValue)
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
camera = cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
selector,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
PreviewView(context).apply {
|
||||
this.scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { previewView ->
|
||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
)
|
||||
|
||||
// 扫描框覆盖层
|
||||
ScanOverlay()
|
||||
|
||||
// 顶部控制栏
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
camera?.let {
|
||||
if (it.cameraInfo.hasFlashUnit()) {
|
||||
it.cameraControl.enableTorch(!flashEnabled)
|
||||
flashEnabled = !flashEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (flashEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff,
|
||||
contentDescription = if (flashEnabled) "关闭闪光灯" else "打开闪光灯",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 底部说明文字
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "将二维码对准扫描框",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScanOverlay() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
) {
|
||||
// 扫描框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(280.dp)
|
||||
.align(Alignment.Center)
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
)
|
||||
|
||||
// 四个角的装饰
|
||||
val cornerSize = 24.dp
|
||||
val cornerThickness = 4.dp
|
||||
val boxSize = 280.dp
|
||||
val offsetFromCenter = (boxSize - cornerSize) / 2
|
||||
|
||||
// 左上角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = -offsetFromCenter, y = -offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(topStart = 8.dp)
|
||||
)
|
||||
)
|
||||
|
||||
// 右上角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = offsetFromCenter, y = -offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(topEnd = 8.dp)
|
||||
)
|
||||
)
|
||||
|
||||
// 左下角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = -offsetFromCenter, y = offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(bottomStart = 8.dp)
|
||||
)
|
||||
)
|
||||
|
||||
// 右下角
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = offsetFromCenter, y = offsetFromCenter)
|
||||
.size(cornerSize)
|
||||
.border(
|
||||
width = cornerThickness,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(bottomEnd = 8.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
|
||||
import cn.deepfal.band.TOTPauthenticator.utils.LocalAccountManager
|
||||
import com.xiaomi.xms.wearable.message.MessageApi
|
||||
import com.xiaomi.xms.wearable.message.OnMessageReceivedListener
|
||||
import com.xiaomi.xms.wearable.node.Node
|
||||
import com.xiaomi.xms.wearable.node.NodeApi
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SyncAccount(
|
||||
val account: TOTPInfo,
|
||||
val status: SyncStatus
|
||||
)
|
||||
|
||||
enum class SyncStatus {
|
||||
PHONE_ONLY, // 仅在手机
|
||||
WATCH_ONLY, // 仅在手环
|
||||
SYNCED // 已同步
|
||||
}
|
||||
|
||||
class SyncManager(
|
||||
private val context: Context,
|
||||
private val nodeApi: NodeApi,
|
||||
private val messageApi: MessageApi
|
||||
) {
|
||||
private val localAccountManager = LocalAccountManager(context)
|
||||
private var nodeId: String? = null
|
||||
private var currentNode: Node? = null
|
||||
|
||||
// 消息监听器
|
||||
private val messageListener = OnMessageReceivedListener { deviceId, message ->
|
||||
try {
|
||||
val jsonString = String(message)
|
||||
val jsonObject = JSONObject(jsonString)
|
||||
// 处理来自手环的消息
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initializeConnection(): Boolean {
|
||||
return try {
|
||||
val task = nodeApi.connectedNodes
|
||||
task.addOnSuccessListener { nodes ->
|
||||
if (nodes.isNotEmpty()) {
|
||||
currentNode = nodes[0]
|
||||
nodeId = currentNode?.id
|
||||
setupMessageListener()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMessageListener() {
|
||||
nodeId?.let { id ->
|
||||
messageApi.addListener(id, messageListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPhoneAccounts(): List<TOTPInfo> {
|
||||
return localAccountManager.loadAccounts()
|
||||
}
|
||||
|
||||
suspend fun getWatchAccounts(): List<TOTPInfo> {
|
||||
// 发送请求到手环获取账户列表
|
||||
// 这里需要等待异步响应
|
||||
return emptyList() // 临时返回空列表
|
||||
}
|
||||
|
||||
fun getMergedAccounts(phoneAccounts: List<TOTPInfo>, watchAccounts: List<TOTPInfo>): List<SyncAccount> {
|
||||
val merged = mutableListOf<SyncAccount>()
|
||||
val phoneMap = phoneAccounts.associateBy { "${it.name}_${it.key}" }
|
||||
val watchMap = watchAccounts.associateBy { "${it.name}_${it.key}" }
|
||||
|
||||
// 添加手机账户
|
||||
phoneAccounts.forEach { account ->
|
||||
val key = "${account.name}_${account.key}"
|
||||
val status = if (watchMap.containsKey(key)) {
|
||||
SyncStatus.SYNCED
|
||||
} else {
|
||||
SyncStatus.PHONE_ONLY
|
||||
}
|
||||
merged.add(SyncAccount(account, status))
|
||||
}
|
||||
|
||||
// 添加仅手环账户
|
||||
watchAccounts.forEach { account ->
|
||||
val key = "${account.name}_${account.key}"
|
||||
if (!phoneMap.containsKey(key)) {
|
||||
merged.add(SyncAccount(account, SyncStatus.WATCH_ONLY))
|
||||
}
|
||||
}
|
||||
|
||||
return merged.sortedBy { it.account.name }
|
||||
}
|
||||
|
||||
suspend fun syncToWatch(accounts: List<TOTPInfo>): Boolean {
|
||||
return try {
|
||||
nodeId?.let { id ->
|
||||
val jsonMessage = """{"list":${accounts.joinToString(",", "[", "]") { it.toJsonString() }}}"""
|
||||
val task = messageApi.sendMessage(id, jsonMessage.toByteArray())
|
||||
task.addOnSuccessListener {
|
||||
// 同步成功
|
||||
}
|
||||
true
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun syncToPhone(accounts: List<TOTPInfo>) {
|
||||
accounts.forEach { account ->
|
||||
localAccountManager.addAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
nodeId?.let { id ->
|
||||
messageApi.removeListener(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@ -0,0 +1,59 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BandTOTPAPPTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@ -0,0 +1,160 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import cn.deepfal.band.TOTPauthenticator.data.TOTPInfo
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 本地账户存储管理器
|
||||
* 使用SharedPreferences存储TOTP账户信息
|
||||
*/
|
||||
class LocalAccountManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "totp_accounts"
|
||||
private const val KEY_ACCOUNTS = "accounts"
|
||||
}
|
||||
|
||||
private val sharedPrefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* 保存账户列表
|
||||
*/
|
||||
fun saveAccounts(accounts: List<TOTPInfo>) {
|
||||
try {
|
||||
val jsonArray = JSONArray()
|
||||
accounts.forEach { account ->
|
||||
val jsonObject = JSONObject().apply {
|
||||
put("name", account.name)
|
||||
put("usr", account.usr)
|
||||
put("key", account.key)
|
||||
put("algorithm", account.algorithm)
|
||||
put("digits", account.digits)
|
||||
put("period", account.period)
|
||||
put("type", account.type)
|
||||
}
|
||||
jsonArray.put(jsonObject)
|
||||
}
|
||||
|
||||
sharedPrefs.edit()
|
||||
.putString(KEY_ACCOUNTS, jsonArray.toString())
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载账户列表
|
||||
*/
|
||||
fun loadAccounts(): List<TOTPInfo> {
|
||||
return try {
|
||||
val jsonString = sharedPrefs.getString(KEY_ACCOUNTS, null) ?: return emptyList()
|
||||
val jsonArray = JSONArray(jsonString)
|
||||
val accounts = mutableListOf<TOTPInfo>()
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(i)
|
||||
val account = TOTPInfo(
|
||||
name = jsonObject.getString("name"),
|
||||
usr = jsonObject.getString("usr"),
|
||||
key = jsonObject.getString("key"),
|
||||
algorithm = jsonObject.optString("algorithm", "SHA1"),
|
||||
digits = jsonObject.optInt("digits", 6),
|
||||
period = jsonObject.optInt("period", 30),
|
||||
type = jsonObject.optString("type", "totp")
|
||||
)
|
||||
accounts.add(account)
|
||||
}
|
||||
|
||||
accounts
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户
|
||||
*/
|
||||
fun addAccount(account: TOTPInfo) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
|
||||
// 检查是否已存在相同的账户(基于secret和name)
|
||||
val existingIndex = currentAccounts.indexOfFirst {
|
||||
it.key == account.key && it.name == account.name
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新现有账户
|
||||
currentAccounts[existingIndex] = account
|
||||
} else {
|
||||
// 添加新账户
|
||||
currentAccounts.add(account)
|
||||
}
|
||||
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
fun removeAccount(account: TOTPInfo) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
currentAccounts.removeAll {
|
||||
it.key == account.key && it.name == account.name
|
||||
}
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息(支持修改服务名和用户名)
|
||||
*/
|
||||
fun updateAccount(originalAccount: TOTPInfo, newServiceName: String, newUsername: String) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
val targetIndex = currentAccounts.indexOfFirst {
|
||||
it.key == originalAccount.key && it.name == originalAccount.name && it.usr == originalAccount.usr
|
||||
}
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
// 创建更新后的账户信息
|
||||
val updatedAccount = originalAccount.copy(
|
||||
name = newServiceName,
|
||||
usr = newUsername
|
||||
)
|
||||
currentAccounts[targetIndex] = updatedAccount
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有账户
|
||||
*/
|
||||
fun clearAllAccounts() {
|
||||
sharedPrefs.edit().remove(KEY_ACCOUNTS).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加账户
|
||||
*/
|
||||
fun addAccounts(newAccounts: List<TOTPInfo>) {
|
||||
val currentAccounts = loadAccounts().toMutableList()
|
||||
|
||||
newAccounts.forEach { newAccount ->
|
||||
val existingIndex = currentAccounts.indexOfFirst {
|
||||
it.key == newAccount.key && it.name == newAccount.name
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
currentAccounts[existingIndex] = newAccount
|
||||
} else {
|
||||
currentAccounts.add(newAccount)
|
||||
}
|
||||
}
|
||||
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,198 @@
|
||||
package cn.deepfal.band.TOTPauthenticator.utils
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) 生成器
|
||||
* 基于RFC 6238标准实现
|
||||
*/
|
||||
object TOTPGenerator {
|
||||
|
||||
/**
|
||||
* 生成TOTP验证码
|
||||
* @param secret Base32编码的密钥
|
||||
* @param algorithm 算法类型 (SHA1, SHA256, SHA512)
|
||||
* @param digits 验证码位数 (通常是6位)
|
||||
* @param period 时间周期 (通常是30秒)
|
||||
* @return 生成的验证码
|
||||
*/
|
||||
fun generateTOTP(
|
||||
secret: String,
|
||||
algorithm: String = "SHA1",
|
||||
digits: Int = 6,
|
||||
period: Int = 30
|
||||
): String {
|
||||
return try {
|
||||
val key = decodeBase32(secret)
|
||||
val timeCounter = System.currentTimeMillis() / 1000 / period
|
||||
val hmac = generateHMAC(key, timeCounter, algorithm)
|
||||
val code = truncate(hmac, digits)
|
||||
code.toString().padStart(digits, '0')
|
||||
} catch (e: Exception) {
|
||||
"000000" // 出错时返回默认值
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间步长内剩余的秒数
|
||||
* @param period 时间周期
|
||||
* @return 剩余秒数
|
||||
*/
|
||||
fun getRemainingSeconds(period: Int = 30): Int {
|
||||
val currentTime = System.currentTimeMillis() / 1000
|
||||
return period - (currentTime % period).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间步长的进度百分比
|
||||
* @param period 时间周期
|
||||
* @return 进度百分比 (0.0 - 1.0)
|
||||
*/
|
||||
fun getProgress(period: Int = 30): Float {
|
||||
val currentTime = System.currentTimeMillis() / 1000
|
||||
val elapsed = (currentTime % period).toInt()
|
||||
return elapsed.toFloat() / period.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32解码
|
||||
*/
|
||||
private fun decodeBase32(base32: String): ByteArray {
|
||||
val cleanInput = base32.replace(" ", "").replace("=", "").uppercase()
|
||||
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
val output = mutableListOf<Byte>()
|
||||
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
|
||||
for (char in cleanInput) {
|
||||
val index = alphabet.indexOf(char)
|
||||
if (index == -1) continue
|
||||
|
||||
buffer = (buffer shl 5) or index
|
||||
bitsLeft += 5
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
output.add((buffer shr (bitsLeft - 8)).toByte())
|
||||
bitsLeft -= 8
|
||||
}
|
||||
}
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成HMAC
|
||||
*/
|
||||
private fun generateHMAC(key: ByteArray, counter: Long, algorithm: String): ByteArray {
|
||||
val algorithmName = when (algorithm.uppercase()) {
|
||||
"SHA1" -> "HmacSHA1"
|
||||
"SHA256" -> "HmacSHA256"
|
||||
"SHA512" -> "HmacSHA512"
|
||||
else -> "HmacSHA1"
|
||||
}
|
||||
|
||||
val mac = Mac.getInstance(algorithmName)
|
||||
val keySpec = SecretKeySpec(key, algorithmName)
|
||||
mac.init(keySpec)
|
||||
|
||||
val counterBytes = ByteBuffer.allocate(8).putLong(counter).array()
|
||||
return mac.doFinal(counterBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态截取
|
||||
*/
|
||||
private fun truncate(hmac: ByteArray, digits: Int): Int {
|
||||
val offset = (hmac[hmac.size - 1].toInt() and 0x0F)
|
||||
|
||||
val code = ((hmac[offset].toInt() and 0x7F) shl 24) or
|
||||
((hmac[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((hmac[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(hmac[offset + 3].toInt() and 0xFF)
|
||||
|
||||
return code % (10.0.pow(digits).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam TOTP 生成器
|
||||
* Steam使用特殊的字符集和算法,密钥为Base32编码(从URI中提取)
|
||||
*/
|
||||
object SteamTOTPGenerator {
|
||||
private const val STEAM_CHARS = "23456789BCDFGHJKMNPQRTVWXY"
|
||||
|
||||
fun generateSteamCode(secret: String): String {
|
||||
return try {
|
||||
// 🔄 修复:现在Steam使用URI中的Base32编码的secret
|
||||
val key = decodeBase32(secret)
|
||||
|
||||
val timeDiv30 = System.currentTimeMillis() / 1000 / 30
|
||||
|
||||
// 创建8字节的时间计数器
|
||||
val timeBytes = ByteArray(8)
|
||||
timeBytes[4] = ((timeDiv30 shr 24) and 0xFF).toByte()
|
||||
timeBytes[5] = ((timeDiv30 shr 16) and 0xFF).toByte()
|
||||
timeBytes[6] = ((timeDiv30 shr 8) and 0xFF).toByte()
|
||||
timeBytes[7] = (timeDiv30 and 0xFF).toByte()
|
||||
|
||||
// 生成HMAC-SHA1
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
val keySpec = SecretKeySpec(key, "HmacSHA1")
|
||||
mac.init(keySpec)
|
||||
val hmacBytes = mac.doFinal(timeBytes)
|
||||
|
||||
// 获取偏移量 (最后一个字节的低4位)
|
||||
val offset = (hmacBytes[19].toInt() and 0x0F)
|
||||
|
||||
// 截取4字节并转换为32位整数
|
||||
var fullcode = ((hmacBytes[offset].toInt() and 0xFF) shl 24) or
|
||||
((hmacBytes[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((hmacBytes[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(hmacBytes[offset + 3].toInt() and 0xFF)
|
||||
|
||||
// 取31位 (去掉符号位)
|
||||
fullcode = fullcode and 0x7FFFFFFF
|
||||
|
||||
// 生成5位Steam验证码
|
||||
val result = StringBuilder()
|
||||
for (i in 0 until 5) {
|
||||
result.append(STEAM_CHARS[fullcode % STEAM_CHARS.length])
|
||||
fullcode /= STEAM_CHARS.length
|
||||
}
|
||||
|
||||
result.toString()
|
||||
} catch (e: Exception) {
|
||||
"23456" // Steam默认5位字符
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBase32(base32: String): ByteArray {
|
||||
val cleanInput = base32.replace(" ", "").replace("=", "").uppercase()
|
||||
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
val output = mutableListOf<Byte>()
|
||||
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
|
||||
for (char in cleanInput) {
|
||||
val index = alphabet.indexOf(char)
|
||||
if (index == -1) continue
|
||||
|
||||
buffer = (buffer shl 5) or index
|
||||
bitsLeft += 5
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
output.add((buffer shr (bitsLeft - 8)).toByte())
|
||||
bitsLeft -= 8
|
||||
}
|
||||
}
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
BIN
app/src/main/res/drawable/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
33
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
19
app/src/main/res/layout/content_main.xml
Normal 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>
|
||||
35
app/src/main/res/layout/fragment_first.xml
Normal 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>
|
||||
35
app/src/main/res/layout/fragment_second.xml
Normal 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>
|
||||
10
app/src/main/res/menu/menu_main.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
28
app/src/main/res/navigation/nav_graph.xml
Normal 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>
|
||||
3
app/src/main/res/values-land/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
7
app/src/main/res/values-night/themes.xml
Normal 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>
|
||||
9
app/src/main/res/values-v23/themes.xml
Normal 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>
|
||||
3
app/src/main/res/values-w1240dp/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">200dp</dimen>
|
||||
</resources>
|
||||
3
app/src/main/res/values-w600dp/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
5
app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
app/src/main/res/values/dimens.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<dimen name="fab_margin">16dp</dimen>
|
||||
</resources>
|
||||
46
app/src/main/res/values/strings.xml
Normal 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>
|
||||
9
app/src/main/res/values/themes.xml
Normal 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>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
BIN
appkeystoreband_keystore.jks
Normal file
5
build.gradle.kts
Normal 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
@ -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
@ -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
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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
@ -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
@ -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
BIN
keystoreband_keystore.jks
Normal file
BIN
keystoreband_keystore.p12
Normal file
23
settings.gradle.kts
Normal 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")
|
||||