This commit is contained in:
DeepFal committed 2025-08-18 11:17:38 +08:00
commit d2a3e61b54
89 files changed
+6390

No files matched your search

+1
View File
@@ -0,0 +1 @@
/build
+128
View File
@@ -0,0 +1,128 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
}
// 加载签名配置 - 参考官方demo
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android {
namespace = "cn.deepfal.band.TOTPauthenticator"
compileSdk = 36
defaultConfig {
applicationId = "cn.deepfal.band.TOTPauthenticator"
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// 签名配置 - 参考官方demo
signingConfigs {
getByName("debug") {
if (keystoreProperties.isNotEmpty()) {
keyAlias = keystoreProperties["debug.key.alias"].toString()
keyPassword = keystoreProperties["debug.key.password"].toString()
storeFile = file(keystoreProperties["debug.store.file"].toString())
storePassword = keystoreProperties["debug.store.password"].toString()
}
}
create("release") {
if (keystoreProperties.isNotEmpty()) {
keyAlias = keystoreProperties["release.key.alias"].toString()
keyPassword = keystoreProperties["release.key.password"].toString()
storeFile = file(keystoreProperties["release.store.file"].toString())
storePassword = keystoreProperties["release.store.password"].toString()
}
}
}
buildTypes {
getByName("debug") {
signingConfig = signingConfigs.getByName("debug")
}
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
viewBinding = true
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
// 小米可穿戴SDK
implementation(files("libs/xms-wearable-lib_1.4_release.aar"))
// Compose相关依赖
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Material Icons - 现代化图标
implementation("androidx.compose.material:material-icons-extended:1.6.0")
// 相机和二维码扫描
implementation("androidx.camera:camera-camera2:1.3.1")
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")
implementation("com.google.mlkit:barcode-scanning:17.2.0")
// 权限处理
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
// 文件选择器
implementation("androidx.activity:activity-compose:1.8.2")
// 拖拽排序
implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6")
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Gson for JSON
implementation(libs.gson)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 小米可穿戴设备通信权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Android 12+ 蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 文件读取权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- 相机权限 - 二维码扫描 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BandTOTPAPP">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.BandTOTPAPP">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
File diff suppressed because it is too large. Load diff
@@ -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))
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -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()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

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

After

Width:  |  Height:  |  Size: 3.0 KiB

+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_main" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="@dimen/fab_margin"
android:layout_marginBottom="16dp"
app:srcCompat="@android:drawable/ic_dialog_email" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<fragment
android:id="@+id/nav_host_fragment_content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FirstFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<Button
android:id="@+id/button_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/next"
app:layout_constraintBottom_toTopOf="@id/textview_first"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textview_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/lorem_ipsum"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_first" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".SecondFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<Button
android:id="@+id/button_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/previous"
app:layout_constraintBottom_toTopOf="@id/textview_second"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textview_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/lorem_ipsum"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button_second" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
+10
View File
@@ -0,0 +1,10 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="cn.deepfal.band.TOTPauthenticator.MainActivity">
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/action_settings"
app:showAsAction="never" />
</menu>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/FirstFragment">
<fragment
android:id="@+id/FirstFragment"
android:name="cn.deepfal.band.TOTPauthenticator.FirstFragment"
android:label="@string/first_fragment_label"
tools:layout="@layout/fragment_first">
<action
android:id="@+id/action_FirstFragment_to_SecondFragment"
app:destination="@id/SecondFragment" />
</fragment>
<fragment
android:id="@+id/SecondFragment"
android:name="cn.deepfal.band.TOTPauthenticator.SecondFragment"
android:label="@string/second_fragment_label"
tools:layout="@layout/fragment_second">
<action
android:id="@+id/action_SecondFragment_to_FirstFragment"
app:destination="@id/FirstFragment" />
</fragment>
</navigation>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.BandTOTPAPP" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.BandTOTPAPP" parent="Base.Theme.BandTOTPAPP">
<!-- Transparent system bars for edge-to-edge. -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
</style>
</resources>
@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">200dp</dimen>
</resources>
@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">16dp</dimen>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<resources>
<string name="app_name">BandTOTP-APP</string>
<string name="action_settings">Settings</string>
<!-- Strings used for fragments for navigation -->
<string name="first_fragment_label">First Fragment</string>
<string name="second_fragment_label">Second Fragment</string>
<string name="next">Next</string>
<string name="previous">Previous</string>
<string name="lorem_ipsum">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris
volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus
dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad
litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend
diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a,
ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum tellus.\n\n
Suspendisse interdum ornare ante. Aliquam nec cursus lorem. Morbi id magna felis. Vivamus
egestas, est a condimentum egestas, turpis nisl iaculis ipsum, in dictum tellus dolor sed
neque. Morbi tellus erat, dapibus ut sem a, iaculis tincidunt dui. Interdum et malesuada
fames ac ante ipsum primis in faucibus. Curabitur et eros porttitor, ultricies urna vitae,
molestie nibh. Phasellus at commodo eros, non aliquet metus. Sed maximus nisl nec dolor
bibendum, vel congue leo egestas.\n\n
Sed interdum tortor nibh, in sagittis risus mollis quis. Curabitur mi odio, condimentum sit
amet auctor at, mollis non turpis. Nullam pretium libero vestibulum, finibus orci vel,
molestie quam. Fusce blandit tincidunt nulla, quis sollicitudin libero facilisis et. Integer
interdum nunc ligula, et fermentum metus hendrerit id. Vestibulum lectus felis, dictum at
lacinia sit amet, tristique id quam. Cras eu consequat dui. Suspendisse sodales nunc ligula,
in lobortis sem porta sed. Integer id ultrices magna, in luctus elit. Sed a pellentesque
est.\n\n
Aenean nunc velit, lacinia sed dolor sed, ultrices viverra nulla. Etiam a venenatis nibh.
Morbi laoreet, tortor sed facilisis varius, nibh orci rhoncus nulla, id elementum leo dui
non lorem. Nam mollis ipsum quis auctor varius. Quisque elementum eu libero sed commodo. In
eros nisl, imperdiet vel imperdiet et, scelerisque a mauris. Pellentesque varius ex nunc,
quis imperdiet eros placerat ac. Duis finibus orci et est auctor tincidunt. Sed non viverra
ipsum. Nunc quis augue egestas, cursus lorem at, molestie sem. Morbi a consectetur ipsum, a
placerat diam. Etiam vulputate dignissim convallis. Integer faucibus mauris sit amet finibus
convallis.\n\n
Phasellus in aliquet mi. Pellentesque habitant morbi tristique senectus et netus et
malesuada fames ac turpis egestas. In volutpat arcu ut felis sagittis, in finibus massa
gravida. Pellentesque id tellus orci. Integer dictum, lorem sed efficitur ullamcorper,
libero justo consectetur ipsum, in mollis nisl ex sed nisl. Donec maximus ullamcorper
sodales. Praesent bibendum rhoncus tellus nec feugiat. In a ornare nulla. Donec rhoncus
libero vel nunc consequat, quis tincidunt nisl eleifend. Cras bibendum enim a justo luctus
vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim.
</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.BandTOTPAPP" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.BandTOTPAPP" parent="Base.Theme.BandTOTPAPP" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -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)
}
}