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