diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a9d74aa..0a03017 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -4,8 +4,9 @@ import java.time.Instant
plugins {
alias(libs.plugins.self.application)
alias(libs.plugins.self.compose)
- alias(libs.plugins.self.room)
+ alias(libs.plugins.androidx.room)
alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.ksp)
}
val baseVersionName = "0.2.5"
@@ -87,9 +88,13 @@ androidComponents.onVariants { variant ->
}
}
+room3 {
+ schemaDirectory("$projectDir/schemas")
+}
+
dependencies {
+ implementation(project(":brand"))
implementation(project(":core"))
- implementation(project(":logo"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
@@ -106,10 +111,13 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.room.runtime)
+ ksp(libs.androidx.room.compiler)
implementation(libs.koin.android)
implementation(libs.koin.compose)
implementation(libs.koin.compose.navigation3)
implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
}
diff --git a/app/schemas/dev.sanmer.authenticator.database.AppDatabase/3.json b/app/schemas/dev.sanmer.authenticator.database.AppDatabase/3.json
new file mode 100644
index 0000000..ed6a8ec
--- /dev/null
+++ b/app/schemas/dev.sanmer.authenticator.database.AppDatabase/3.json
@@ -0,0 +1,87 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "e6844b57f2d0ab509a4ff97ac517ab00",
+ "entities": [
+ {
+ "tableName": "Auth",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY NOT NULL, `name` TEXT NOT NULL, `issuer` TEXT NOT NULL, `type` TEXT NOT NULL, `trashedAt` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "issuer",
+ "columnName": "issuer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trashedAt",
+ "columnName": "trashedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ],
+ "algorithm": "ROWID"
+ }
+ },
+ {
+ "tableName": "AuthProperty",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authId` INTEGER NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`authId`, `key`))",
+ "fields": [
+ {
+ "fieldPath": "authId",
+ "columnName": "authId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "authId",
+ "key"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e6844b57f2d0ab509a4ff97ac517ab00')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/debug/res/drawable/launcher_foreground.xml b/app/src/debug/res/drawable/launcher_foreground.xml
index d5df5ec..b9aa196 100644
--- a/app/src/debug/res/drawable/launcher_foreground.xml
+++ b/app/src/debug/res/drawable/launcher_foreground.xml
@@ -9,58 +9,10 @@
android:translateX="6.72"
android:translateY="6.72">
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/app/src/debug/res/drawable/launcher_splash.xml b/app/src/debug/res/drawable/launcher_splash.xml
deleted file mode 100644
index 5c33747..0000000
--- a/app/src/debug/res/drawable/launcher_splash.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 09e2eac..340d35a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,39 +20,29 @@
-
+
+
-
-
+
+
-
+
+
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/App.kt b/app/src/main/kotlin/dev/sanmer/authenticator/App.kt
index e05e258..875ae49 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/App.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/App.kt
@@ -6,8 +6,8 @@ import androidx.camera.camera2.Camera2Config
import androidx.camera.core.CameraXConfig
import dev.sanmer.authenticator.di.DataStore
import dev.sanmer.authenticator.di.Database
+import dev.sanmer.authenticator.di.Navigation
import dev.sanmer.authenticator.di.Repositories
-import dev.sanmer.authenticator.ui.di.Navigation
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt b/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt
index ca78be4..6fe3d2e 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt
@@ -1,5 +1,24 @@
package dev.sanmer.authenticator
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.format.char
+import kotlin.time.Instant
+
object Const {
const val GITHUB_URL = "https://github.com/SanmerApps/Authenticator"
+
+ val INSTANT_ZERO = Instant.fromEpochSeconds(0, 0)
+ inline val Instant.isZero get() = this == INSTANT_ZERO
+
+ val TIME_DISPLAY = LocalTime.Format {
+ hour(); char(':'); minute(); char(':'); second()
+ }
+
+ val DATETIME_DISPLAY = LocalDateTime.Format {
+ date(LocalDate.Formats.ISO)
+ char(' ')
+ time(TIME_DISPLAY)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/Logo.kt b/app/src/main/kotlin/dev/sanmer/authenticator/Logo.kt
new file mode 100644
index 0000000..3674153
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/Logo.kt
@@ -0,0 +1,119 @@
+package dev.sanmer.authenticator
+
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+val Aliyun = ImageVector.Builder(
+ name = "Aliyun",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 1608f,
+ viewportHeight = 1024f
+).apply {
+ // 中间横条
+ path(
+ fill = SolidColor(Color(0xFFFF6A00))
+ ) {
+ moveTo(537.6f, 445.44f)
+ lineTo(1075.2f, 445.44f)
+ lineTo(1075.2f, 568.32f)
+ lineTo(537.6f, 568.32f)
+ close()
+ }
+
+ // 阿里云主体图形
+ path(
+ fill = SolidColor(Color(0xFFFF6A00))
+ ) {
+ moveTo(1341.44f, 5.12f)
+ lineTo(988.16f, 5.12f)
+ lineTo(1075.2f, 128f)
+ lineTo(1331.2f, 209.92f)
+
+ curveTo(
+ 1377.28f, 225.28f,
+ 1408f, 271.36f,
+ 1408f, 317.44f
+ )
+
+ lineTo(1408f, 706.56f)
+
+ curveTo(
+ 1408f, 752.64f,
+ 1377.28f, 798.72f,
+ 1331.2f, 814.08f
+ )
+
+ lineTo(1075.2f, 896f)
+ lineTo(988.16f, 1018.88f)
+ lineTo(1341.44f, 1018.88f)
+
+ curveTo(
+ 1489.92f, 1018.88f,
+ 1607.68f, 901.12f,
+ 1607.68f, 752.64f
+ )
+
+ lineTo(1607.68f, 276.48f)
+
+ curveTo(
+ 1607.68f, 128f,
+ 1489.92f, 5.12f,
+ 1341.44f, 5.12f
+ )
+
+ moveTo(276.48f, 814.08f)
+ curveTo(
+ 230.4f, 798.72f,
+ 199.68f, 752.64f,
+ 199.68f, 706.56f
+ )
+
+ lineTo(199.68f, 317.44f)
+
+ curveTo(
+ 199.68f, 271.36f,
+ 230.4f, 225.28f,
+ 276.48f, 209.92f
+ )
+
+ lineTo(532.48f, 128f)
+ lineTo(619.52f, 5.12f)
+ lineTo(266.24f, 5.12f)
+
+ curveTo(
+ 117.76f, 5.12f,
+ 0f, 128f,
+ 0f, 276.48f
+ )
+
+ lineTo(0f, 747.52f)
+
+ curveTo(
+ 0f, 896f,
+ 117.76f, 1013.76f,
+ 266.24f, 1013.76f
+ )
+
+ lineTo(619.52f, 1013.76f)
+ lineTo(532.48f, 890.88f)
+ lineTo(276.48f, 814.08f)
+
+ close()
+ }
+}.build()
+
+@Preview
+@Composable
+private fun Aliyun() {
+ Icon(
+ imageVector = Aliyun,
+ contentDescription = null
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/compat/BuildCompat.kt b/app/src/main/kotlin/dev/sanmer/authenticator/compat/BuildCompat.kt
new file mode 100644
index 0000000..56ecc46
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/compat/BuildCompat.kt
@@ -0,0 +1,12 @@
+package dev.sanmer.authenticator.compat
+
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+
+object BuildCompat {
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
+ val atLeastT inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+ val atLeastS inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt b/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt
index 8042c9d..799c708 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt
@@ -1,11 +1,12 @@
package dev.sanmer.authenticator.compat
+import android.app.Activity
import android.content.Context
+import android.content.ContextWrapper
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
-import dev.sanmer.authenticator.ktx.findActivity
import java.util.UUID
object PermissionCompat {
@@ -35,6 +36,15 @@ object PermissionCompat {
permissions = listOf(permission)
).allGranted
+ private fun Context.findActivity(): Activity? {
+ var context = this
+ while (context is ContextWrapper) {
+ if (context is Activity) return context
+ context = context.baseContext
+ }
+ return null
+ }
+
fun requestPermissions(
context: Context,
permissions: List,
diff --git a/app/src/main/kotlin/dev/sanmer/crypto/BiometricKey.kt b/app/src/main/kotlin/dev/sanmer/authenticator/crypto/BiometricKey.kt
similarity index 75%
rename from app/src/main/kotlin/dev/sanmer/crypto/BiometricKey.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/crypto/BiometricKey.kt
index ca40baa..e79aba1 100644
--- a/app/src/main/kotlin/dev/sanmer/crypto/BiometricKey.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/crypto/BiometricKey.kt
@@ -1,8 +1,8 @@
-package dev.sanmer.crypto
+package dev.sanmer.authenticator.crypto
-import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
+import androidx.activity.ComponentActivity
import androidx.biometric.AuthenticationRequest.Biometric
import androidx.biometric.AuthenticationResult
import androidx.biometric.AuthenticationResultLauncher
@@ -10,31 +10,28 @@ import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators
import androidx.biometric.BiometricPrompt
import androidx.biometric.registerForAuthenticationResult
-import androidx.fragment.app.FragmentActivity
+import dev.sanmer.auth.crypto.Crypto
+import dev.sanmer.auth.crypto.SessionKey
import dev.sanmer.authenticator.BuildConfig
import dev.sanmer.authenticator.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withContext
+import java.security.Key
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
-import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
-class BiometricKey(context: Context) : Crypto {
- private val key by lazy { getSecretKey() }
-
- private val builder by lazy {
- Biometric.Builder(
- title = context.getString(R.string.biometric_title)
- ).apply {
- setIsConfirmationRequired(true)
- }
- }
+class BiometricKey private constructor(
+ val key: Key
+) : Crypto {
+ constructor() : this(getSecretKey())
private suspend fun Cipher.authenticated() = withContext(Dispatchers.Main) {
val launcher = checkNotNull(launcher) { "BiometricKey uninitialized" }
+ val builder = checkNotNull(builder) { "BiometricKey uninitialized" }
+
val biometric = builder.setMinStrength(
Biometric.Strength.Class3(BiometricPrompt.CryptoObject(this@authenticated))
).build()
@@ -67,15 +64,26 @@ class BiometricKey(context: Context) : Crypto {
}
companion object Default {
- fun canAuthenticate(context: Context) = BiometricManager.from(context)
- .canAuthenticate(Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
-
private var launcher: AuthenticationResultLauncher? = null
+ private var builder: Biometric.Builder? = null
private val channel = Channel()
- fun init(activity: FragmentActivity) {
+
+ val isInitialized get() = launcher != null && builder != null
+
+ fun init(activity: ComponentActivity) {
+ if (BiometricManager.from(activity)
+ .canAuthenticate(Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS
+ ) {
+ return
+ }
launcher = activity.registerForAuthenticationResult { result ->
channel.trySend(result)
}
+ builder = Biometric.Builder(
+ title = activity.getString(R.string.setting_biometric_verify)
+ ).apply {
+ setIsConfirmationRequired(true)
+ }
}
private fun generateSecretKey(keyGenParameterSpec: KeyGenParameterSpec) =
@@ -88,7 +96,7 @@ class BiometricKey(context: Context) : Crypto {
.getKey(BuildConfig.APPLICATION_ID, null)
.let { requireNotNull(it) { "Expect key(alias=${BuildConfig.APPLICATION_ID})" } }
- fun new(): SecretKey = generateSecretKey(
+ fun new() = generateSecretKey(
KeyGenParameterSpec.Builder(
BuildConfig.APPLICATION_ID,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
@@ -99,16 +107,15 @@ class BiometricKey(context: Context) : Crypto {
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setInvalidatedByBiometricEnrollment(true)
.build()
- )
+ ).let(::BiometricKey)
- suspend fun SessionKey.getKeyEncryptedByBiometric(context: Context) =
- BiometricKey(context).encrypt(key.encoded)
+ suspend fun SessionKey.getKeyEncryptedByBiometric() =
+ new().encrypt(key.encoded)
suspend fun SessionKey.Default.decryptKeyByBiometric(
- key: ByteArray,
- context: Context
+ key: ByteArray
) = SessionKey(
- key = BiometricKey(context).decrypt(key).toSecretKey()
+ key = BiometricKey().decrypt(key).toSecretKey()
)
}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt
index 4fe00f6..f988a0c 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt
@@ -1,44 +1,65 @@
package dev.sanmer.authenticator.database
import android.content.Context
-import androidx.room.Database
-import androidx.room.Room
-import androidx.room.RoomDatabase
-import androidx.room.TypeConverter
-import androidx.room.TypeConverters
-import androidx.room.migration.Migration
-import dev.sanmer.authenticator.database.dao.TotpDao
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.otp.Otp
-
-@Database(version = 2, entities = [TotpEntity::class])
-@TypeConverters(AppDatabase.Default::class)
+import androidx.room3.ColumnTypeConverter
+import androidx.room3.ColumnTypeConverters
+import androidx.room3.Database
+import androidx.room3.Room
+import androidx.room3.RoomDatabase
+import androidx.room3.migration.Migration
+import androidx.sqlite.execSQL
+import dev.sanmer.authenticator.database.dao.AuthDao
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.database.model.AuthProperty
+import kotlin.time.Instant
+
+@Database(
+ entities = [
+ Auth::class,
+ AuthProperty::class
+ ],
+ version = 3
+)
+@ColumnTypeConverters(AppDatabase.Default::class)
abstract class AppDatabase : RoomDatabase() {
- abstract fun totp(): TotpDao
+ abstract fun auth(): AuthDao
companion object Default {
- @TypeConverter
- fun nameToHash(value: String) = Otp.Hash.valueOf(value)
+ @ColumnTypeConverter
+ fun fromEpochMilliseconds(value: Long) = Instant.fromEpochMilliseconds(value)
- @TypeConverter
- fun hashToName(value: Otp.Hash) = value.name
+ @ColumnTypeConverter
+ fun toEpochMilliseconds(value: Instant) = value.toEpochMilliseconds()
fun build(context: Context) =
Room.databaseBuilder(
context = context,
name = "auth"
).addMigrations(
- MIGRATION_1_2
+ MIGRATION_1_2,
+ MIGRATION_2_3
).build()
- private val MIGRATION_1_2 = Migration(1, 2) {
- it.execSQL("DROP TABLE trash")
- it.execSQL("DROP TABLE hotp")
+ private val MIGRATION_1_2 = Migration(1, 2) { connection ->
+ connection.execSQL("DROP TABLE trash")
+ connection.execSQL("DROP TABLE hotp")
+
+ connection.execSQL("CREATE TABLE totp_new (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, deletedAt INTEGER NOT NULL, issuer TEXT NOT NULL, name TEXT NOT NULL, secret TEXT NOT NULL, hash TEXT NOT NULL, digits INTEGER NOT NULL, period INTEGER NOT NULL)")
+ connection.execSQL("INSERT INTO totp_new (deletedAt, issuer, name, secret, hash, digits, period) SELECT 0, issuer, name, secret, hash, digits, period FROM totp")
+ connection.execSQL("DROP TABLE totp")
+ connection.execSQL("ALTER TABLE totp_new RENAME TO totp")
+ }
+
+ private val MIGRATION_2_3 = Migration(2, 3) { connection ->
+ connection.execSQL("CREATE TABLE Auth (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, issuer TEXT NOT NULL, type TEXT NOT NULL, trashedAt INTEGER NOT NULL)")
+ connection.execSQL("INSERT INTO Auth (id, name, issuer, type, trashedAt) SELECT id, name, issuer, 'TOTP', deletedAt FROM totp")
- it.execSQL("CREATE TABLE IF NOT EXISTS totp_2 (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, deletedAt INTEGER NOT NULL, issuer TEXT NOT NULL, name TEXT NOT NULL, secret TEXT NOT NULL, hash TEXT NOT NULL, digits INTEGER NOT NULL, period INTEGER NOT NULL)")
- it.execSQL("INSERT INTO totp_2 (deletedAt, issuer, name, secret, hash, digits, period) SELECT 0, issuer, name, secret, hash, digits, period FROM totp")
- it.execSQL("DROP TABLE totp")
- it.execSQL("ALTER TABLE totp_2 RENAME TO totp")
+ connection.execSQL("CREATE TABLE AuthProperty (authId INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(authId, key))")
+ connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Secret', secret FROM totp")
+ connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Hash', hash FROM totp")
+ connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Digits', CAST(digits AS TEXT) FROM totp")
+ connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Period', CAST(period AS TEXT) FROM totp")
+ connection.execSQL("DROP TABLE totp")
}
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/AuthDao.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/AuthDao.kt
new file mode 100644
index 0000000..3fcc5d7
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/AuthDao.kt
@@ -0,0 +1,71 @@
+package dev.sanmer.authenticator.database.dao
+
+import androidx.room3.Dao
+import androidx.room3.Query
+import androidx.room3.Transaction
+import androidx.room3.Upsert
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.database.model.AuthProperty
+import kotlinx.coroutines.flow.Flow
+import kotlin.time.Instant
+
+@Dao
+interface AuthDao {
+ @Transaction
+ @Query("SELECT * FROM Auth")
+ suspend fun getAllAuthProperties(): List
+
+ @Transaction
+ @Query("SELECT * FROM Auth WHERE trashedAt = 0")
+ suspend fun getUntrashedAuthProperties(): List
+
+ @Query("SELECT * FROM AuthProperty WHERE key IN (:key)")
+ suspend fun getProperties(vararg key: AuthProperty.Key): List
+
+ @Transaction
+ @Query("SELECT * FROM Auth")
+ fun getAllAuthPropertiesAsFlow(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM Auth WHERE trashedAt = 0")
+ fun getUntrashedAuthPropertiesAsFlow(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM Auth WHERE trashedAt != 0")
+ fun getTrashedAuthAsFlow(): Flow>
+
+ @Query("SELECT COUNT(*) FROM Auth WHERE trashedAt != 0")
+ fun getTrashedCountAsFlow(): Flow
+
+ @Transaction
+ @Query("SELECT * FROM Auth WHERE id = :id")
+ fun getAuthPropertiesAsFlow(id: Long): Flow
+
+ @Upsert
+ suspend fun upsertAuth(auth: Auth): Long
+
+ @Query("UPDATE Auth SET trashedAt = :trashedAt WHERE id = :id")
+ suspend fun trashAuth(id: Long, trashedAt: Instant)
+
+ @Query("DELETE FROM Auth WHERE id = :id")
+ suspend fun deleteAuth(id: Long)
+
+ @Upsert
+ suspend fun upsertProperties(properties: List)
+
+ @Query("DELETE FROM AuthProperty WHERE authId = :authId")
+ suspend fun deleteProperties(authId: Long)
+
+ @Transaction
+ suspend fun upsert(auth: Auth, properties: List) {
+ val authId = upsertAuth(auth).coerceAtLeast(auth.id)
+ upsertProperties(properties.map { it.copy(authId = authId) })
+ }
+
+ @Transaction
+ suspend fun delete(authId: Long) {
+ deleteAuth(authId)
+ deleteProperties(authId)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt
deleted file mode 100644
index 8e31ce3..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package dev.sanmer.authenticator.database.dao
-
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.Query
-import androidx.room.Update
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface TotpDao {
- @Query("SELECT * FROM totp WHERE deletedAt != 0")
- suspend fun getAllTrashed(): List
-
- @Query("SELECT * FROM totp WHERE deletedAt = 0")
- fun getAllEnabledAsFlow(): Flow>
-
- @Query("SELECT * FROM totp WHERE deletedAt != 0")
- fun getAllTrashedAsFlow(): Flow>
-
- @Query("SELECT * FROM totp WHERE id = :id")
- fun getByIdAsFlow(id: Long): Flow
-
- @Query("SELECT * FROM totp")
- suspend fun getAll(): List
-
- @Insert
- suspend fun insert(entity: TotpEntity)
-
- @Insert
- suspend fun insert(entities: List)
-
- @Update
- suspend fun update(entity: TotpEntity)
-
- @Update
- suspend fun update(entities: List)
-
- @Delete
- suspend fun delete(entity: TotpEntity)
-
- @Delete
- suspend fun delete(entities: List)
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt
deleted file mode 100644
index 519fca2..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package dev.sanmer.authenticator.database.entity
-
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import dev.sanmer.otp.Otp
-import kotlin.time.Duration.Companion.days
-import kotlin.time.Duration.Companion.milliseconds
-
-@Entity(tableName = "totp")
-data class TotpEntity(
- @PrimaryKey(autoGenerate = true)
- val id: Long = 0,
- val deletedAt: Long = 0,
- val issuer: String,
- val name: String,
- val secret: String,
- val hash: Otp.Hash,
- val digits: Int,
- val period: Long
-) {
- val lifetime by lazy { (System.currentTimeMillis() - deletedAt).milliseconds }
- val displayName by lazy { "$issuer ($name)" }
-
- fun toTrash() = copy(deletedAt = System.currentTimeMillis())
-
- companion object Default {
- val LIFETIME_MAX = 7.days
- }
-}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/model/Auth.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/Auth.kt
new file mode 100644
index 0000000..c3836ed
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/Auth.kt
@@ -0,0 +1,23 @@
+package dev.sanmer.authenticator.database.model
+
+import androidx.room3.Entity
+import androidx.room3.PrimaryKey
+import dev.sanmer.authenticator.Const.INSTANT_ZERO
+import kotlin.time.Instant
+
+@Entity
+data class Auth(
+ @PrimaryKey(
+ autoGenerate = true,
+ algorithm = PrimaryKey.Algorithm.ROWID
+ )
+ val id: Long = 0,
+ val name: String,
+ val issuer: String,
+ val type: Type,
+ val trashedAt: Instant = INSTANT_ZERO
+) {
+ enum class Type {
+ TOTP
+ }
+}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperties.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperties.kt
new file mode 100644
index 0000000..42630f3
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperties.kt
@@ -0,0 +1,41 @@
+package dev.sanmer.authenticator.database.model
+
+import androidx.room3.Embedded
+import androidx.room3.Relation
+
+data class AuthProperties(
+ @Embedded
+ val auth: Auth,
+ @Relation(parentColumns = ["id"], entityColumns = ["authId"])
+ val properties: List,
+) {
+ private val propertyMap by lazy { properties.associateBy { it.key } }
+
+ fun getValue(key: AuthProperty.Key, transform: (String) -> T) =
+ transform(requireNotNull(propertyMap[key]) { "Expect $key" }.value)
+
+ fun getValue(key: AuthProperty.Key, default: T, transform: (String) -> T) =
+ propertyMap[key]?.let { transform(it.value) } ?: default
+
+ inline fun protectValue(transform: (String) -> String) = copy(
+ auth = auth,
+ properties = properties.map {
+ when (it.key) {
+ AuthProperty.Key.Secret -> it.copy(value = transform(it.value))
+ else -> it
+ }
+ }
+ )
+
+ companion object Default {
+ fun build(
+ auth: Auth,
+ properties: List>
+ ) = AuthProperties(
+ auth = auth,
+ properties = properties.map { (key, value) ->
+ AuthProperty(authId = auth.id, key = key, value = value)
+ }
+ )
+ }
+}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperty.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperty.kt
new file mode 100644
index 0000000..7f8ff1f
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperty.kt
@@ -0,0 +1,17 @@
+package dev.sanmer.authenticator.database.model
+
+import androidx.room3.Entity
+
+@Entity(primaryKeys = ["authId", "key"])
+data class AuthProperty(
+ val authId: Long = 0,
+ val key: Key,
+ val value: String
+) {
+ enum class Key {
+ Secret,
+ Hash,
+ Digits,
+ Period
+ }
+}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt
index 8f0f734..81fdace 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt
@@ -3,21 +3,33 @@ package dev.sanmer.authenticator.datastore
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import dev.sanmer.authenticator.datastore.model.Preference
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
+import kotlinx.serialization.decodeFromByteArray
+import kotlinx.serialization.encodeToByteArray
+import kotlinx.serialization.protobuf.ProtoBuf
import java.io.InputStream
import java.io.OutputStream
class PreferenceSerializer : Serializer {
override val defaultValue = Preference()
- override suspend fun readFrom(input: InputStream) =
+ override suspend fun readFrom(input: InputStream) = withContext(Dispatchers.IO) {
try {
- Preference.decodeFromStream(input)
+ val bytes = input.use { it.readBytes() }
+ ProtoBuf.decodeFromByteArray(bytes)
} catch (e: SerializationException) {
throw CorruptionException("Failed to read proto", e)
}
-
- override suspend fun writeTo(t: Preference, output: OutputStream) {
- t.encodeToStream(output)
}
+
+ override suspend fun writeTo(t: Preference, output: OutputStream) =
+ withContext(Dispatchers.IO) {
+ output.use {
+ it.write(
+ ProtoBuf.encodeToByteArray(t)
+ )
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt
index 4a3371d..ce75620 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt
@@ -1,12 +1,8 @@
package dev.sanmer.authenticator.datastore.model
+import dev.sanmer.auth.ntp.NtpServer
import kotlinx.serialization.Serializable
-import kotlinx.serialization.decodeFromByteArray
-import kotlinx.serialization.encodeToByteArray
-import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.serialization.protobuf.ProtoNumber
-import java.io.InputStream
-import java.io.OutputStream
@Serializable
data class Preference(
@@ -17,17 +13,22 @@ data class Preference(
@ProtoNumber(3)
val ntpAddress: String = "",
@ProtoNumber(4)
- val ntp: Ntp = Ntp.Cloudflare
+ val ntp: Ntp = Ntp.Cloudflare,
+ @ProtoNumber(5)
+ val secureWindow: Boolean = false
) {
val isEncrypted inline get() = keyEncryptedByPassword.isNotEmpty()
val isBiometric inline get() = keyEncryptedByBiometric.isNotEmpty()
- fun encodeToStream(output: OutputStream) = output.write(
- ProtoBuf.encodeToByteArray(this)
- )
-
- companion object Default {
- fun decodeFromStream(input: InputStream): Preference =
- ProtoBuf.decodeFromByteArray(input.readBytes())
+ fun ntpServer() = when (ntp) {
+ Ntp.Custom -> NtpServer.Custom(ntpAddress)
+ Ntp.Alibaba -> NtpServer.Alibaba
+ Ntp.Apple -> NtpServer.Apple
+ Ntp.Amazon -> NtpServer.Amazon
+ Ntp.Cloudflare -> NtpServer.Cloudflare
+ Ntp.Google -> NtpServer.Google
+ Ntp.Meta -> NtpServer.Meta
+ Ntp.Microsoft -> NtpServer.Microsoft
+ Ntp.Tencent -> NtpServer.Tencent
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt
index 81388c5..f4f47d4 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt
@@ -7,7 +7,6 @@ import androidx.datastore.core.Serializer
import androidx.datastore.dataStoreFile
import dev.sanmer.authenticator.datastore.PreferenceSerializer
import dev.sanmer.authenticator.datastore.model.Preference
-import dev.sanmer.authenticator.ktx.deviceProtectedContext
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
@@ -19,7 +18,7 @@ val DataStore = module {
DataStoreFactory.create(
serializer = get()
) {
- get().deviceProtectedContext.dataStoreFile("preference.pb")
+ get().createDeviceProtectedStorageContext().dataStoreFile("preference.pb")
}
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt
index 6846984..e3469b6 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt
@@ -2,15 +2,14 @@ package dev.sanmer.authenticator.di
import android.content.Context
import dev.sanmer.authenticator.database.AppDatabase
-import dev.sanmer.authenticator.ktx.deviceProtectedContext
import org.koin.dsl.module
val Database = module {
single {
- AppDatabase.build(get().deviceProtectedContext)
+ AppDatabase.build(get().createDeviceProtectedStorageContext())
}
single {
- get().totp()
+ get().auth()
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/NavigationModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/NavigationModule.kt
similarity index 54%
rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/di/NavigationModule.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/di/NavigationModule.kt
index 7c8445c..ce1e5f4 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/NavigationModule.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/NavigationModule.kt
@@ -1,25 +1,40 @@
-package dev.sanmer.authenticator.ui.di
+package dev.sanmer.authenticator.di
import androidx.navigation3.runtime.NavBackStack
-import dev.sanmer.authenticator.ui.screens.Screen
-import dev.sanmer.authenticator.ui.screens.edit.EditScreen
-import dev.sanmer.authenticator.ui.screens.encode.EncodeScreen
-import dev.sanmer.authenticator.ui.screens.home.HomeScreen
-import dev.sanmer.authenticator.ui.screens.ntp.NtpScreen
-import dev.sanmer.authenticator.ui.screens.scan.ScanScreen
-import dev.sanmer.authenticator.ui.screens.security.SecurityScreen
-import dev.sanmer.authenticator.ui.screens.settings.SettingsScreen
-import dev.sanmer.authenticator.ui.screens.trash.TrashScreen
+import dev.sanmer.authenticator.ui.screen.Screen
+import dev.sanmer.authenticator.ui.screen.brand.BrandScreen
+import dev.sanmer.authenticator.ui.screen.edit.EditScreen
+import dev.sanmer.authenticator.ui.screen.edit.EditViewModel
+import dev.sanmer.authenticator.ui.screen.export.ExportScreen
+import dev.sanmer.authenticator.ui.screen.export.ExportViewModel
+import dev.sanmer.authenticator.ui.screen.home.HomeScreen
+import dev.sanmer.authenticator.ui.screen.home.HomeViewModel
+import dev.sanmer.authenticator.ui.screen.main.MainViewModel
+import dev.sanmer.authenticator.ui.screen.ntp.NtpScreen
+import dev.sanmer.authenticator.ui.screen.ntp.NtpViewModel
+import dev.sanmer.authenticator.ui.screen.scan.ScanViewModel
+import dev.sanmer.authenticator.ui.screen.setting.SettingScreen
+import dev.sanmer.authenticator.ui.screen.setting.SettingViewModel
+import dev.sanmer.authenticator.ui.screen.trash.TrashScreen
+import dev.sanmer.authenticator.ui.screen.trash.TrashViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.scope.dsl.activityRetainedScope
import org.koin.core.annotation.KoinExperimentalAPI
+import org.koin.core.module.dsl.viewModelOf
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
import org.koin.dsl.navigation3.navigation
@OptIn(KoinExperimentalAPI::class)
val Navigation = module {
- includes(ViewModels)
+ viewModelOf(::MainViewModel)
+ viewModelOf(::HomeViewModel)
+ viewModelOf(::EditViewModel)
+ viewModelOf(::ScanViewModel)
+ viewModelOf(::SettingViewModel)
+ viewModelOf(::TrashViewModel)
+ viewModelOf(::NtpViewModel)
+ viewModelOf(::ExportViewModel)
activityRetainedScope {
scoped { NavBackStack(Screen.Home) }
@@ -32,36 +47,28 @@ val Navigation = module {
)
}
- navigation {
- val backStack = get>()
- SettingsScreen(
- viewModel = koinViewModel(),
- goTo = backStack::add,
- goBack = backStack::removeLastOrNull
- )
- }
-
navigation {
val backStack = get>()
EditScreen(
- viewModel = koinViewModel { parametersOf(it.id, it.uri) },
+ viewModel = koinViewModel { parametersOf(it.authId, it.otpUri) },
goBack = backStack::removeLastOrNull
)
}
- navigation {
+ navigation {
val backStack = get>()
- ScanScreen(
+ SettingScreen(
viewModel = koinViewModel(),
goTo = backStack::add,
goBack = backStack::removeLastOrNull
)
}
- navigation {
+ navigation {
val backStack = get>()
- EncodeScreen(
+ TrashScreen(
viewModel = koinViewModel(),
+ goTo = backStack::add,
goBack = backStack::removeLastOrNull
)
}
@@ -74,17 +81,17 @@ val Navigation = module {
)
}
- navigation {
+ navigation {
val backStack = get>()
- SecurityScreen(
+ ExportScreen(
+ viewModel = koinViewModel(),
goBack = backStack::removeLastOrNull
)
}
- navigation {
+ navigation {
val backStack = get>()
- TrashScreen(
- viewModel = koinViewModel(),
+ BrandScreen(
goBack = backStack::removeLastOrNull
)
}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt
index 8f426e1..9baf5f2 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt
@@ -2,6 +2,8 @@ package dev.sanmer.authenticator.di
import dev.sanmer.authenticator.repository.DbRepository
import dev.sanmer.authenticator.repository.DbRepositoryImpl
+import dev.sanmer.authenticator.repository.OtpRepository
+import dev.sanmer.authenticator.repository.OtpRepositoryImpl
import dev.sanmer.authenticator.repository.PreferenceRepository
import dev.sanmer.authenticator.repository.PreferenceRepositoryImpl
import dev.sanmer.authenticator.repository.TimeRepository
@@ -14,4 +16,5 @@ val Repositories = module {
singleOf(::PreferenceRepositoryImpl) { bind() }
singleOf(::DbRepositoryImpl) { bind() }
singleOf(::TimeRepositoryImpl) { bind() }
+ singleOf(::OtpRepositoryImpl) { bind() }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt
deleted file mode 100644
index df85cfa..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package dev.sanmer.authenticator.ktx
-
-import android.app.Activity
-import android.content.Context
-import android.content.ContextWrapper
-import android.content.Intent
-import androidx.core.app.LocaleManagerCompat
-import java.util.Locale
-
-val Context.applicationLocale: Locale?
- inline get() = LocaleManagerCompat.getApplicationLocales(applicationContext)
- .toList().firstOrNull()
-
-val Context.deviceProtectedContext: Context
- inline get() = createDeviceProtectedStorageContext()
-
-fun Context.viewUrl(url: String) {
- startActivity(
- Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
- )
-}
-
-fun Context.findActivity(): Activity? {
- var context = this
- while (context is ContextWrapper) {
- if (context is Activity) return context
- context = context.baseContext
- }
-
- return null
-}
-
-fun Context.finishActivity() {
- if (this is Activity) finish()
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/Flow.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/Flow.kt
new file mode 100644
index 0000000..acdda0a
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/Flow.kt
@@ -0,0 +1,15 @@
+package dev.sanmer.authenticator.ktx
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
+
+fun Pair>.stateIn(
+ scope: CoroutineScope,
+ started: SharingStarted,
+) = second.stateIn(
+ scope = scope,
+ started = started,
+ initialValue = first
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt
deleted file mode 100644
index d7dba8d..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package dev.sanmer.authenticator.ktx
-
-import java.util.Locale
-
-val Locale.localizedDisplayName: String
- inline get() = getDisplayName(this)
- .replaceFirstChar {
- if (it.isLowerCase()) {
- it.titlecase(this)
- } else {
- it.toString()
- }
- }
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt
deleted file mode 100644
index 2a85d89..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.sanmer.authenticator.ktx
-
-import androidx.core.os.LocaleListCompat
-import java.util.Locale
-
-fun LocaleListCompat.toList(): List = List(size()) { this[it]!! }
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt
deleted file mode 100644
index 539b068..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package dev.sanmer.authenticator.ktx
-
-import kotlin.math.min
-
-fun String.hidden(limit: Int = 6, mask: Char = '\u2022'): String {
- val showLength = min(length / 2, limit)
- val hiddenLength = min(length - showLength, limit)
- val sb = StringBuilder()
- sb.appendRange(this, 0, showLength)
- repeat(hiddenLength) { sb.append(mask) }
- return sb.toString()
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/LoadData.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/LoadData.kt
new file mode 100644
index 0000000..aceaff6
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/LoadData.kt
@@ -0,0 +1,56 @@
+package dev.sanmer.authenticator.model
+
+sealed class LoadData {
+ data object Pending : LoadData()
+ data object Loading : LoadData()
+ data class Success(val value: V) : LoadData()
+ data class Failure(val error: Throwable) : LoadData()
+
+ val isPending inline get() = this == Pending
+ val isLoading inline get() = this == Loading
+ val isSuccess inline get() = this is Success
+ val isFailure inline get() = this is Failure
+
+ fun getOrThrow(): V {
+ return when (this) {
+ Pending -> throw IllegalStateException("Pending")
+ Loading -> throw IllegalStateException("Loading")
+ is Success -> value
+ is Failure -> throw error
+ }
+ }
+
+ inline fun getOrElse(transform: (V) -> R?, defaultValue: () -> R): R {
+ return (this as? Success)?.value?.let(transform) ?: defaultValue()
+ }
+
+ inline fun onPending(action: () -> Unit): LoadData {
+ if (this == Pending) action()
+ return this
+ }
+
+ inline fun onLoading(action: () -> Unit): LoadData {
+ if (this == Loading) action()
+ return this
+ }
+
+ inline fun onSuccess(action: (V) -> Unit): LoadData {
+ (this as? Success)?.value?.let(action)
+ return this
+ }
+
+ inline fun onFailure(action: (Throwable) -> Unit): LoadData {
+ (this as? Failure)?.error?.let(action)
+ return this
+ }
+
+ companion object Default {
+ inline fun T.loadData(block: T.() -> R): LoadData {
+ return try {
+ Success(block())
+ } catch (e: Throwable) {
+ Failure(e)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/AuthType.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/AuthType.kt
deleted file mode 100644
index 955acd1..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/AuthType.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package dev.sanmer.authenticator.model.auth
-
-enum class AuthType {
- TOTP
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/TotpAuth.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/TotpAuth.kt
deleted file mode 100644
index 1a8d583..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/TotpAuth.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package dev.sanmer.authenticator.model.auth
-
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.encoding.decodeBase32
-import dev.sanmer.otp.Otp
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.map
-
-data class TotpAuth(
- val entity: TotpEntity,
- val epochSeconds: StateFlow
-) {
- val secret by lazy { entity.secret.decodeBase32() }
-
- val otp = epochSeconds.map {
- Otp.otp(
- hash = entity.hash,
- secret = secret,
- digits = entity.digits,
- counter = it / entity.period
- )
- }
-
- fun otp() = Otp.otp(
- hash = entity.hash,
- secret = secret,
- digits = entity.digits,
- counter = epochSeconds.value / entity.period
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/otp/Totp.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/otp/Totp.kt
new file mode 100644
index 0000000..e0ace24
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/otp/Totp.kt
@@ -0,0 +1,61 @@
+package dev.sanmer.authenticator.model.otp
+
+import dev.sanmer.auth.Otp
+import dev.sanmer.auth.OtpUri
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.database.model.AuthProperty
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Totp(
+ val name: String,
+ val issuer: String,
+ val secret: String,
+ val hash: Otp.Hash,
+ val digits: Int,
+ val period: Long
+) {
+ constructor(auth: AuthProperties) : this(
+ name = auth.auth.name,
+ issuer = auth.auth.issuer,
+ secret = auth.getValue(AuthProperty.Key.Secret) { it },
+ hash = auth.getValue(AuthProperty.Key.Hash, Otp.Hash::valueOf),
+ digits = auth.getValue(AuthProperty.Key.Digits, String::toInt),
+ period = auth.getValue(AuthProperty.Key.Period, String::toLong)
+ )
+
+ constructor(uri: OtpUri) : this(
+ name = uri.name,
+ issuer = uri.issuer,
+ secret = uri.secret,
+ hash = uri.algorithm?.let(Otp.Hash::valueOf) ?: Otp.Hash.SHA1,
+ digits = uri.digits ?: 6,
+ period = uri.period ?: 30
+ )
+
+ fun toAuth(id: Long = 0) = AuthProperties.build(
+ auth = Auth(
+ id = id,
+ name = name,
+ issuer = issuer,
+ type = Auth.Type.TOTP
+ ),
+ properties = listOf(
+ AuthProperty.Key.Secret to secret,
+ AuthProperty.Key.Hash to hash.name,
+ AuthProperty.Key.Digits to digits.toString(),
+ AuthProperty.Key.Period to period.toString()
+ )
+ )
+
+ fun toUri() = OtpUri(
+ type = "totp",
+ name = name,
+ issuer = issuer,
+ secret = secret,
+ algorithm = hash.name,
+ digits = digits,
+ period = period
+ ).toUri()
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/AuthSet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/AuthSet.kt
new file mode 100644
index 0000000..b3d00b2
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/AuthSet.kt
@@ -0,0 +1,69 @@
+package dev.sanmer.authenticator.model.serializable
+
+import android.net.Uri
+import dev.sanmer.auth.OtpUri.Default.toOtpUri
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.model.otp.Totp
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import kotlinx.serialization.json.encodeToStream
+import java.io.InputStream
+import java.io.OutputStream
+
+@Serializable
+data class AuthSet(
+ val totp: List
+) {
+ inline fun map(action: (AuthProperties) -> T): List {
+ val list = ArrayList()
+ totp.mapTo(list) { action(it.toAuth()) }
+ return list
+ }
+
+ fun encodeToJson(output: OutputStream) {
+ endpointJson.encodeToStream(this, output)
+ }
+
+ fun encodeToUri(output: OutputStream) {
+ val writer = output.bufferedWriter()
+ totp.forEach { writer.appendLine(it.toUri().toString()) }
+ writer.flush()
+ }
+
+ companion object Default {
+ private val endpointJson = Json {
+ prettyPrint = true
+ }
+
+ fun List.toAuthSet(): AuthSet {
+ val totp = ArrayList()
+ forEach {
+ when (it.auth.type) {
+ Auth.Type.TOTP -> totp.add(Totp(it))
+ }
+ }
+ return AuthSet(
+ totp = totp
+ )
+ }
+
+ fun decodeFromJson(input: InputStream) = endpointJson.decodeFromStream(input)
+
+ fun decodeFromUri(input: InputStream): AuthSet {
+ val totp = ArrayList()
+ input.bufferedReader().forEachLine {
+ runCatching {
+ val uri = Uri.parse(it).toOtpUri()
+ if (uri.type.equals("totp", ignoreCase = true)) {
+ totp.add(Totp(uri))
+ }
+ }
+ }
+ return AuthSet(
+ totp = totp
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/UriSerializer.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/UriSerializer.kt
new file mode 100644
index 0000000..440f81e
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/UriSerializer.kt
@@ -0,0 +1,26 @@
+package dev.sanmer.authenticator.model.serializable
+
+import android.net.Uri
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.descriptors.serialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+object UriSerializer : KSerializer {
+ override val descriptor = SerialDescriptor(
+ serialName = "android.net.Uri",
+ original = serialDescriptor()
+ )
+
+ override fun serialize(
+ encoder: Encoder,
+ value: Uri
+ ) {
+ encoder.encodeString(value.toString())
+ }
+
+ override fun deserialize(decoder: Decoder): Uri {
+ return Uri.parse(decoder.decodeString())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt
deleted file mode 100644
index d3eba46..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package dev.sanmer.authenticator.model.serializer
-
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.decodeFromStream
-import kotlinx.serialization.json.encodeToStream
-import java.io.InputStream
-import java.io.OutputStream
-
-@Serializable
-data class AuthJson(
- val totp: List
-) {
- fun entities() = totp.map(TotpJson::entity)
-
- fun encodeTo(output: OutputStream) {
- endpointJson.encodeToStream(this, output)
- }
-
- companion object Default {
- private val endpointJson = Json {
- prettyPrint = true
- }
-
- const val MIME_TYPE = "application/json"
- const val FILE_NAME = "auth.json"
-
- fun entities(
- totp: List
- ) = AuthJson(
- totp = totp.map(::TotpJson)
- )
-
- fun decodeFrom(input: InputStream): AuthJson =
- Json.decodeFromStream(input)
- }
-}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthUri.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthUri.kt
deleted file mode 100644
index 2c97d76..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthUri.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package dev.sanmer.authenticator.model.serializer
-
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.otp.Otp
-import dev.sanmer.otp.OtpUri
-import dev.sanmer.otp.OtpUri.Default.toOtpUri
-import java.io.InputStream
-import java.io.OutputStream
-
-data class AuthUri(
- val totp: List
-) {
- fun encodeTo(output: OutputStream) {
- val uris = totp.map { it.uri().toString() }
- val content = uris.joinToString(separator = "\n")
- output.write(content.toByteArray())
- }
-
- companion object Default {
- const val MIME_TYPE = "text/plain"
- const val FILE_NAME = "auth.txt"
-
- fun TotpEntity.uri() = OtpUri(
- type = "totp",
- name = name,
- issuer = issuer,
- secret = secret,
- algorithm = hash.name,
- digits = digits,
- period = period
- )
-
- fun OtpUri.toTotpEntity(): TotpEntity {
- require(type.equals("totp", ignoreCase = true)) { "Expected type = totp" }
- val hash = algorithm?.let { Otp.Hash.valueOf(it.uppercase()) }
- return TotpEntity(
- issuer = issuer,
- name = name,
- secret = secret,
- hash = hash ?: Otp.Hash.SHA1,
- digits = digits ?: 6,
- period = period ?: 30,
- )
- }
-
- fun decodeFrom(input: InputStream): AuthUri {
- val totp = input.bufferedReader()
- .lineSequence()
- .mapNotNull {
- runCatching {
- it.toOtpUri().toTotpEntity()
- }.getOrNull()
- }
- return AuthUri(
- totp = totp.toList()
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpJson.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpJson.kt
deleted file mode 100644
index a5bef28..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpJson.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package dev.sanmer.authenticator.model.serializer
-
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.otp.Otp
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class TotpJson(
- val issuer: String,
- val name: String,
- val secret: String,
- val hash: Otp.Hash,
- val digits: Int,
- val period: Long
-) {
- constructor(entity: TotpEntity) : this(
- issuer = entity.issuer,
- name = entity.name,
- secret = entity.secret,
- hash = entity.hash,
- digits = entity.digits,
- period = entity.period
- )
-
- fun entity() = TotpEntity(
- issuer = issuer,
- name = name,
- secret = secret,
- hash = hash,
- digits = digits,
- period = period
- )
-}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt
index 56026f7..6fb3896 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt
@@ -1,23 +1,24 @@
package dev.sanmer.authenticator.repository
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.crypto.Crypto
+import dev.sanmer.auth.crypto.Crypto
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.database.model.AuthProperties
import kotlinx.coroutines.flow.Flow
+import kotlin.time.Instant
interface DbRepository {
- fun setSessionKey(key: Crypto)
+ suspend fun setSessionKey(key: Crypto)
+ suspend fun getSessionKey(): Crypto
suspend fun encrypt(key: Crypto)
- suspend fun decrypt(key: Crypto)
- suspend fun reEncrypt(old: Crypto, new: Crypto)
+ suspend fun decrypt()
- suspend fun getTotpAllDecryptedAsFlow(): Flow>
- suspend fun getTotpDecryptedByIdAsFlow(id: Long): Flow
- suspend fun getTotpAllDecryptedTrashedAsFlow(): Flow>
- suspend fun getTotpAllTrashed(dead: Boolean): List
- suspend fun insertTotp(entity: TotpEntity)
- suspend fun insertTotp(entities: List)
- suspend fun updateTotp(entity: TotpEntity)
- suspend fun updateTotp(entities: List)
- suspend fun deleteTotp(entity: TotpEntity)
- suspend fun deleteTotp(entities: List)
+ suspend fun getAllAuthProperties(): List
+ suspend fun getUntrashedAuthProperties(): List
+ suspend fun getUntrashedAuthPropertiesAsFlow(): Flow>
+ fun getTrashedAuthAsFlow(): Flow>
+ fun getTrashedCountAsFlow(): Flow
+ suspend fun getAuthPropertiesAsFlow(id: Long): Flow
+ suspend fun upsert(auth: AuthProperties)
+ suspend fun trash(authId: Long, trashedAt: Instant)
+ suspend fun delete(authId: Long)
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt
index 5f8d13a..63466ff 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt
@@ -1,99 +1,107 @@
package dev.sanmer.authenticator.repository
-import dev.sanmer.authenticator.database.dao.TotpDao
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.crypto.Crypto
-import dev.sanmer.encoding.decodeBase64
-import dev.sanmer.encoding.encodeBase64
+import dev.sanmer.auth.crypto.Crypto
+import dev.sanmer.authenticator.database.dao.AuthDao
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.database.model.AuthProperty
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import kotlin.time.Instant
class DbRepositoryImpl(
- private val totp: TotpDao
+ private val authDao: AuthDao
) : DbRepository {
- private var key: Crypto = Crypto.None
+ private val mutex = Mutex()
+ private var _key: Crypto = Crypto.Default
- private suspend inline fun String.toEncrypted() = key.encrypt(this)
-
- private suspend inline fun String.toDecrypted() = key.decrypt(this)
+ private suspend inline fun withKey(block: (Crypto) -> T) = mutex.withLock {
+ block(_key)
+ }
- override fun setSessionKey(key: Crypto) {
- this.key = key
+ override suspend fun setSessionKey(key: Crypto) = mutex.withLock {
+ _key = key
}
+ override suspend fun getSessionKey() = withKey { it }
+
override suspend fun encrypt(key: Crypto) = withContext(Dispatchers.IO) {
- totp.update(
- totp.getAll().map {
- val encrypted = key.encrypt(it.secret)
- it.copy(secret = encrypted)
- }
- )
+ val secrets = authDao.getProperties(AuthProperty.Key.Secret)
+ val encrypted = secrets.map {
+ it.copy(value = key.encrypt(it.value))
+ }
+ setSessionKey(key)
+ authDao.upsertProperties(encrypted)
}
- override suspend fun decrypt(key: Crypto) = withContext(Dispatchers.IO) {
- totp.update(
- totp.getAll().map {
- val decrypted = key.decrypt(it.secret)
- it.copy(secret = decrypted)
+ override suspend fun decrypt() = withContext(Dispatchers.IO) {
+ val secrets = authDao.getProperties(AuthProperty.Key.Secret)
+ val decrypted = withKey { current ->
+ secrets.map {
+ it.copy(value = current.decrypt(it.value))
}
- )
+ }
+ setSessionKey(Crypto.Default)
+ authDao.upsertProperties(decrypted)
}
- override suspend fun reEncrypt(old: Crypto, new: Crypto) = withContext(Dispatchers.IO) {
- totp.update(
- totp.getAll().map {
- val decrypted = old.decrypt(it.secret.decodeBase64())
- val encrypted = new.encrypt(decrypted)
- it.copy(secret = encrypted.encodeBase64())
+ override suspend fun getAllAuthProperties() = withContext(Dispatchers.IO) {
+ withKey { current ->
+ authDao.getAllAuthProperties().map { auth ->
+ auth.protectValue { current.decrypt(it) }
}
- )
+ }
}
- override suspend fun getTotpAllDecryptedAsFlow() = totp.getAllEnabledAsFlow()
- .map { entries -> entries.map { it.copy(secret = it.secret.toDecrypted()) } }
-
- override suspend fun getTotpDecryptedByIdAsFlow(id: Long) =
- totp.getByIdAsFlow(id).filterNotNull()
- .map { it.copy(secret = it.secret.toDecrypted()) }
-
- override suspend fun getTotpAllDecryptedTrashedAsFlow() = totp.getAllTrashedAsFlow()
- .map { entries -> entries.map { it.copy(secret = it.secret.toDecrypted()) } }
-
- override suspend fun getTotpAllTrashed(dead: Boolean) = withContext(Dispatchers.IO) {
- totp.getAllTrashed().filter { if (dead) it.lifetime > TotpEntity.LIFETIME_MAX else true }
+ override suspend fun getUntrashedAuthProperties() = withContext(Dispatchers.IO) {
+ withKey { current ->
+ authDao.getUntrashedAuthProperties().map { auth ->
+ auth.protectValue { current.decrypt(it) }
+ }
+ }
}
- override suspend fun insertTotp(entity: TotpEntity) = withContext(Dispatchers.IO) {
- totp.insert(
- entity.copy(secret = entity.secret.toEncrypted())
- )
- }
+ override suspend fun getUntrashedAuthPropertiesAsFlow() =
+ authDao.getUntrashedAuthPropertiesAsFlow()
+ .map { list ->
+ withKey { current ->
+ list.map { auth ->
+ auth.protectValue { current.decrypt(it) }
+ }
+ }
+ }
- override suspend fun insertTotp(entities: List) = withContext(Dispatchers.IO) {
- totp.insert(
- entities.map { it.copy(secret = it.secret.toEncrypted()) }
- )
- }
+ override fun getTrashedAuthAsFlow() = authDao.getTrashedAuthAsFlow()
- override suspend fun updateTotp(entity: TotpEntity) = withContext(Dispatchers.IO) {
- totp.update(
- entity.copy(secret = entity.secret.toEncrypted())
- )
- }
+ override fun getTrashedCountAsFlow() = authDao.getTrashedCountAsFlow()
+
+ override suspend fun getAuthPropertiesAsFlow(id: Long) =
+ authDao.getAuthPropertiesAsFlow(id)
+ .filterNotNull()
+ .map { auth ->
+ withKey { current ->
+ auth.protectValue { current.decrypt(it) }
+ }
+ }
- override suspend fun updateTotp(entities: List) = withContext(Dispatchers.IO) {
- totp.update(
- entities.map { it.copy(secret = it.secret.toEncrypted()) }
+ override suspend fun upsert(auth: AuthProperties) = withContext(Dispatchers.IO) {
+ val auth = withKey { current ->
+ auth.protectValue { current.encrypt(it) }
+ }
+ authDao.upsert(
+ auth = auth.auth,
+ properties = auth.properties
)
}
- override suspend fun deleteTotp(entity: TotpEntity) = withContext(Dispatchers.IO) {
- totp.delete(entity)
+ override suspend fun trash(authId: Long, trashedAt: Instant) = withContext(Dispatchers.IO) {
+ authDao.trashAuth(authId, trashedAt)
}
- override suspend fun deleteTotp(entities: List) = withContext(Dispatchers.IO) {
- totp.delete(entities)
+ override suspend fun delete(authId: Long) = withContext(Dispatchers.IO) {
+ authDao.delete(authId)
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepository.kt
new file mode 100644
index 0000000..44170c8
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepository.kt
@@ -0,0 +1,14 @@
+package dev.sanmer.authenticator.repository
+
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.model.otp.Totp
+import kotlinx.coroutines.flow.Flow
+
+interface OtpRepository {
+ suspend fun otp(auth: AuthProperties) = when (auth.auth.type) {
+ Auth.Type.TOTP -> totp(Totp(auth))
+ }
+
+ suspend fun totp(totp: Totp): Pair>
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepositoryImpl.kt
new file mode 100644
index 0000000..32b31a2
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepositoryImpl.kt
@@ -0,0 +1,35 @@
+package dev.sanmer.authenticator.repository
+
+import dev.sanmer.auth.Otp
+import dev.sanmer.auth.decodeBase32
+import dev.sanmer.authenticator.model.otp.Totp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+
+class OtpRepositoryImpl(
+ private val timeRepository: TimeRepository
+) : OtpRepository {
+ override suspend fun totp(totp: Totp) = withContext(Dispatchers.IO) {
+ val secret = totp.secret.decodeBase32()
+ val now = Otp.otp(
+ hash = totp.hash,
+ secret = secret,
+ counter = timeRepository.now().epochSeconds / totp.period,
+ digits = totp.digits
+ )
+ val otp = timeRepository.now
+ .map { it.epochSeconds / totp.period }
+ .distinctUntilChanged()
+ .map {
+ Otp.otp(
+ hash = totp.hash,
+ secret = secret,
+ counter = it,
+ digits = totp.digits
+ )
+ }
+ now to otp
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt
index 266e353..cba5492 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt
@@ -10,4 +10,5 @@ interface PreferenceRepository {
suspend fun setKeyEncryptedByBiometric(value: String)
suspend fun setNtpAddress(value: String)
suspend fun setNtp(value: Ntp)
+ suspend fun setSecureWindow(value: Boolean)
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt
index 943c47a..c121781 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt
@@ -42,4 +42,12 @@ class PreferenceRepositoryImpl(
}
}
}
+
+ override suspend fun setSecureWindow(value: Boolean) {
+ withContext(Dispatchers.IO) {
+ dataStore.updateData {
+ it.copy(secureWindow = value)
+ }
+ }
+ }
}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt
index 9c98a25..9a48f05 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt
@@ -1,11 +1,15 @@
package dev.sanmer.authenticator.repository
-import dev.sanmer.authenticator.datastore.model.Preference
-import dev.sanmer.ntp.NtpServer
+import dev.sanmer.auth.ntp.NtpClock
+import dev.sanmer.auth.ntp.NtpServer
+import dev.sanmer.authenticator.model.LoadData
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
+import kotlin.time.Clock
+import kotlin.time.Instant
-interface TimeRepository {
- val ntpTime: StateFlow
- val epochSeconds: StateFlow
- suspend fun sync(preference: Preference, times: Int = 3): Result
+interface TimeRepository : Clock {
+ val clock: StateFlow>
+ val now: Flow
+ suspend fun sync(server: NtpServer)
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt
index a42ffb7..794090d 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt
@@ -1,98 +1,53 @@
package dev.sanmer.authenticator.repository
+import dev.sanmer.auth.ntp.NtpClock
+import dev.sanmer.auth.ntp.NtpServer
import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.datastore.model.Ntp
-import dev.sanmer.authenticator.datastore.model.Preference
-import dev.sanmer.ntp.NtpServer
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.NonCancellable
+import dev.sanmer.authenticator.model.LoadData
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.updateAndGet
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-
-class TimeRepositoryImpl(
- private val preferenceRepository: PreferenceRepository
-) : TimeRepository {
- private val coroutineScope = CoroutineScope(Dispatchers.IO)
- private var job: Job = NonCancellable
-
- private val _ntpTime = MutableStateFlow(NtpServer.NtpTime())
- override val ntpTime get() = _ntpTime.asStateFlow()
-
- private val _epochSeconds = MutableStateFlow( System.currentTimeMillis() / 1000)
- override val epochSeconds get() = _epochSeconds.asStateFlow()
-
- private val logger = Logger.Android("TimeRepositoryImpl")
-
- init {
- preferenceObserver()
- ntpObserver()
- }
-
- private fun preferenceObserver() {
- coroutineScope.launch {
- preferenceRepository.data
- .distinctUntilChanged { old, new ->
- old.ntp == new.ntp && old.ntpAddress == new.ntpAddress
- }
- .collectLatest {
- sync(it)
- }
+import java.net.SocketTimeoutException
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.nanoseconds
+import kotlin.time.Duration.Companion.seconds
+
+class TimeRepositoryImpl : TimeRepository {
+ private val _clock = MutableStateFlow>(LoadData.Pending)
+
+ override val clock = _clock.asStateFlow()
+
+ override val now = flow {
+ while (currentCoroutineContext().isActive) {
+ val now = now()
+ emit(now)
+ delay(1.seconds - now.nanosecondsOfSecond.nanoseconds)
}
}
- private fun ntpObserver() {
- coroutineScope.launch {
- _ntpTime.collect {
- start(it)
+ private val logger = Logger.Android("NTP")
+
+ override fun now() = clock.value.getOrElse({ it }) { Clock.System }.now()
+
+ override suspend fun sync(server: NtpServer) {
+ _clock.update { LoadData.Loading }
+ var data: LoadData = LoadData.Pending
+ for (times in 1..3) {
+ try {
+ data = LoadData.Success(server.sync())
+ } catch (e: SocketTimeoutException) {
+ data = LoadData.Failure(e)
+ delay(200.milliseconds * times)
+ } catch (e: Throwable) {
+ data = LoadData.Failure(e)
+ logger.e(e)
}
}
- }
-
- private fun start(ntpTime: NtpServer.NtpTime) {
- if (job.isActive) {
- job.cancel()
- }
- job = coroutineScope.launch {
- delay(1000 - (ntpTime.currentTimeMillis % 1000))
- while (isActive) {
- _epochSeconds.value = ntpTime.currentTimeMillis / 1000
- delay(1000)
- }
- }
- }
-
- override suspend fun sync(preference: Preference, times: Int): Result {
- val server = when (preference.ntp) {
- Ntp.Custom -> NtpServer.Custom(preference.ntpAddress)
- Ntp.Alibaba -> NtpServer.Alibaba
- Ntp.Apple -> NtpServer.Apple
- Ntp.Amazon -> NtpServer.Amazon
- Ntp.Cloudflare -> NtpServer.Cloudflare
- Ntp.Google -> NtpServer.Google
- Ntp.Meta -> NtpServer.Meta
- Ntp.Microsoft -> NtpServer.Microsoft
- Ntp.Tencent -> NtpServer.Tencent
- }
-
- var result = Result.failure(Throwable())
- repeat(times) {
- result = runCatching {
- _ntpTime.updateAndGet { server.sync() }
- }.onSuccess {
- logger.i("NTP(${server.address}): $it")
- return Result.success(it)
- }
- }
- return result.onFailure {
- logger.w(it)
- }
+ _clock.update { data }
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/AuthorizeActivity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/AuthorizeActivity.kt
deleted file mode 100644
index 5f77bd0..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/AuthorizeActivity.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-package dev.sanmer.authenticator.ui
-
-import android.content.Context
-import android.content.Intent
-import android.graphics.Color
-import android.os.Bundle
-import androidx.activity.SystemBarStyle
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.ActivityResultRegistryOwner
-import androidx.activity.result.contract.ActivityResultContract
-import androidx.fragment.app.FragmentActivity
-import dev.sanmer.authenticator.ui.screens.authorize.AuthorizeScreen
-import dev.sanmer.authenticator.ui.screens.authorize.AuthorizeViewModel
-import dev.sanmer.authenticator.ui.theme.AppTheme
-import dev.sanmer.crypto.BiometricKey
-import kotlinx.coroutines.suspendCancellableCoroutine
-import org.koin.androidx.viewmodel.ext.android.viewModel
-import java.util.UUID
-import kotlin.coroutines.resume
-
-class AuthorizeActivity : FragmentActivity() {
- private val viewModel by viewModel()
-
- init {
- BiometricKey.init(this)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- enableEdgeToEdge(
- navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)
- )
- super.onCreate(savedInstanceState)
-
- viewModel.updateFromIntent(::getIntent)
-
- setContent {
- AppTheme {
- AuthorizeScreen()
- }
- }
- }
-
- override fun finish() {
- Intent().apply {
- putExtra(EXTRA_SUCCEED, viewModel.type.isSucceed)
- setResult(RESULT_OK, this)
- }
- super.finish()
- }
-
- enum class Action(
- val original: String
- ) {
- SetupPassword(ACTION_SETUP_PASSWORD),
- ChangePassword(ACTION_CHANGE_PASSWORD),
- RemovePassword(ACTION_REMOVE_PASSWORD),
- SetupBiometric(ACTION_SETUP_BIOMETRIC),
- RemoveBiometric(ACTION_REMOVE_BIOMETRIC),
- Auth(ACTION_AUTH);
-
- companion object Default {
- operator fun invoke(action: String?): Action {
- return entries.first { it.original == action }
- }
- }
- }
-
- companion object Default {
- private const val ACTION_SETUP_PASSWORD = "dev.sanmer.authenticator.action.SETUP_PASSWORD"
- private const val ACTION_CHANGE_PASSWORD = "dev.sanmer.authenticator.action.CHANGE_PASSWORD"
- private const val ACTION_REMOVE_PASSWORD = "dev.sanmer.authenticator.action.REMOVE_PASSWORD"
- private const val ACTION_SETUP_BIOMETRIC = "dev.sanmer.authenticator.action.SETUP_BIOMETRIC"
- private const val ACTION_REMOVE_BIOMETRIC =
- "dev.sanmer.authenticator.action.REMOVE_BIOMETRIC"
- private const val ACTION_AUTH = "dev.sanmer.authenticator.action.AUTH"
- const val EXTRA_SUCCEED = "dev.sanmer.authenticator.extra.SUCCEED"
-
- val Intent.isSucceed: Boolean
- inline get() = getBooleanExtra(EXTRA_SUCCEED, false)
-
- private class Authorize(
- val action: Action
- ) : ActivityResultContract() {
- override fun createIntent(context: Context, input: Unit): Intent {
- return Intent(context, AuthorizeActivity::class.java).also {
- it.action = action.original
- }
- }
-
- override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
- if (resultCode != RESULT_OK) return false
- if (intent == null) return false
- return intent.isSucceed
- }
-
- }
-
- fun start(
- context: Context,
- action: Action,
- callback: (Boolean) -> Unit
- ) {
- require(context is ActivityResultRegistryOwner) { "Expected ActivityResultRegistryOwner" }
-
- val activityResultRegistry = context.activityResultRegistry
- val launcher = activityResultRegistry.register(
- key = UUID.randomUUID().toString(),
- contract = Authorize(action),
- callback = callback
- )
-
- launcher.launch(Unit)
- }
-
- fun auth(
- context: Context,
- callback: (Boolean) -> Unit
- ) = start(
- context = context,
- action = Action.Auth,
- callback = callback
- )
-
- suspend fun auth(context: Context) = suspendCancellableCoroutine { continuation ->
- auth(context) { continuation.resume(it) }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/CryptoActivity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/CryptoActivity.kt
deleted file mode 100644
index c923dce..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/CryptoActivity.kt
+++ /dev/null
@@ -1,177 +0,0 @@
-package dev.sanmer.authenticator.ui
-
-import android.content.Context
-import android.content.Intent
-import android.graphics.Color
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.SystemBarStyle
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.ActivityResultRegistryOwner
-import androidx.activity.result.contract.ActivityResultContract
-import dev.sanmer.authenticator.ui.screens.crypto.CryptoScreen
-import dev.sanmer.authenticator.ui.screens.crypto.CryptoViewModel
-import dev.sanmer.authenticator.ui.theme.AppTheme
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.suspendCancellableCoroutine
-import org.koin.androidx.viewmodel.ext.android.viewModel
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-
-class CryptoActivity : ComponentActivity() {
- private val viewModel by viewModel()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- enableEdgeToEdge(
- navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)
- )
- super.onCreate(savedInstanceState)
-
- viewModel.updateFromIntent(::getIntent)
-
- setContent {
- AppTheme {
- CryptoScreen()
- }
- }
- }
-
- override fun finish() {
- Intent().apply {
- putExtra(EXTRA_OUTPUT, viewModel.output.toTypedArray())
- setResult(RESULT_OK, this)
- }
- super.finish()
- }
-
- enum class Action(
- val original: String,
- ) {
- Encrypt(ACTION_ENCRYPT),
- Decrypt(ACTION_DECRYPT);
-
- companion object Default {
- operator fun invoke(action: String?): Action {
- return entries.first { it.original == action }
- }
- }
- }
-
- companion object Default {
- private const val ACTION_ENCRYPT = "dev.sanmer.authenticator.action.ENCRYPT"
- private const val ACTION_DECRYPT = "dev.sanmer.authenticator.action.DECRYPT"
- const val EXTRA_BYPASS = "dev.sanmer.authenticator.extra.BYPASS"
- const val EXTRA_INPUT = "dev.sanmer.authenticator.extra.INPUT"
- const val EXTRA_OUTPUT = "dev.sanmer.authenticator.extra.OUTPUT"
-
- val Intent.bypass: Boolean
- inline get() = getBooleanExtra(EXTRA_BYPASS, true)
-
- val Intent.input: Array
- inline get() = checkNotNull(getStringArrayExtra(EXTRA_INPUT))
-
- val Intent.output: Array
- inline get() = checkNotNull(getStringArrayExtra(EXTRA_OUTPUT))
-
- private class Crypto(
- private val action: Action,
- private val bypass: Boolean
- ) : ActivityResultContract, List>() {
- override fun createIntent(context: Context, input: List): Intent {
- return Intent(context, CryptoActivity::class.java).also {
- it.action = action.original
- it.putExtra(EXTRA_BYPASS, bypass)
- it.putExtra(EXTRA_INPUT, input.toTypedArray())
- }
- }
-
- override fun parseResult(resultCode: Int, intent: Intent?): List {
- if (resultCode != RESULT_OK) return emptyList()
- if (intent == null) return emptyList()
- return intent.output.toList()
- }
- }
-
- private fun start(
- context: Context,
- input: List,
- action: Action,
- bypass: Boolean,
- callback: (List) -> Unit
- ) {
- require(context is ActivityResultRegistryOwner) { "Expected ActivityResultRegistryOwner" }
-
- val activityResultRegistry = context.activityResultRegistry
- val launcher = activityResultRegistry.register(
- key = input.toString(),
- contract = Crypto(action, bypass),
- callback = { callback(it) }
- )
-
- launcher.launch(input)
- }
-
- fun encrypt(
- context: Context,
- input: List,
- bypass: Boolean = true,
- callback: (List) -> Unit
- ) = start(
- context = context,
- input = input,
- action = Action.Encrypt,
- bypass = bypass,
- callback = callback
- )
-
- suspend fun encrypt(
- context: Context,
- input: List,
- bypass: Boolean = true,
- ) = suspendCancellableCoroutine { continuation ->
- encrypt(
- context = context,
- input = input,
- bypass = bypass
- ) { output ->
- if (output.isNotEmpty()) {
- continuation.resume(output)
- } else {
- continuation.resumeWithException(CancellationException())
- }
- }
- }
-
- fun decrypt(
- context: Context,
- input: List,
- bypass: Boolean = true,
- callback: (List) -> Unit
- ) = start(
- context = context,
- input = input,
- action = Action.Decrypt,
- bypass = bypass,
- callback = callback
- )
-
- suspend fun decrypt(
- context: Context,
- input: List,
- bypass: Boolean = true,
- ) = suspendCancellableCoroutine { continuation ->
- decrypt(
- context = context,
- input = input,
- bypass = bypass
- ) { output ->
- if (output.isNotEmpty()) {
- continuation.resume(output)
- } else {
- continuation.resumeWithException(CancellationException())
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt
index 7db4941..c0cdfc5 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt
@@ -1,25 +1,33 @@
package dev.sanmer.authenticator.ui
+import android.content.Intent
import android.graphics.Color
import android.os.Bundle
+import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.animation.Crossfade
+import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.navigation3.runtime.NavBackStack
+import dev.sanmer.authenticator.crypto.BiometricKey
import dev.sanmer.authenticator.datastore.compose.LocalPreference
-import dev.sanmer.authenticator.ui.screens.main.LockScreen
-import dev.sanmer.authenticator.ui.screens.main.MainScreen
-import dev.sanmer.authenticator.ui.screens.main.MainViewModel
-import dev.sanmer.authenticator.ui.screens.main.MainViewModel.LoadState
+import dev.sanmer.authenticator.ui.screen.Screen
+import dev.sanmer.authenticator.ui.screen.main.MainScreen
+import dev.sanmer.authenticator.ui.screen.main.MainViewModel
+import dev.sanmer.authenticator.ui.screen.main.UnlockScreen
import dev.sanmer.authenticator.ui.theme.AppTheme
-import org.koin.android.ext.android.get
+import org.koin.android.ext.android.inject
import org.koin.android.scope.AndroidScopeComponent
import org.koin.androidx.compose.navigation3.getEntryProvider
import org.koin.androidx.scope.activityRetainedScope
@@ -31,6 +39,20 @@ import org.koin.core.scope.Scope
class MainActivity : ComponentActivity(), AndroidScopeComponent {
override val scope: Scope by activityRetainedScope()
private val viewModel by viewModel()
+ private val backStack by inject>()
+
+ private fun setSecureWindow(secure: Boolean) {
+ if (secure) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ intent.data?.let { backStack.add(Screen.Edit(otpUri = it)) }
+ }
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
@@ -39,29 +61,42 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent {
)
super.onCreate(savedInstanceState)
- splashScreen.setKeepOnScreenCondition { viewModel.isPending }
+ BiometricKey.init(this)
+ splashScreen.setKeepOnScreenCondition { viewModel.preference.isPending }
+ intent.data?.let { backStack.add(Screen.Edit(otpUri = it)) }
setContent {
- when (viewModel.loadState) {
- LoadState.Pending -> {}
- else -> CompositionLocalProvider(
- LocalPreference provides viewModel.preference
+ viewModel.preference.onSuccess { preference ->
+ DisposableEffect(preference.secureWindow) {
+ setSecureWindow(preference.secureWindow)
+ onDispose {}
+ }
+
+ CompositionLocalProvider(
+ LocalPreference provides preference
) {
AppTheme {
- Crossfade(
- modifier = Modifier.background(
- color = MaterialTheme.colorScheme.background
- ),
- targetState = viewModel.isLocked,
- animationSpec = tween(400)
- ) { isLocked ->
- if (isLocked) {
- LockScreen()
- } else {
+ AnimatedContent(
+ modifier = Modifier.background(MaterialTheme.colorScheme.background),
+ targetState = viewModel.isDecrypted(preference),
+ transitionSpec = {
+ fadeIn(
+ animationSpec = tween(500)
+ ) togetherWith fadeOut(
+ animationSpec = tween(500)
+ )
+ }
+ ) { isDecrypted ->
+ if (isDecrypted) {
MainScreen(
- backStack = get(),
+ backStack = backStack,
entryProvider = getEntryProvider()
)
+ } else {
+ UnlockScreen(
+ viewModel = viewModel,
+ preference = preference
+ )
}
}
}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt
deleted file mode 100644
index ed9d5e3..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package dev.sanmer.authenticator.ui.component
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.shape.CornerBasedShape
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.DpOffset
-import androidx.compose.ui.window.PopupProperties
-
-@Composable
-fun DropdownMenu(
- expanded: Boolean,
- onDismissRequest: () -> Unit,
- modifier: Modifier = Modifier,
- shape: CornerBasedShape = MaterialTheme.shapes.medium,
- contentAlignment: Alignment = Alignment.TopStart,
- offset: DpOffset = DpOffset.Zero,
- properties: PopupProperties = PopupProperties(focusable = true),
- surface: @Composable () -> Unit,
- content: @Composable ColumnScope.() -> Unit
-) = Box(
- modifier = modifier
-) {
- surface()
-
- Box(
- modifier = Modifier.align(contentAlignment),
- contentAlignment = contentAlignment
- ) {
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = onDismissRequest,
- offset = offset,
- properties = properties,
- shape = shape,
- content = content
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/IconRow.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/IconRow.kt
new file mode 100644
index 0000000..f23c397
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/IconRow.kt
@@ -0,0 +1,56 @@
+package dev.sanmer.authenticator.ui.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun IconRow(
+ modifier: Modifier = Modifier,
+ leadingIcon: @Composable RowScope.() -> Unit = { FixedBox() },
+ trailingIcon: @Composable RowScope.() -> Unit = { FixedBox() },
+ horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(5.dp),
+ verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
+ content: @Composable RowScope.() -> Unit,
+) = Row(
+ modifier = modifier,
+ horizontalArrangement = horizontalArrangement,
+ verticalAlignment = verticalAlignment
+) {
+ leadingIcon()
+ content()
+ trailingIcon()
+}
+
+@Composable
+fun FixedBox(
+ content: @Composable (BoxScope.() -> Unit) = {}
+) = Box(
+ modifier = Modifier.size(48.dp),
+ contentAlignment = Alignment.Center,
+ content = content
+)
+
+@Composable
+fun FixedIcon(
+ painter: Painter,
+ contentDescription: String? = null,
+ tint: Color = LocalContentColor.current
+) = FixedBox {
+ Icon(
+ painter = painter,
+ contentDescription = contentDescription,
+ tint = tint
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt
index d8458c4..59ee7c3 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
@@ -22,6 +23,8 @@ fun LabelText(
text = text,
style = style,
color = color,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
modifier = modifier
.background(
color = containerColor,
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt
deleted file mode 100644
index 8941cd4..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package dev.sanmer.authenticator.ui.component
-
-import androidx.annotation.DrawableRes
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.res.painterResource
-
-@Composable
-fun Logo(
- @DrawableRes icon: Int,
- modifier: Modifier = Modifier,
- shape: Shape = CircleShape,
- contentColor: Color = MaterialTheme.colorScheme.onPrimary,
- containerColor: Color = MaterialTheme.colorScheme.primary,
- fraction: Float = 0.6f
-) = Surface(
- modifier = modifier,
- shape = shape,
- color = containerColor,
- contentColor = contentColor
-) {
- Box(
- contentAlignment = Alignment.Center
- ) {
- Icon(
- modifier = Modifier.fillMaxSize(fraction),
- painter = painterResource(id = icon),
- contentDescription = null,
- tint = LocalContentColor.current
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/PageIndicator.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/PageIndicator.kt
deleted file mode 100644
index f2735f8..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/PageIndicator.kt
+++ /dev/null
@@ -1,145 +0,0 @@
-package dev.sanmer.authenticator.ui.component
-
-import androidx.annotation.DrawableRes
-import androidx.annotation.StringRes
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.isSpecified
-import androidx.compose.ui.unit.sp
-import dev.sanmer.authenticator.R
-
-@Composable
-fun PageIndicator(
- icon: @Composable ColumnScope.() -> Unit,
- modifier: Modifier = Modifier,
- text: @Composable ColumnScope.() -> Unit = {},
- height: Dp = Dp.Unspecified
-) = Column(
- modifier = modifier then (if (height.isSpecified) {
- Modifier
- .height(height = height)
- .fillMaxWidth()
- } else {
- Modifier.fillMaxSize()
- }),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
-) {
- icon()
- Spacer(modifier = Modifier.height(20.dp))
- text()
-}
-
-@Composable
-fun PageIndicator(
- @DrawableRes icon: Int,
- @StringRes text: Int,
- modifier: Modifier = Modifier,
- height: Dp = Dp.Unspecified
-) = PageIndicator(
- icon = {
- Icon(
- painter = painterResource(icon),
- contentDescription = null,
- tint = PageIndicatorDefaults.ContentColor,
- modifier = Modifier.size(PageIndicatorDefaults.IconSize)
- )
- },
- text = {
- Text(
- text = stringResource(text),
- style = PageIndicatorDefaults.TextStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- modifier = modifier,
- height = height
-)
-
-@Composable
-fun Loading(
- modifier: Modifier = Modifier,
- height: Dp = Dp.Unspecified
-) = PageIndicator(
- icon = {
- CircularProgressIndicator(
- modifier = Modifier.size(PageIndicatorDefaults.IconSize),
- strokeWidth = 5.dp
- )
- },
- modifier = modifier,
- height = height
-)
-
-@Composable
-fun Failed(
- message: String,
- modifier: Modifier = Modifier,
- height: Dp = Dp.Unspecified
-) = PageIndicator(
- icon = {
- Icon(
- painter = painterResource(R.drawable.bug),
- contentDescription = null,
- tint = PageIndicatorDefaults.ContentColor,
- modifier = Modifier.size(PageIndicatorDefaults.IconSize)
- )
- },
- text = {
- Text(
- text = message,
- style = PageIndicatorDefaults.TextStyle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- },
- modifier = modifier,
- height = height
-)
-
-@Composable
-fun Failed(
- error: Throwable,
- modifier: Modifier = Modifier,
- height: Dp = Dp.Unspecified
-) = Failed(
- message = error.message ?: error.javaClass.name,
- modifier = modifier,
- height = height
-)
-
-object PageIndicatorDefaults {
- val IconSize = 48.dp
- val ContentColor @Composable get() = MaterialTheme.colorScheme.outline.copy(0.5f)
-
- val TextStyle @Composable get() = TextStyle(
- color = ContentColor,
- fontSize = 20.sp,
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.SemiBold,
- textAlign = TextAlign.Center
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Placeholder.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Placeholder.kt
new file mode 100644
index 0000000..0482fad
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Placeholder.kt
@@ -0,0 +1,76 @@
+package dev.sanmer.authenticator.ui.component
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun Loading(
+ modifier: Modifier = Modifier
+) = Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ strokeWidth = 4.dp
+ )
+}
+
+@Composable
+fun Finished(
+ label: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.outlineVariant,
+ textStyle: TextStyle = MaterialTheme.typography.titleLarge
+) = Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+) {
+ CompositionLocalProvider(
+ LocalContentColor provides color,
+ ) {
+ ProvideTextStyle(textStyle) {
+ label()
+ }
+ }
+}
+
+@Composable
+fun Finished(
+ label: String,
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.outlineVariant,
+ textStyle: TextStyle = MaterialTheme.typography.titleLarge
+) = Finished(
+ label = { Text(text = label) },
+ modifier = modifier,
+ color = color,
+ textStyle = textStyle
+)
+
+@Composable
+fun Finished(
+ @StringRes label: Int,
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.outlineVariant,
+ textStyle: TextStyle = MaterialTheme.typography.titleLarge
+) = Finished(
+ label = stringResource(label),
+ modifier = modifier,
+ color = color,
+ textStyle = textStyle
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Point.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Point.kt
new file mode 100644
index 0000000..9cfe3e1
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Point.kt
@@ -0,0 +1,81 @@
+package dev.sanmer.authenticator.ui.component
+
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.animateValue
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.isSpecified
+
+@Composable
+fun Point(
+ modifier: Modifier,
+ color: Color,
+ ringColor: Color = Color.Unspecified,
+ ringScale: Float = 1f,
+ ringAlpha: Float = 1f
+) = Canvas(modifier = modifier) {
+ val radius = size.minDimension / 2f
+ val innerRadius = radius * 0.6f
+
+ drawCircle(
+ color = color,
+ radius = innerRadius,
+ center = center
+ )
+
+ if (ringColor.isSpecified) {
+ val ringRadius = (radius * ringScale + innerRadius) / 2
+ val strokeWidth = radius * ringScale - innerRadius
+
+ drawCircle(
+ color = ringColor,
+ alpha = ringAlpha,
+ radius = ringRadius,
+ center = center,
+ style = Stroke(width = strokeWidth)
+ )
+ }
+}
+
+@Composable
+fun AnimatedPoint(
+ modifier: Modifier,
+ color: Color,
+ ringColor: Color = color,
+) {
+ val transition = rememberInfiniteTransition()
+ val scale by transition.animateValue(
+ initialValue = 0.1f,
+ targetValue = 1f,
+ typeConverter = Float.VectorConverter,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1200),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+ val alpha by transition.animateValue(
+ initialValue = 0.15f,
+ targetValue = 0.45f,
+ typeConverter = Float.VectorConverter,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1200),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+
+ Point(
+ modifier = modifier,
+ color = color,
+ ringColor = ringColor,
+ ringScale = scale,
+ ringAlpha = alpha
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/SegmentedButton.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/SegmentedButton.kt
new file mode 100644
index 0000000..6fba7b5
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/SegmentedButton.kt
@@ -0,0 +1,22 @@
+package dev.sanmer.authenticator.ui.component
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import dev.sanmer.authenticator.R
+
+@Composable
+fun SegmentedButtonDefaults.Check(
+ active: Boolean
+) = Icon(
+ active = active,
+ activeContent = {
+ androidx.compose.material3.Icon(
+ painter = painterResource(R.drawable.check),
+ contentDescription = null,
+ modifier = Modifier.size(IconSize)
+ )
+ }
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Swipe.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Swipe.kt
deleted file mode 100644
index 9d6d1be..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Swipe.kt
+++ /dev/null
@@ -1,163 +0,0 @@
-package dev.sanmer.authenticator.ui.component
-
-import androidx.compose.animation.core.animate
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.draggable
-import androidx.compose.foundation.gestures.rememberDraggableState
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.offset
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.platform.LocalLayoutDirection
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.LayoutDirection
-import kotlin.math.abs
-import kotlin.math.max
-import kotlin.math.min
-import kotlin.math.roundToInt
-
-@Composable
-fun SwipeContent(
- content: @Composable (() -> Unit) -> Unit,
- surface: @Composable () -> Unit
-) {
- var deltaValue by remember { mutableFloatStateOf(0f) }
- var offsetX by remember { mutableFloatStateOf(0f) }
- var targetOffsetX = remember { 0f }
-
- var expanded by remember { mutableStateOf(false) }
- LaunchedEffect(expanded) {
- if (expanded) animate(offsetX, -targetOffsetX, animationSpec = tween(200)) { value, _ ->
- offsetX = value
- }
- }
-
- var released by remember { mutableStateOf(false) }
- LaunchedEffect(released) {
- if (released) animate(offsetX, 0f, animationSpec = tween(200)) { value, _ ->
- offsetX = value
- }
- }
-
- Box {
- Box(
- modifier = Modifier
- .align(Alignment.CenterEnd)
- .onSizeChanged { targetOffsetX = it.width.toFloat() }
- ) {
- content { released = true }
- }
-
- Box(
- modifier = Modifier
- .offset { IntOffset(offsetX.roundToInt(), 0) }
- .draggable(
- reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl,
- orientation = Orientation.Horizontal,
- state = rememberDraggableState { delta ->
- if (targetOffsetX == 0f) return@rememberDraggableState
- val p = 1f - abs(offsetX) / (targetOffsetX * 1.25f)
- offsetX += delta * min(p, 1f)
- deltaValue = delta
- },
- onDragStarted = {
- expanded = false
- released = false
- deltaValue = 0f
- },
- onDragStopped = {
- if (deltaValue > 0) {
- released = true
- } else {
- if (abs(offsetX) / targetOffsetX > 0.35) {
- expanded = true
- } else {
- released = true
- }
- }
- }
- )
- ) {
- surface()
- }
- }
-}
-
-@Composable
-fun SwipeFloor(
- onSwipe: (SwipeValue) -> Unit,
- floor: @Composable () -> Unit,
- content: @Composable () -> Unit
-) {
- val currentOnSwipe by rememberUpdatedState(newValue = onSwipe)
-
- var offsetX by remember { mutableFloatStateOf(0f) }
- var triggerOffsetX = remember { 0f }
- val swipeProgress by remember {
- derivedStateOf {
- when {
- offsetX > 0 -> min(offsetX / triggerOffsetX, 1f)
- offsetX < 0 -> max(offsetX / triggerOffsetX, -1f)
- else -> 0f
- }
- }
- }
-
- var released by remember { mutableStateOf(false) }
- LaunchedEffect(released) {
- if (released) animate(offsetX, 0f, animationSpec = tween(300)) { value, _ ->
- offsetX = value
- currentOnSwipe(SwipeValue.Closing(swipeProgress))
- }
- }
-
- Box(
- contentAlignment = Alignment.Center
- ) {
- if (offsetX != 0f) floor()
-
- Box(
- modifier = Modifier
- .onSizeChanged { triggerOffsetX = it.width / 5f * 2 }
- .offset { IntOffset(offsetX.roundToInt(), 0) }
- .draggable(
- reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl,
- orientation = Orientation.Horizontal,
- state = rememberDraggableState { delta ->
- if (triggerOffsetX == 0f) return@rememberDraggableState
- val p = 1f - abs(offsetX) / (triggerOffsetX * 1.25f)
- offsetX += delta * min(p, 1f)
- currentOnSwipe(SwipeValue.Swiping(swipeProgress))
- },
- onDragStarted = {
- currentOnSwipe(SwipeValue.Started())
- released = false
- },
- onDragStopped = {
- currentOnSwipe(SwipeValue.Stopped(swipeProgress))
- released = true
- }
- )
- ) {
- content()
- }
- }
-}
-
-sealed class SwipeValue(val progress: Float) {
- class Started : SwipeValue(0f)
- class Swiping(progress: Float) : SwipeValue(progress)
- class Stopped(progress: Float) : SwipeValue(progress)
- class Closing(progress: Float) : SwipeValue(progress)
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/ViewModelsModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/ViewModelsModule.kt
deleted file mode 100644
index fa30cfc..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/ViewModelsModule.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package dev.sanmer.authenticator.ui.di
-
-import dev.sanmer.authenticator.ui.screens.authorize.AuthorizeViewModel
-import dev.sanmer.authenticator.ui.screens.crypto.CryptoViewModel
-import dev.sanmer.authenticator.ui.screens.edit.EditViewModel
-import dev.sanmer.authenticator.ui.screens.encode.EncodeViewModel
-import dev.sanmer.authenticator.ui.screens.home.HomeViewModel
-import dev.sanmer.authenticator.ui.screens.main.MainViewModel
-import dev.sanmer.authenticator.ui.screens.ntp.NtpViewModel
-import dev.sanmer.authenticator.ui.screens.scan.ScanViewModel
-import dev.sanmer.authenticator.ui.screens.settings.SettingsViewModel
-import dev.sanmer.authenticator.ui.screens.trash.TrashViewModel
-import org.koin.core.module.dsl.viewModelOf
-import org.koin.dsl.module
-
-val ViewModels = module {
- viewModelOf(::MainViewModel)
- viewModelOf(::AuthorizeViewModel)
- viewModelOf(::CryptoViewModel)
- viewModelOf(::HomeViewModel)
- viewModelOf(::EditViewModel)
- viewModelOf(::SettingsViewModel)
- viewModelOf(::ScanViewModel)
- viewModelOf(::NtpViewModel)
- viewModelOf(::EncodeViewModel)
- viewModelOf(::TrashViewModel)
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ClipboardExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Clipboard.kt
similarity index 67%
rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ClipboardExt.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Clipboard.kt
index b15ca71..dcefdbf 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ClipboardExt.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Clipboard.kt
@@ -5,15 +5,13 @@ import android.content.ClipDescription
import android.os.PersistableBundle
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.toClipEntry
+import dev.sanmer.authenticator.compat.BuildCompat
-suspend fun Clipboard.setSensitiveText(
- content: String
-) {
+suspend fun Clipboard.setSensitiveText(content: String) {
val data = ClipData.newPlainText("plain text", content).apply {
description.extras = PersistableBundle().apply {
- putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
+ if (BuildCompat.atLeastT) putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
}
}
-
setClipEntry(data.toClipEntry())
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListStateExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListState.kt
similarity index 100%
rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListStateExt.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListState.kt
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ModifierExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Modifier.kt
similarity index 70%
rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ModifierExt.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Modifier.kt
index cbf7d6f..cc35b01 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ModifierExt.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Modifier.kt
@@ -3,17 +3,15 @@ package dev.sanmer.authenticator.ui.ktx
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
-@Stable
fun Modifier.surface(
shape: Shape,
backgroundColor: Color,
- border: BorderStroke? = null
-) = then(if (border != null) Modifier.border(border = border, shape = shape) else Modifier)
+ border: BorderStroke? = null,
+) = then(if (border != null) Modifier.border(border, shape) else Modifier)
.background(color = backgroundColor, shape = shape)
- .clip(shape = shape)
\ No newline at end of file
+ .clip(shape)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValuesExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValues.kt
similarity index 86%
rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValuesExt.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValues.kt
index 7597675..21007c1 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValuesExt.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValues.kt
@@ -1,5 +1,3 @@
-@file:Suppress("NOTHING_TO_INLINE")
-
package dev.sanmer.authenticator.ui.ktx
import androidx.compose.foundation.layout.PaddingValues
@@ -7,10 +5,10 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
-inline operator fun PaddingValues.plus(other: PaddingValues): PaddingValues =
+operator fun PaddingValues.plus(other: PaddingValues): PaddingValues =
OperatorPaddingValues(this, other, Dp::plus)
-inline operator fun PaddingValues.minus(other: PaddingValues): PaddingValues =
+operator fun PaddingValues.minus(other: PaddingValues): PaddingValues =
OperatorPaddingValues(this, other, Dp::minus)
@Immutable
@@ -42,4 +40,4 @@ class OperatorPaddingValues(
that.calculateTopPadding(),
other.calculateTopPadding()
)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Shape.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Shape.kt
new file mode 100644
index 0000000..79c8a07
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Shape.kt
@@ -0,0 +1,29 @@
+package dev.sanmer.authenticator.ui.ktx
+
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.ui.unit.Dp
+
+fun CornerBasedShape.top(size: Dp) = copy(
+ topStart = CornerSize(size), topEnd = CornerSize(size)
+)
+
+fun CornerBasedShape.bottom(size: Dp) = copy(
+ bottomStart = CornerSize(size), bottomEnd = CornerSize(size)
+)
+
+infix fun CornerBasedShape.topWith(other: CornerBasedShape) = copy(
+ topStart = other.topStart, topEnd = other.topEnd
+)
+
+infix fun CornerBasedShape.bottomWith(other: CornerBasedShape) = copy(
+ bottomStart = other.bottomStart, bottomEnd = other.bottomEnd
+)
+
+infix fun CornerBasedShape.startWith(other: CornerBasedShape) = copy(
+ topStart = other.topStart, bottomStart = other.bottomStart
+)
+
+infix fun CornerBasedShape.endWith(other: CornerBasedShape) = copy(
+ topEnd = other.topEnd, bottomEnd = other.bottomEnd
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt
deleted file mode 100644
index 9b3b521..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.sanmer.authenticator.ui.ktx
-
-import androidx.compose.foundation.shape.CornerBasedShape
-import androidx.compose.foundation.shape.CornerSize
-import androidx.compose.ui.unit.Dp
-
-fun CornerBasedShape.bottom(size: Dp) =
- copy(bottomStart = CornerSize(size), bottomEnd = CornerSize(size))
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/Screen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/Screen.kt
similarity index 53%
rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/Screen.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/Screen.kt
index 0c05554..8ec97a4 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/Screen.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/Screen.kt
@@ -1,33 +1,33 @@
-package dev.sanmer.authenticator.ui.screens
+package dev.sanmer.authenticator.ui.screen
+import android.net.Uri
import androidx.navigation3.runtime.NavKey
+import dev.sanmer.authenticator.model.serializable.UriSerializer
import kotlinx.serialization.Serializable
sealed interface Screen : NavKey {
@Serializable
data object Home : Screen
- @Serializable
- data object Settings : Screen
-
@Serializable
data class Edit(
- val id: Long = Long.MAX_VALUE,
- val uri: String = ""
+ val authId: Long = -1,
+ @Serializable(UriSerializer::class)
+ val otpUri: Uri = Uri.EMPTY
) : Screen
@Serializable
- data object Scan : Screen
+ data object Setting : Screen
@Serializable
- data object Encode : Screen
+ data object Trash : Screen
@Serializable
data object Ntp : Screen
@Serializable
- data object Security : Screen
+ data object Export : Screen
@Serializable
- data object Trash : Screen
+ data object Brand : Screen
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/brand/BrandScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/brand/BrandScreen.kt
new file mode 100644
index 0000000..ddfb0b2
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/brand/BrandScreen.kt
@@ -0,0 +1,110 @@
+package dev.sanmer.authenticator.ui.screen.brand
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.ktx.plus
+import dev.sanmer.brand.Brand
+
+@Composable
+fun BrandScreen(
+ goBack: () -> Unit
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ Scaffold(
+ topBar = {
+ TopBar(
+ onBack = goBack,
+ scrollBehavior = scrollBehavior
+ )
+ }
+ ) { contentPadding ->
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(100.dp),
+ contentPadding = PaddingValues(15.dp) + contentPadding,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ ) {
+ items(
+ count = Brand.entries.size
+ ) {
+ BrandItem(
+ brand = Brand.entries[it]
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BrandItem(
+ brand: Brand
+) = Column(
+ modifier = Modifier
+ .border(
+ border = CardDefaults.outlinedCardBorder(false),
+ shape = MaterialTheme.shapes.large
+ )
+ .size(100.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+) {
+ Image(
+ painter = painterResource(brand.id),
+ contentDescription = null,
+ modifier = Modifier.size(45.dp)
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ Text(
+ text = brand.name,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.outline
+ )
+}
+
+@Composable
+private fun TopBar(
+ onBack: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior
+) = TopAppBar(
+ title = { Text(text = stringResource(R.string.setting_brand)) },
+ navigationIcon = {
+ IconButton(
+ onClick = onBack
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_left),
+ contentDescription = null
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditScreen.kt
new file mode 100644
index 0000000..e496ef8
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditScreen.kt
@@ -0,0 +1,275 @@
+package dev.sanmer.authenticator.ui.screen.edit
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.ktx.plus
+import dev.sanmer.authenticator.ui.screen.edit.EditViewModel.BottomSheet
+import dev.sanmer.authenticator.ui.screen.edit.component.DigitsItem
+import dev.sanmer.authenticator.ui.screen.edit.component.IssuerItem
+import dev.sanmer.authenticator.ui.screen.edit.component.NameItem
+import dev.sanmer.authenticator.ui.screen.edit.component.PreviewBottomSheet
+import dev.sanmer.authenticator.ui.screen.edit.component.QrcodeBottomSheet
+import dev.sanmer.authenticator.ui.screen.edit.component.SecretItem
+import dev.sanmer.authenticator.ui.screen.edit.component.TypeItem
+import dev.sanmer.authenticator.ui.screen.scan.ScanScreen
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Composable
+fun EditScreen(
+ viewModel: EditViewModel,
+ goBack: () -> Unit
+) = AnimatedContent(
+ targetState = viewModel.bottomSheet,
+ transitionSpec = {
+ fadeIn(
+ animationSpec = tween(500)
+ ) togetherWith fadeOut(
+ animationSpec = tween(500)
+ )
+ },
+ contentKey = { it == BottomSheet.Scan }
+) {
+ when (it) {
+ BottomSheet.Scan -> ScanScreen(
+ viewModel = koinViewModel { parametersOf(viewModel::fromScan) },
+ goBack = { viewModel.bottomSheet = BottomSheet.None }
+ )
+
+ else -> EditScreen(
+ viewModel = viewModel,
+ goBack = goBack,
+ onScan = { viewModel.bottomSheet = BottomSheet.Scan }
+ )
+ }
+}
+
+@Composable
+fun EditScreen(
+ viewModel: EditViewModel,
+ goBack: () -> Unit,
+ onScan: () -> Unit
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ when (val bs = viewModel.bottomSheet) {
+ BottomSheet.None, BottomSheet.Scan -> {}
+ is BottomSheet.Preview -> PreviewBottomSheet(
+ onClose = { viewModel.bottomSheet = BottomSheet.None },
+ preview = bs.preview,
+ onSave = { viewModel.save(it, goBack) }
+ )
+
+ is BottomSheet.Qrcode -> QrcodeBottomSheet(
+ onClose = { viewModel.bottomSheet = BottomSheet.None },
+ qrcode = bs.qrcode
+ )
+ }
+
+ Scaffold(
+ modifier = Modifier.imePadding(),
+ topBar = {
+ TopBar(
+ isEdit = viewModel.isEdit,
+ isTrashed = viewModel.isTrashed,
+ onBack = goBack,
+ onTrash = viewModel::trash,
+ onDelete = { viewModel.delete(goBack) },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ floatingActionButton = {
+ ActionButton(
+ isTrashed = viewModel.isTrashed,
+ onRestore = viewModel::restore,
+ onSave = { viewModel.preview() },
+ visible = viewModel.input.isNotEmpty
+ )
+ }
+ ) { contentPadding ->
+ Column(
+ modifier = Modifier
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .verticalScroll(rememberScrollState())
+ .padding(PaddingValues(horizontal = 5.dp, vertical = 15.dp) + contentPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ NameItem(
+ name = viewModel.input.name,
+ enabled = !viewModel.isTrashed
+ )
+
+ IssuerItem(
+ issuer = viewModel.input.issuer,
+ brand = viewModel.brand,
+ onMatchesBrand = viewModel::matchesBrand,
+ enabled = !viewModel.isTrashed
+ )
+
+ SecretItem(
+ secret = viewModel.input.secret,
+ hidden = viewModel.input.hidden,
+ isEdit = viewModel.isEdit,
+ enabled = !viewModel.isTrashed
+ )
+
+ TypeItem(
+ type = viewModel.input.type,
+ hash = viewModel.input.hash,
+ enabled = !viewModel.isTrashed
+ )
+
+ DigitsItem(
+ digits = viewModel.input.digits,
+ period = viewModel.input.period,
+ enabled = !viewModel.isTrashed
+ )
+
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val density = LocalDensity.current
+ val color = MaterialTheme.colorScheme.onSurface
+ FilledTonalIconButton(
+ onClick = {
+ keyboardController?.hide()
+ if (viewModel.isEdit) {
+ viewModel.qrcode(density, color)
+ } else {
+ onScan()
+ }
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.qrcode),
+ contentDescription = null
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TopBar(
+ isEdit: Boolean,
+ isTrashed: Boolean,
+ onBack: () -> Unit,
+ onTrash: () -> Unit,
+ onDelete: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior
+) = TopAppBar(
+ title = {
+ Text(
+ text = stringResource(
+ if (isEdit) R.string.token_edit_title
+ else R.string.token_add_title
+ )
+ )
+ },
+ navigationIcon = {
+ val keyboardController = LocalSoftwareKeyboardController.current
+ IconButton(
+ onClick = {
+ keyboardController?.hide()
+ onBack()
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_left),
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ if (isEdit) AnimatedContent(
+ targetState = isTrashed,
+ transitionSpec = {
+ fadeIn() + scaleIn(
+ animationSpec = spring(stiffness = Spring.StiffnessLow)
+ ) togetherWith scaleOut(
+ animationSpec = spring(stiffness = Spring.StiffnessLow)
+ ) + fadeOut()
+ }
+ ) { isTrashed ->
+ IconButton(
+ onClick = {
+ when {
+ transition.isRunning -> {}
+ else -> if (isTrashed) onDelete() else onTrash()
+ }
+ }
+ ) {
+ Icon(
+ painter = painterResource(
+ if (isTrashed) R.drawable.trash_x
+ else R.drawable.trash
+ ),
+ contentDescription = null
+ )
+ }
+ }
+ },
+ scrollBehavior = scrollBehavior
+)
+
+@Composable
+private fun ActionButton(
+ isTrashed: Boolean,
+ onRestore: () -> Unit,
+ onSave: () -> Unit,
+ visible: Boolean = true
+) = AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn() + scaleIn(),
+ exit = scaleOut() + fadeOut()
+) {
+ val keyboardController = LocalSoftwareKeyboardController.current
+ FloatingActionButton(
+ onClick = {
+ keyboardController?.hide()
+ if (isTrashed) onRestore() else onSave()
+ },
+ ) {
+ Icon(
+ painter = painterResource(
+ if (isTrashed) R.drawable.restore
+ else R.drawable.device_floppy
+ ),
+ contentDescription = null
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditViewModel.kt
new file mode 100644
index 0000000..c2bb66f
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditViewModel.kt
@@ -0,0 +1,270 @@
+package dev.sanmer.authenticator.ui.screen.edit
+
+import android.net.Uri
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.sanmer.auth.Otp
+import dev.sanmer.auth.OtpUri.Default.isOtpUri
+import dev.sanmer.auth.OtpUri.Default.toOtpUri
+import dev.sanmer.auth.QRCode
+import dev.sanmer.authenticator.Const.INSTANT_ZERO
+import dev.sanmer.authenticator.Const.isZero
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.database.model.AuthProperty
+import dev.sanmer.authenticator.ktx.stateIn
+import dev.sanmer.authenticator.model.otp.Totp
+import dev.sanmer.authenticator.repository.DbRepository
+import dev.sanmer.authenticator.repository.OtpRepository
+import dev.sanmer.authenticator.repository.TimeRepository
+import dev.sanmer.brand.Brand
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+class EditViewModel(
+ private val authId: Long,
+ private val otpUri: Uri,
+ private val dbRepository: DbRepository,
+ private val otpRepository: OtpRepository,
+ private val timeRepository: TimeRepository
+) : ViewModel() {
+ val isEdit = authId > 0
+
+ val input = Input()
+ var isTrashed by mutableStateOf(false)
+ private set
+
+ var brand by mutableStateOf(null)
+ private set
+
+ var bottomSheet by mutableStateOf(BottomSheet.None)
+
+ private val logger = Logger.Android("EditViewModel")
+
+ init {
+ logger.d("init")
+ loadData()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ if (isEdit) {
+ dbRepository.getAuthPropertiesAsFlow(authId)
+ .collect {
+ input.update(it)
+ isTrashed = !it.auth.trashedAt.isZero
+ }
+ } else {
+ fromOtpUri(otpUri)
+ }
+ }
+ }
+
+ private fun fromOtpUri(uri: Uri) {
+ if (!uri.isOtpUri()) return
+ runCatching {
+ val otpUri = uri.toOtpUri()
+ if (otpUri.type.equals("totp", ignoreCase = true)) {
+ input.update(Totp(otpUri).toAuth())
+ }
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+
+ fun fromScan(uri: Uri) {
+ fromOtpUri(uri)
+ bottomSheet = BottomSheet.None
+ }
+
+ fun matchesBrand() {
+ viewModelScope.launch {
+ brand = Brand.matches(input.issuerValue)
+ brand?.let { input.issuer.setTextAndPlaceCursorAtEnd(it.name) }
+ }
+ }
+
+ fun preview() {
+ viewModelScope.launch {
+ val preview = runCatching {
+ val authId = authId.coerceAtLeast(0)
+ when (input.typeValue) {
+ Auth.Type.TOTP -> {
+ val totp = input.totp()
+ val auth = totp.toAuth(authId)
+ auth to otpRepository.totp(totp)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly
+ )
+ }
+ }
+ }.onFailure {
+ logger.e(it)
+ }
+ bottomSheet = BottomSheet.Preview(preview)
+ }
+ }
+
+ fun save(auth: AuthProperties, onBack: () -> Unit = {}) {
+ viewModelScope.launch {
+ runCatching {
+ dbRepository.upsert(auth)
+ bottomSheet = BottomSheet.None
+ if (!isEdit) onBack()
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ }
+
+ fun trash() {
+ viewModelScope.launch {
+ runCatching {
+ runCatching {
+ dbRepository.trash(authId, timeRepository.now())
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ }
+ }
+
+ fun restore() {
+ viewModelScope.launch {
+ runCatching {
+ dbRepository.trash(authId, INSTANT_ZERO)
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ }
+
+ fun delete(onBack: () -> Unit = {}) {
+ viewModelScope.launch {
+ runCatching {
+ dbRepository.delete(authId)
+ onBack()
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ }
+
+ fun qrcode(density: Density, color: Color) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val qrcode = runCatching {
+ val uri = when (input.typeValue) {
+ Auth.Type.TOTP -> input.totp().toUri()
+ }
+ val content = uri.toString()
+ content to QRCode.encodeToBitmap(
+ content = content,
+ size = with(density) { 360.dp.roundToPx() },
+ foregroundColor = color.toArgb(),
+ backgroundColor = Color.Transparent.toArgb()
+ ).asImageBitmap()
+ }.onFailure {
+ logger.e(it)
+ }
+ bottomSheet = BottomSheet.Qrcode(qrcode)
+ }
+ }
+
+ data class Input(
+ val name: TextFieldState,
+ val issuer: TextFieldState,
+ val secret: TextFieldState,
+ val hidden: MutableState,
+ val type: MutableState,
+ val hash: MutableState,
+ val digits: TextFieldState,
+ val period: TextFieldState
+ ) {
+ constructor(
+ name: String = "",
+ issuer: String = "",
+ secret: String = "",
+ hidden: Boolean = true,
+ type: Auth.Type = Auth.Type.TOTP,
+ hash: Otp.Hash = Otp.Hash.SHA1,
+ digits: Int = 6,
+ period: Long = 30
+ ) : this(
+ hidden = mutableStateOf(hidden),
+ name = TextFieldState(name),
+ issuer = TextFieldState(issuer),
+ secret = TextFieldState(secret),
+ type = mutableStateOf(type),
+ hash = mutableStateOf(hash),
+ digits = TextFieldState(digits.toString()),
+ period = TextFieldState(period.toString())
+ )
+
+ val nameValue inline get() = name.text.trim().toString()
+ val issuerValue inline get() = issuer.text.trim().toString()
+ val secretValue inline get() = secret.text.trim().toString()
+ var typeValue by type
+ var hashValue by hash
+ val digitsValue inline get() = digits.text.toString().toInt()
+ val periodValue inline get() = period.text.toString().toLong()
+
+ val isNotEmpty by derivedStateOf {
+ secret.text.isNotEmpty() && digits.text.isNotEmpty() && period.text.isNotEmpty()
+ }
+
+ fun update(auth: AuthProperties) {
+ name.setTextAndPlaceCursorAtEnd(
+ auth.auth.name
+ )
+ issuer.setTextAndPlaceCursorAtEnd(
+ auth.auth.issuer
+ )
+ secret.setTextAndPlaceCursorAtEnd(
+ auth.getValue(AuthProperty.Key.Secret, "") { it }
+ )
+ typeValue = auth.auth.type
+ hashValue = auth.getValue(AuthProperty.Key.Hash, Otp.Hash.SHA1, Otp.Hash::valueOf)
+ digits.setTextAndPlaceCursorAtEnd(
+ auth.getValue(AuthProperty.Key.Digits, "6") { it }
+ )
+ period.setTextAndPlaceCursorAtEnd(
+ auth.getValue(AuthProperty.Key.Period, "30") { it }
+ )
+ }
+
+ fun totp() = Totp(
+ name = nameValue,
+ issuer = issuerValue,
+ secret = secretValue,
+ hash = hashValue,
+ digits = digitsValue.coerceAtLeast(1),
+ period = periodValue.coerceAtLeast(1)
+ )
+ }
+
+ sealed interface BottomSheet {
+ data object None : BottomSheet
+ data object Scan : BottomSheet
+ data class Preview(val preview: Result>>) :
+ BottomSheet
+
+ data class Qrcode(val qrcode: Result>) : BottomSheet
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/DigitsItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/DigitsItem.kt
new file mode 100644
index 0000000..7a1a090
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/DigitsItem.kt
@@ -0,0 +1,72 @@
+package dev.sanmer.authenticator.ui.screen.edit.component
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.InputTransformation
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.maxLength
+import androidx.compose.foundation.text.input.then
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.core.text.isDigitsOnly
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.component.IconRow
+
+@Composable
+fun DigitsItem(
+ digits: TextFieldState,
+ period: TextFieldState,
+ enabled: Boolean = true
+) = IconRow {
+ OutlinedTextField(
+ state = digits,
+ enabled = enabled,
+ inputTransformation = InputTransformation.maxLength(2).then {
+ if (!asCharSequence().isDigitsOnly()) {
+ revertAllChanges()
+ }
+ },
+ label = { Text(text = stringResource(R.string.token_digits)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
+ shape = MaterialTheme.shapes.medium,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ modifier = Modifier
+ .weight(1f)
+ .padding(bottom = 8.dp)
+ )
+
+ Spacer(modifier = Modifier.width(15.dp))
+
+ OutlinedTextField(
+ state = period,
+ enabled = enabled,
+ inputTransformation = InputTransformation.maxLength(2).then {
+ if (!asCharSequence().isDigitsOnly()) {
+ revertAllChanges()
+ }
+ },
+ label = { Text(text = stringResource(R.string.token_period)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
+ shape = MaterialTheme.shapes.medium,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ modifier = Modifier
+ .weight(1f)
+ .padding(bottom = 8.dp)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/IssuerItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/IssuerItem.kt
new file mode 100644
index 0000000..2113359
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/IssuerItem.kt
@@ -0,0 +1,65 @@
+package dev.sanmer.authenticator.ui.screen.edit.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.component.FixedBox
+import dev.sanmer.authenticator.ui.component.IconRow
+import dev.sanmer.brand.Brand
+
+@Composable
+fun IssuerItem(
+ issuer: TextFieldState,
+ brand: Brand?,
+ onMatchesBrand: () -> Unit,
+ enabled: Boolean = true
+) = IconRow(
+ leadingIcon = {
+ FixedBox {
+ if (brand != null) Image(
+ painter = painterResource(brand.id),
+ contentDescription = null
+ )
+ }
+ },
+ trailingIcon = {
+ IconButton(
+ onClick = onMatchesBrand
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.search),
+ contentDescription = null
+ )
+ }
+ }
+) {
+ OutlinedTextField(
+ state = issuer,
+ enabled = enabled,
+ label = { Text(text = stringResource(R.string.token_issuer)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next
+ ),
+ shape = MaterialTheme.shapes.medium,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ modifier = Modifier
+ .weight(1f)
+ .padding(bottom = 8.dp)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/NameItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/NameItem.kt
new file mode 100644
index 0000000..d2d7436
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/NameItem.kt
@@ -0,0 +1,42 @@
+package dev.sanmer.authenticator.ui.screen.edit.component
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.component.FixedIcon
+import dev.sanmer.authenticator.ui.component.IconRow
+
+@Composable
+fun NameItem(
+ name: TextFieldState,
+ enabled: Boolean = true
+) = IconRow(
+ leadingIcon = { FixedIcon(painter = painterResource(R.drawable.user)) }
+) {
+ OutlinedTextField(
+ state = name,
+ enabled = enabled,
+ label = { Text(text = stringResource(R.string.token_name)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next
+ ),
+ shape = MaterialTheme.shapes.medium,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ modifier = Modifier
+ .weight(1f)
+ .padding(bottom = 8.dp)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/PreviewBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/PreviewBottomSheet.kt
new file mode 100644
index 0000000..fee00b1
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/PreviewBottomSheet.kt
@@ -0,0 +1,81 @@
+package dev.sanmer.authenticator.ui.screen.edit.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.ui.component.DragHandle
+import dev.sanmer.authenticator.ui.component.Finished
+import dev.sanmer.authenticator.ui.ktx.bottom
+import dev.sanmer.authenticator.ui.screen.home.component.AuthItem
+import kotlinx.coroutines.flow.StateFlow
+
+@Composable
+fun PreviewBottomSheet(
+ onClose: () -> Unit,
+ preview: Result>>,
+ onSave: (AuthProperties) -> Unit
+) = ModalBottomSheet(
+ onDismissRequest = onClose,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ shape = MaterialTheme.shapes.large.bottom(0.dp),
+ dragHandle = null
+) {
+ DragHandle()
+
+ Text(
+ text = stringResource(R.string.token_preview),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(15.dp),
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ preview.onSuccess { (auth, otp) ->
+ AuthItem(
+ auth = auth.auth,
+ otp = otp
+ )
+
+ Button(
+ onClick = { onSave(auth) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.token_save))
+ }
+ }.onFailure {
+ Finished(
+ label = it.message ?: it.javaClass.name,
+ modifier = Modifier
+ .height(120.dp)
+ .fillMaxWidth()
+ )
+
+ Button(
+ onClick = onClose,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.token_close))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/QrcodeBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/QrcodeBottomSheet.kt
new file mode 100644
index 0000000..0e98f4c
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/QrcodeBottomSheet.kt
@@ -0,0 +1,101 @@
+package dev.sanmer.authenticator.ui.screen.edit.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.component.DragHandle
+import dev.sanmer.authenticator.ui.component.Finished
+import dev.sanmer.authenticator.ui.ktx.bottom
+import dev.sanmer.authenticator.ui.ktx.setSensitiveText
+import kotlinx.coroutines.launch
+
+@Composable
+fun QrcodeBottomSheet(
+ onClose: () -> Unit,
+ qrcode: Result>
+) = ModalBottomSheet(
+ onDismissRequest = onClose,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ shape = MaterialTheme.shapes.large.bottom(0.dp),
+ dragHandle = null
+) {
+ DragHandle()
+
+ Text(
+ text = stringResource(R.string.token_preview),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(15.dp),
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ qrcode.onSuccess { (content, bitmap) ->
+ Image(
+ bitmap = bitmap,
+ contentDescription = null,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(
+ border = CardDefaults.outlinedCardBorder(false),
+ shape = MaterialTheme.shapes.medium
+ )
+ .padding(15.dp)
+ )
+
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ Button(
+ onClick = { scope.launch { clipboard.setSensitiveText(content) } },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.token_copy))
+ }
+ }.onFailure {
+ Finished(
+ label = it.message ?: it.javaClass.name,
+ modifier = Modifier
+ .height(120.dp)
+ .fillMaxWidth()
+ )
+
+ Button(
+ onClick = onClose,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.token_close))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/SecretItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/SecretItem.kt
new file mode 100644
index 0000000..0cc95d5
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/SecretItem.kt
@@ -0,0 +1,75 @@
+package dev.sanmer.authenticator.ui.screen.edit.component
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.TextObfuscationMode
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedSecureTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.sanmer.auth.encodeBase32
+import dev.sanmer.authenticator.BuildConfig
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.component.FixedIcon
+import dev.sanmer.authenticator.ui.component.IconRow
+import kotlin.random.Random
+
+@Composable
+fun SecretItem(
+ secret: TextFieldState,
+ hidden: MutableState,
+ isEdit: Boolean,
+ enabled: Boolean = true
+) = IconRow(
+ leadingIcon = { FixedIcon(painter = painterResource(R.drawable.key)) },
+ trailingIcon = {
+ if (BuildConfig.DEBUG && !isEdit) IconButton(
+ onClick = {
+ val value = ByteArray(16).let(Random::nextBytes)
+ secret.setTextAndPlaceCursorAtEnd(value.encodeBase32())
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrows_shuffle),
+ contentDescription = null
+ )
+ } else IconButton(
+ onClick = { hidden.value = !hidden.value },
+ enabled = enabled
+ ) {
+ Icon(
+ painter = painterResource(
+ if (hidden.value) R.drawable.sparkles_2
+ else R.drawable.sparkles_2_off
+ ),
+ contentDescription = null
+ )
+ }
+ }
+) {
+ OutlinedSecureTextField(
+ state = secret,
+ enabled = enabled,
+ label = { Text(text = stringResource(R.string.token_secret)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ shape = MaterialTheme.shapes.medium,
+ textObfuscationMode = with(TextObfuscationMode) { if (hidden.value) Hidden else Visible },
+ modifier = Modifier
+ .weight(1f)
+ .padding(bottom = 8.dp)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/TypeItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/TypeItem.kt
new file mode 100644
index 0000000..5dc1c17
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/TypeItem.kt
@@ -0,0 +1,123 @@
+package dev.sanmer.authenticator.ui.screen.edit.component
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.auth.Otp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.ui.component.FixedIcon
+import dev.sanmer.authenticator.ui.component.IconRow
+import kotlin.enums.enumEntries
+
+@Composable
+fun TypeItem(
+ type: MutableState,
+ hash: MutableState,
+ enabled: Boolean = true
+) = IconRow(
+ leadingIcon = { FixedIcon(painter = painterResource(R.drawable.math_function)) }
+) {
+ val (typeExpanded, onTypeExpandedChange) = remember { mutableStateOf(false) }
+ val (hashExpanded, onHashExpandedChange) = remember { mutableStateOf(false) }
+
+ EnumTextField(
+ value = type.value,
+ onValueChange = { type.value = it },
+ expanded = typeExpanded,
+ onExpandedChange = onTypeExpandedChange,
+ label = stringResource(R.string.token_type),
+ modifier = Modifier.weight(1f),
+ enabled = enabled
+ )
+
+ Spacer(modifier = Modifier.width(15.dp))
+
+ EnumTextField(
+ value = hash.value,
+ onValueChange = { hash.value = it },
+ expanded = hashExpanded,
+ onExpandedChange = onHashExpandedChange,
+ label = stringResource(R.string.token_hash),
+ modifier = Modifier.weight(1f),
+ enabled = enabled
+ )
+}
+
+@Composable
+private inline fun > EnumTextField(
+ value: T,
+ crossinline onValueChange: (T) -> Unit,
+ expanded: Boolean,
+ crossinline onExpandedChange: (Boolean) -> Unit,
+ label: String,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) = ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { onExpandedChange(it) },
+ modifier = modifier
+) {
+ OutlinedTextField(
+ value = value.name,
+ onValueChange = {},
+ enabled = enabled,
+ readOnly = true,
+ label = { Text(text = label) },
+ shape = MaterialTheme.shapes.medium,
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(
+ type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
+ enabled = enabled
+ )
+ .padding(bottom = 8.dp)
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { onExpandedChange(false) },
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.padding(vertical = 0.dp)
+ ) {
+ enumEntries().forEachIndexed { index, value ->
+ if (index != 0) Spacer(modifier = Modifier.height(4.dp))
+
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = value.name,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ onClick = {
+ onValueChange(value)
+ onExpandedChange(false)
+ },
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .clip(shape = MaterialTheme.shapes.small)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportScreen.kt
new file mode 100644
index 0000000..562dcfb
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportScreen.kt
@@ -0,0 +1,167 @@
+package dev.sanmer.authenticator.ui.screen.export
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.ktx.isScrollingUp
+import dev.sanmer.authenticator.ui.ktx.plus
+import dev.sanmer.authenticator.ui.screen.export.component.AuthItem
+import dev.sanmer.authenticator.ui.screen.export.component.ButtonsItem
+import dev.sanmer.authenticator.ui.screen.export.component.ErrorItem
+import dev.sanmer.authenticator.ui.screen.home.component.AuthItem
+
+@Composable
+fun ExportScreen(
+ viewModel: ExportViewModel,
+ goBack: () -> Unit
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ val listState = rememberLazyListState()
+ val isScrollingUp by listState.isScrollingUp()
+
+ Scaffold(
+ topBar = {
+ TopBar(
+ onBack = goBack,
+ onClear = viewModel::clear,
+ scrollBehavior = scrollBehavior
+ )
+ },
+ floatingActionButton = {
+ ActionButton(
+ onClick = if (viewModel.isNotEmpty) {
+ viewModel::dbExport
+ } else {
+ viewModel::dbImport
+ },
+ isSave = viewModel.isNotEmpty,
+ visible = isScrollingUp,
+ )
+ }
+ ) { contentPadding ->
+ LazyColumn(
+ state = listState,
+ contentPadding = PaddingValues(15.dp) + contentPadding,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(15.dp),
+ modifier = Modifier
+ .animateContentSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ ) {
+ item {
+ ButtonsItem(
+ password = viewModel.input.password,
+ hidden = viewModel.input.hidden,
+ type = viewModel.input.type,
+ onImport = viewModel::import,
+ onExport = viewModel::export,
+ isNotEmpty = viewModel.isNotEmpty
+ )
+ }
+
+ items(
+ items = viewModel.list
+ ) { (auth, otp) ->
+ otp.onSuccess {
+ AuthItem(
+ auth = auth.auth,
+ otp = it,
+ isSelected = viewModel.isSelected(auth),
+ onClick = { viewModel.pick(auth) }
+ )
+ }.onFailure {
+ AuthItem(
+ auth = auth.auth,
+ error = it
+ )
+ }
+ }
+
+ item {
+ viewModel.data.onFailure {
+ ErrorItem(
+ error = it
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TopBar(
+ onBack: () -> Unit,
+ onClear: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior
+) = TopAppBar(
+ title = { Text(text = stringResource(R.string.setting_import_export)) },
+ navigationIcon = {
+ IconButton(
+ onClick = onBack
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_left),
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = onClear
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.clear_all),
+ contentDescription = null
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+)
+
+@Composable
+private fun ActionButton(
+ onClick: () -> Unit,
+ isSave: Boolean,
+ visible: Boolean = true
+) = AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn() + scaleIn(),
+ exit = scaleOut() + fadeOut()
+) {
+ FloatingActionButton(
+ onClick = onClick
+ ) {
+ Icon(
+ painter = painterResource(
+ if (isSave) R.drawable.device_floppy
+ else R.drawable.database_import
+ ),
+ contentDescription = null
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportViewModel.kt
new file mode 100644
index 0000000..a8633eb
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportViewModel.kt
@@ -0,0 +1,199 @@
+package dev.sanmer.authenticator.ui.screen.export
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.sanmer.auth.crypto.Crypto
+import dev.sanmer.auth.crypto.PasswordKey
+import dev.sanmer.authenticator.Const.isZero
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.database.model.AuthProperties
+import dev.sanmer.authenticator.ktx.stateIn
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.model.LoadData.Default.loadData
+import dev.sanmer.authenticator.model.serializable.AuthSet
+import dev.sanmer.authenticator.model.serializable.AuthSet.Default.toAuthSet
+import dev.sanmer.authenticator.repository.DbRepository
+import dev.sanmer.authenticator.repository.OtpRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+class ExportViewModel(
+ private val dbRepository: DbRepository,
+ private val otpRepository: OtpRepository
+) : ViewModel() {
+ val input = Input()
+
+ var data by mutableStateOf>>>>>(
+ LoadData.Pending
+ )
+ val list by derivedStateOf {
+ data.getOrElse({ it }, ::emptyList)
+ }
+
+ private val _selected = mutableStateListOf()
+ val isNotEmpty get() = _selected.isNotEmpty()
+
+ private val logger = Logger.Android("ExportViewModel")
+
+ init {
+ logger.d("init")
+ }
+
+ fun isSelected(auth: AuthProperties) = _selected.contains(auth)
+
+ fun pick(auth: AuthProperties) = if (isSelected(auth)) {
+ _selected.remove(auth)
+ } else {
+ _selected.add(auth)
+ }
+
+ fun clear() {
+ _selected.clear()
+ data = LoadData.Pending
+ }
+
+ fun import(context: Context, uri: Uri) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val cr = context.contentResolver
+ val stream = cr.openInputStream(uri) ?: return@launch
+ data = loadData {
+ when (input.typeValue) {
+ Input.Type.Json -> {
+ val set = stream.use(AuthSet::decodeFromJson)
+ val password = input.passwordValue
+ val key = if (password.isNotEmpty()) {
+ PasswordKey.new(password)
+ } else {
+ Crypto.Default
+ }
+ set.map { auth ->
+ val auth = auth.protectValue { key.decrypt(it) }
+ auth to runCatching {
+ otpRepository.otp(auth)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly
+ )
+ }.onSuccess {
+ _selected.add(auth)
+ }
+ }
+ }
+
+ Input.Type.Uri -> {
+ val set = stream.use(AuthSet::decodeFromUri)
+ set.map { auth ->
+ auth to runCatching {
+ otpRepository.otp(auth)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly
+ )
+ }.onSuccess {
+ _selected.add(auth)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun export(context: Context, uri: Uri) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val cr = context.contentResolver
+ val stream = cr.openOutputStream(uri) ?: return@launch
+ runCatching {
+ when (input.typeValue) {
+ Input.Type.Json -> {
+ val password = input.passwordValue
+ val key = if (password.isNotEmpty()) {
+ PasswordKey.new(password)
+ } else {
+ Crypto.Default
+ }
+ val set = _selected.map { auth ->
+ auth.protectValue { key.encrypt(it) }
+ }.toAuthSet()
+ stream.use(set::encodeToJson)
+ clear()
+ }
+
+ Input.Type.Uri -> {
+ val set = _selected.toAuthSet()
+ stream.use(set::encodeToUri)
+ clear()
+ }
+ }
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ }
+
+ fun dbImport() {
+ viewModelScope.launch {
+ data = loadData {
+ dbRepository.getAllAuthProperties()
+ .map {
+ if (it.auth.trashedAt.isZero) _selected.add(it)
+ it to runCatching {
+ otpRepository.otp(it)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun dbExport() {
+ viewModelScope.launch {
+ _selected.forEach { auth ->
+ runCatching {
+ dbRepository.upsert(auth)
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ clear()
+ }
+ }
+
+ data class Input(
+ val password: TextFieldState,
+ val hidden: MutableState,
+ val type: MutableState,
+ ) {
+ constructor(
+ password: String = "",
+ hidden: Boolean = true,
+ type: Type = Type.Json
+ ) : this(
+ password = TextFieldState(password),
+ hidden = mutableStateOf(hidden),
+ type = mutableStateOf(type)
+ )
+
+ val passwordValue inline get() = password.text.trim()
+ var typeValue by type
+
+ enum class Type(val mimeType: String) {
+ Json("application/json"),
+ Uri("text/plain")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/AuthItem.kt
new file mode 100644
index 0000000..0c100d6
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/AuthItem.kt
@@ -0,0 +1,53 @@
+package dev.sanmer.authenticator.ui.screen.export.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.ui.component.LabelText
+import dev.sanmer.authenticator.ui.ktx.surface
+
+@Composable
+fun AuthItem(
+ auth: Auth,
+ error: Throwable,
+ modifier: Modifier = Modifier
+) = Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .surface(
+ shape = MaterialTheme.shapes.large,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ border = CardDefaults.outlinedCardBorder(false)
+ )
+ .padding(15.dp)
+) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (auth.name.isNotEmpty()) Text(
+ text = auth.name,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ if (auth.issuer.isNotEmpty()) LabelText(
+ text = auth.issuer
+ )
+ }
+
+ Text(
+ text = error.message ?: error.javaClass.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ButtonsItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ButtonsItem.kt
new file mode 100644
index 0000000..93c0cd6
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ButtonsItem.kt
@@ -0,0 +1,141 @@
+package dev.sanmer.authenticator.ui.screen.export.component
+
+import android.content.Context
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.TextObfuscationMode
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedSecureTextField
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.ktx.surface
+import dev.sanmer.authenticator.ui.screen.export.ExportViewModel
+
+@Composable
+fun ButtonsItem(
+ password: TextFieldState,
+ hidden: MutableState,
+ type: MutableState,
+ onImport: (Context, Uri) -> Unit,
+ onExport: (Context, Uri) -> Unit,
+ isNotEmpty: Boolean,
+ modifier: Modifier = Modifier
+) = Column(
+ modifier = modifier
+ .surface(
+ shape = MaterialTheme.shapes.large,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ border = CardDefaults.outlinedCardBorder(false)
+ )
+ .padding(15.dp),
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+) {
+ val isJson by remember(type) {
+ derivedStateOf {
+ type.value == ExportViewModel.Input.Type.Json
+ }
+ }
+
+ OutlinedSecureTextField(
+ state = password,
+ enabled = isJson,
+ placeholder = { Text(text = stringResource(R.string.setting_password)) },
+ trailingIcon = {
+ IconButton(
+ onClick = { hidden.value = !hidden.value },
+ enabled = isJson
+ ) {
+ Icon(
+ painter = painterResource(
+ if (hidden.value) R.drawable.sparkles_2
+ else R.drawable.sparkles_2_off
+ ),
+ contentDescription = null
+ )
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ shape = MaterialTheme.shapes.medium,
+ textObfuscationMode = with(TextObfuscationMode) { if (hidden.value) Hidden else Visible },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(15.dp),
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ SingleChoiceSegmentedButtonRow {
+ ExportViewModel.Input.Type.entries.forEachIndexed { index, value ->
+ SegmentedButton(
+ selected = type.value == value,
+ onClick = { type.value = value },
+ shape = SegmentedButtonDefaults.itemShape(
+ index,
+ ExportViewModel.Input.Type.entries.size
+ ),
+ label = { Text(text = value.name) }
+ )
+ }
+ }
+
+ val context = LocalContext.current
+ val importer = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent(),
+ onResult = { if (it != null) onImport(context, it) }
+ )
+ FilledTonalIconButton(
+ onClick = { importer.launch(type.value.mimeType) },
+ enabled = !isNotEmpty
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.download),
+ contentDescription = null
+ )
+ }
+
+ val exporter = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument(type.value.mimeType),
+ onResult = { if (it != null) onExport(context, it) }
+ )
+ FilledTonalIconButton(
+ onClick = { exporter.launch("auth") },
+ enabled = isNotEmpty
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.upload),
+ contentDescription = null
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ErrorItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ErrorItem.kt
new file mode 100644
index 0000000..49c25c7
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ErrorItem.kt
@@ -0,0 +1,29 @@
+package dev.sanmer.authenticator.ui.screen.export.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.ui.ktx.surface
+
+@Composable
+fun ErrorItem(
+ error: Throwable,
+ modifier: Modifier = Modifier
+) = Text(
+ text = error.message ?: error.javaClass.name,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ modifier = modifier
+ .fillMaxWidth()
+ .surface(
+ shape = MaterialTheme.shapes.large,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ border = CardDefaults.outlinedCardBorder(false)
+ )
+ .padding(15.dp)
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeScreen.kt
new file mode 100644
index 0000000..5072cd6
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeScreen.kt
@@ -0,0 +1,158 @@
+package dev.sanmer.authenticator.ui.screen.home
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dev.sanmer.auth.ntp.NtpClock
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.datastore.compose.LocalPreference
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.ui.component.AnimatedPoint
+import dev.sanmer.authenticator.ui.component.Point
+import dev.sanmer.authenticator.ui.ktx.isScrollingUp
+import dev.sanmer.authenticator.ui.screen.Screen
+import dev.sanmer.authenticator.ui.screen.home.component.AuthList
+
+@Composable
+fun HomeScreen(
+ viewModel: HomeViewModel,
+ goTo: (Screen) -> Unit
+) {
+ val clock by viewModel.clock.collectAsStateWithLifecycle()
+ val time by viewModel.time.collectAsStateWithLifecycle("")
+
+ val preference = LocalPreference.current
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ val listState = rememberLazyListState()
+ val isScrollingUp by listState.isScrollingUp()
+
+ Scaffold(
+ topBar = {
+ TopBar(
+ time = time,
+ clock = clock,
+ onSync = { viewModel.sync(preference.ntpServer()) },
+ onSetting = { goTo(Screen.Setting) },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ floatingActionButton = {
+ ActionButton(
+ onClick = { goTo(Screen.Edit()) },
+ visible = isScrollingUp,
+ )
+ }
+ ) { contentPadding ->
+ viewModel.data.onSuccess { list ->
+ AuthList(
+ list = list,
+ state = listState,
+ onEdit = { goTo(Screen.Edit(authId = it.id)) },
+ contentPadding = contentPadding,
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ )
+ }
+ }
+}
+
+@Composable
+private fun TopBar(
+ time: String,
+ clock: LoadData,
+ onSync: () -> Unit,
+ onSetting: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior
+) = TopAppBar(
+ title = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(text = time)
+
+ AnimatedContent(
+ targetState = clock,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ contentAlignment = Alignment.Center
+ ) {
+ when (it) {
+ LoadData.Loading -> AnimatedPoint(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.secondaryContainer
+ )
+
+ is LoadData.Failure -> Point(
+ modifier = Modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .clickable(onClick = onSync),
+ color = MaterialTheme.colorScheme.errorContainer
+ )
+
+ else -> Spacer(
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = onSetting
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.settings_2),
+ contentDescription = null
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+)
+
+@Composable
+private fun ActionButton(
+ onClick: () -> Unit,
+ visible: Boolean = true
+) = AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn() + scaleIn(),
+ exit = scaleOut() + fadeOut()
+) {
+ FloatingActionButton(
+ onClick = onClick
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.plus),
+ contentDescription = null
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeViewModel.kt
new file mode 100644
index 0000000..3842ea1
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeViewModel.kt
@@ -0,0 +1,73 @@
+package dev.sanmer.authenticator.ui.screen.home
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.sanmer.auth.ntp.NtpServer
+import dev.sanmer.authenticator.Const.TIME_DISPLAY
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.ktx.stateIn
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.repository.DbRepository
+import dev.sanmer.authenticator.repository.OtpRepository
+import dev.sanmer.authenticator.repository.TimeRepository
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.format
+import kotlinx.datetime.toLocalDateTime
+
+class HomeViewModel(
+ private val dbRepository: DbRepository,
+ private val otpRepository: OtpRepository,
+ private val timeRepository: TimeRepository
+) : ViewModel() {
+ val clock = timeRepository.clock
+
+ val time = timeRepository.now
+ .map {
+ it.toLocalDateTime(TimeZone.currentSystemDefault()).time
+ .format(TIME_DISPLAY)
+ }
+
+ var data by mutableStateOf>>>>(LoadData.Loading)
+ private set
+
+ private val logger = Logger.Android("HomeViewModel")
+
+ init {
+ logger.d("init")
+ loadData()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ dbRepository.getUntrashedAuthPropertiesAsFlow()
+ .collectLatest { list ->
+ data = LoadData.Success(
+ list.sortedBy { it.auth.name.lowercase() }
+ .sortedBy { it.auth.issuer.lowercase() }
+ .map {
+ it.auth to otpRepository.otp(it)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly
+ )
+ }
+ )
+ }
+ }
+ }
+
+ fun sync(server: NtpServer) {
+ viewModelScope.launch {
+ timeRepository.sync(server)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthItem.kt
new file mode 100644
index 0000000..7cba778
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthItem.kt
@@ -0,0 +1,171 @@
+package dev.sanmer.authenticator.ui.screen.home.component
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.ui.component.LabelText
+import dev.sanmer.authenticator.ui.ktx.setSensitiveText
+import dev.sanmer.authenticator.ui.ktx.surface
+import dev.sanmer.brand.Brand
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+@Composable
+fun AuthItem(
+ auth: Auth,
+ otp: StateFlow,
+ modifier: Modifier = Modifier,
+ isSelected: Boolean = false,
+ onClick: () -> Unit = {}
+) {
+ val otp by otp.collectAsStateWithLifecycle()
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .surface(
+ shape = MaterialTheme.shapes.large,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ border = CardDefaults.outlinedCardBorder(false)
+ )
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = {
+ scope.launch { clipboard.setSensitiveText(otp) }
+ }
+ )
+ .padding(15.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(13.dp)
+ ) {
+ val brand by remember(auth.id) {
+ derivedStateOf { Brand.valueOfOrNull(auth.issuer) }
+ }
+
+ Image(
+ painter = painterResource(brand?.id ?: R.drawable.fingerprint_thin),
+ contentDescription = null,
+ modifier = Modifier.size(45.dp),
+ colorFilter = if (brand != null) null else
+ ColorFilter.tint(MaterialTheme.colorScheme.primary)
+ )
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OtpItem(
+ otp = otp
+ )
+
+ if (brand == null && auth.issuer.isNotEmpty()) LabelText(
+ text = auth.issuer
+ )
+ }
+
+ if (auth.name.isNotEmpty()) Text(
+ text = auth.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+
+ if (isSelected) Icon(
+ painter = painterResource(R.drawable.circle_check_filled),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(30.dp)
+ )
+ }
+}
+
+@Composable
+private fun OtpItem(
+ otp: String,
+ modifier: Modifier = Modifier
+) = Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+) {
+ otp.forEachIndexed { index, char ->
+ AnimatedDigit(
+ digit = char,
+ position = index
+ )
+ if ((index + 1) % 3 == 0 && index < otp.lastIndex) {
+ Spacer(modifier = Modifier.width(12.dp))
+ }
+ }
+}
+
+@Composable
+private fun AnimatedDigit(
+ digit: Char,
+ position: Int,
+ isOddPosition: Boolean = position % 2 == 0,
+ towards: SlideDirection = if (isOddPosition) SlideDirection.Up else SlideDirection.Down
+) = AnimatedContent(
+ targetState = digit,
+ transitionSpec = {
+ slideIntoContainer(
+ towards = towards,
+ animationSpec = tween(500)
+ ) + scaleIn(
+ animationSpec = tween(500)
+ ) + fadeIn(
+ animationSpec = tween(200)
+ ) togetherWith slideOutOfContainer(
+ towards = towards,
+ animationSpec = tween(500)
+ ) + scaleOut(
+ animationSpec = tween(500)
+ ) + fadeOut(
+ animationSpec = tween(200)
+ )
+ }
+) { digit ->
+ Text(
+ text = digit.toString(),
+ style = MaterialTheme.typography.headlineSmall,
+ fontFamily = FontFamily.Monospace
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthList.kt
new file mode 100644
index 0000000..ce9042a
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthList.kt
@@ -0,0 +1,39 @@
+package dev.sanmer.authenticator.ui.screen.home.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.ui.ktx.plus
+import kotlinx.coroutines.flow.StateFlow
+
+@Composable
+fun AuthList(
+ list: List>>,
+ onEdit: (Auth) -> Unit,
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ contentPadding: PaddingValues = PaddingValues(0.dp)
+) = LazyColumn(
+ modifier = modifier,
+ state = state,
+ contentPadding = PaddingValues(15.dp) + contentPadding,
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+) {
+ items(
+ items = list,
+ key = { (auth, _) -> auth.id }
+ ) { (auth, otp) ->
+ AuthItem(
+ auth = auth,
+ otp = otp,
+ onClick = { onEdit(auth) }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainScreen.kt
similarity index 94%
rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainScreen.kt
rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainScreen.kt
index 11d4d75..963808b 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainScreen.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainScreen.kt
@@ -1,4 +1,4 @@
-package dev.sanmer.authenticator.ui.screens.main
+package dev.sanmer.authenticator.ui.screen.main
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -13,7 +13,7 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
-import dev.sanmer.authenticator.ui.screens.Screen
+import dev.sanmer.authenticator.ui.screen.Screen
@Composable
fun MainScreen(
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainViewModel.kt
new file mode 100644
index 0000000..e7bdbf6
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainViewModel.kt
@@ -0,0 +1,87 @@
+package dev.sanmer.authenticator.ui.screen.main
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.sanmer.auth.crypto.SessionKey
+import dev.sanmer.auth.decodeBase64
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.crypto.BiometricKey.Default.decryptKeyByBiometric
+import dev.sanmer.authenticator.datastore.model.Preference
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.repository.DbRepository
+import dev.sanmer.authenticator.repository.PreferenceRepository
+import dev.sanmer.authenticator.repository.TimeRepository
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+class MainViewModel(
+ private val dbRepository: DbRepository,
+ private val preferenceRepository: PreferenceRepository,
+ private val timeRepository: TimeRepository
+) : ViewModel() {
+ var preference by mutableStateOf>(LoadData.Pending)
+ private set
+
+ var isEncrypted by mutableStateOf(false)
+ private set
+
+ val password = TextFieldState()
+ var isError by mutableStateOf(false)
+ private set
+
+ private val logger = Logger.Android("MainViewModel")
+
+ init {
+ logger.d("init")
+ loadData()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ preferenceRepository.data
+ .onEach {
+ logger.d("$it")
+ preference = LoadData.Success(it)
+ }
+ .distinctUntilChanged { old, new ->
+ old.ntp == new.ntp && old.ntpAddress == new.ntpAddress
+ }
+ .collectLatest {
+ timeRepository.sync(it.ntpServer())
+ }
+ }
+ }
+
+ fun isDecrypted(preference: Preference): Boolean {
+ if (!preference.isEncrypted) isEncrypted = true
+ return isEncrypted
+ }
+
+ fun decryptByPassword(key: String) {
+ viewModelScope.launch {
+ runCatching {
+ val key = SessionKey.decryptKeyByPassword(key.decodeBase64(), password.text)
+ dbRepository.setSessionKey(key)
+ isEncrypted = true
+ }.onFailure {
+ isError = true
+ }
+ }
+ }
+
+ fun decryptByBiometric(key: String) {
+ viewModelScope.launch {
+ runCatching {
+ val key = SessionKey.decryptKeyByBiometric(key.decodeBase64())
+ dbRepository.setSessionKey(key)
+ isEncrypted = true
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/UnlockScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/UnlockScreen.kt
new file mode 100644
index 0000000..07304e9
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/UnlockScreen.kt
@@ -0,0 +1,84 @@
+package dev.sanmer.authenticator.ui.screen.main
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextObfuscationMode
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedSecureTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.datastore.model.Preference
+
+@Composable
+fun UnlockScreen(
+ viewModel: MainViewModel,
+ preference: Preference
+) = Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+) {
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ OutlinedSecureTextField(
+ state = viewModel.password,
+ isError = viewModel.isError,
+ textObfuscationMode = TextObfuscationMode.Hidden,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Go
+ ),
+ onKeyboardAction = {
+ keyboardController?.hide()
+ viewModel.decryptByPassword(preference.keyEncryptedByPassword)
+ },
+ shape = MaterialTheme.shapes.medium
+ )
+
+ Row(
+ modifier = Modifier.padding(top = 20.dp),
+ horizontalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ if (preference.isBiometric) {
+ DisposableEffect(Unit) {
+ viewModel.decryptByBiometric(preference.keyEncryptedByBiometric)
+ onDispose {}
+ }
+
+ FilledTonalIconButton(
+ onClick = { viewModel.decryptByBiometric(preference.keyEncryptedByBiometric) }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.fingerprint),
+ contentDescription = null
+ )
+ }
+ }
+
+ FilledTonalIconButton(
+ onClick = {
+ keyboardController?.hide()
+ viewModel.decryptByPassword(preference.keyEncryptedByPassword)
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_right),
+ contentDescription = null
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpScreen.kt
new file mode 100644
index 0000000..ec6f5e4
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpScreen.kt
@@ -0,0 +1,97 @@
+package dev.sanmer.authenticator.ui.screen.ntp
+
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.datastore.compose.LocalPreference
+import dev.sanmer.authenticator.ui.screen.ntp.NtpViewModel.BottomSheet
+import dev.sanmer.authenticator.ui.screen.ntp.component.CustomBottomSheet
+import dev.sanmer.authenticator.ui.screen.ntp.component.NtpList
+import dev.sanmer.authenticator.ui.screen.ntp.component.NtpMessageBottomSheet
+
+@Composable
+fun NtpScreen(
+ viewModel: NtpViewModel,
+ goBack: () -> Unit
+) {
+ val preference = LocalPreference.current
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ when (val bs = viewModel.bottomSheet) {
+ BottomSheet.None -> {}
+ BottomSheet.Custom -> CustomBottomSheet(
+ onClose = { viewModel.bottomSheet = BottomSheet.None },
+ ntpAddress = viewModel.ntpAddress,
+ onSave = viewModel::setNtpAddress
+ )
+
+ is BottomSheet.NtpMsg -> NtpMessageBottomSheet(
+ onClose = { viewModel.bottomSheet = BottomSheet.None },
+ ntp = bs.ntp,
+ message = bs.msg
+ )
+ }
+
+ Scaffold(
+ modifier = Modifier.imePadding(),
+ topBar = {
+ TopBar(
+ onBack = goBack,
+ onCustom = { viewModel.bottomSheet = BottomSheet.Custom },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ ) { contentPadding ->
+ NtpList(
+ list = viewModel.list,
+ clock = { viewModel.clock(it.address) },
+ isSelected = { it == preference.ntp },
+ onPick = viewModel::pick,
+ onView = { ntp, msg -> viewModel.bottomSheet = BottomSheet.NtpMsg(ntp, msg) },
+ contentPadding = contentPadding,
+ state = viewModel.listState,
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ )
+ }
+}
+
+@Composable
+private fun TopBar(
+ onBack: () -> Unit,
+ onCustom: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior
+) = TopAppBar(
+ title = { Text(text = stringResource(R.string.ntp_title)) },
+ navigationIcon = {
+ IconButton(
+ onClick = onBack
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_left),
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = onCustom
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.plus),
+ contentDescription = null
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpViewModel.kt
new file mode 100644
index 0000000..00f51ae
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpViewModel.kt
@@ -0,0 +1,141 @@
+package dev.sanmer.authenticator.ui.screen.ntp
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.sanmer.auth.ntp.NtpClock
+import dev.sanmer.auth.ntp.NtpMessage
+import dev.sanmer.auth.ntp.NtpServer
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.datastore.model.Ntp
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.model.LoadData.Default.loadData
+import dev.sanmer.authenticator.repository.PreferenceRepository
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.launch
+import kotlin.time.Duration
+
+class NtpViewModel(
+ private val preferenceRepository: PreferenceRepository
+) : ViewModel() {
+ private val _list = mutableStateListOf(
+ Ntp.Alibaba to NtpServer.Alibaba,
+ Ntp.Apple to NtpServer.Apple,
+ Ntp.Amazon to NtpServer.Amazon,
+ Ntp.Cloudflare to NtpServer.Cloudflare,
+ Ntp.Google to NtpServer.Google,
+ Ntp.Meta to NtpServer.Meta,
+ Ntp.Microsoft to NtpServer.Microsoft,
+ Ntp.Tencent to NtpServer.Tencent
+ )
+
+ private val clocks = mutableStateMapOf>()
+
+ val list by derivedStateOf {
+ _list.sortedBy { (_, server) ->
+ clock(server.address).getOrElse({ it.rtt }, Duration::INFINITE)
+ }
+ }
+ val listState = LazyListState()
+
+ val ntpAddress = TextFieldState()
+ var bottomSheet by mutableStateOf(BottomSheet.None)
+
+ private val logger = Logger.Android("NtpViewModel")
+
+ init {
+ logger.d("init")
+ loadData()
+ syncAll()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ preferenceRepository.data
+ .distinctUntilChangedBy { it.ntpAddress }
+ .collect {
+ ntpAddress.setTextAndPlaceCursorAtEnd(it.ntpAddress)
+ val index = _list.indexOfFirst { (ntp, _) -> ntp == Ntp.Custom }
+ if (it.ntpAddress.isNotEmpty()) {
+ val server = NtpServer.Custom(it.ntpAddress).also(::sync)
+ if (index == -1) {
+ _list.add(Ntp.Custom to server)
+ } else {
+ _list[index] = Ntp.Custom to server
+ }
+ } else if (index != -1) {
+ _list.removeAt(index)
+ }
+ }
+ }
+ }
+
+ private fun syncAll() {
+ viewModelScope.launch {
+ _list.map { (_, server) ->
+ async {
+ clocks[server.address] = LoadData.Loading
+ clocks[server.address] = loadData {
+ server.sync()
+ }.onFailure {
+ logger.w(it)
+ }
+ }
+ }.awaitAll()
+ listState.requestScrollToItem(0)
+ }
+ }
+
+ fun clock(address: String) = clocks.getOrDefault(address, LoadData.Pending)
+
+ private fun sync(server: NtpServer) {
+ viewModelScope.launch {
+ when (clock(server.address)) {
+ LoadData.Pending, is LoadData.Failure -> {
+ clocks[server.address] = LoadData.Loading
+ clocks[server.address] = loadData {
+ server.sync()
+ }.onSuccess {
+ listState.requestScrollToItem(0)
+ }.onFailure {
+ logger.w(it)
+ }
+ }
+
+ else -> {}
+ }
+ }
+ }
+
+ fun pick(ntp: Ntp, server: NtpServer) {
+ if (clock(server.address).isSuccess) {
+ viewModelScope.launch {
+ preferenceRepository.setNtp(ntp)
+ }
+ } else {
+ sync(server)
+ }
+ }
+
+ fun setNtpAddress() {
+ viewModelScope.launch {
+ preferenceRepository.setNtpAddress(ntpAddress.text.trim().toString())
+ }
+ }
+
+ sealed interface BottomSheet {
+ data object None : BottomSheet
+ data object Custom : BottomSheet
+ data class NtpMsg(val ntp: Ntp, val msg: NtpMessage) : BottomSheet
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/CustomBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/CustomBottomSheet.kt
new file mode 100644
index 0000000..db95f48
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/CustomBottomSheet.kt
@@ -0,0 +1,87 @@
+package dev.sanmer.authenticator.ui.screen.ntp.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.datastore.compose.LocalPreference
+import dev.sanmer.authenticator.ui.component.DragHandle
+import dev.sanmer.authenticator.ui.ktx.bottom
+
+@Composable
+fun CustomBottomSheet(
+ onClose: () -> Unit,
+ ntpAddress: TextFieldState,
+ onSave: () -> Unit
+) = ModalBottomSheet(
+ onDismissRequest = onClose,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ shape = MaterialTheme.shapes.large.bottom(0.dp),
+ dragHandle = null
+) {
+ DragHandle()
+
+ Text(
+ text = stringResource(R.string.ntp_custom),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(15.dp),
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ val preference = LocalPreference.current
+ val isChanged by remember(ntpAddress) {
+ derivedStateOf {
+ ntpAddress.text.trim() != preference.ntpAddress
+ }
+ }
+
+ OutlinedTextField(
+ state = ntpAddress,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done
+ ),
+ shape = MaterialTheme.shapes.medium,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Button(
+ onClick = {
+ onSave()
+ onClose()
+ },
+ enabled = isChanged,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.token_save))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpItem.kt
new file mode 100644
index 0000000..33aa6cb
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpItem.kt
@@ -0,0 +1,201 @@
+package dev.sanmer.authenticator.ui.screen.ntp.component
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.auth.ntp.NtpClock
+import dev.sanmer.auth.ntp.NtpServer
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.datastore.model.Ntp
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.ui.component.LabelText
+import dev.sanmer.authenticator.ui.ktx.surface
+import java.net.SocketTimeoutException
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+
+private fun Ntp.brand() = when (this) {
+ Ntp.Custom -> R.drawable.clock_record_thin
+ Ntp.Alibaba -> dev.sanmer.brand.R.drawable.brand_aliyun
+ Ntp.Apple -> dev.sanmer.brand.R.drawable.brand_apple
+ Ntp.Amazon -> dev.sanmer.brand.R.drawable.brand_aws
+ Ntp.Cloudflare -> dev.sanmer.brand.R.drawable.brand_cloudflare
+ Ntp.Google -> dev.sanmer.brand.R.drawable.brand_googlecloud
+ Ntp.Meta -> dev.sanmer.brand.R.drawable.brand_meta
+ Ntp.Microsoft -> dev.sanmer.brand.R.drawable.brand_azure
+ Ntp.Tencent -> dev.sanmer.brand.R.drawable.brand_tencentcloud
+}
+
+@Composable
+fun Ntp.name() = when (this) {
+ Ntp.Custom -> stringResource(R.string.ntp_custom)
+ else -> name
+}
+
+@Composable
+fun NtpItem(
+ ntp: Ntp,
+ server: NtpServer,
+ clock: LoadData,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier
+) = Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .surface(
+ shape = MaterialTheme.shapes.large,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ border = CardDefaults.outlinedCardBorder(false)
+ )
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick
+ )
+ .padding(15.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(13.dp)
+) {
+ Image(
+ painter = painterResource(ntp.brand()),
+ contentDescription = null,
+ modifier = Modifier.size(45.dp),
+ colorFilter = if (ntp != Ntp.Custom) null else
+ ColorFilter.tint(MaterialTheme.colorScheme.primary)
+ )
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = ntp.name(),
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Text(
+ text = server.address,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+
+ if (!clock.isPending) AnimatedContent(
+ modifier = Modifier.padding(top = 5.dp),
+ targetState = clock,
+ transitionSpec = { fadeIn() togetherWith fadeOut() }
+ ) { clock ->
+ clock.onLoading {
+ LinearProgressIndicator(
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ .size(width = 120.dp, height = (5.5).dp)
+ )
+ }.onSuccess {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ RTTLabel(it.rtt)
+ OffsetLabel(it.offset)
+ }
+ }.onFailure {
+ ErrorLabel(it)
+ }
+ }
+ }
+
+ if (isSelected) Icon(
+ painter = painterResource(R.drawable.circle_check_filled),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(30.dp)
+ .align(Alignment.Top)
+ )
+}
+
+@Composable
+private fun RTTLabel(
+ rtt: Duration
+) {
+ val value by remember(rtt) {
+ derivedStateOf { rtt.toLong(DurationUnit.MILLISECONDS) }
+ }
+ val display by remember(rtt) {
+ derivedStateOf { rtt.toString(DurationUnit.MILLISECONDS) }
+ }
+
+ LabelText(
+ text = display,
+ containerColor = if (isSystemInDarkTheme()) {
+ when (value) {
+ in 0..<100 -> Color(0xFF1B5E20)
+ in 100..500 -> Color(0xFFF57F17)
+ else -> Color(0xFFB71C1C)
+ }
+ } else {
+ when (value) {
+ in 0..<100 -> Color(0xFF81C784)
+ in 100..500 -> Color(0xFFFFF176)
+ else -> Color(0xFFE57373)
+ }
+ }
+ )
+}
+
+@Composable
+private fun OffsetLabel(
+ offset: Duration
+) {
+ val display by remember(offset) {
+ derivedStateOf {
+ offset.toString(DurationUnit.MILLISECONDS)
+ .let {
+ if (offset.isPositive()) "+$it" else it
+ }
+ }
+ }
+
+ LabelText(text = display)
+}
+
+@Composable
+private fun ErrorLabel(
+ error: Throwable
+) = LabelText(
+ text = if (error is SocketTimeoutException) {
+ stringResource(R.string.ntp_timeout)
+ } else {
+ error.message ?: error.javaClass.name
+ },
+ containerColor = if (isSystemInDarkTheme()) {
+ Color(0xFFB71C1C)
+ } else {
+ Color(0xFFE57373)
+ }
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpList.kt
new file mode 100644
index 0000000..0b33dbb
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpList.kt
@@ -0,0 +1,52 @@
+package dev.sanmer.authenticator.ui.screen.ntp.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.sanmer.auth.ntp.NtpClock
+import dev.sanmer.auth.ntp.NtpMessage
+import dev.sanmer.auth.ntp.NtpServer
+import dev.sanmer.authenticator.datastore.model.Ntp
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.ui.ktx.plus
+
+@Composable
+fun NtpList(
+ list: List>,
+ clock: (NtpServer) -> LoadData,
+ isSelected: (Ntp) -> Boolean,
+ onPick: (Ntp, NtpServer) -> Unit,
+ onView: (Ntp, NtpMessage) -> Unit,
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ contentPadding: PaddingValues = PaddingValues(0.dp)
+) = LazyColumn(
+ modifier = modifier,
+ state = state,
+ contentPadding = PaddingValues(15.dp) + contentPadding,
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+) {
+ items(
+ items = list,
+ key = { (ntp, _) -> ntp }
+ ) { (ntp, server) ->
+ NtpItem(
+ ntp = ntp,
+ server = server,
+ clock = clock(server),
+ isSelected = isSelected(ntp),
+ onClick = { onPick(ntp, server) },
+ onLongClick = {
+ clock(server).onSuccess {
+ onView(ntp, it.message)
+ }
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpMessageBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpMessageBottomSheet.kt
new file mode 100644
index 0000000..64a5de1
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpMessageBottomSheet.kt
@@ -0,0 +1,98 @@
+package dev.sanmer.authenticator.ui.screen.ntp.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.sanmer.auth.ntp.NtpMessage
+import dev.sanmer.authenticator.Const.DATETIME_DISPLAY
+import dev.sanmer.authenticator.datastore.model.Ntp
+import dev.sanmer.authenticator.ui.component.DragHandle
+import dev.sanmer.authenticator.ui.ktx.bottom
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.format
+import kotlinx.datetime.toLocalDateTime
+
+@Composable
+fun NtpMessageBottomSheet(
+ onClose: () -> Unit,
+ ntp: Ntp,
+ message: NtpMessage
+) = ModalBottomSheet(
+ onDismissRequest = onClose,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ shape = MaterialTheme.shapes.large.bottom(0.dp),
+ dragHandle = null
+) {
+ DragHandle()
+
+ Text(
+ text = ntp.name(),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(15.dp),
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ ValueItem(
+ name = "Version",
+ value = message.version
+ )
+
+ ValueItem(
+ name = "Stratum",
+ value = message.stratum
+ )
+
+ ValueItem(
+ name = "Precision",
+ value = message.precision
+ )
+
+ ValueItem(
+ name = "Root Delay",
+ value = message.rootDelay
+ )
+
+ ValueItem(
+ name = "Root Dispersion",
+ value = message.rootDispersion
+ )
+
+ if (message.stratum == NtpMessage.Stratum.Primary) {
+ ValueItem(
+ name = "Reference ID",
+ value = message.referenceIdentifier.kissCode()
+ )
+ }
+
+ val referenceTimestamp by remember {
+ derivedStateOf {
+ message.referenceTimestamp
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .format(DATETIME_DISPLAY)
+ }
+ }
+
+ ValueItem(
+ name = "Reference Timestamp",
+ value = referenceTimestamp
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/ValueItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/ValueItem.kt
new file mode 100644
index 0000000..d564ee8
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/ValueItem.kt
@@ -0,0 +1,38 @@
+package dev.sanmer.authenticator.ui.screen.ntp.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.ui.ktx.surface
+
+@Composable
+fun ValueItem(
+ name: String,
+ value: Any
+) = Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .surface(
+ shape = MaterialTheme.shapes.medium,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ border = CardDefaults.outlinedCardBorder(false)
+ )
+ .padding(horizontal = 15.dp, vertical = 10.dp)
+) {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Text(
+ text = value.toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanScreen.kt
new file mode 100644
index 0000000..82caba9
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanScreen.kt
@@ -0,0 +1,214 @@
+package dev.sanmer.authenticator.ui.screen.scan
+
+import android.content.Context
+import android.net.Uri
+import android.util.Size
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
+import androidx.camera.compose.CameraXViewfinder
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.viewfinder.compose.MutableCoordinateTransformer
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import dev.sanmer.authenticator.R
+
+@Composable
+fun ScanScreen(
+ viewModel: ScanViewModel,
+ goBack: () -> Unit
+) {
+ val context = LocalContext.current
+
+ BackHandler(
+ onBack = goBack
+ )
+
+ Box(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.background)
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Crossfade(
+ targetState = viewModel.isAllowed(context)
+ ) { isAllowed ->
+ if (isAllowed) CameraPreview(
+ surfaceRequest = viewModel.surfaceRequest,
+ onBind = viewModel::bindCamera,
+ onFocusChange = viewModel::setFocus,
+ onZoomChange = viewModel::setZoom
+ ) else CameraOff(
+ onClick = { viewModel.requestPermission(context) }
+ )
+ }
+
+ BottomBar(
+ onBack = goBack,
+ onImage = viewModel::fromImage,
+ torchEnabled = viewModel.torchEnabled,
+ onTorchEnabledChange = viewModel::setTorch,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .alpha(0.8f)
+ )
+ }
+}
+
+@Composable
+private fun CameraOff(
+ onClick: () -> Unit
+) = Icon(
+ painter = painterResource(R.drawable.camera_off),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .size(45.dp)
+ .clickable(
+ onClick = onClick,
+ indication = ripple(bounded = false, radius = 45.dp),
+ interactionSource = remember { MutableInteractionSource() }
+ )
+)
+
+@Composable
+private fun CameraPreview(
+ surfaceRequest: SurfaceRequest?,
+ onBind: suspend (Context, LifecycleOwner) -> Unit,
+ onFocusChange: (Size, Offset) -> Unit,
+ onZoomChange: (Float) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val coordinateTransformer = remember { MutableCoordinateTransformer() }
+
+ LaunchedEffect(lifecycleOwner) {
+ onBind(context, lifecycleOwner)
+ }
+
+ surfaceRequest?.let {
+ CameraXViewfinder(
+ surfaceRequest = it,
+ coordinateTransformer = coordinateTransformer,
+ contentScale = ContentScale.Crop,
+ modifier = modifier
+ .pointerInput(Unit) {
+ detectTapGestures { offset ->
+ with(coordinateTransformer) {
+ onFocusChange(it.resolution, offset.transform())
+ }
+ }
+ }
+ .pointerInput(Unit) {
+ detectTransformGestures { _, _, zoom, _ ->
+ onZoomChange(zoom)
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun BottomBar(
+ onBack: () -> Unit,
+ onImage: (Context, Uri) -> Unit,
+ torchEnabled: Boolean,
+ onTorchEnabledChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier
+) = Row(
+ modifier = modifier
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(20.dp),
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ verticalAlignment = Alignment.Bottom
+) {
+ val context = LocalContext.current
+ val isPhotoPickerAvailable = remember { PickVisualMedia.isPhotoPickerAvailable(context) }
+ val picker = rememberLauncherForActivityResult(PickVisualMedia()) {
+ if (it != null) onImage(context, it)
+ }
+
+ if (isPhotoPickerAvailable) ActionButton(
+ onClick = { picker.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.photo),
+ contentDescription = null
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ ActionButton(
+ onClick = { onTorchEnabledChange(!torchEnabled) }
+ ) {
+ Icon(
+ painter = painterResource(
+ if (torchEnabled) R.drawable.bolt
+ else R.drawable.bolt_off
+ ),
+ contentDescription = null
+ )
+ }
+
+ ActionButton(
+ onClick = onBack
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.x),
+ contentDescription = null
+ )
+ }
+ }
+}
+
+@Composable
+private fun ActionButton(
+ onClick: () -> Unit,
+ content: @Composable () -> Unit
+) = FloatingActionButton(
+ onClick = onClick,
+ elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
+ shape = CircleShape,
+ content = content
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanViewModel.kt
new file mode 100644
index 0000000..b31feb3
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanViewModel.kt
@@ -0,0 +1,170 @@
+package dev.sanmer.authenticator.ui.screen.scan
+
+import android.Manifest
+import android.content.Context
+import android.net.Uri
+import android.util.Size
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.FocusMeteringAction
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceOrientedMeteringPointFactory
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.core.TorchState
+import androidx.camera.core.resolutionselector.AspectRatioStrategy
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.core.resolutionselector.ResolutionStrategy
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.lifecycle.awaitInstance
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ViewModel
+import dev.sanmer.auth.OtpUri.Default.isOtpUri
+import dev.sanmer.auth.QRCode
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.compat.PermissionCompat
+import kotlinx.coroutines.awaitCancellation
+import java.nio.ByteBuffer
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+class ScanViewModel(
+ private val onUri: (Uri) -> Unit
+) : ViewModel() {
+ var isAllowed by mutableStateOf(false)
+ private set
+
+ var surfaceRequest by mutableStateOf(null)
+ private set
+
+ private val preview = Preview.Builder()
+ .setResolutionSelector(
+ ResolutionSelector.Builder()
+ .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY)
+ .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
+ .build()
+ )
+ .build()
+ .apply {
+ setSurfaceProvider { surfaceRequest = it }
+ }
+
+ private val imageAnalysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
+ .build()
+ .apply {
+ setAnalyzer(Executors.newSingleThreadExecutor()) { image ->
+ try {
+ val plane = image.planes.first()
+ val data = plane.buffer.asByteArray()
+ val content = QRCode.decodeFromYuv(
+ yuvData = data,
+ dataWidth = plane.rowStride,
+ dataHeight = image.height,
+ width = image.width,
+ height = image.height,
+ )
+ val uri = Uri.parse(content)
+ if (uri.isOtpUri()) onUri(uri)
+ } catch (_: Throwable) {
+
+ } finally {
+ image.close()
+ }
+ }
+ }
+
+ private var camera: Camera? = null
+ var torchEnabled by mutableStateOf(false)
+ private set
+
+ private val logger = Logger.Android("ScanViewModel")
+
+ init {
+ logger.d("init")
+ }
+
+ fun isAllowed(context: Context) = if (isAllowed) {
+ true
+ } else {
+ isAllowed = PermissionCompat.checkPermission(context, Manifest.permission.CAMERA)
+ isAllowed
+ }
+
+ fun requestPermission(context: Context) =
+ PermissionCompat.requestPermission(context, Manifest.permission.CAMERA) {
+ isAllowed = it
+ }
+
+ suspend fun bindCamera(context: Context, lifecycleOwner: LifecycleOwner) {
+ val processCameraProvider = ProcessCameraProvider.awaitInstance(context)
+ val camera = processCameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ CameraSelector.DEFAULT_BACK_CAMERA,
+ preview, imageAnalysis
+ ).also { camera = it }
+
+ camera.cameraInfo.torchState.observe(lifecycleOwner) {
+ torchEnabled = it == TorchState.ON
+ }
+
+ try {
+ awaitCancellation()
+ } finally {
+ camera.cameraInfo.torchState.removeObservers(lifecycleOwner)
+ processCameraProvider.unbindAll()
+ }
+ }
+
+ fun setTorch(enabled: Boolean) {
+ val camera = camera ?: return
+ camera.cameraControl.enableTorch(enabled)
+ }
+
+ fun setFocus(resolution: Size, offset: Offset) {
+ val camera = camera ?: return
+ val point = SurfaceOrientedMeteringPointFactory(
+ resolution.width.toFloat(),
+ resolution.height.toFloat()
+ ).createPoint(offset.x, offset.y)
+ camera.cameraControl.startFocusAndMetering(
+ FocusMeteringAction.Builder(
+ point, FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE
+ ).setAutoCancelDuration(3, TimeUnit.SECONDS).build()
+ )
+ }
+
+ fun setZoom(zoom: Float) {
+ val camera = camera ?: return
+ val zoomState = camera.cameraInfo.zoomState.value ?: return
+ val newRatio = (zoomState.zoomRatio * zoom).coerceIn(
+ zoomState.minZoomRatio,
+ zoomState.maxZoomRatio
+ )
+ camera.cameraControl.setZoomRatio(newRatio)
+ }
+
+ fun fromImage(context: Context, uri: Uri) {
+ runCatching {
+ val cr = context.contentResolver
+ val stream = cr.openInputStream(uri) ?: return
+ val content = stream.use(QRCode::decodeFromStream) ?: return
+ val uri = Uri.parse(content)
+ if (uri.isOtpUri()) onUri(uri)
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+
+ private fun ByteBuffer.asByteArray(): ByteArray {
+ rewind()
+ val dst = ByteArray(remaining())
+ get(dst)
+ return dst
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingScreen.kt
new file mode 100644
index 0000000..756dc11
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingScreen.kt
@@ -0,0 +1,263 @@
+package dev.sanmer.authenticator.ui.screen.setting
+
+import android.content.Intent
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.Const
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.crypto.BiometricKey
+import dev.sanmer.authenticator.datastore.compose.LocalPreference
+import dev.sanmer.authenticator.ui.ktx.bottomWith
+import dev.sanmer.authenticator.ui.ktx.plus
+import dev.sanmer.authenticator.ui.ktx.topWith
+import dev.sanmer.authenticator.ui.screen.Screen
+import dev.sanmer.authenticator.ui.screen.ntp.component.name
+import dev.sanmer.authenticator.ui.screen.setting.SettingViewModel.BottomSheet
+import dev.sanmer.authenticator.ui.screen.setting.component.PasswordBottomSheet
+import dev.sanmer.authenticator.ui.screen.setting.component.SettingColumn
+import dev.sanmer.authenticator.ui.screen.setting.component.SettingItem
+import dev.sanmer.brand.Brand
+
+@Composable
+fun SettingScreen(
+ viewModel: SettingViewModel,
+ goTo: (Screen) -> Unit,
+ goBack: () -> Unit
+) {
+ val preference = LocalPreference.current
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ when (viewModel.bottomSheet) {
+ BottomSheet.None -> {}
+ BottomSheet.Password -> PasswordBottomSheet(
+ onClose = { viewModel.bottomSheet = BottomSheet.None },
+ password = viewModel.password,
+ hidden = viewModel.hidden,
+ onSet = viewModel::setupPassword,
+ onRemove = viewModel::removePassword,
+ onChange = viewModel::changePassword
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopBar(
+ onBack = goBack,
+ scrollBehavior = scrollBehavior
+ )
+ }
+ ) { contentPadding ->
+ Column(
+ modifier = Modifier
+ .animateContentSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .verticalScroll(rememberScrollState())
+ .padding(PaddingValues(15.dp) + contentPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ SettingColumn(
+ title = stringResource(R.string.setting_security)
+ ) {
+ SettingItem(
+ onClick = { viewModel.bottomSheet = BottomSheet.Password },
+ title = stringResource(R.string.setting_password),
+ value = stringResource(
+ if (preference.isEncrypted) R.string.setting_password_set
+ else R.string.setting_password_not_set
+ ),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.shield),
+ contentDescription = null
+ )
+ },
+ shape = with(MaterialTheme.shapes) { large bottomWith extraSmall }
+ )
+
+ if (preference.isEncrypted && BiometricKey.isInitialized) SettingItem(
+ onClick = if (preference.isBiometric) {
+ viewModel::removeBiometric
+ } else {
+ viewModel::setupBiometric
+ },
+ title = stringResource(R.string.setting_biometric),
+ value = stringResource(R.string.setting_biometric_desc),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.fingerprint),
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ Switch(
+ checked = preference.isBiometric,
+ onCheckedChange = null
+ )
+ }
+ )
+
+ SettingItem(
+ onClick = { viewModel.setSecureWindow(!preference.secureWindow) },
+ title = stringResource(R.string.setting_prevent_screenshot),
+ value = stringResource(R.string.setting_prevent_screenshot_desc),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.spy),
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ Switch(
+ checked = preference.secureWindow,
+ onCheckedChange = null
+ )
+ },
+ shape = with(MaterialTheme.shapes) { large topWith extraSmall }
+ )
+ }
+
+ SettingColumn(
+ title = stringResource(R.string.setting_database)
+ ) {
+ SettingItem(
+ onClick = { goTo(Screen.Trash) },
+ title = stringResource(R.string.trash_title),
+ value = if (viewModel.isTrashNotEmpty) viewModel.trashed.toString()
+ else stringResource(R.string.trash_empty),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.trash),
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.chevron_right),
+ contentDescription = null
+ )
+ },
+ enabled = viewModel.isTrashNotEmpty,
+ shape = with(MaterialTheme.shapes) { large bottomWith extraSmall }
+ )
+
+ SettingItem(
+ onClick = { goTo(Screen.Export) },
+ title = stringResource(R.string.setting_import_export),
+ value = stringResource(R.string.setting_import_export_desc),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.database),
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.chevron_right),
+ contentDescription = null
+ )
+ },
+ shape = with(MaterialTheme.shapes) { large topWith extraSmall }
+ )
+ }
+
+ SettingColumn(
+ title = stringResource(R.string.setting_other)
+ ) {
+ SettingItem(
+ onClick = { goTo(Screen.Ntp) },
+ title = stringResource(R.string.ntp_title),
+ value = preference.ntp.name(),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.timezone),
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.chevron_right),
+ contentDescription = null
+ )
+ },
+ shape = with(MaterialTheme.shapes) { large bottomWith extraSmall }
+ )
+
+ SettingItem(
+ onClick = { goTo(Screen.Brand) },
+ title = stringResource(R.string.setting_brand),
+ value = Brand.entries.size.toString(),
+ leadingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.copyright),
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ Icon(
+ painter = painterResource(R.drawable.chevron_right),
+ contentDescription = null
+ )
+ },
+ shape = with(MaterialTheme.shapes) { large topWith extraSmall }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TopBar(
+ onBack: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior
+) = TopAppBar(
+ title = { Text(text = stringResource(R.string.setting_title)) },
+ navigationIcon = {
+ IconButton(
+ onClick = onBack
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_left),
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ val context = LocalContext.current
+ IconButton(
+ onClick = {
+ context.startActivity(
+ Intent.parseUri(Const.GITHUB_URL, Intent.URI_INTENT_SCHEME)
+ )
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.brand_github_2),
+ contentDescription = null
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingViewModel.kt
new file mode 100644
index 0000000..08dbc64
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingViewModel.kt
@@ -0,0 +1,101 @@
+package dev.sanmer.authenticator.ui.screen.setting
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.sanmer.auth.crypto.SessionKey
+import dev.sanmer.auth.encodeBase64
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.crypto.BiometricKey.Default.getKeyEncryptedByBiometric
+import dev.sanmer.authenticator.repository.DbRepository
+import dev.sanmer.authenticator.repository.PreferenceRepository
+import kotlinx.coroutines.launch
+
+class SettingViewModel(
+ private val dbRepository: DbRepository,
+ private val preferenceRepository: PreferenceRepository
+) : ViewModel() {
+ var trashed by mutableIntStateOf(0)
+ private set
+ val isTrashNotEmpty inline get() = trashed > 0
+
+ val password = TextFieldState()
+ val hidden = mutableStateOf(true)
+
+ var bottomSheet by mutableStateOf(BottomSheet.None)
+
+ private val logger = Logger.Android("SettingViewModel")
+
+ init {
+ logger.d("init")
+ loadData()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ dbRepository.getTrashedCountAsFlow()
+ .collect { trashed = it }
+ }
+ }
+
+ fun setupPassword() {
+ viewModelScope.launch {
+ val key = SessionKey.new()
+ val keyEncrypted = key.getKeyEncryptedByPassword(password.text.trim())
+ preferenceRepository.setKeyEncryptedByPassword(keyEncrypted.encodeBase64())
+ dbRepository.encrypt(key)
+ password.clearText()
+ }
+ }
+
+ fun changePassword() {
+ viewModelScope.launch {
+ val key = dbRepository.getSessionKey()
+ if (key is SessionKey) {
+ val keyEncrypted = key.getKeyEncryptedByPassword(password.text.trim())
+ preferenceRepository.setKeyEncryptedByPassword(keyEncrypted.encodeBase64())
+ password.clearText()
+ }
+ }
+ }
+
+ fun removePassword() {
+ viewModelScope.launch {
+ preferenceRepository.setKeyEncryptedByPassword("")
+ preferenceRepository.setKeyEncryptedByBiometric("")
+ dbRepository.decrypt()
+ }
+ }
+
+ fun setupBiometric() {
+ viewModelScope.launch {
+ val key = dbRepository.getSessionKey()
+ if (key is SessionKey) {
+ val keyEncrypted = key.getKeyEncryptedByBiometric()
+ preferenceRepository.setKeyEncryptedByBiometric(keyEncrypted.encodeBase64())
+ }
+ }
+ }
+
+ fun removeBiometric() {
+ viewModelScope.launch {
+ preferenceRepository.setKeyEncryptedByBiometric("")
+ }
+ }
+
+ fun setSecureWindow(value: Boolean) {
+ viewModelScope.launch {
+ preferenceRepository.setSecureWindow(value)
+ }
+ }
+
+ sealed interface BottomSheet {
+ data object None : BottomSheet
+ data object Password : BottomSheet
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/PasswordBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/PasswordBottomSheet.kt
new file mode 100644
index 0000000..043343b
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/PasswordBottomSheet.kt
@@ -0,0 +1,129 @@
+package dev.sanmer.authenticator.ui.screen.setting.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.TextObfuscationMode
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedSecureTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.datastore.compose.LocalPreference
+import dev.sanmer.authenticator.ui.component.DragHandle
+import dev.sanmer.authenticator.ui.ktx.bottom
+
+@Composable
+fun PasswordBottomSheet(
+ onClose: () -> Unit,
+ password: TextFieldState,
+ hidden: MutableState,
+ onSet: () -> Unit,
+ onRemove: () -> Unit,
+ onChange: () -> Unit,
+) = ModalBottomSheet(
+ onDismissRequest = onClose,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ shape = MaterialTheme.shapes.large.bottom(0.dp),
+ dragHandle = null
+) {
+ DragHandle()
+
+ Text(
+ text = stringResource(R.string.setting_password),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(15.dp),
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ val preference = LocalPreference.current
+ val isEmpty by remember(password) {
+ derivedStateOf {
+ password.text.trim().isEmpty()
+ }
+ }
+
+ OutlinedSecureTextField(
+ state = password,
+ trailingIcon = {
+ IconButton(
+ onClick = { hidden.value = !hidden.value }
+ ) {
+ Icon(
+ painter = painterResource(
+ if (hidden.value) R.drawable.sparkles_2
+ else R.drawable.sparkles_2_off
+ ),
+ contentDescription = null
+ )
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ shape = MaterialTheme.shapes.medium,
+ textObfuscationMode = with(TextObfuscationMode) { if (hidden.value) Hidden else Visible },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(15.dp)
+ ) {
+ if (preference.isEncrypted) {
+ OutlinedButton(
+ onClick = onRemove,
+ enabled = isEmpty,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = stringResource(R.string.setting_password_remove))
+ }
+
+ Button(
+ onClick = onChange,
+ enabled = !isEmpty,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = stringResource(R.string.setting_password_change))
+ }
+ } else {
+ Button(
+ onClick = onSet,
+ enabled = !isEmpty,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = stringResource(R.string.token_save))
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingColumn.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingColumn.kt
new file mode 100644
index 0000000..8aaf9f4
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingColumn.kt
@@ -0,0 +1,29 @@
+package dev.sanmer.authenticator.ui.screen.setting.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SettingColumn(
+ modifier: Modifier = Modifier,
+ title: String = "",
+ content: @Composable (ColumnScope.() -> Unit)
+) = Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+) {
+ if (title.isNotEmpty()) Text(
+ text = title,
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(start = 20.dp, bottom = 8.dp)
+ )
+
+ content()
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingItem.kt
new file mode 100644
index 0000000..c9ff0a8
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingItem.kt
@@ -0,0 +1,62 @@
+package dev.sanmer.authenticator.ui.screen.setting.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.ui.ktx.surface
+
+@Composable
+fun SettingItem(
+ onClick: () -> Unit,
+ title: String,
+ value: String,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ leadingIcon: @Composable RowScope.() -> Unit = {},
+ trailingIcon: @Composable RowScope. () -> Unit = {},
+ shape: Shape = MaterialTheme.shapes.extraSmall
+) = Row(
+ modifier = modifier
+ .surface(
+ shape = shape,
+ backgroundColor = MaterialTheme.colorScheme.surfaceContainer
+ )
+ .clickable(
+ enabled = enabled,
+ onClick = onClick
+ )
+ .padding(horizontal = 20.dp, vertical = 15.dp),
+ horizontalArrangement = Arrangement.spacedBy(15.dp),
+ verticalAlignment = Alignment.CenterVertically
+) {
+ leadingIcon()
+
+ Column(
+ modifier = Modifier
+ .padding(start = 5.dp)
+ .weight(1f)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+
+ trailingIcon()
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashScreen.kt
new file mode 100644
index 0000000..d61ec4b
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashScreen.kt
@@ -0,0 +1,141 @@
+package dev.sanmer.authenticator.ui.screen.trash
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.ui.screen.Screen
+import dev.sanmer.authenticator.ui.screen.trash.component.AuthList
+
+@Composable
+fun TrashScreen(
+ viewModel: TrashViewModel,
+ goTo: (Screen) -> Unit,
+ goBack: () -> Unit
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ BackHandler(
+ enabled = viewModel.isPick,
+ onBack = viewModel::clearSelected
+ )
+
+ Scaffold(
+ topBar = {
+ TopBar(
+ onBack = {
+ if (viewModel.isPick) {
+ viewModel.clearSelected()
+ } else {
+ goBack()
+ }
+ },
+ selected = viewModel.selected,
+ isPick = viewModel.isPick,
+ onDelete = viewModel::delete,
+ scrollBehavior = scrollBehavior
+ )
+ },
+ floatingActionButton = {
+ ActionButton(
+ onClick = viewModel::restore,
+ visible = viewModel.isPick
+ )
+ }
+ ) { contentPadding ->
+ viewModel.data.onSuccess { list ->
+ AuthList(
+ list = list,
+ onEdit = {
+ if (viewModel.isPick) {
+ viewModel.pick(it)
+ } else {
+ goTo(Screen.Edit(authId = it.id))
+ }
+ },
+ isSelected = viewModel::isSelected,
+ onPick = viewModel::pick,
+ contentPadding = contentPadding,
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ )
+ }
+ }
+}
+
+@Composable
+private fun TopBar(
+ onBack: () -> Unit,
+ selected: Int,
+ isPick: Boolean,
+ onDelete: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior
+) = TopAppBar(
+ title = {
+ Text(
+ text = if (isPick) selected.toString()
+ else stringResource(R.string.trash_title)
+ )
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = onBack
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_left),
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ AnimatedVisibility(
+ visible = isPick,
+ enter = fadeIn() + scaleIn(),
+ exit = scaleOut() + fadeOut()
+ ) {
+ IconButton(
+ onClick = onDelete
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.trash_x),
+ contentDescription = null
+ )
+ }
+ }
+ },
+ scrollBehavior = scrollBehavior
+)
+
+@Composable
+private fun ActionButton(
+ onClick: () -> Unit,
+ visible: Boolean = true
+) = AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn() + scaleIn(),
+ exit = scaleOut() + fadeOut()
+) {
+ FloatingActionButton(
+ onClick = onClick
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.restore),
+ contentDescription = null
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashViewModel.kt
new file mode 100644
index 0000000..ce62a38
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashViewModel.kt
@@ -0,0 +1,81 @@
+package dev.sanmer.authenticator.ui.screen.trash
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dev.sanmer.authenticator.Const.INSTANT_ZERO
+import dev.sanmer.authenticator.Logger
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.model.LoadData
+import dev.sanmer.authenticator.repository.DbRepository
+import kotlinx.coroutines.launch
+
+class TrashViewModel(
+ private val dbRepository: DbRepository
+) : ViewModel() {
+ var data by mutableStateOf>>(LoadData.Loading)
+ private set
+
+ private val _selected = mutableStateListOf()
+ val selected get() = _selected.size
+ val isPick get() = _selected.isNotEmpty()
+
+ private val logger = Logger.Android("TrashViewModel")
+
+ init {
+ logger.d("init")
+ loadData()
+ }
+
+ private fun loadData() {
+ viewModelScope.launch {
+ dbRepository.getTrashedAuthAsFlow()
+ .collect { list ->
+ data = LoadData.Success(
+ list.sortedByDescending { it.trashedAt }
+ )
+ }
+ }
+ }
+
+ fun isSelected(auth: Auth) = _selected.contains(auth.id)
+
+ fun pick(auth: Auth) = if (isSelected(auth)) {
+ _selected.remove(auth.id)
+ } else {
+ _selected.add(auth.id)
+ }
+
+ fun clearSelected() {
+ _selected.clear()
+ }
+
+ fun restore() {
+ viewModelScope.launch {
+ _selected.forEach { authId ->
+ runCatching {
+ dbRepository.trash(authId, INSTANT_ZERO)
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ clearSelected()
+ }
+ }
+
+ fun delete() {
+ viewModelScope.launch {
+ _selected.forEach { authId ->
+ runCatching {
+ dbRepository.delete(authId)
+ }.onFailure {
+ logger.e(it)
+ }
+ }
+ clearSelected()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthItem.kt
new file mode 100644
index 0000000..7338e3a
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthItem.kt
@@ -0,0 +1,107 @@
+package dev.sanmer.authenticator.ui.screen.trash.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.Const.DATETIME_DISPLAY
+import dev.sanmer.authenticator.R
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.ui.component.LabelText
+import dev.sanmer.authenticator.ui.ktx.surface
+import dev.sanmer.brand.Brand
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.format
+import kotlinx.datetime.toLocalDateTime
+
+@Composable
+fun AuthItem(
+ auth: Auth,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ modifier: Modifier = Modifier
+) = Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .surface(
+ shape = MaterialTheme.shapes.large,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ border = CardDefaults.outlinedCardBorder(false)
+ )
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick
+ )
+ .padding(15.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(13.dp)
+) {
+ val brand by remember(auth.id) {
+ derivedStateOf { Brand.valueOfOrNull(auth.issuer) }
+ }
+ val trashedAt by remember(auth.id) {
+ derivedStateOf {
+ auth.trashedAt
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .format(DATETIME_DISPLAY)
+ }
+ }
+
+ Image(
+ painter = painterResource(brand?.id ?: R.drawable.fingerprint_thin),
+ contentDescription = null,
+ modifier = Modifier.size(45.dp),
+ colorFilter = if (brand != null) null else
+ ColorFilter.tint(MaterialTheme.colorScheme.primary)
+ )
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (auth.name.isNotEmpty()) Text(
+ text = auth.name,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ if (brand == null && auth.issuer.isNotEmpty()) LabelText(
+ text = auth.issuer
+ )
+ }
+
+ Text(
+ text = stringResource(R.string.trashed_at, trashedAt),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+
+ if (isSelected) Icon(
+ painter = painterResource(R.drawable.circle_check_filled),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(30.dp)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthList.kt
new file mode 100644
index 0000000..83138ca
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthList.kt
@@ -0,0 +1,41 @@
+package dev.sanmer.authenticator.ui.screen.trash.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.sanmer.authenticator.database.model.Auth
+import dev.sanmer.authenticator.ui.ktx.plus
+
+@Composable
+fun AuthList(
+ list: List,
+ onEdit: (Auth) -> Unit,
+ isSelected: (Auth) -> Boolean,
+ onPick: (Auth) -> Unit,
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ contentPadding: PaddingValues = PaddingValues(0.dp)
+) = LazyColumn(
+ modifier = modifier,
+ state = state,
+ contentPadding = PaddingValues(15.dp) + contentPadding,
+ verticalArrangement = Arrangement.spacedBy(15.dp)
+) {
+ items(
+ items = list,
+ key = { it.id }
+ ) {
+ AuthItem(
+ auth = it,
+ isSelected = isSelected(it),
+ onClick = { onEdit(it) },
+ onLongClick = { onPick(it) }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeScreen.kt
deleted file mode 100644
index cc30005..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeScreen.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.authorize
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.ui.platform.LocalContext
-import dev.sanmer.authenticator.ktx.finishActivity
-import dev.sanmer.authenticator.ui.AuthorizeActivity.Action
-import dev.sanmer.authenticator.ui.screens.authorize.component.ChangePasswordItem
-import dev.sanmer.authenticator.ui.screens.authorize.component.EnterPasswordItem
-import org.koin.androidx.compose.koinViewModel
-
-@Composable
-fun AuthorizeScreen(
- viewModel: AuthorizeViewModel = koinViewModel()
-) {
- if (!viewModel.loadState.isReady) return
- val context = LocalContext.current
-
- DisposableEffect(viewModel.type) {
- if (viewModel.type.isBiometricPending) {
- viewModel.loadSessionKeyByBiometric(context)
- }
- if (viewModel.type.isSucceed) {
- context.finishActivity()
- }
- onDispose {}
- }
-
- when (viewModel.action) {
- Action.ChangePassword -> ChangePasswordItem(
- onDismiss = context::finishActivity,
- isPasswordError = viewModel.type.isPasswordFailed,
- onChange = viewModel::changePassword
- )
-
- Action.Auth -> if (viewModel.type.isPassword) EnterPasswordItem(
- onDismiss = context::finishActivity,
- action = viewModel.action,
- isPasswordError = viewModel.type.isPasswordFailed,
- onEnter = viewModel::loadSessionKeyByPassword,
- enableBiometric = viewModel.isSupportedBiometric,
- onBiometric = viewModel::retryBiometric
- )
-
- Action.SetupPassword -> EnterPasswordItem(
- onDismiss = context::finishActivity,
- action = viewModel.action,
- isPasswordError = viewModel.type.isPasswordFailed,
- onEnter = viewModel::setupPassword
- )
-
- Action.RemovePassword -> EnterPasswordItem(
- onDismiss = context::finishActivity,
- action = viewModel.action,
- isPasswordError = viewModel.type.isPasswordFailed,
- onEnter = viewModel::removePassword
- )
-
- Action.SetupBiometric -> EnterPasswordItem(
- onDismiss = context::finishActivity,
- action = viewModel.action,
- isPasswordError = viewModel.type.isPasswordFailed,
- onEnter = { viewModel.setupBiometric(it, context) }
- )
-
- Action.RemoveBiometric -> EnterPasswordItem(
- onDismiss = context::finishActivity,
- action = viewModel.action,
- isPasswordError = viewModel.type.isPasswordFailed,
- onEnter = viewModel::removeBiometric
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeViewModel.kt
deleted file mode 100644
index f4ad836..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeViewModel.kt
+++ /dev/null
@@ -1,212 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.authorize
-
-import android.content.Context
-import android.content.Intent
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.repository.DbRepository
-import dev.sanmer.authenticator.repository.PreferenceRepository
-import dev.sanmer.authenticator.ui.AuthorizeActivity
-import dev.sanmer.authenticator.ui.screens.main.MainViewModel.LoadState
-import dev.sanmer.crypto.BiometricKey
-import dev.sanmer.crypto.BiometricKey.Default.decryptKeyByBiometric
-import dev.sanmer.crypto.BiometricKey.Default.getKeyEncryptedByBiometric
-import dev.sanmer.crypto.Crypto
-import dev.sanmer.crypto.SessionKey
-import dev.sanmer.encoding.decodeBase64
-import dev.sanmer.encoding.encodeBase64
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-
-class AuthorizeViewModel(
- private val preferenceRepository: PreferenceRepository,
- private val dbRepository: DbRepository
-) : ViewModel() {
- var action by mutableStateOf(AuthorizeActivity.Action.Auth)
- private set
-
- var type by mutableStateOf(Type.PasswordPending)
- private set
-
- var loadState by mutableStateOf(LoadState.Pending)
- private set
- private val preference inline get() = loadState.preference
-
- var isSupportedBiometric by mutableStateOf(false)
- private set
-
- private val logger = Logger.Android("AuthorizeViewModel")
-
- init {
- logger.d("init")
- preferenceObserver()
- }
-
- private fun preferenceObserver() {
- viewModelScope.launch {
- preferenceRepository.data.collect {
- loadState = LoadState.Ready(it)
- if (it.isBiometric) {
- type = Type.BiometricPending
- isSupportedBiometric = true
- }
- }
- }
- }
-
- fun updateFromIntent(getIntent: () -> Intent) {
- val intent = getIntent()
- action = AuthorizeActivity.Action.Default(intent.action)
- }
-
- fun setupPassword(new: String) {
- viewModelScope.launch {
- runCatching {
- val sessionKey = SessionKey.new()
- val newKey = sessionKey.getKeyEncryptedByPassword(new).encodeBase64()
- preferenceRepository.setKeyEncryptedByPassword(newKey)
- dbRepository.encrypt(sessionKey)
- dbRepository.setSessionKey(sessionKey)
- type = Type.PasswordSucceed
- }.onFailure {
- logger.w(it)
- }
- }
- }
-
- private fun checkPassword(
- current: String,
- callback: suspend CoroutineScope.(SessionKey) -> Unit
- ) {
- if (type.isPasswordFailed) type = Type.PasswordPending
- viewModelScope.launch {
- val key = preference.keyEncryptedByPassword
- runCatching {
- callback(
- SessionKey.decryptKeyByPassword(key.decodeBase64(), current)
- )
- }.onFailure {
- type = Type.PasswordFailed
- logger.w(it)
- }
- }
- }
-
- fun changePassword(current: String, new: String) = checkPassword(current) { sessionKey ->
- val newSessionKey = SessionKey.new()
- val newKey = newSessionKey.getKeyEncryptedByPassword(new).encodeBase64()
- preferenceRepository.setKeyEncryptedByPassword(newKey)
- preferenceRepository.setKeyEncryptedByBiometric("")
- dbRepository.reEncrypt(sessionKey, newSessionKey)
- dbRepository.setSessionKey(newSessionKey)
- type = Type.PasswordSucceed
- }
-
- fun removePassword(current: String) = checkPassword(current) { sessionKey ->
- preferenceRepository.setKeyEncryptedByPassword("")
- preferenceRepository.setKeyEncryptedByBiometric("")
- dbRepository.decrypt(sessionKey)
- dbRepository.setSessionKey(Crypto.None)
- type = Type.PasswordSucceed
- }
-
- fun setupBiometric(
- current: String,
- context: Context
- ) = checkPassword(current) { sessionKey ->
- runCatching {
- BiometricKey.new()
- val key = sessionKey.getKeyEncryptedByBiometric(context).encodeBase64()
- preferenceRepository.setKeyEncryptedByBiometric(key)
- type = Type.BiometricSucceed
- }.onFailure {
- type = Type.BiometricFailed
- logger.w(it)
- }
- }
-
- fun removeBiometric(
- current: String
- ) = checkPassword(current) {
- preferenceRepository.setKeyEncryptedByBiometric("")
- type = Type.PasswordSucceed
- }
-
- fun loadSessionKeyByPassword(current: String) {
- viewModelScope.launch {
- val key = preference.keyEncryptedByPassword
- runCatching {
- dbRepository.setSessionKey(
- SessionKey.decryptKeyByPassword(key.decodeBase64(), current)
- )
- type = Type.PasswordSucceed
- }.onFailure {
- type = Type.PasswordFailed
- logger.w(it)
- }
- }
- }
-
- fun retryBiometric() {
- type = Type.BiometricPending
- }
-
- fun loadSessionKeyByBiometric(context: Context) {
- if (action != AuthorizeActivity.Action.Auth) return
- viewModelScope.launch {
- if (!BiometricKey.canAuthenticate(context)) {
- isSupportedBiometric = false
- return@launch
- }
-
- runCatching {
- val key = preference.keyEncryptedByBiometric.decodeBase64()
- dbRepository.setSessionKey(
- SessionKey.decryptKeyByBiometric(key, context)
- )
- type = Type.BiometricSucceed
- }.onFailure {
- logger.w(it)
- type = Type.PasswordPending
- }
- }
- }
-
- sealed class Type {
- abstract val state: State
-
- data class Biometric(
- override val state: State
- ) : Type()
-
- data class Password(
- override val state: State
- ) : Type()
-
- val isPending inline get() = state == State.Pending
- val isFailed inline get() = state == State.Failed
- val isSucceed inline get() = state == State.Succeed
- val isPassword inline get() = this is Password
- val isBiometricPending inline get() = this is Biometric && isPending
- val isPasswordFailed inline get() = isPassword && isFailed
-
- companion object Default {
- val BiometricPending = Biometric(State.Pending)
- val BiometricSucceed = Biometric(State.Succeed)
- val BiometricFailed = Biometric(State.Failed)
- val PasswordPending = Password(State.Pending)
- val PasswordSucceed = Password(State.Succeed)
- val PasswordFailed = Password(State.Failed)
- }
- }
-
- enum class State {
- Pending,
- Succeed,
- Failed,
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt
deleted file mode 100644
index d10fc20..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.authorize.component
-
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.ImeAction
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet
-
-@Composable
-fun ChangePasswordItem(
- onDismiss: () -> Unit,
- isPasswordError: Boolean,
- onChange: (String, String) -> Unit
-) {
- var currentPassword by remember { mutableStateOf("") }
- var newPassword by remember { mutableStateOf("") }
-
- SettingBottomSheet(
- onDismiss = onDismiss,
- title = stringResource(id = R.string.security_change_password),
- ) {
- PasswordTextField(
- password = currentPassword,
- onPasswordChange = { currentPassword = it },
- imeAction = ImeAction.Next,
- isError = isPasswordError,
- title = stringResource(id = R.string.security_current_password),
- modifier = Modifier.fillMaxWidth()
- )
-
- PasswordTextField(
- password = newPassword,
- onPasswordChange = { newPassword = it },
- onImeDone = { onChange(currentPassword, newPassword) },
- title = stringResource(id = R.string.security_new_password),
- modifier = Modifier.fillMaxWidth()
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt
deleted file mode 100644
index 7e610c0..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.authorize.component
-
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.AuthorizeActivity.Action
-import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet
-
-@Composable
-fun EnterPasswordItem(
- onDismiss: () -> Unit,
- action: Action,
- isPasswordError: Boolean,
- onEnter: (String) -> Unit,
- enableBiometric: Boolean = false,
- onBiometric: () -> Unit = {}
-) {
- var currentPassword by remember { mutableStateOf("") }
-
- SettingBottomSheet(
- onDismiss = onDismiss,
- title = stringResource(
- id = when (action) {
- Action.SetupPassword -> R.string.security_set_password
- Action.RemovePassword -> R.string.security_remove_password
- else -> R.string.security_enter_password
- }
- ),
- ) {
- PasswordTextField(
- password = currentPassword,
- onPasswordChange = { currentPassword = it },
- onImeDone = { onEnter(currentPassword) },
- isError = isPasswordError,
- title = stringResource(
- id = when (action) {
- Action.SetupPassword -> R.string.security_new_password
- else -> R.string.security_current_password
- }
- ),
- modifier = Modifier.fillMaxWidth(),
- actionIcon = if (enableBiometric) R.drawable.fingerprint else null,
- onActionClick = if (enableBiometric) onBiometric else null
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/PasswordTextField.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/PasswordTextField.kt
deleted file mode 100644
index 58b22c5..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/PasswordTextField.kt
+++ /dev/null
@@ -1,96 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.authorize.component
-
-import androidx.annotation.DrawableRes
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.input.VisualTransformation
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-
-@Composable
-fun PasswordTextField(
- modifier: Modifier = Modifier,
- password: String,
- onPasswordChange: (String) -> Unit,
- imeAction: ImeAction = ImeAction.Done,
- onImeDone: () -> Unit = {},
- title: String? = null,
- isError: Boolean = false,
- placeholder: (@Composable () -> Unit)? = null,
- @DrawableRes actionIcon: Int? = null,
- onActionClick: (() -> Unit)? = null
-) {
- var hidden by remember { mutableStateOf(true) }
- val passwordVisualTransformation = remember { PasswordVisualTransformation() }
-
- Column(
- verticalArrangement = Arrangement.spacedBy(5.dp)
- ) {
- if (title != null) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium
- )
- }
-
- OutlinedTextField(
- value = password,
- onValueChange = onPasswordChange,
- shape = MaterialTheme.shapes.medium,
- singleLine = true,
- isError = isError,
- modifier = modifier,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Password,
- imeAction = imeAction
- ),
- keyboardActions = KeyboardActions(
- onDone = {
- onImeDone()
- defaultKeyboardAction(ImeAction.Done)
- }
- ),
- visualTransformation = if (hidden) {
- passwordVisualTransformation
- } else {
- VisualTransformation.None
- },
- placeholder = placeholder,
- trailingIcon = {
- IconButton(
- onClick = onActionClick ?: {
- hidden = !hidden
- }
- ) {
- Icon(
- painter = painterResource(
- id = actionIcon ?: if (hidden) {
- R.drawable.eye_closed
- } else {
- R.drawable.eye
- }
- ),
- contentDescription = null
- )
- }
- }
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt
deleted file mode 100644
index a8dced3..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.crypto
-
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ktx.finishActivity
-import dev.sanmer.authenticator.ui.screens.authorize.component.PasswordTextField
-import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet
-import org.koin.androidx.compose.koinViewModel
-
-@Composable
-fun CryptoScreen(
- viewModel: CryptoViewModel = koinViewModel()
-) {
- val context = LocalContext.current
-
- DisposableEffect(viewModel.state) {
- if (viewModel.state.isSucceed) context.finishActivity()
- onDispose {}
- }
-
- SettingBottomSheet(
- onDismiss = context::finishActivity,
- title = stringResource(
- id = if (viewModel.isEncrypt) {
- R.string.crypto_encrypt
- } else {
- R.string.crypto_decrypt
- }
- )
- ) {
- PasswordTextField(
- password = viewModel.password,
- onPasswordChange = viewModel::updatePassword,
- onImeDone = viewModel::crypto,
- isError = viewModel.state.isFailed,
- title = stringResource(id = R.string.security_password),
- modifier = Modifier.fillMaxWidth(),
- actionIcon = if (viewModel.isSkip) R.drawable.lock_off else null,
- onActionClick = if (viewModel.isSkip) viewModel::crypto else null
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoViewModel.kt
deleted file mode 100644
index 087c872..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoViewModel.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.crypto
-
-import android.content.Intent
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.ui.CryptoActivity
-import dev.sanmer.authenticator.ui.CryptoActivity.Default.bypass
-import dev.sanmer.authenticator.ui.CryptoActivity.Default.input
-import dev.sanmer.crypto.PasswordKey
-import kotlinx.coroutines.launch
-
-class CryptoViewModel : ViewModel() {
- private var data = emptyList()
- private var action by mutableStateOf(CryptoActivity.Action.Encrypt)
- private var bypass = true
-
- var state by mutableStateOf(State.Pending)
- private set
-
- var password by mutableStateOf("")
- private set
-
- val isSkip by derivedStateOf { password.isEmpty() && bypass }
- val isEncrypt by lazy { action == CryptoActivity.Action.Encrypt }
- val isDecrypt by lazy { action == CryptoActivity.Action.Decrypt }
-
- val output get() = if (state.isSucceed) data else emptyList()
-
- private val logger = Logger.Android("CryptoViewModel")
-
- init {
- logger.d("init")
- }
-
- fun crypto() {
- if (state.isRunning) return
- if (isSkip) {
- state = State.Succeed
- return
- }
-
- viewModelScope.launch {
- runCatching {
- val key = PasswordKey.new(password)
- state = State.Running
- data = when (action) {
- CryptoActivity.Action.Encrypt -> data.map { key.encrypt(it) }
- CryptoActivity.Action.Decrypt -> data.map { key.decrypt(it) }
- }
- state = State.Succeed
- }.onFailure {
- state = State.Failed
- }
- }
- }
-
- fun updateFromIntent(getIntent: () -> Intent) {
- val intent = getIntent()
- action = CryptoActivity.Action.Default(intent.action)
- bypass = intent.bypass
- data = intent.input.toList()
- }
-
- fun updatePassword(value: String) {
- password = value
- }
-
- enum class State {
- Pending,
- Running,
- Succeed,
- Failed;
-
- val isRunning inline get() = this == Running
- val isFailed inline get() = this == Failed
- val isSucceed inline get() = this == Succeed
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditScreen.kt
deleted file mode 100644
index 4a70b91..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditScreen.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.edit
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalClipboard
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.ktx.setSensitiveText
-import dev.sanmer.authenticator.ui.screens.edit.EditViewModel.Value
-import dev.sanmer.authenticator.ui.screens.edit.component.DigitsAndPeriodItem
-import dev.sanmer.authenticator.ui.screens.edit.component.SecretItem
-import dev.sanmer.authenticator.ui.screens.edit.component.TextFieldItem
-import dev.sanmer.authenticator.ui.screens.edit.component.TypeAndHashItem
-import kotlinx.coroutines.launch
-
-@Composable
-fun EditScreen(
- viewModel: EditViewModel,
- goBack: () -> Unit,
-) {
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
-
- Scaffold(
- topBar = {
- TopBar(
- isEdit = viewModel.isEdit,
- uri = viewModel.uri,
- onClose = goBack,
- onSave = { viewModel.save { if (!viewModel.isEdit) goBack() } },
- scrollBehavior = scrollBehavior
- )
- }
- ) { contentPadding ->
- Column(
- modifier = Modifier
- .imePadding()
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .verticalScroll(rememberScrollState())
- .padding(contentPadding)
- .padding(vertical = 15.dp, horizontal = 5.dp),
- verticalArrangement = Arrangement.spacedBy(15.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- TextFieldItem(
- value = viewModel.input.name,
- onValueChange = { name ->
- viewModel.update { it.copy(name = name) }
- },
- leadingIcon = R.drawable.user,
- label = stringResource(id = R.string.edit_name),
- isError = viewModel.isError(Value.Name)
- )
-
- TextFieldItem(
- value = viewModel.input.issuer,
- onValueChange = { issuer ->
- viewModel.update { it.copy(issuer = issuer) }
- },
- label = stringResource(id = R.string.edit_issuer),
- isError = viewModel.isError(Value.Issuer)
- )
-
- SecretItem(
- secret = viewModel.input.secret,
- uri = viewModel.uri,
- onSecretChange = { secret ->
- viewModel.update { it.copy(secret = secret) }
- },
- isError = viewModel.isError(Value.Secret),
- readOnly = viewModel.isEdit
- )
-
- TypeAndHashItem(
- type = viewModel.input.type,
- onTypeChange = { type ->
- viewModel.update { it.copy(type = type) }
- },
- hash = viewModel.input.hash,
- onHashChange = { hash ->
- viewModel.update { it.copy(hash = hash) }
- },
- readOnly = viewModel.isEdit
- )
-
- DigitsAndPeriodItem(
- type = viewModel.input.type,
- digits = viewModel.input.digits,
- onDigitsChange = { digits ->
- viewModel.update { it.copy(digits = digits) }
- },
- period = viewModel.input.period,
- onPeriodChange = { period ->
- viewModel.update { it.copy(period = period) }
- },
- readOnly = viewModel.isEdit
- )
- }
- }
-}
-
-@Composable
-private fun TopBar(
- isEdit: Boolean,
- uri: String,
- onClose: () -> Unit,
- onSave: () -> Unit,
- scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
- title = {
- Text(
- text = stringResource(
- id = if (isEdit) R.string.edit_edit_title else R.string.edit_add_title
- )
- )
- },
- navigationIcon = {
- IconButton(
- onClick = onClose
- ) {
- Icon(
- painter = painterResource(id = R.drawable.x),
- contentDescription = null
- )
- }
- },
- actions = {
- val clipboard = LocalClipboard.current
- val scope = rememberCoroutineScope()
-
- if (uri.isNotBlank()) {
- IconButton(
- onClick = {
- scope.launch {
- clipboard.setSensitiveText(uri)
- }
- },
- ) {
- Icon(
- painter = painterResource(id = R.drawable.clipboard_text),
- contentDescription = null
- )
- }
- }
-
- IconButton(
- onClick = onSave
- ) {
- Icon(
- painter = painterResource(id = R.drawable.device_floppy),
- contentDescription = null
- )
- }
- },
- scrollBehavior = scrollBehavior
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditViewModel.kt
deleted file mode 100644
index 642deb1..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditViewModel.kt
+++ /dev/null
@@ -1,128 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.edit
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.authenticator.model.auth.AuthType
-import dev.sanmer.authenticator.model.serializer.AuthUri.Default.toTotpEntity
-import dev.sanmer.authenticator.model.serializer.AuthUri.Default.uri
-import dev.sanmer.authenticator.repository.DbRepository
-import dev.sanmer.encoding.isBase32
-import dev.sanmer.otp.Otp
-import dev.sanmer.otp.OtpUri.Default.isOtpUri
-import dev.sanmer.otp.OtpUri.Default.toOtpUri
-import kotlinx.coroutines.launch
-
-class EditViewModel(
- private val dbRepository: DbRepository,
- private val id: Long,
- uri: String
-) : ViewModel() {
- val isEdit = id != Long.MAX_VALUE
-
- var input by mutableStateOf(Input())
- private set
-
- var uri by mutableStateOf(uri)
- private set
-
- private val result = mutableStateMapOf()
-
- private val logger = Logger.Android("EditViewModel")
-
- init {
- logger.d("init")
- updateFromUri(uri)
- dataObserver()
- }
-
- private fun dataObserver() {
- viewModelScope.launch {
- dbRepository.getTotpDecryptedByIdAsFlow(id)
- .collect { entity ->
- update { Input(entity) }
- uri = entity.uri().toString()
- }
- }
- }
-
- private fun isAllOk(): Boolean {
- Value.Name.ok(input.name, result::put)
- Value.Issuer.ok(input.issuer, result::put)
- Value.Secret.ok(input.secret, result::put)
- return result.all { it.value }
- }
-
- fun isError(value: Value) = !(result[value] ?: true)
-
- private fun updateFromUri(uriString: String) {
- if (!uriString.isOtpUri()) return
- runCatching {
- uriString.toOtpUri().toTotpEntity()
- }.onSuccess { entity ->
- update { Input(entity) }
- }.onFailure {
- logger.e(it)
- }
- }
-
- fun update(block: (Input) -> Input) {
- input = block(input)
- }
-
- fun save(block: () -> Unit = {}) {
- if (isAllOk()) {
- viewModelScope.launch {
- when {
- isEdit -> dbRepository.updateTotp(input.entity(id))
- else -> dbRepository.insertTotp(input.entity())
- }
- block()
- }
- }
- }
-
- data class Input(
- val type: AuthType = AuthType.TOTP,
- val name: String = "",
- val issuer: String = "",
- val secret: String = "",
- val hash: Otp.Hash = Otp.Hash.SHA1,
- val digits: String = "6",
- val period: String = "30"
- ) {
- constructor(entity: TotpEntity) : this(
- name = entity.name,
- issuer = entity.issuer,
- secret = entity.secret,
- hash = entity.hash,
- digits = entity.digits.toString(),
- period = entity.period.toString()
- )
-
- fun entity(id: Long = 0) = TotpEntity(
- id = id,
- name = name.trim(),
- issuer = issuer.trim(),
- secret = secret.replace("\\s+".toRegex(), ""),
- hash = hash,
- digits = digits.toInt(),
- period = period.toLong()
- )
- }
-
- enum class Value(val ok: (String) -> Boolean) {
- Issuer(String::isNotBlank),
- Name(String::isNotBlank),
- Secret(String::isBase32)
- }
-
- private inline fun Value.ok(value: String, block: (Value, Boolean) -> Unit) {
- block(this, ok(value))
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt
deleted file mode 100644
index 693c30c..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.edit.component
-
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.model.auth.AuthType
-
-@Composable
-fun DigitsAndPeriodItem(
- type: AuthType,
- digits: String,
- onDigitsChange: (String) -> Unit,
- period: String,
- onPeriodChange: (String) -> Unit,
- readOnly: Boolean
-) = TextFieldContent {
- TextField(
- value = digits,
- onValueChange = onDigitsChange,
- label = stringResource(id = R.string.edit_digits),
- modifier = Modifier.weight(1f),
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Number,
- imeAction = ImeAction.Next
- ),
- readOnly = readOnly
- )
-
- Spacer(modifier = Modifier.width(15.dp))
-
- TextField(
- value = when (type) {
- AuthType.TOTP -> period
- },
- onValueChange = when (type) {
- AuthType.TOTP -> onPeriodChange
- },
- label = when (type) {
- AuthType.TOTP -> stringResource(id = R.string.edit_period)
- },
- modifier = Modifier.weight(1f),
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Number,
- imeAction = ImeAction.Done
- ),
- readOnly = readOnly
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/SecretItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/SecretItem.kt
deleted file mode 100644
index fbf4acc..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/SecretItem.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.edit.component
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.BottomSheetDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.input.VisualTransformation
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.component.DragHandle
-import dev.sanmer.authenticator.ui.ktx.bottom
-import dev.sanmer.qrcode.QRCode
-
-@Composable
-fun SecretItem(
- secret: String,
- uri: String,
- onSecretChange: (String) -> Unit,
- isError: Boolean,
- readOnly: Boolean
-) {
- var show by remember { mutableStateOf(false) }
- if (show) QRCodeBottomSheet(
- onDismiss = { show = false },
- uri = uri
- )
-
- TextFieldContent(
- leading = { TextFieldContentIcon(icon = R.drawable.key) },
- trailing = {
- if (readOnly && uri.isNotEmpty()) {
- IconButton(
- onClick = { show = true }
- ) {
- Icon(
- painter = painterResource(R.drawable.qrcode),
- contentDescription = null
- )
- }
- } else {
- TextFieldContentSpacer()
- }
- }
- ) {
- val passwordVisualTransformation = remember { PasswordVisualTransformation() }
-
- TextField(
- value = secret,
- onValueChange = onSecretChange,
- label = stringResource(id = R.string.edit_secret),
- modifier = Modifier.weight(1f),
- readOnly = readOnly,
- isError = isError,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Password,
- imeAction = ImeAction.Done
- ),
- visualTransformation = if (readOnly) {
- passwordVisualTransformation
- } else {
- VisualTransformation.None
- },
- )
- }
-}
-
-@Composable
-private fun QRCodeBottomSheet(
- onDismiss: () -> Unit,
- uri: String,
-) = ModalBottomSheet(
- onDismissRequest = onDismiss,
- shape = MaterialTheme.shapes.large.bottom(0.dp),
- dragHandle = null
-) {
- DragHandle()
-
- Text(
- text = stringResource(id = R.string.edit_qrcode),
- style = MaterialTheme.typography.headlineSmall,
- modifier = Modifier.align(Alignment.CenterHorizontally)
- )
-
- Box(
- modifier = Modifier
- .padding(vertical = 15.dp, horizontal = 40.dp)
- .align(Alignment.CenterHorizontally),
- contentAlignment = Alignment.Center
- ) {
- val sizePx = with(LocalDensity.current) {
- BottomSheetDefaults.SheetMaxWidth.roundToPx()
- }
-
- val foregroundColor = MaterialTheme.colorScheme.onSurface.toArgb()
- val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow.toArgb()
-
- val bitmap by remember {
- derivedStateOf {
- QRCode.encodeToBitmap(
- contents = uri,
- size = sizePx,
- foregroundColor = foregroundColor,
- backgroundColor = backgroundColor
- )
- }
- }
-
- Image(
- bitmap = bitmap.asImageBitmap(),
- contentDescription = null
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldContent.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldContent.kt
deleted file mode 100644
index 4ae4f7b..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldContent.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.edit.component
-
-import androidx.annotation.DrawableRes
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.PressInteraction
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.RowScope
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.input.VisualTransformation
-import androidx.compose.ui.unit.DpOffset
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.component.DropdownMenu
-
-@Composable
-fun TextFieldContentIcon(
- @DrawableRes icon: Int
-) = Box(
- modifier = Modifier.size(48.dp),
- contentAlignment = Alignment.Center
-) {
- Icon(
- painter = painterResource(id = icon),
- contentDescription = null
- )
-}
-
-@Composable
-fun TextFieldContentSpacer() = Spacer(modifier = Modifier.width(48.dp))
-
-@Composable
-fun TextFieldContent(
- modifier: Modifier = Modifier,
- leading: @Composable (() -> Unit)? = null,
- trailing: @Composable (() -> Unit)? = null,
- content: @Composable RowScope.() -> Unit
-) = Row(
- modifier = modifier,
- verticalAlignment = Alignment.CenterVertically
-) {
- if (leading != null) {
- leading()
- } else {
- TextFieldContentSpacer()
- }
- content()
- if (trailing != null) {
- trailing()
- } else {
- TextFieldContentSpacer()
- }
-}
-
-@Composable
-fun TextField(
- value: String,
- onValueChange: (String) -> Unit,
- label: String,
- modifier: Modifier = Modifier,
- readOnly: Boolean = false,
- trailingIcon: @Composable (() -> Unit)? = null,
- isError: Boolean = false,
- visualTransformation: VisualTransformation = VisualTransformation.None,
- keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
- interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
-) = OutlinedTextField(
- value = value,
- onValueChange = onValueChange,
- modifier = modifier,
- readOnly = readOnly,
- label = { Text(text = label) },
- trailingIcon = trailingIcon,
- shape = MaterialTheme.shapes.medium,
- isError = isError,
- visualTransformation = visualTransformation,
- keyboardOptions = keyboardOptions,
- singleLine = true,
- interactionSource = interactionSource
-)
-
-@Composable
-fun TextFieldDropdownMenu(
- value: T,
- values: Collection,
- onValueChange: (T) -> Unit,
- label: String,
- modifier: Modifier = Modifier,
- readOnly: Boolean = false
-) {
- var expanded by remember { mutableStateOf(false) }
- val interactionSource = remember { MutableInteractionSource() }
-
- LaunchedEffect(interactionSource) {
- interactionSource.interactions.collect { interaction ->
- if (interaction is PressInteraction.Release) {
- expanded = !expanded
- }
- }
- }
-
- DropdownMenu(
- modifier = modifier,
- expanded = expanded,
- onDismissRequest = { expanded = false },
- contentAlignment = Alignment.BottomStart,
- offset = DpOffset(0.dp, 3.dp),
- surface = {
- TextField(
- value = value.toString(),
- onValueChange = {},
- label = label,
- modifier = Modifier.fillMaxWidth(),
- readOnly = true,
- trailingIcon = {
- Icon(
- painter = painterResource(
- id = if (expanded) {
- R.drawable.caret_up
- } else {
- R.drawable.caret_down
- }
- ),
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- },
- interactionSource = if (readOnly) {
- remember { MutableInteractionSource() }
- } else {
- interactionSource
- }
- )
- }
- ) {
- values.forEach { value ->
- DropdownMenuItem(
- text = {
- Text(
- text = value.toString(),
- style = MaterialTheme.typography.bodyLarge
- )
- },
- onClick = {
- onValueChange(value)
- expanded = false
- }
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt
deleted file mode 100644
index 34c70c6..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.edit.component
-
-import androidx.annotation.DrawableRes
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.input.VisualTransformation
-
-@Composable
-fun TextFieldItem(
- value: String,
- onValueChange: (String) -> Unit,
- label: String,
- modifier: Modifier = Modifier,
- @DrawableRes leadingIcon: Int? = null,
- trailingIcon: @Composable (() -> Unit)? = null,
- hidden: Boolean = false,
- isError: Boolean = false,
- keyboardOptions: KeyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Text,
- imeAction = ImeAction.Next
- )
-) = TextFieldContent(
- modifier = modifier,
- leading = leadingIcon?.let { { TextFieldContentIcon(icon = it) } },
- trailing = trailingIcon
-) {
- val passwordVisualTransformation = remember { PasswordVisualTransformation() }
-
- TextField(
- value = value,
- onValueChange = onValueChange,
- label = label,
- modifier = Modifier.weight(1f),
- readOnly = hidden,
- isError = isError,
- keyboardOptions = keyboardOptions,
- visualTransformation = if (hidden) {
- passwordVisualTransformation
- } else {
- VisualTransformation.None
- },
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt
deleted file mode 100644
index 02ee925..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.edit.component
-
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.width
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.model.auth.AuthType
-import dev.sanmer.otp.Otp
-
-@Composable
-fun TypeAndHashItem(
- type: AuthType,
- onTypeChange: (AuthType) -> Unit,
- hash: Otp.Hash,
- onHashChange: (Otp.Hash) -> Unit,
- readOnly: Boolean
-) = TextFieldContent(
- leading = { TextFieldContentIcon(icon = R.drawable.math_function) }
-) {
- TextFieldDropdownMenu(
- value = type,
- values = AuthType.entries,
- onValueChange = onTypeChange,
- label = stringResource(id = R.string.edit_type),
- readOnly = readOnly,
- modifier = Modifier.weight(1f)
- )
-
- Spacer(modifier = Modifier.width(15.dp))
-
- TextFieldDropdownMenu(
- value = hash,
- values = Otp.Hash.entries,
- onValueChange = onHashChange,
- label = stringResource(id = R.string.edit_hash),
- readOnly = readOnly,
- modifier = Modifier.weight(1f)
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeScreen.kt
deleted file mode 100644
index dd654ea..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeScreen.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.encode
-
-import androidx.annotation.DrawableRes
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SegmentedButton
-import androidx.compose.material3.SegmentedButtonDefaults
-import androidx.compose.material3.SingleChoiceSegmentedButtonRow
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-
-@Composable
-fun EncodeScreen(
- viewModel: EncodeViewModel,
- goBack: () -> Unit
-) {
- val configuration = LocalConfiguration.current
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
-
- val maxWidth by remember {
- derivedStateOf { configuration.smallestScreenWidthDp * 0.8f }
- }
-
- Scaffold(
- topBar = {
- TopBar(
- onBack = goBack,
- scrollBehavior = scrollBehavior
- )
- }
- ) { contentPadding ->
- Column(
- modifier = Modifier
- .imePadding()
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .verticalScroll(rememberScrollState())
- .padding(contentPadding)
- .padding(vertical = 20.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(15.dp)
- ) {
- SingleChoiceSegmentedButtonRow {
- EncodeViewModel.Type.entries.forEachIndexed { index, type ->
- val selected by remember { derivedStateOf { viewModel.type == type } }
- SegmentedButton(
- selected = selected,
- onClick = { viewModel.updateType(type) },
- shape = SegmentedButtonDefaults.itemShape(
- index = index,
- count = EncodeViewModel.Type.entries.size
- ),
- icon = {
- SegmentedIcon(active = selected)
- }
- ) {
- Text(text = type.name)
- }
- }
- }
-
- OutlinedTextField(
- value = viewModel.decoded,
- onValueChange = { viewModel.updateDecoded(it) },
- shape = MaterialTheme.shapes.large,
- modifier = Modifier.size(width = maxWidth.dp, height = 180.dp),
- isError = viewModel.isError(EncodeViewModel.Error.Encode),
- placeholder = { Text(text = stringResource(id = R.string.encode_decoded)) }
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center
- ) {
- ButtonItem(
- onClick = viewModel::encode,
- icon = R.drawable.arrow_bar_down
- )
-
- Spacer(modifier = Modifier.width(10.dp))
-
- ButtonItem(
- onClick = viewModel::decode,
- icon = R.drawable.arrow_bar_up
- )
- }
-
- OutlinedTextField(
- value = viewModel.encoded,
- onValueChange = { viewModel.updateEncoded(it) },
- shape = MaterialTheme.shapes.large,
- modifier = Modifier.size(width = maxWidth.dp, height = 180.dp),
- isError = viewModel.isError(EncodeViewModel.Error.Decode),
- placeholder = { Text(text = stringResource(id = R.string.encode_encoded)) }
- )
- }
- }
-}
-
-@Composable
-private fun SegmentedIcon(
- active: Boolean
-) = SegmentedButtonDefaults.Icon(
- active = active,
- activeContent = {
- Icon(
- painter = painterResource(id = R.drawable.check),
- contentDescription = null,
- modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
- )
- }
-)
-
-@Composable
-private fun ButtonItem(
- onClick: () -> Unit,
- @DrawableRes icon: Int
-) = IconButton(
- onClick = onClick
-) {
- Icon(
- painter = painterResource(id = icon),
- contentDescription = null
- )
-}
-
-@Composable
-private fun TopBar(
- onBack: () -> Unit,
- scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
- title = { Text(text = stringResource(id = R.string.settings_encode_decode)) },
- navigationIcon = {
- IconButton(
- onClick = onBack
- ) {
- Icon(
- painter = painterResource(id = R.drawable.arrow_left),
- contentDescription = null
- )
- }
- },
- scrollBehavior = scrollBehavior
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeViewModel.kt
deleted file mode 100644
index a26fd6b..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeViewModel.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.encode
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.text.TextRange
-import androidx.compose.ui.text.input.TextFieldValue
-import androidx.lifecycle.ViewModel
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.encoding.decodeBase32
-import dev.sanmer.encoding.decodeBase64
-import dev.sanmer.encoding.encodeBase32
-import dev.sanmer.encoding.encodeBase64
-
-class EncodeViewModel : ViewModel() {
- var encoded by mutableStateOf(TextFieldValue())
- private set
-
- var decoded by mutableStateOf(TextFieldValue())
- private set
-
- var type by mutableStateOf(Type.Base64)
- private set
-
- private val errors = mutableStateMapOf()
-
- private val logger = Logger.Android("EncodeViewModel")
-
- init {
- logger.d("init")
- }
-
- fun updateEncoded(value: TextFieldValue) {
- encoded = value
- }
-
- fun updateDecoded(value: TextFieldValue) {
- decoded = value
- }
-
- fun updateType(value: Type) {
- type = value
- }
-
- fun isError(value: Error) =
- errors.getOrDefault(value, false)
-
- fun encode() {
- errors[Error.Decode] = false
- errors[Error.Encode] = runCatching {
- encoded = type.encode(decoded.text).asTextFieldValue()
- }.isFailure
- }
-
- fun decode() {
- errors[Error.Encode] = false
- errors[Error.Decode] = runCatching {
- decoded = type.decode(encoded.text).asTextFieldValue()
- }.isFailure
- }
-
- private fun String.asTextFieldValue() =
- TextFieldValue(
- text = this,
- selection = TextRange(length)
- )
-
- enum class Type(
- val encode: (String) -> String,
- val decode: (String) -> String
- ) {
- Base32(
- encode = String::encodeBase32,
- decode = String::decodeBase32
- ),
- Base64(
- encode = String::encodeBase64,
- decode = String::decodeBase64
- )
- }
-
- enum class Error {
- Encode,
- Decode
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeScreen.kt
deleted file mode 100644
index 52d62a7..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeScreen.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.home
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.component.LabelText
-import dev.sanmer.authenticator.ui.component.PageIndicator
-import dev.sanmer.authenticator.ui.ktx.isScrollingUp
-import dev.sanmer.authenticator.ui.screens.Screen
-import dev.sanmer.authenticator.ui.screens.home.component.AuthList
-import java.time.LocalTime
-
-@Composable
-fun HomeScreen(
- viewModel: HomeViewModel,
- goTo: (Screen) -> Unit,
-) {
- val time by viewModel.time.collectAsStateWithLifecycle()
-
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
- val listState = rememberLazyListState()
- val isScrollingUp by listState.isScrollingUp()
-
- Scaffold(
- topBar = {
- TopBar(
- time = time,
- scrollBehavior = scrollBehavior
- )
- },
- floatingActionButton = {
- AnimatedVisibility(
- visible = isScrollingUp,
- enter = fadeIn() + scaleIn(),
- exit = scaleOut() + fadeOut()
- ) {
- ActionButton(
- onClick = { goTo(Screen.Settings) }
- )
- }
- }
- ) { contentPadding ->
- Box(
- modifier = Modifier
- .imePadding()
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .fillMaxSize(),
- contentAlignment = Alignment.TopCenter
- ) {
- if (viewModel.totp.isEmpty() && !viewModel.isPending) {
- PageIndicator(
- icon = R.drawable.key,
- text = R.string.home_empty,
- modifier = Modifier.padding(contentPadding)
- )
- }
-
- AuthList(
- state = listState,
- totp = viewModel.totp,
- onEdit = { goTo(Screen.Edit(id = it.id)) },
- onDelete = viewModel::moveToTrash,
- contentPadding = contentPadding
- )
- }
- }
-}
-
-@Composable
-private fun ActionButton(
- onClick: () -> Unit
-) {
- FloatingActionButton(
- onClick = onClick
- ) {
- Icon(
- painter = painterResource(id = R.drawable.settings_2),
- contentDescription = null
- )
- }
-}
-
-@Composable
-private fun TopBar(
- time: LocalTime,
- scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
- title = { Text(text = stringResource(id = R.string.app_name)) },
- actions = {
- LabelText(
- text = time.toString(),
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.padding(end = 15.dp)
- )
- },
- scrollBehavior = scrollBehavior
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeViewModel.kt
deleted file mode 100644
index 0e13bc9..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeViewModel.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.home
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.authenticator.model.auth.TotpAuth
-import dev.sanmer.authenticator.repository.DbRepository
-import dev.sanmer.authenticator.repository.TimeRepository
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
-import java.time.Instant
-import java.time.LocalTime
-import java.time.ZoneId
-
-class HomeViewModel(
- private val dbRepository: DbRepository,
- private val timeRepository: TimeRepository
-) : ViewModel() {
- var loadState by mutableStateOf(LoadState.Pending)
- private set
- val totp inline get() = loadState.totp
- val isPending inline get() = loadState.isPending
-
- val time = timeRepository.epochSeconds.map { epochSecond ->
- Instant.ofEpochSecond(epochSecond)
- .atZone(ZoneId.systemDefault())
- .toLocalTime()
- }.stateIn(
- scope = viewModelScope,
- started = SharingStarted.Eagerly,
- initialValue = LocalTime.now()
- )
-
- private val logger = Logger.Android("HomeViewModel")
-
- init {
- logger.d("init")
- dataObserver()
- clearTrash()
- }
-
- private fun dataObserver() {
- viewModelScope.launch {
- dbRepository.getTotpAllDecryptedAsFlow()
- .collect { totp ->
- loadState = LoadState.Ready(
- totp = totp.map {
- TotpAuth(
- entity = it,
- epochSeconds = timeRepository.epochSeconds
- )
- }.sortedBy { it.entity.issuer.lowercase() },
- )
- }
- }
- }
-
- private fun clearTrash() {
- viewModelScope.launch {
- dbRepository.deleteTotp(
- dbRepository.getTotpAllTrashed(dead = true)
- )
- }
- }
-
- fun moveToTrash(entity: TotpEntity) {
- viewModelScope.launch {
- dbRepository.updateTotp(entity.toTrash())
- }
- }
-
- sealed class LoadState {
- abstract val totp: List
-
- data object Pending : LoadState() {
- override val totp = emptyList()
- }
-
- data class Ready(
- override val totp: List
- ) : LoadState()
-
- val isPending inline get() = this is Pending
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthItem.kt
deleted file mode 100644
index 4cf369f..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthItem.kt
+++ /dev/null
@@ -1,216 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.home.component
-
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
-import androidx.compose.animation.slideIn
-import androidx.compose.animation.slideOut
-import androidx.compose.animation.togetherWith
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.sizeIn
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.FilledTonalIconButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.model.auth.TotpAuth
-import dev.sanmer.authenticator.ui.component.SwipeContent
-import dev.sanmer.authenticator.ui.ktx.surface
-import dev.sanmer.logo.Logo
-
-@Composable
-fun AuthItem(
- auth: TotpAuth,
- onEdit: () -> Unit,
- onDelete: () -> Unit,
- enabled: Boolean = true,
- onClick: () -> Unit = {}
-) {
- SwipeContent(
- content = { release ->
- AuthItemButtons(
- onEdit = {
- release()
- onEdit()
- },
- onDelete = {
- release()
- onDelete()
- }
- )
- },
- surface = {
- AuthItemContent(
- auth = auth,
- enabled = enabled,
- onClick = onClick
- )
- }
- )
-}
-
-@Composable
-private fun AuthItemButtons(
- onEdit: () -> Unit,
- onDelete: () -> Unit
-) = Row(
- modifier = Modifier.padding(horizontal = 10.dp),
- horizontalArrangement = Arrangement.spacedBy(5.dp)
-) {
- FilledTonalIconButton(
- onClick = onEdit
- ) {
- Icon(
- painter = painterResource(id = R.drawable.edit),
- contentDescription = null
- )
- }
-
- FilledTonalIconButton(
- onClick = onDelete,
- colors = IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer
- )
- ) {
- Icon(
- painter = painterResource(id = R.drawable.trash_x),
- contentDescription = null
- )
- }
-}
-
-@Composable
-private fun AuthItemContent(
- auth: TotpAuth,
- enabled: Boolean = true,
- onClick: () -> Unit = {}
-) = Row(
- modifier = Modifier
- .sizeIn(maxWidth = 450.dp)
- .fillMaxWidth()
- .surface(
- shape = MaterialTheme.shapes.large,
- backgroundColor = MaterialTheme.colorScheme.surface,
- border = CardDefaults.outlinedCardBorder(false)
- )
- .clickable(
- enabled = enabled,
- onClick = onClick
- )
- .padding(all = 15.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
-) {
- val logo by remember(auth.entity.issuer) {
- derivedStateOf { Logo.getOrDefault(auth.entity.issuer) }
- }
-
- val otp by auth.otp.collectAsStateWithLifecycle(initialValue = auth.otp())
-
- Image(
- painter = painterResource(id = logo.res),
- contentDescription = null,
- modifier = Modifier.size(40.dp),
- colorFilter = if (logo.refillable) {
- ColorFilter.tint(MaterialTheme.colorScheme.primary)
- } else null
- )
-
- Column(
- horizontalAlignment = Alignment.Start
- ) {
- OtpItem(
- otp = otp
- )
-
- Text(
- text = auth.entity.displayName,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline
- )
- }
-}
-
-@Composable
-private fun OtpItem(
- otp: String
-) = Row(
- verticalAlignment = Alignment.CenterVertically
-) {
- otp.forEachIndexed { index, char ->
- AnimatedDigit(
- digit = char,
- position = index
- )
- if ((index + 1) % 3 == 0 && index < otp.length - 1) {
- Spacer(modifier = Modifier.width(12.dp))
- }
- }
-}
-
-@Composable
-private fun AnimatedDigit(
- digit: Char,
- position: Int
-) {
- val isOddPosition = remember { (position % 2 == 0) }
- val enterDirection = remember { if (isOddPosition) -1 else 1 }
- val exitDirection = remember { if (isOddPosition) 1 else -1 }
-
- AnimatedContent(
- targetState = digit,
- transitionSpec = {
- slideIn(
- animationSpec = tween(500)
- ) {
- IntOffset(0, enterDirection * it.height)
- } + scaleIn(
- animationSpec = tween(durationMillis = 500)
- ) + fadeIn(
- animationSpec = tween(durationMillis = 500)
- ) togetherWith slideOut(
- animationSpec = tween(500)
- ) {
- IntOffset(0, exitDirection * it.height)
- } + scaleOut(
- animationSpec = tween(durationMillis = 500)
- ) + fadeOut(
- animationSpec = tween(durationMillis = 500)
- )
- }
- ) { targetDigit ->
- Text(
- text = targetDigit.toString(),
- style = MaterialTheme.typography.headlineSmall.copy(
- fontFamily = FontFamily.Monospace
- )
- )
- }
-}
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt
deleted file mode 100644
index 3252f2d..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.home.component
-
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.items
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.authenticator.model.auth.TotpAuth
-import dev.sanmer.authenticator.ui.ktx.plus
-
-@Composable
-fun AuthList(
- state: LazyListState,
- totp: List,
- onEdit: (TotpEntity) -> Unit,
- onDelete: (TotpEntity) -> Unit,
- contentPadding: PaddingValues = PaddingValues(0.dp)
-) {
- LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .animateContentSize(),
- state = state,
- contentPadding = contentPadding + PaddingValues(15.dp),
- verticalArrangement = Arrangement.spacedBy(15.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- items(totp) {
- AuthItem(
- auth = it,
- enabled = false,
- onEdit = { onEdit(it.entity) },
- onDelete = { onDelete(it.entity) }
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/LockScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/LockScreen.kt
deleted file mode 100644
index 862f5f7..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/LockScreen.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.main
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ktx.finishActivity
-import dev.sanmer.authenticator.ui.AuthorizeActivity
-import org.koin.androidx.compose.koinViewModel
-
-@Composable
-fun LockScreen(
- viewModel: MainViewModel = koinViewModel()
-) {
- val context = LocalContext.current
-
- LaunchedEffect(true) {
- if (AuthorizeActivity.auth(context)) {
- viewModel.setUnlocked()
- }
- }
-
- BackHandler(onBack = context::finishActivity)
-
- Box(
- modifier = Modifier
- .clickable(
- interactionSource = null,
- indication = null,
- onClick = {
- AuthorizeActivity.auth(context) {
- if (it) viewModel.setUnlocked()
- }
- }
- )
- .background(color = MaterialTheme.colorScheme.background)
- .fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- painter = painterResource(id = R.drawable.lock),
- contentDescription = null,
- modifier = Modifier.size(50.dp),
- tint = MaterialTheme.colorScheme.outline
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainViewModel.kt
deleted file mode 100644
index 0b7664b..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainViewModel.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.main
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.datastore.model.Preference
-import dev.sanmer.authenticator.repository.PreferenceRepository
-import kotlinx.coroutines.launch
-
-class MainViewModel(
- private val preferenceRepository: PreferenceRepository
-) : ViewModel() {
- var loadState by mutableStateOf(LoadState.Pending)
- private set
-
- val isPending inline get() = loadState.isPending
- val isLocked inline get() = loadState.isLocked
- val preference inline get() = loadState.preference
-
- private val logger = Logger.Android("MainViewModel")
-
- init {
- logger.d("init")
- preferenceObserver()
- }
-
- private fun preferenceObserver() {
- viewModelScope.launch {
- preferenceRepository.data.collect {
- loadState = if (loadState.isReady || !it.isEncrypted) {
- LoadState.Ready(it)
- } else {
- LoadState.Locked(it)
- }
- }
- }
- }
-
- fun setUnlocked() {
- loadState = LoadState.Ready(preference)
- }
-
- sealed class LoadState {
- abstract val preference: Preference
-
- data object Pending : LoadState() {
- override val preference = Preference()
- }
-
- data class Locked(
- override val preference: Preference
- ) : LoadState()
-
- data class Ready(
- override val preference: Preference
- ) : LoadState()
-
- val isPending inline get() = this is Pending
- val isLocked inline get() = this is Locked
- val isReady inline get() = this is Ready
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpScreen.kt
deleted file mode 100644
index 91b62f9..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpScreen.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.ntp
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.spring
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.screens.ntp.component.NtpList
-
-@Composable
-fun NtpScreen(
- viewModel: NtpViewModel,
- goBack: () -> Unit
-) {
- val ntps by viewModel.ntps.collectAsStateWithLifecycle(initialValue = emptyList())
- val syncState by viewModel.syncState.collectAsStateWithLifecycle()
-
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
- val listState = rememberLazyListState()
-
- Scaffold(
- topBar = {
- TopBar(
- syncState = syncState,
- onBack = goBack,
- onSync = viewModel::syncAll,
- scrollBehavior = scrollBehavior
- )
- }
- ) { contentPadding ->
- Box(
- modifier = Modifier
- .imePadding()
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .fillMaxSize(),
- contentAlignment = Alignment.TopCenter
- ) {
- NtpList(
- state = listState,
- ntps = ntps,
- setNtp = viewModel::setNtp,
- contentPadding = contentPadding
- )
- }
- }
-}
-
-@Composable
-private fun TopBar(
- syncState: NtpViewModel.SyncState,
- onBack: () -> Unit,
- onSync: () -> Unit,
- scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
- title = { Text(text = stringResource(id = R.string.settings_ntp_server)) },
- navigationIcon = {
- IconButton(
- onClick = onBack
- ) {
- Icon(
- painter = painterResource(id = R.drawable.arrow_left),
- contentDescription = null
- )
- }
- },
- actions = {
- IconButton(
- onClick = onSync,
- ) {
- val animatedScale by animateFloatAsState(
- targetValue = if (syncState.isRunning) 0.65f else 1f,
- animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
- )
-
- AnimatedVisibility(
- visible = syncState.isRunning,
- enter = fadeIn() + scaleIn(),
- exit = scaleOut() + fadeOut()
- ) {
- CircularProgressIndicator(
- progress = { syncState.progress },
- strokeWidth = 2.dp,
- modifier = Modifier.size(24.dp)
- )
- }
-
- Icon(
- painter = painterResource(id = R.drawable.refresh),
- contentDescription = null,
- modifier = Modifier.scale(animatedScale)
- )
- }
- },
- scrollBehavior = scrollBehavior
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpViewModel.kt
deleted file mode 100644
index f18205b..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpViewModel.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.ntp
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.datastore.model.Ntp
-import dev.sanmer.authenticator.repository.PreferenceRepository
-import dev.sanmer.authenticator.repository.TimeRepository
-import dev.sanmer.logo.Brand
-import dev.sanmer.ntp.NtpServer
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlin.time.Duration
-
-class NtpViewModel(
- private val preferenceRepository: PreferenceRepository,
- private val timeRepository: TimeRepository
-) : ViewModel() {
- private val _ntps = MutableStateFlow(defaultNtps)
- val ntps = _ntps.map { list ->
- list.sortedBy { it.ntpTime.rtt }
- }
-
- private val _syncState = MutableStateFlow(SyncState.Pending)
- val syncState = _syncState.asStateFlow()
-
- private val logger = Logger.Android("NtpViewModel")
-
- init {
- logger.d("init")
- timeObserver()
- syncAll()
- }
-
- private fun timeObserver() {
- viewModelScope.launch {
- timeRepository.ntpTime
- .collect { ntpTime ->
- val preference = preferenceRepository.data.first()
- _ntps.update { ntps ->
- val index = ntps.indexOfFirst { it.ntp == preference.ntp }
- ntps.toMutableList().apply {
- set(index, ntps[index].copy(ntpTime = ntpTime))
- }
- }
- }
- }
- }
-
- fun syncAll() {
- viewModelScope.launch {
- val preference = preferenceRepository.data.first()
- _syncState.update { SyncState.Ready(defaultNtps.size) }
- defaultNtps.mapIndexed { index, ntp ->
- async {
- val ntpTime = if (ntp.ntp == preference.ntp) {
- timeRepository.sync(preference)
- } else {
- sync(ntp.server)
- }.getOrDefault(
- NtpServer.NtpTime(Duration.INFINITE, Duration.ZERO)
- )
-
- _syncState.update { SyncState.Updating(it.size, it.finished + 1) }
- _ntps.update {
- it.toMutableList().apply { set(index, ntp.copy(ntpTime = ntpTime)) }
- }
- }
- }.awaitAll()
-
- _syncState.update { SyncState.Finished(it.size, it.finished) }
- }
- }
-
- fun setNtp(ntp: Ntp) {
- viewModelScope.launch {
- preferenceRepository.setNtp(ntp)
- }
- }
-
- private suspend fun sync(server: NtpServer) =
- runCatching {
- server.sync()
- }.onSuccess {
- logger.d("NTP(${server.address}): $it")
- }.onFailure {
- logger.w(it)
- }
-
- data class NtpCompat(
- val brand: Brand,
- val ntp: Ntp,
- val server: NtpServer,
- val ntpTime: NtpServer.NtpTime
- )
-
- sealed class SyncState {
- abstract val size: Int
- abstract val finished: Int
-
- data object Pending : SyncState() {
- override val size = 1
- override val finished = -1
- }
-
- data class Ready(
- override val size: Int
- ) : SyncState() {
- override val finished = 0
- }
-
- data class Updating(
- override val size: Int,
- override val finished: Int
- ) : SyncState()
-
- data class Finished(
- override val size: Int,
- override val finished: Int
- ) : SyncState()
-
- val progress inline get() = finished / size.toFloat()
-
- val isRunning inline get() = this is Ready || this is Updating
-
- }
-
- companion object Default {
- private val defaultNtps = listOf(
- NtpCompat(
- brand = Brand.Aliyun,
- ntp = Ntp.Alibaba,
- server = NtpServer.Alibaba,
- ntpTime = NtpServer.NtpTime()
- ),
- NtpCompat(
- brand = Brand.Apple,
- ntp = Ntp.Apple,
- server = NtpServer.Apple,
- ntpTime = NtpServer.NtpTime()
- ),
- NtpCompat(
- brand = Brand.AWS,
- ntp = Ntp.Amazon,
- server = NtpServer.Amazon,
- ntpTime = NtpServer.NtpTime()
- ),
- NtpCompat(
- brand = Brand.Cloudflare,
- ntp = Ntp.Cloudflare,
- server = NtpServer.Cloudflare,
- ntpTime = NtpServer.NtpTime()
- ),
- NtpCompat(
- brand = Brand.Google,
- ntp = Ntp.Google,
- server = NtpServer.Google,
- ntpTime = NtpServer.NtpTime()
- ),
- NtpCompat(
- brand = Brand.Meta,
- ntp = Ntp.Meta,
- server = NtpServer.Meta,
- ntpTime = NtpServer.NtpTime()
- ),
- NtpCompat(
- brand = Brand.Microsoft,
- ntp = Ntp.Microsoft,
- server = NtpServer.Microsoft,
- ntpTime = NtpServer.NtpTime()
- ),
- NtpCompat(
- brand = Brand.TencentCloud,
- ntp = Ntp.Tencent,
- server = NtpServer.Tencent,
- ntpTime = NtpServer.NtpTime()
- )
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpItem.kt
deleted file mode 100644
index 3d822a8..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpItem.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.ntp.component
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.component.LabelText
-import dev.sanmer.authenticator.ui.ktx.surface
-import dev.sanmer.authenticator.ui.screens.ntp.NtpViewModel
-import kotlin.time.Duration
-import kotlin.time.DurationUnit
-
-@Composable
-fun NtpItem(
- ntp: NtpViewModel.NtpCompat,
- selected: Boolean,
- enabled: Boolean = true,
- onClick: () -> Unit = {}
-) = Row(
- modifier = Modifier
- .fillMaxWidth()
- .surface(
- shape = MaterialTheme.shapes.large,
- backgroundColor = MaterialTheme.colorScheme.surface,
- border = CardDefaults.outlinedCardBorder(false)
- )
- .clickable(
- enabled = enabled,
- onClick = onClick
- )
- .padding(all = 15.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp)
-) {
- Image(
- painter = painterResource(id = ntp.brand.res),
- contentDescription = null,
- modifier = Modifier.size(40.dp)
- )
-
- Column(
- horizontalAlignment = Alignment.Start
- ) {
- Text(
- text = ntp.ntp.name,
- style = MaterialTheme.typography.titleMedium
- )
-
- Text(
- text = ntp.server.address,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline
- )
-
- Row(
- modifier = Modifier.padding(top = 5.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- RTTLabel(rtt = ntp.ntpTime.rtt)
- OffsetLabel(offset = ntp.ntpTime.offset)
- }
- }
-
- if (selected) {
- Spacer(Modifier.weight(1f))
-
- Icon(
- painter = painterResource(id = R.drawable.circle_check_filled),
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier
- .size(30.dp)
- .align(Alignment.Top)
- )
- }
-}
-
-@Composable
-private fun OffsetLabel(
- offset: Duration
-) {
- val valueStr by remember(offset) {
- derivedStateOf { offset.toString(DurationUnit.MILLISECONDS) }
- }
-
- LabelText(
- text = stringResource(id = R.string.ntp_offset, valueStr)
- )
-}
-
-@Composable
-private fun RTTLabel(
- rtt: Duration
-) {
- val value by remember(rtt) {
- derivedStateOf { rtt.toLong(DurationUnit.MILLISECONDS) }
- }
- val valueStr by remember(rtt) {
- derivedStateOf { rtt.toString(DurationUnit.MILLISECONDS) }
- }
-
- LabelText(
- text = if (rtt != Duration.INFINITE) {
- stringResource(id = R.string.ntp_rtt, valueStr)
- } else {
- stringResource(id = R.string.ntp_timeout)
- },
- containerColor = if (isSystemInDarkTheme()) {
- when (value) {
- in 0..<100 -> colorResource(R.color.material_green_900)
- in 100..500 -> colorResource(R.color.material_yellow_900)
- else -> colorResource(R.color.material_red_900)
- }
- } else {
- when (value) {
- in 0..<100 -> colorResource(R.color.material_green_300)
- in 100..500 -> colorResource(R.color.material_yellow_300)
- else -> colorResource(R.color.material_red_300)
- }
- }
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt
deleted file mode 100644
index 04e9ce2..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.ntp.component
-
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.items
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.datastore.compose.LocalPreference
-import dev.sanmer.authenticator.datastore.model.Ntp
-import dev.sanmer.authenticator.ui.ktx.plus
-import dev.sanmer.authenticator.ui.screens.ntp.NtpViewModel
-
-@Composable
-fun NtpList(
- state: LazyListState,
- ntps: List,
- setNtp: (Ntp) -> Unit,
- contentPadding: PaddingValues = PaddingValues(0.dp)
-) {
- val preference = LocalPreference.current
-
- LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .animateContentSize(),
- state = state,
- contentPadding = contentPadding + PaddingValues(15.dp),
- verticalArrangement = Arrangement.spacedBy(15.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- items(ntps) {
- NtpItem(
- ntp = it,
- selected = preference.ntp == it.ntp,
- onClick = { setNtp(it.ntp) }
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanScreen.kt
deleted file mode 100644
index 19b1bfe..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanScreen.kt
+++ /dev/null
@@ -1,233 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.scan
-
-import android.content.Context
-import android.net.Uri
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.PickVisualMediaRequest
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.camera.compose.CameraXViewfinder
-import androidx.camera.core.SurfaceOrientedMeteringPointFactory
-import androidx.camera.viewfinder.compose.MutableCoordinateTransformer
-import androidx.compose.animation.Crossfade
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.gestures.detectTransformGestures
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.navigationBars
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.windowInsetsPadding
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.screens.Screen
-import dev.sanmer.otp.OtpUri.Default.isOtpUri
-
-@Composable
-fun ScanScreen(
- viewModel: ScanViewModel,
- goTo: (Screen) -> Unit,
- goBack: () -> Unit
-) {
- val context = LocalContext.current
- val text by viewModel.text.collectAsStateWithLifecycle()
-
- DisposableEffect(text) {
- if (text.isOtpUri()) goTo(Screen.Edit(uri = text))
- onDispose { viewModel.rewind() }
- }
-
- Box(
- modifier = Modifier.fillMaxSize()
- ) {
- Crossfade(
- targetState = viewModel.isAllowed,
- animationSpec = tween(600)
- ) { isAllowed ->
- if (isAllowed) {
- CameraXPreview(
- viewModel = viewModel,
- modifier = Modifier.fillMaxSize()
- )
- } else {
- CameraOff(
- onClick = { viewModel.requestPermission(context) },
- modifier = Modifier.fillMaxSize()
- )
- }
- }
-
- ActionButtons(
- modifier = Modifier.align(Alignment.BottomEnd),
- scanImage = viewModel::scanImage,
- onBack = goBack,
- torchEnabled = viewModel.torchEnabled,
- enableTorch = viewModel::enableTorch
- )
- }
-}
-
-@Composable
-private fun CameraOff(
- onClick: () -> Unit,
- modifier: Modifier = Modifier
-) = Box(
- modifier = modifier
- .background(
- color = MaterialTheme.colorScheme.surfaceContainer
- )
- .clickable(
- enabled = true,
- onClick = onClick
- ),
- contentAlignment = Alignment.Center
-) {
- Icon(
- painter = painterResource(id = R.drawable.camera_off),
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(45.dp),
- )
-}
-
-@Composable
-private fun CameraXPreview(
- viewModel: ScanViewModel,
- modifier: Modifier = Modifier,
-) {
- val lifecycleOwner = LocalLifecycleOwner.current
- val coordinateTransformer = remember { MutableCoordinateTransformer() }
- val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle()
-
- LaunchedEffect(lifecycleOwner) {
- viewModel.bindToCamera(lifecycleOwner)
- }
-
- val request = surfaceRequest ?: return
- val meteringFactory by remember(request) {
- derivedStateOf {
- with(request.resolution) {
- SurfaceOrientedMeteringPointFactory(width.toFloat(), height.toFloat())
- }
- }
- }
- CameraXViewfinder(
- surfaceRequest = request,
- coordinateTransformer = coordinateTransformer,
- modifier = modifier
- .pointerInput(true) {
- detectTapGestures { offset ->
- val surfacePoint = with(coordinateTransformer) {
- offset.transform()
- }
- val focusPoint = meteringFactory.createPoint(
- surfacePoint.x,
- surfacePoint.y
- )
- viewModel.startFocusAndMetering(focusPoint)
- }
- }
- .pointerInput(true) {
- detectTransformGestures { _, _, zoom, _ ->
- viewModel.setZoomRatio(zoom)
- }
- }
- )
-}
-
-@Composable
-private fun ActionButtons(
- modifier: Modifier = Modifier,
- onBack: () -> Unit,
- scanImage: (Context, Uri) -> Unit,
- torchEnabled: Boolean,
- enableTorch: (Boolean) -> Unit
-) {
- val context = LocalContext.current
- val pickImage = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.PickVisualMedia(),
- onResult = { if (it != null) scanImage(context, it) }
- )
-
- Row(
- modifier = modifier
- .windowInsetsPadding(WindowInsets.navigationBars)
- .padding(all = 20.dp),
- verticalAlignment = Alignment.Bottom
- ) {
- ActionButton(
- onClick = {
- pickImage.launch(
- PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
- )
- },
- ) {
- Icon(
- painter = painterResource(id = R.drawable.photo),
- contentDescription = null
- )
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- Column(
- verticalArrangement = Arrangement.spacedBy(20.dp)
- ) {
- ActionButton(
- onClick = { enableTorch(!torchEnabled) }
- ) {
- Icon(
- painter = painterResource(id = if (torchEnabled) R.drawable.bolt else R.drawable.bolt_off),
- contentDescription = null
- )
- }
-
- ActionButton(
- onClick = onBack
- ) {
- Icon(
- painter = painterResource(id = R.drawable.x),
- contentDescription = null
- )
- }
- }
- }
-}
-
-@Composable
-private fun ActionButton(
- onClick: () -> Unit,
- content: @Composable () -> Unit
-) = FloatingActionButton(
- onClick = onClick,
- containerColor = FloatingActionButtonDefaults.containerColor.copy(alpha = 0.8f),
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
- shape = CircleShape,
- content = content
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanViewModel.kt
deleted file mode 100644
index 466adc6..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanViewModel.kt
+++ /dev/null
@@ -1,157 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.scan
-
-import android.Manifest
-import android.app.Application
-import android.content.Context
-import android.net.Uri
-import androidx.camera.core.Camera
-import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA
-import androidx.camera.core.FocusMeteringAction
-import androidx.camera.core.ImageAnalysis
-import androidx.camera.core.MeteringPoint
-import androidx.camera.core.Preview
-import androidx.camera.core.SurfaceRequest
-import androidx.camera.core.TorchState
-import androidx.camera.lifecycle.ProcessCameraProvider
-import androidx.camera.lifecycle.awaitInstance
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.ViewModel
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.compat.PermissionCompat
-import dev.sanmer.qrcode.QRCode
-import kotlinx.coroutines.awaitCancellation
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import java.nio.ByteBuffer
-import java.util.concurrent.Executors
-import java.util.concurrent.TimeUnit
-
-class ScanViewModel(
- private val context: Application
-) : ViewModel() {
- private val _isAllowed: Boolean
- get() = PermissionCompat.checkPermission(
- context, Manifest.permission.CAMERA
- )
- var isAllowed by mutableStateOf(_isAllowed)
- private set
-
- private val _surfaceRequest = MutableStateFlow(null)
- val surfaceRequest: StateFlow = _surfaceRequest
-
- private val preview = Preview.Builder().build().apply {
- setSurfaceProvider { surfaceRequest ->
- _surfaceRequest.update { surfaceRequest }
- }
- }
-
- private val _text = MutableStateFlow("")
- val text = _text.asStateFlow()
-
- private val imageAnalysis = ImageAnalysis.Builder().apply {
- setBackgroundExecutor(Executors.newSingleThreadExecutor())
- }.build().apply {
- setAnalyzer(context.mainExecutor) { image ->
- try {
- val plane = image.planes.first()
- val data = plane.buffer.asByteArray()
- val content = QRCode.decodeFromYuv(
- yuvData = data,
- dataWidth = plane.rowStride,
- dataHeight = image.height,
- width = image.width,
- height = image.height,
- )
- _text.update { content }
- } catch (_: Throwable) {
-
- } finally {
- image.close()
- }
- }
- }
-
- private var camera: Camera? = null
- var torchEnabled by mutableStateOf(false)
- private set
-
- private val logger = Logger.Android("ScanViewModel")
-
- init {
- logger.d("init")
- }
-
- fun requestPermission(context: Context) {
- PermissionCompat.requestPermission(context, Manifest.permission.CAMERA) {
- isAllowed = it
- }
- }
-
- suspend fun bindToCamera(lifecycleOwner: LifecycleOwner) {
- val processCameraProvider = ProcessCameraProvider.awaitInstance(context)
- val camera = processCameraProvider.bindToLifecycle(
- lifecycleOwner, DEFAULT_BACK_CAMERA, preview, imageAnalysis
- ).also { camera = it }
- camera.cameraInfo.torchState.observe(lifecycleOwner) {
- torchEnabled = it == TorchState.ON
- }
-
- try {
- awaitCancellation()
- } finally {
- camera.cameraInfo.torchState.removeObservers(lifecycleOwner)
- processCameraProvider.unbindAll()
- }
- }
-
- fun enableTorch(enabled: Boolean) {
- val camera = camera ?: return
- camera.cameraControl.enableTorch(enabled)
- }
-
- fun startFocusAndMetering(focusPoint: MeteringPoint) {
- val camera = camera ?: return
- val action = FocusMeteringAction.Builder(
- focusPoint,
- FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE
- ).setAutoCancelDuration(3, TimeUnit.SECONDS).build()
- camera.cameraControl.startFocusAndMetering(action)
- }
-
- fun setZoomRatio(zoom: Float) {
- val camera = camera ?: return
- val zoomState = camera.cameraInfo.zoomState.value ?: return
- val newRatio = (zoomState.zoomRatio * zoom).coerceIn(
- zoomState.minZoomRatio,
- zoomState.maxZoomRatio
- )
- camera.cameraControl.setZoomRatio(newRatio)
- }
-
- fun rewind() {
- _text.update { "" }
- }
-
- fun scanImage(context: Context, uri: Uri) {
- runCatching {
- val cr = context.contentResolver
- checkNotNull(cr.openInputStream(uri)).use(QRCode::decodeFromStream)
- }.onSuccess { content ->
- content?.let { _text.update { content } }
- }.onFailure {
- logger.e(it)
- }
- }
-
- private fun ByteBuffer.asByteArray(): ByteArray {
- rewind()
- val dst = ByteArray(remaining())
- get(dst)
- return dst
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/SecurityScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/SecurityScreen.kt
deleted file mode 100644
index 42f2c27..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/SecurityScreen.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.security
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.datastore.compose.LocalPreference
-import dev.sanmer.authenticator.ui.AuthorizeActivity
-import dev.sanmer.authenticator.ui.screens.security.component.SecurityItem
-import dev.sanmer.crypto.BiometricKey
-
-@Composable
-fun SecurityScreen(
- goBack: () -> Unit
-) {
- val context = LocalContext.current
- val preference = LocalPreference.current
-
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
-
- Scaffold(
- topBar = {
- TopBar(
- onBack = goBack,
- scrollBehavior = scrollBehavior
- )
- }
- ) { contentPadding ->
- Column(
- modifier = Modifier
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .verticalScroll(rememberScrollState())
- .padding(contentPadding)
- ) {
- SecurityItem(
- icon = R.drawable.shield,
- title = stringResource(id = R.string.security_password),
- desc = stringResource(
- id = if (preference.isEncrypted) {
- R.string.security_password_mask
- } else {
- R.string.security_password_empty
- }
- ),
- onClick = {
- val action = if (preference.isEncrypted) {
- AuthorizeActivity.Action.ChangePassword
- } else {
- AuthorizeActivity.Action.SetupPassword
- }
- AuthorizeActivity.start(context, action) {}
- }
- )
-
- SecurityItem(
- icon = R.drawable.fingerprint,
- title = stringResource(id = R.string.security_biometric),
- desc = stringResource(id = R.string.security_biometric_desc),
- enabled = preference.isEncrypted && BiometricKey.canAuthenticate(context),
- onClick = {
- val action = if (preference.isBiometric) {
- AuthorizeActivity.Action.RemoveBiometric
- } else {
- AuthorizeActivity.Action.SetupBiometric
- }
- AuthorizeActivity.start(context, action) {}
- },
- trailing = {
- Switch(
- checked = preference.isBiometric,
- onCheckedChange = null
- )
- }
- )
-
- SecurityItem(
- icon = R.drawable.shield_off,
- title = stringResource(id = R.string.security_remove_password),
- desc = stringResource(id = R.string.security_remove_password_desc),
- enabled = preference.isEncrypted,
- onClick = {
- val action = AuthorizeActivity.Action.RemovePassword
- AuthorizeActivity.start(context, action) {}
- }
- )
- }
- }
-}
-
-@Composable
-private fun TopBar(
- onBack: () -> Unit,
- scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
- title = { Text(text = stringResource(id = R.string.settings_security)) },
- navigationIcon = {
- IconButton(
- onClick = onBack
- ) {
- Icon(
- painter = painterResource(id = R.drawable.arrow_left),
- contentDescription = null
- )
- }
- },
- scrollBehavior = scrollBehavior
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/component/SecurityItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/component/SecurityItem.kt
deleted file mode 100644
index 72e15ca..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/component/SecurityItem.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.security.component
-
-import androidx.annotation.DrawableRes
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
-
-@Composable
-fun SecurityItem(
- @DrawableRes icon: Int,
- title: String,
- desc: String,
- enabled: Boolean = true,
- onClick: () -> Unit = {},
- trailing: @Composable (() -> Unit)? = null,
-) = Row(
- modifier = Modifier
- .clickable(
- enabled = enabled,
- onClick = onClick
- )
- .alpha(if (enabled) 1f else 0.5f)
- .padding(vertical = 20.dp, horizontal = 25.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(20.dp)
-) {
- Box(
- modifier = Modifier
- .border(
- width = 0.5.dp,
- color = MaterialTheme.colorScheme.outlineVariant,
- shape = CircleShape
- )
- .size(40.dp),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- painter = painterResource(id = icon),
- contentDescription = null
- )
- }
-
- Column(
- modifier = Modifier.weight(1f),
- horizontalAlignment = Alignment.Start
- ) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium
- )
-
- Text(
- text = desc,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline
- )
- }
-
- trailing?.invoke()
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt
deleted file mode 100644
index 15dc7cd..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt
+++ /dev/null
@@ -1,219 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.settings
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.Const
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ktx.viewUrl
-import dev.sanmer.authenticator.ui.screens.Screen
-import dev.sanmer.authenticator.ui.screens.settings.SettingsViewModel.BottomSheet
-import dev.sanmer.authenticator.ui.screens.settings.component.DatabaseItem
-import dev.sanmer.authenticator.ui.screens.settings.component.PreferenceItem
-import dev.sanmer.authenticator.ui.screens.settings.component.SettingIcon
-import dev.sanmer.authenticator.ui.screens.settings.component.SettingItem
-import dev.sanmer.authenticator.ui.screens.settings.component.TokenItem
-import dev.sanmer.authenticator.ui.screens.settings.component.ToolItem
-
-@Composable
-fun SettingsScreen(
- viewModel: SettingsViewModel,
- goTo: (Screen) -> Unit,
- goBack: () -> Unit,
-) {
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
-
- when (viewModel.bottomSheet) {
- BottomSheet.Closed -> Unit
- BottomSheet.Token -> TokenItem(
- onDismiss = viewModel::closeBottomSheet,
- onEdit = { goTo(Screen.Edit()) },
- onScan = { goTo(Screen.Scan) }
- )
-
- BottomSheet.Database -> DatabaseItem(
- onDismiss = viewModel::closeBottomSheet,
- isEmpty = viewModel.isEmpty,
- importJson = viewModel::importJson,
- importUri = viewModel::importUri,
- encrypt = viewModel::encrypt,
- exportJson = viewModel::exportJson,
- exportUri = viewModel::exportUri,
- )
-
- BottomSheet.Tool -> ToolItem(
- onDismiss = viewModel::closeBottomSheet,
- onEncode = { goTo(Screen.Encode) },
- decryptFromJson = viewModel::decryptFromJson,
- decryptedToJson = viewModel::decryptedToJson,
- )
-
- BottomSheet.Preference -> PreferenceItem(
- onDismiss = viewModel::closeBottomSheet,
- onSecurity = { goTo(Screen.Security) },
- onNtp = { goTo(Screen.Ntp) }
- )
- }
-
- Scaffold(
- topBar = {
- TopBar(
- onBack = goBack,
- scrollBehavior = scrollBehavior
- )
- },
- floatingActionButton = {
- AnimatedVisibility(
- visible = viewModel.bottomSheet.isClosed,
- enter = fadeIn() + scaleIn(),
- exit = scaleOut() + fadeOut()
- ) {
- ActionButton(
- onClick = { goTo(Screen.Trash) }
- )
- }
- }
- ) { contentPadding ->
- Column(
- modifier = Modifier
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .verticalScroll(rememberScrollState())
- .padding(contentPadding)
- .padding(all = 15.dp),
- verticalArrangement = Arrangement.spacedBy(15.dp)
- ) {
- SettingItem(
- icon = {
- SettingIcon(
- icon = R.drawable.key,
- color = if (isSystemInDarkTheme()) {
- colorResource(id = R.color.material_orange_900)
- } else {
- colorResource(id = R.color.material_orange_300)
- }
- )
- },
- title = stringResource(id = R.string.settings_token),
- text = stringResource(id = R.string.settings_token_desc),
- onClick = { viewModel.updateBottomSheet { BottomSheet.Token } }
- )
-
- SettingItem(
- icon = {
- SettingIcon(
- icon = R.drawable.database,
- color = if (isSystemInDarkTheme()) {
- colorResource(id = R.color.material_green_900)
- } else {
- colorResource(id = R.color.material_green_300)
- }
- )
- },
- title = stringResource(id = R.string.settings_import_export),
- text = stringResource(id = R.string.settings_import_export_desc),
- onClick = { viewModel.updateBottomSheet { BottomSheet.Database } }
- )
-
- SettingItem(
- icon = {
- SettingIcon(
- icon = R.drawable.tool,
- color = if (isSystemInDarkTheme()) {
- colorResource(id = R.color.material_blue_900)
- } else {
- colorResource(id = R.color.material_blue_300)
- }
- )
- },
- title = stringResource(id = R.string.settings_tools),
- text = stringResource(id = R.string.settings_tools_desc),
- onClick = { viewModel.updateBottomSheet { BottomSheet.Tool } }
- )
-
- SettingItem(
- icon = {
- SettingIcon(
- icon = R.drawable.mood_heart,
- color = if (isSystemInDarkTheme()) {
- colorResource(id = R.color.material_deep_orange_900)
- } else {
- colorResource(id = R.color.material_deep_orange_300)
- }
- )
- },
- title = stringResource(id = R.string.settings_preference),
- text = stringResource(id = R.string.settings_preference_desc),
- onClick = { viewModel.updateBottomSheet { BottomSheet.Preference } }
- )
- }
- }
-}
-
-@Composable
-private fun ActionButton(
- onClick: () -> Unit
-) {
- FloatingActionButton(
- onClick = onClick
- ) {
- Icon(
- painter = painterResource(id = R.drawable.trash),
- contentDescription = null
- )
- }
-}
-
-@Composable
-private fun TopBar(
- onBack: () -> Unit,
- scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
- title = { Text(text = stringResource(id = R.string.settings_title)) },
- navigationIcon = {
- IconButton(
- onClick = onBack
- ) {
- Icon(
- painter = painterResource(id = R.drawable.arrow_left),
- contentDescription = null
- )
- }
- },
- actions = {
- val context = LocalContext.current
-
- IconButton(
- onClick = { context.viewUrl(Const.GITHUB_URL) }
- ) {
- Icon(
- painter = painterResource(id = R.drawable.brand_github_2),
- contentDescription = null
- )
- }
- },
- scrollBehavior = scrollBehavior
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsViewModel.kt
deleted file mode 100644
index f5f0894..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsViewModel.kt
+++ /dev/null
@@ -1,257 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.settings
-
-import android.content.Context
-import android.net.Uri
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.authenticator.model.serializer.AuthJson
-import dev.sanmer.authenticator.model.serializer.AuthUri
-import dev.sanmer.authenticator.repository.DbRepository
-import dev.sanmer.authenticator.ui.CryptoActivity
-import dev.sanmer.encoding.isBase32
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import java.io.InputStream
-import java.io.OutputStream
-
-class SettingsViewModel(
- private val dbRepository: DbRepository
-) : ViewModel() {
- private var entities by mutableStateOf(emptyList())
- val isEmpty get() = entities.isEmpty()
-
- private var tmp = emptyList()
-
- var bottomSheet by mutableStateOf(BottomSheet.Closed)
- private set
-
- private val logger = Logger.Android("SettingsViewModel")
-
- init {
- logger.d("init")
- dataObserver()
- }
-
- private fun dataObserver() {
- viewModelScope.launch {
- dbRepository.getTotpAllDecryptedAsFlow()
- .collect { list ->
- entities = list
- }
- }
- }
-
- fun updateBottomSheet(block: (BottomSheet) -> BottomSheet) {
- bottomSheet = block(bottomSheet)
- }
-
- fun closeBottomSheet() {
- bottomSheet = BottomSheet.Closed
- }
-
- private suspend fun import(
- fileType: FileType,
- context: Context,
- uri: Uri,
- bypass: Boolean,
- ) = withContext(Dispatchers.IO) {
- runCatching {
- val cr = context.contentResolver
- val entities = checkNotNull(cr.openInputStream(uri)).use(fileType::decodeFrom)
- when (fileType) {
- FileType.Json -> {
- val secrets = CryptoActivity.decrypt(
- context = context,
- input = entities.map { it.secret },
- bypass = bypass
- )
- entities.mapIndexed { index, it ->
- it.copy(secret = secrets[index])
- }
- }
-
- FileType.Uri -> entities
- }
- }.onFailure {
- logger.e(it)
- }
- }
-
- private fun import(
- fileType: FileType,
- context: Context,
- uri: Uri
- ) {
- viewModelScope.launch {
- import(
- fileType = fileType,
- context = context,
- uri = uri,
- bypass = true
- ).onSuccess { entities ->
- dbRepository.insertTotp(
- entities.filter { it.secret.isBase32() }
- )
- }
- }
- }
-
- fun importJson(
- context: Context,
- uri: Uri
- ) = import(
- fileType = FileType.Json,
- context = context,
- uri
- )
-
- fun importUri(
- context: Context,
- uri: Uri
- ) = import(
- fileType = FileType.Uri,
- context = context,
- uri
- )
-
- private suspend fun export(
- entities: List,
- fileType: FileType,
- context: Context,
- uri: Uri,
- ) = withContext(Dispatchers.IO) {
- runCatching {
- val cr = context.contentResolver
- checkNotNull(cr.openOutputStream(uri)).use { fileType.decodeTo(entities, it) }
- }.onFailure {
- logger.e(it)
- }
- }
-
- fun encrypt(
- context: Context,
- onReady: () -> Unit
- ) {
- viewModelScope.launch {
- runCatching {
- val secrets = CryptoActivity.encrypt(
- context = context,
- input = entities.map { it.secret }
- )
- tmp = entities.mapIndexed { index, it ->
- it.copy(secret = secrets[index])
- }
- }.onSuccess {
- onReady()
- }
- }
- }
-
- private fun export(
- fileType: FileType,
- context: Context,
- uri: Uri
- ) {
- viewModelScope.launch {
- export(
- entities = tmp,
- fileType = fileType,
- context = context,
- uri = uri,
- )
- tmp = emptyList()
- }
- }
-
- fun exportJson(
- context: Context,
- uri: Uri
- ) = export(
- fileType = FileType.Json,
- context = context,
- uri = uri
- )
-
- fun exportUri(
- context: Context,
- uri: Uri
- ) = export(
- fileType = FileType.Uri,
- context = context,
- uri = uri
- )
-
- fun decryptFromJson(
- context: Context,
- uri: Uri,
- onReady: () -> Unit
- ) {
- viewModelScope.launch {
- import(
- fileType = FileType.Json,
- context = context,
- uri = uri,
- bypass = false,
- ).onSuccess { entities ->
- tmp = entities
- onReady()
- }
- }
- }
-
- fun decryptedToJson(
- context: Context,
- uri: Uri,
- ) {
- viewModelScope.launch {
- export(
- entities = tmp,
- fileType = FileType.Json,
- context = context,
- uri = uri,
- )
- tmp = emptyList()
- }
- }
-
- private sealed interface FileType {
- fun decodeFrom(input: InputStream): List
- fun decodeTo(auths: List, output: OutputStream)
-
- data object Uri : FileType {
- override fun decodeFrom(input: InputStream): List {
- return AuthUri.decodeFrom(input).totp
- }
-
- override fun decodeTo(auths: List, output: OutputStream) {
- AuthUri(auths).encodeTo(output)
- }
- }
-
- data object Json : FileType {
- override fun decodeFrom(input: InputStream): List {
- return AuthJson.decodeFrom(input).entities()
- }
-
- override fun decodeTo(auths: List, output: OutputStream) {
- AuthJson.entities(auths).encodeTo(output)
- }
- }
- }
-
- enum class BottomSheet {
- Closed,
- Token,
- Database,
- Tool,
- Preference;
-
- val isClosed inline get() = this == Closed
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/DatabaseItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/DatabaseItem.kt
deleted file mode 100644
index 69197e8..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/DatabaseItem.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.settings.component
-
-import android.content.Context
-import android.net.Uri
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.model.serializer.AuthJson
-import dev.sanmer.authenticator.model.serializer.AuthUri
-
-@Composable
-fun DatabaseItem(
- onDismiss: () -> Unit,
- isEmpty: Boolean,
- importJson: (Context, Uri) -> Unit,
- importUri: (Context, Uri) -> Unit,
- encrypt: (Context, () -> Unit) -> Unit,
- exportJson: (Context, Uri) -> Unit,
- exportUri: (Context, Uri) -> Unit,
-) {
- val context = LocalContext.current
- val jsonImporter = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.GetContent(),
- onResult = { if (it != null) importJson(context, it) }
- )
- val jsonExporter = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.CreateDocument(AuthJson.MIME_TYPE),
- onResult = { if (it != null) exportJson(context, it) }
- )
- val uriImporter = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.GetContent(),
- onResult = { if (it != null) importUri(context, it) }
- )
- val uriExporter = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.CreateDocument(AuthUri.MIME_TYPE),
- onResult = { if (it != null) exportUri(context, it) }
- )
-
- SettingBottomSheet(
- onDismiss = onDismiss,
- title = stringResource(id = R.string.settings_import_export)
- ) {
- SettingItem(
- icon = R.drawable.database_import,
- title = stringResource(id = R.string.settings_import_json),
- onClick = { jsonImporter.launch(AuthJson.MIME_TYPE) }
- )
-
- if (!isEmpty) SettingItem(
- icon = R.drawable.database_export,
- title = stringResource(id = R.string.settings_export_json),
- onClick = { encrypt(context) { jsonExporter.launch(AuthJson.FILE_NAME) } }
- )
-
- SettingItem(
- icon = R.drawable.file_export,
- title = stringResource(id = R.string.settings_import_txt),
- onClick = { uriImporter.launch(AuthUri.MIME_TYPE) }
- )
-
- if (!isEmpty) SettingItem(
- icon = R.drawable.file_import,
- title = stringResource(id = R.string.settings_export_txt),
- onClick = { uriExporter.launch(AuthUri.FILE_NAME) }
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt
deleted file mode 100644
index baa33cf..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.settings.component
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-
-@Composable
-fun PreferenceItem(
- onDismiss: () -> Unit,
- onSecurity: () -> Unit,
- onNtp: () -> Unit
-) {
- SettingBottomSheet(
- onDismiss = onDismiss,
- title = stringResource(id = R.string.settings_preference)
- ) {
- SettingItem(
- icon = R.drawable.shield,
- title = stringResource(id = R.string.settings_security),
- onClick = {
- onSecurity()
- onDismiss()
- }
- )
-
- SettingItem(
- icon = R.drawable.timezone,
- title = stringResource(id = R.string.settings_ntp_server),
- onClick = {
- onNtp()
- onDismiss()
- }
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/SettingItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/SettingItem.kt
deleted file mode 100644
index 33f4224..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/SettingItem.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.settings.component
-
-import androidx.annotation.DrawableRes
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.ui.component.DragHandle
-import dev.sanmer.authenticator.ui.component.Logo
-import dev.sanmer.authenticator.ui.ktx.bottom
-import dev.sanmer.authenticator.ui.ktx.surface
-
-@Composable
-fun SettingItem(
- icon: @Composable () -> Unit,
- title: String,
- text: String,
- onClick: () -> Unit
-) = Row(
- modifier = Modifier
- .surface(
- shape = MaterialTheme.shapes.large,
- backgroundColor = MaterialTheme.colorScheme.surfaceContainer,
- border = CardDefaults.outlinedCardBorder(false)
- )
- .clickable(onClick = onClick)
- .padding(all = 20.dp)
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(20.dp),
- verticalAlignment = Alignment.CenterVertically
-) {
- icon()
-
- Column(
- modifier = Modifier.weight(1f)
- ) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium
- )
-
- Text(
- text = text,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline
- )
- }
-}
-
-@Composable
-fun SettingIcon(
- @DrawableRes icon: Int,
- color: Color
-) = Logo(
- icon = icon,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
- containerColor = color,
- modifier = Modifier.size(40.dp)
-)
-
-@Composable
-fun SettingItem(
- @DrawableRes icon: Int,
- title: String,
- onClick: () -> Unit,
- enabled: Boolean = true
-) = Row(
- modifier = Modifier
- .surface(
- shape = MaterialTheme.shapes.medium,
- backgroundColor = MaterialTheme.colorScheme.surfaceContainer,
- border = CardDefaults.outlinedCardBorder(false)
- )
- .clickable(enabled = enabled, onClick = onClick)
- .padding(all = 15.dp)
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
-) {
- Icon(
- painter = painterResource(id = icon),
- contentDescription = null
- )
-
- Text(
- text = title,
- style = MaterialTheme.typography.titleSmall
- )
-}
-
-@Composable
-fun SettingBottomSheet(
- onDismiss: () -> Unit,
- title: String,
- content: @Composable ColumnScope.() -> Unit
-) = ModalBottomSheet(
- onDismissRequest = onDismiss,
- shape = MaterialTheme.shapes.large.bottom(0.dp),
- dragHandle = null
-) {
- DragHandle()
-
- Text(
- text = title,
- style = MaterialTheme.typography.headlineSmall,
- modifier = Modifier.align(Alignment.CenterHorizontally)
- )
-
- Column(
- modifier = Modifier.padding(all = 20.dp),
- verticalArrangement = Arrangement.spacedBy(20.dp),
- content = content
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt
deleted file mode 100644
index d726ae7..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.settings.component
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-
-@Composable
-fun TokenItem(
- onDismiss: () -> Unit,
- onEdit: () -> Unit,
- onScan: () -> Unit
-) {
- SettingBottomSheet(
- onDismiss = onDismiss,
- title = stringResource(id = R.string.settings_token)
- ) {
- SettingItem(
- icon = R.drawable.edit,
- title = stringResource(id = R.string.settings_enter),
- onClick = {
- onEdit()
- onDismiss()
- }
- )
-
- SettingItem(
- icon = R.drawable.scan,
- title = stringResource(id = R.string.settings_scan),
- onClick = {
- onScan()
- onDismiss()
- }
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt
deleted file mode 100644
index cb501e6..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.settings.component
-
-import android.content.Context
-import android.net.Uri
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.model.serializer.AuthJson
-
-@Composable
-fun ToolItem(
- onDismiss: () -> Unit,
- onEncode: () -> Unit,
- decryptFromJson: (Context, Uri, () -> Unit) -> Unit,
- decryptedToJson: (Context, Uri) -> Unit
-) {
- val context = LocalContext.current
- val jsonExporter = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.CreateDocument(AuthJson.MIME_TYPE),
- onResult = { if (it != null) decryptedToJson(context, it) }
- )
- val jsonImporter = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.GetContent(),
- onResult = {
- if (it != null) decryptFromJson(context, it) {
- jsonExporter.launch(AuthJson.FILE_NAME)
- }
- }
- )
-
- SettingBottomSheet(
- onDismiss = onDismiss,
- title = stringResource(id = R.string.settings_tools),
- ) {
- SettingItem(
- icon = R.drawable.lock_open,
- title = stringResource(id = R.string.settings_decrypt),
- onClick = { jsonImporter.launch(AuthJson.MIME_TYPE) }
- )
-
- SettingItem(
- icon = R.drawable.a_b,
- title = stringResource(id = R.string.settings_encode_decode),
- onClick = {
- onEncode()
- onDismiss()
- }
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashScreen.kt
deleted file mode 100644
index 98fde2a..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashScreen.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.trash
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.ui.component.PageIndicator
-import dev.sanmer.authenticator.ui.screens.trash.component.AuthList
-
-@Composable
-fun TrashScreen(
- viewModel: TrashViewModel,
- goBack: () -> Unit
-) {
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
- val listState = rememberLazyListState()
-
- Scaffold(
- topBar = {
- TopBar(
- onBack = goBack,
- onRestore = viewModel::restoreAll,
- scrollBehavior = scrollBehavior
- )
- }
- ) { contentPadding ->
- Box(
- modifier = Modifier
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .fillMaxSize(),
- contentAlignment = Alignment.TopCenter
- ) {
- if (viewModel.totp.isEmpty() && !viewModel.isPending) {
- PageIndicator(
- icon = R.drawable.trash,
- text = R.string.trash_empty,
- modifier = Modifier.padding(contentPadding)
- )
- }
-
- AuthList(
- state = listState,
- totp = viewModel.totp,
- restore = viewModel::restore,
- delete = viewModel::delete,
- contentPadding = contentPadding
- )
- }
- }
-}
-
-@Composable
-private fun TopBar(
- onBack: () -> Unit,
- onRestore: () -> Unit,
- scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
- title = { Text(text = stringResource(id = R.string.trash_title)) },
- navigationIcon = {
- IconButton(
- onClick = onBack
- ) {
- Icon(
- painter = painterResource(id = R.drawable.arrow_left),
- contentDescription = null
- )
- }
- },
- actions = {
- IconButton(
- onClick = onRestore
- ) {
- Icon(
- painter = painterResource(id = R.drawable.restore),
- contentDescription = null
- )
- }
- },
- scrollBehavior = scrollBehavior
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashViewModel.kt
deleted file mode 100644
index 1f1e68f..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashViewModel.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.trash
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dev.sanmer.authenticator.Logger
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.authenticator.repository.DbRepository
-import kotlinx.coroutines.launch
-
-class TrashViewModel(
- private val dbRepository: DbRepository
-) : ViewModel() {
- var loadState by mutableStateOf(LoadState.Pending)
- private set
- val totp inline get() = loadState.totp
- val isPending inline get() = loadState.isPending
-
- private val logger = Logger.Android("TrashViewModel")
-
- init {
- logger.d("init")
- dataObserver()
- }
-
- private fun dataObserver() {
- viewModelScope.launch {
- dbRepository.getTotpAllDecryptedTrashedAsFlow()
- .collect { totp ->
- loadState = LoadState.Ready(totp)
- }
- }
- }
-
- fun restoreAll() {
- viewModelScope.launch {
- dbRepository.updateTotp(totp.map { it.copy(deletedAt = 0) })
- }
- }
-
- fun restore(entity: TotpEntity) {
- viewModelScope.launch {
- dbRepository.updateTotp(entity.copy(deletedAt = 0))
- }
- }
-
- fun delete(entity: TotpEntity) {
- viewModelScope.launch {
- dbRepository.deleteTotp(entity)
- }
- }
-
- sealed class LoadState {
- abstract val totp: List
-
- data object Pending : LoadState() {
- override val totp = emptyList()
- }
-
- data class Ready(
- override val totp: List
- ) : LoadState()
-
- val isPending inline get() = this is Pending
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthItem.kt
deleted file mode 100644
index ad08efc..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthItem.kt
+++ /dev/null
@@ -1,150 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.trash.component
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.sizeIn
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.FilledTonalIconButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.R
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.authenticator.ktx.hidden
-import dev.sanmer.authenticator.ui.component.LabelText
-import dev.sanmer.authenticator.ui.component.SwipeContent
-import dev.sanmer.authenticator.ui.ktx.surface
-import dev.sanmer.logo.Logo
-import kotlin.time.DurationUnit
-
-@Composable
-fun AuthItem(
- entity: TotpEntity,
- onRestore: () -> Unit,
- onDelete: () -> Unit
-) = SwipeContent(
- content = { release ->
- AuthItemButtons(
- onRestore = {
- release()
- onRestore()
- },
- onDelete = {
- release()
- onDelete()
- }
- )
- },
- surface = {
- AuthItemContent(
- entity = entity
- )
- }
-)
-
-@Composable
-private fun AuthItemContent(
- entity: TotpEntity
-) = Row(
- modifier = Modifier
- .sizeIn(maxWidth = 450.dp)
- .fillMaxWidth()
- .surface(
- shape = MaterialTheme.shapes.large,
- backgroundColor = MaterialTheme.colorScheme.surface,
- border = CardDefaults.outlinedCardBorder(false)
- )
- .padding(all = 15.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
-) {
- val logo by remember(entity.issuer) { derivedStateOf { Logo.getOrDefault(entity.issuer) } }
- val hiddenSecret by remember { derivedStateOf { entity.secret.hidden() } }
- val lifetimeString by remember {
- derivedStateOf { entity.lifetime.toString(unit = DurationUnit.HOURS, decimals = 2) }
- }
-
- Image(
- painter = painterResource(id = logo.res),
- contentDescription = null,
- modifier = Modifier.size(40.dp),
- colorFilter = if (logo.refillable) {
- ColorFilter.tint(MaterialTheme.colorScheme.primary)
- } else null
- )
-
- Column(
- modifier = Modifier.fillMaxWidth()
- ) {
- Row(
- verticalAlignment = Alignment.Top
- ) {
- Text(
- text = hiddenSecret,
- style = MaterialTheme.typography.headlineSmall.copy(
- fontFamily = FontFamily.Monospace
- ),
- modifier = Modifier.weight(1f)
- )
-
- Spacer(modifier = Modifier.width(10.dp))
-
- LabelText(text = lifetimeString)
- }
-
- Text(
- text = entity.displayName,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.outline
- )
- }
-}
-
-@Composable
-private fun AuthItemButtons(
- onRestore: () -> Unit,
- onDelete: () -> Unit
-) = Row(
- modifier = Modifier.padding(horizontal = 10.dp),
- horizontalArrangement = Arrangement.spacedBy(5.dp)
-) {
- FilledTonalIconButton(
- onClick = onRestore
- ) {
- Icon(
- painter = painterResource(id = R.drawable.restore),
- contentDescription = null
- )
- }
-
- FilledTonalIconButton(
- onClick = onDelete,
- colors = IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer
- )
- ) {
- Icon(
- painter = painterResource(id = R.drawable.trash_x),
- contentDescription = null
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt
deleted file mode 100644
index 41f5ddc..0000000
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package dev.sanmer.authenticator.ui.screens.trash.component
-
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.items
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import dev.sanmer.authenticator.database.entity.TotpEntity
-import dev.sanmer.authenticator.ui.ktx.plus
-
-@Composable
-fun AuthList(
- state: LazyListState,
- totp: List,
- restore: (TotpEntity) -> Unit,
- delete: (TotpEntity) -> Unit,
- contentPadding: PaddingValues = PaddingValues(0.dp)
-) = LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .animateContentSize(),
- state = state,
- contentPadding = contentPadding + PaddingValues(15.dp),
- verticalArrangement = Arrangement.spacedBy(15.dp),
- horizontalAlignment = Alignment.CenterHorizontally
-) {
- items(totp) {
- AuthItem(
- entity = it,
- onRestore = { restore(it) },
- onDelete = { delete(it) }
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Color.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Color.kt
new file mode 100644
index 0000000..02d9a04
--- /dev/null
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Color.kt
@@ -0,0 +1,106 @@
+package dev.sanmer.authenticator.ui.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.ui.graphics.Color
+
+val LightColorScheme = ColorScheme(
+ primary = Color(0xFF000000),
+ onPrimary = Color(0xFFE2E2E2),
+ primaryContainer = Color(0xFF3B3B3B),
+ onPrimaryContainer = Color(0xFFFFFFFF),
+ inversePrimary = Color(0xFFFFFFFF),
+ secondary = Color(0xFF5E5E5E),
+ onSecondary = Color(0xFFFFFFFF),
+ secondaryContainer = Color(0xFFD4D4D4),
+ onSecondaryContainer = Color(0xFF1B1B1B),
+ tertiary = Color(0xFF3B3B3B),
+ onTertiary = Color(0xFFE2E2E2),
+ tertiaryContainer = Color(0xFF747474),
+ onTertiaryContainer = Color(0xFFFFFFFF),
+ background = Color(0xFFF9F9F9),
+ onBackground = Color(0xFF1B1B1B),
+ surface = Color(0xFFF9F9F9),
+ onSurface = Color(0xFF1B1B1B),
+ surfaceVariant = Color(0xFFE2E2E2),
+ onSurfaceVariant = Color(0xFF474747),
+ surfaceTint = Color(0xFF000000),
+ inverseSurface = Color(0xFF131313),
+ inverseOnSurface = Color(0xFFE2E2E2),
+ error = Color(0xFFB3261E),
+ onError = Color(0xFFFFFFFF),
+ errorContainer = Color(0xFFF9DEDC),
+ onErrorContainer = Color(0xFF410E0B),
+ outline = Color(0xFF777777),
+ outlineVariant = Color(0xFFC6C6C6),
+ scrim = Color(0xFF000000),
+ surfaceBright = Color(0xFFF9F9F9),
+ surfaceDim = Color(0xFFDADADA),
+ surfaceContainer = Color(0xFFEEEEEE),
+ surfaceContainerHigh = Color(0xFFE8E8E8),
+ surfaceContainerHighest = Color(0xFFE2E2E2),
+ surfaceContainerLow = Color(0xFFF3F3F3),
+ surfaceContainerLowest = Color(0xFFFFFFFF),
+ primaryFixed = Color(0xFF5E5E5E),
+ primaryFixedDim = Color(0xFF474747),
+ onPrimaryFixed = Color(0xFFFFFFFF),
+ onPrimaryFixedVariant = Color(0xFFE2E2E2),
+ secondaryFixed = Color(0xFFC6C6C6),
+ secondaryFixedDim = Color(0xFFABABAB),
+ onSecondaryFixed = Color(0xFF1B1B1B),
+ onSecondaryFixedVariant = Color(0xFF3B3B3B),
+ tertiaryFixed = Color(0xFF5E5E5E),
+ tertiaryFixedDim = Color(0xFF474747),
+ onTertiaryFixed = Color(0xFFFFFFFF),
+ onTertiaryFixedVariant = Color(0xFFE2E2E2)
+)
+
+val DarkColorScheme = ColorScheme(
+ primary = Color(0xFFFFFFFF),
+ onPrimary = Color(0xFF1B1B1B),
+ primaryContainer = Color(0xFFD4D4D4),
+ onPrimaryContainer = Color(0xFF000000),
+ inversePrimary = Color(0xFF000000),
+ secondary = Color(0xFFC6C6C6),
+ onSecondary = Color(0xFF1B1B1B),
+ secondaryContainer = Color(0xFF474747),
+ onSecondaryContainer = Color(0xFFE2E2E2),
+ tertiary = Color(0xFFE2E2E2),
+ onTertiary = Color(0xFF1B1B1B),
+ tertiaryContainer = Color(0xFF919191),
+ onTertiaryContainer = Color(0xFF000000),
+ background = Color(0xFF131313),
+ onBackground = Color(0xFFE2E2E2),
+ surface = Color(0xFF131313),
+ onSurface = Color(0xFFE2E2E2),
+ surfaceVariant = Color(0xFF474747),
+ onSurfaceVariant = Color(0xFFC6C6C6),
+ surfaceTint = Color(0xFFFFFFFF),
+ inverseSurface = Color(0xFFF9F9F9),
+ inverseOnSurface = Color(0xFF1B1B1B),
+ error = Color(0xFFF2B8B5),
+ onError = Color(0xFF601410),
+ errorContainer = Color(0xFF8C1D18),
+ onErrorContainer = Color(0xFFF9DEDC),
+ outline = Color(0xFF919191),
+ outlineVariant = Color(0xFF474747),
+ scrim = Color(0xFF000000),
+ surfaceBright = Color(0xFF393939),
+ surfaceDim = Color(0xFF131313),
+ surfaceContainer = Color(0xFF1F1F1F),
+ surfaceContainerHigh = Color(0xFF2A2A2A),
+ surfaceContainerHighest = Color(0xFF353535),
+ surfaceContainerLow = Color(0xFF1B1B1B),
+ surfaceContainerLowest = Color(0xFF0E0E0E),
+ primaryFixed = Color(0xFF5E5E5E),
+ primaryFixedDim = Color(0xFF474747),
+ onPrimaryFixed = Color(0xFFFFFFFF),
+ onPrimaryFixedVariant = Color(0xFFE2E2E2),
+ secondaryFixed = Color(0xFFC6C6C6),
+ secondaryFixedDim = Color(0xFFABABAB),
+ onSecondaryFixed = Color(0xFF1B1B1B),
+ onSecondaryFixedVariant = Color(0xFF3B3B3B),
+ tertiaryFixed = Color(0xFF5E5E5E),
+ tertiaryFixedDim = Color(0xFF474747),
+ onTertiaryFixed = Color(0xFFFFFFFF),
+ onTertiaryFixedVariant = Color(0xFFE2E2E2)
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt
index 338c620..cdb8c7b 100644
--- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt
+++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt
@@ -7,6 +7,7 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
+import dev.sanmer.authenticator.compat.BuildCompat
@Composable
fun AppTheme(
@@ -21,9 +22,16 @@ fun AppTheme(
@Composable
private fun colorScheme(
- darkMode: Boolean,
+ darkMode: Boolean = isSystemInDarkTheme(),
context: Context = LocalContext.current
-) = when {
- darkMode -> dynamicDarkColorScheme(context)
- else -> dynamicLightColorScheme(context)
+) = if (BuildCompat.atLeastS) {
+ when {
+ darkMode -> dynamicDarkColorScheme(context)
+ else -> dynamicLightColorScheme(context)
+ }
+} else {
+ when {
+ darkMode -> DarkColorScheme
+ else -> LightColorScheme
+ }
}
diff --git a/app/src/main/res/drawable/arrow_bar_down.xml b/app/src/main/res/drawable/arrow_bar_down.xml
deleted file mode 100644
index 8c61928..0000000
--- a/app/src/main/res/drawable/arrow_bar_down.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/arrow_bar_up.xml b/app/src/main/res/drawable/arrow_bar_up.xml
deleted file mode 100644
index 97ad2a4..0000000
--- a/app/src/main/res/drawable/arrow_bar_up.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/a_b.xml b/app/src/main/res/drawable/arrow_right.xml
similarity index 50%
rename from app/src/main/res/drawable/a_b.xml
rename to app/src/main/res/drawable/arrow_right.xml
index bb5307a..f3b8907 100644
--- a/app/src/main/res/drawable/a_b.xml
+++ b/app/src/main/res/drawable/arrow_right.xml
@@ -4,21 +4,21 @@
android:viewportWidth="24"
android:viewportHeight="24">
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
diff --git a/app/src/main/res/drawable/arrows_shuffle.xml b/app/src/main/res/drawable/arrows_shuffle.xml
new file mode 100644
index 0000000..28704e8
--- /dev/null
+++ b/app/src/main/res/drawable/arrows_shuffle.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bug.xml b/app/src/main/res/drawable/bug.xml
deleted file mode 100644
index e35ee57..0000000
--- a/app/src/main/res/drawable/bug.xml
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/caret_down.xml b/app/src/main/res/drawable/caret_down.xml
deleted file mode 100644
index ecc4b07..0000000
--- a/app/src/main/res/drawable/caret_down.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/caret_up.xml b/app/src/main/res/drawable/caret_up.xml
deleted file mode 100644
index f02df89..0000000
--- a/app/src/main/res/drawable/caret_up.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/chevron_right.xml b/app/src/main/res/drawable/chevron_right.xml
new file mode 100644
index 0000000..f84f3a4
--- /dev/null
+++ b/app/src/main/res/drawable/chevron_right.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/clear_all.xml b/app/src/main/res/drawable/clear_all.xml
new file mode 100644
index 0000000..51a4ac6
--- /dev/null
+++ b/app/src/main/res/drawable/clear_all.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/clipboard_text.xml b/app/src/main/res/drawable/clipboard_text.xml
deleted file mode 100644
index 91b677e..0000000
--- a/app/src/main/res/drawable/clipboard_text.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/clock_record_thin.xml b/app/src/main/res/drawable/clock_record_thin.xml
new file mode 100644
index 0000000..3bb230b
--- /dev/null
+++ b/app/src/main/res/drawable/clock_record_thin.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/eye.xml b/app/src/main/res/drawable/copyright.xml
similarity index 50%
rename from app/src/main/res/drawable/eye.xml
rename to app/src/main/res/drawable/copyright.xml
index 719a32b..ce772a4 100644
--- a/app/src/main/res/drawable/eye.xml
+++ b/app/src/main/res/drawable/copyright.xml
@@ -4,15 +4,15 @@
android:viewportWidth="24"
android:viewportHeight="24">
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
diff --git a/app/src/main/res/drawable/database_export.xml b/app/src/main/res/drawable/database_export.xml
deleted file mode 100644
index 7bf78ea..0000000
--- a/app/src/main/res/drawable/database_export.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml
new file mode 100644
index 0000000..ef701dc
--- /dev/null
+++ b/app/src/main/res/drawable/download.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/edit.xml b/app/src/main/res/drawable/edit.xml
deleted file mode 100644
index 45a8528..0000000
--- a/app/src/main/res/drawable/edit.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/eye_closed.xml b/app/src/main/res/drawable/eye_closed.xml
deleted file mode 100644
index 89db104..0000000
--- a/app/src/main/res/drawable/eye_closed.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/file_export.xml b/app/src/main/res/drawable/file_export.xml
deleted file mode 100644
index a1138d0..0000000
--- a/app/src/main/res/drawable/file_export.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/file_import.xml b/app/src/main/res/drawable/file_import.xml
deleted file mode 100644
index 1daefc4..0000000
--- a/app/src/main/res/drawable/file_import.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
diff --git a/logo/src/main/res/drawable/normal_default.xml b/app/src/main/res/drawable/fingerprint_thin.xml
similarity index 100%
rename from logo/src/main/res/drawable/normal_default.xml
rename to app/src/main/res/drawable/fingerprint_thin.xml
diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml
deleted file mode 100644
index 40607ed..0000000
--- a/app/src/main/res/drawable/lock.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/lock_off.xml b/app/src/main/res/drawable/lock_off.xml
deleted file mode 100644
index 5a60804..0000000
--- a/app/src/main/res/drawable/lock_off.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/lock_open.xml b/app/src/main/res/drawable/lock_open.xml
deleted file mode 100644
index f0dc6ab..0000000
--- a/app/src/main/res/drawable/lock_open.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/mood_heart.xml b/app/src/main/res/drawable/mood_heart.xml
deleted file mode 100644
index f80c983..0000000
--- a/app/src/main/res/drawable/mood_heart.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/plus.xml b/app/src/main/res/drawable/plus.xml
new file mode 100644
index 0000000..959ea0e
--- /dev/null
+++ b/app/src/main/res/drawable/plus.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/scan.xml b/app/src/main/res/drawable/scan.xml
deleted file mode 100644
index f3a7162..0000000
--- a/app/src/main/res/drawable/scan.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/refresh.xml b/app/src/main/res/drawable/search.xml
similarity index 54%
rename from app/src/main/res/drawable/refresh.xml
rename to app/src/main/res/drawable/search.xml
index 2edf266..27a49cf 100644
--- a/app/src/main/res/drawable/refresh.xml
+++ b/app/src/main/res/drawable/search.xml
@@ -4,15 +4,15 @@
android:viewportWidth="24"
android:viewportHeight="24">
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
diff --git a/app/src/main/res/drawable/shield_off.xml b/app/src/main/res/drawable/shield_off.xml
deleted file mode 100644
index bc8465b..0000000
--- a/app/src/main/res/drawable/shield_off.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/sparkles_2.xml b/app/src/main/res/drawable/sparkles_2.xml
new file mode 100644
index 0000000..aa3458a
--- /dev/null
+++ b/app/src/main/res/drawable/sparkles_2.xml
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/sparkles_2_off.xml b/app/src/main/res/drawable/sparkles_2_off.xml
new file mode 100644
index 0000000..e8222be
--- /dev/null
+++ b/app/src/main/res/drawable/sparkles_2_off.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/spy.xml b/app/src/main/res/drawable/spy.xml
new file mode 100644
index 0000000..581474b
--- /dev/null
+++ b/app/src/main/res/drawable/spy.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/tool.xml b/app/src/main/res/drawable/tool.xml
deleted file mode 100644
index a844205..0000000
--- a/app/src/main/res/drawable/tool.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/upload.xml b/app/src/main/res/drawable/upload.xml
new file mode 100644
index 0000000..60a8747
--- /dev/null
+++ b/app/src/main/res/drawable/upload.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 63d859c..d271dc9 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -1,70 +1,47 @@
-
- 加密
- 解密
-
-
- 没有令牌
-
- 添加令牌
- 编辑令牌
- 名称
- 发行方
- 密钥
- 类型
- 哈希算法
- 位数
- 周期
- 二维码
-
-
- 设置
- 令牌
- 添加新令牌
- 手动输入
- 扫描二维码
- 导入与导出
- 导入与导出数据库
- 从 JSON 导入
- 导出为 JSON
- 从 URI 导入
- 导出为 URI
- 工具
- 解密、编码与解码
- 解密 JSON
- 编码与解码
- 偏好
- 安全性、时间服务器
- 安全性
- 时间服务器
+ 添加令牌
+ 编辑令牌
+ 名称
+ 发行方
+ 密钥
+ 类型
+ 哈希算法
+ 位数
+ 周期
+ 预览
+ 保存
+ 复制
+ 关闭
+
+
+ 设置
+ 安全
+ 密码
+ 未设置
+ 已设置
+ 移除
+ 修改
+ 生物特征
+ 使用生物认证解锁应用
+ 验证身份
+ 禁止截图
+ 防止应用出现在屏幕截图
+ 数据库
+ 导入与导出
+ 导入与导出令牌
+ 其他
+ 商标
回收站
- 没有项目
-
-
- 已编码
- 已解码
-
-
- 验证身份
- 密码
- 未设置密码
- 生物识别
- 使用生物认证解锁应用
- 删除密码
- 删除当前的密码,同时生物认证也将禁用
- 设置密码
- 修改密码
- 输入密码
- 新密码
- 当前密码
+ 空的
+ 丢弃于 %1$s
- 延迟: %1$s
- 偏移: %1$s
+ 时间服务器
+ 自定义
超时
\ No newline at end of file
diff --git a/app/src/main/res/values/colors_material.xml b/app/src/main/res/values/colors_material.xml
deleted file mode 100644
index 34d2c90..0000000
--- a/app/src/main/res/values/colors_material.xml
+++ /dev/null
@@ -1,300 +0,0 @@
-
-
-
-
-
- #FFEBEE
- #FFCDD2
- #EF9A9A
- #E57373
- #EF5350
- #F44336
- #E53935
- #D32F2F
- #C62828
- #B71C1C
- #FF8A80
- #FF5252
- #FF1744
- #D50000
-
-
- #FCE4EC
- #F8BBD0
- #F48FB1
- #F06292
- #EC407A
- #E91E63
- #D81B60
- #C2185B
- #AD1457
- #880E4F
- #FF80AB
- #FF4081
- #F50057
- #C51162
-
-
- #F3E5F5
- #E1BEE7
- #CE93D8
- #BA68C8
- #AB47BC
- #9C27B0
- #8E24AA
- #7B1FA2
- #6A1B9A
- #4A148C
- #EA80FC
- #E040FB
- #D500F9
- #AA00FF
-
-
- #EDE7F6
- #D1C4E9
- #B39DDB
- #9575CD
- #7E57C2
- #673AB7
- #5E35B1
- #512DA8
- #4527A0
- #311B92
- #B388FF
- #7C4DFF
- #651FFF
- #6200EA
-
-
- #E8EAF6
- #C5CAE9
- #9FA8DA
- #7986CB
- #5C6BC0
- #3F51B5
- #3949AB
- #303F9F
- #283593
- #1A237E
- #8C9EFF
- #536DFE
- #3D5AFE
- #304FFE
-
-
- #E3F2FD
- #BBDEFB
- #90CAF9
- #64B5F6
- #42A5F5
- #2196F3
- #1E88E5
- #1976D2
- #1565C0
- #0D47A1
- #82B1FF
- #448AFF
- #2979FF
- #2962FF
-
-
- #E1F5FE
- #B3E5FC
- #81D4fA
- #4fC3F7
- #29B6FC
- #03A9F4
- #039BE5
- #0288D1
- #0277BD
- #01579B
- #80D8FF
- #40C4FF
- #00B0FF
- #0091EA
-
-
- #E0F7FA
- #B2EBF2
- #80DEEA
- #4DD0E1
- #26C6DA
- #00BCD4
- #00ACC1
- #0097A7
- #00838F
- #006064
- #84FFFF
- #18FFFF
- #00E5FF
- #00B8D4
-
-
- #E0F2F1
- #B2DFDB
- #80CBC4
- #4DB6AC
- #26A69A
- #009688
- #00897B
- #00796B
- #00695C
- #004D40
- #A7FFEB
- #64FFDA
- #1DE9B6
- #00BFA5
-
-
- #E8F5E9
- #C8E6C9
- #A5D6A7
- #81C784
- #66BB6A
- #4CAF50
- #43A047
- #388E3C
- #2E7D32
- #1B5E20
- #B9F6CA
- #69F0AE
- #00E676
- #00C853
-
-
- #F1F8E9
- #DCEDC8
- #C5E1A5
- #AED581
- #9CCC65
- #8BC34A
- #7CB342
- #689F38
- #558B2F
- #33691E
- #CCFF90
- #B2FF59
- #76FF03
- #64DD17
-
-
- #F9FBE7
- #F0F4C3
- #E6EE9C
- #DCE775
- #D4E157
- #CDDC39
- #C0CA33
- #A4B42B
- #9E9D24
- #827717
- #F4FF81
- #EEFF41
- #C6FF00
- #AEEA00
-
-
- #FFFDE7
- #FFF9C4
- #FFF590
- #FFF176
- #FFEE58
- #FFEB3B
- #FDD835
- #FBC02D
- #F9A825
- #F57F17
- #FFFF82
- #FFFF00
- #FFEA00
- #FFD600
-
-
- #FFF8E1
- #FFECB3
- #FFE082
- #FFD54F
- #FFCA28
- #FFC107
- #FFB300
- #FFA000
- #FF8F00
- #FF6F00
- #FFE57F
- #FFD740
- #FFC400
- #FFAB00
-
-
- #FFF3E0
- #FFE0B2
- #FFCC80
- #FFB74D
- #FFA726
- #FF9800
- #FB8C00
- #F57C00
- #EF6C00
- #E65100
- #FFD180
- #FFAB40
- #FF9100
- #FF6D00
-
-
- #FBE9A7
- #FFCCBC
- #FFAB91
- #FF8A65
- #FF7043
- #FF5722
- #F4511E
- #E64A19
- #D84315
- #BF360C
- #FF9E80
- #FF6E40
- #FF3D00
- #DD2600
-
-
- #EFEBE9
- #D7CCC8
- #BCAAA4
- #A1887F
- #8D6E63
- #795548
- #6D4C41
- #5D4037
- #4E342E
- #3E2723
-
-
- #FAFAFA
- #F5F5F5
- #EEEEEE
- #E0E0E0
- #BDBDBD
- #9E9E9E
- #757575
- #616161
- #424242
- #303030
- #212121
- #000000
- #ffffff
-
-
- #ECEFF1
- #CFD8DC
- #B0BBC5
- #90A4AE
- #78909C
- #607D8B
- #546E7A
- #455A64
- #37474F
- #263238
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6b1f38b..e3601d0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,70 +1,47 @@
-
- Encrypt
- Decrypt
-
-
- No tokens
-
- Add token
- Edit token
- Name
- Issuer
- Secret
- Type
- Hash
- Digits
- Period
- QR Code
-
-
- Settings
- Token
- Add new token
- Enter manually
- Scan QR Code
- Import & Export
- Import and export database
- Import from JSON
- Export to JSON
- Import from URI
- Export to URI
- Tools
- Decrypt, encode and decode
- Decrypt a JSON
- Encode & Decode
- Preference
- Security, time server
- Security
- Time server
+ Add token
+ Edit token
+ Name
+ Issuer
+ Secret
+ Type
+ Hash
+ Digits
+ Period
+ Preview
+ Save
+ Copy
+ Close
+
+
+ Settings
+ Security
+ Password
+ Not set
+ Set
+ Remove
+ Change
+ Biometric
+ Use biometric authentication to unlock app
+ Verify it\'s you
+ Prevent screenshots
+ Prevent app from appearing in screenshots
+ Database
+ Import and export
+ Import and export tokens
+ Other
+ Brands
Trash
- No items
-
-
- Encoded
- Decoded
-
-
- Verify it\'s you
- Password
- No password set
- Biometric
- Use biometric authentication to unlock app
- Remove password
- Delete the current password and biometric authentication will be disabled
- Set password
- Change password
- Enter password
- New password
- Current password
+ Empty
+ Trashed at %1$s
- RTT: %1$s
- Offset: %1$s
+ Time server
+ Custom
Timeout
\ No newline at end of file
diff --git a/app/src/main/res/values/strings_untranslatable.xml b/app/src/main/res/values/strings_untranslatable.xml
index 186e16b..0b662b5 100644
--- a/app/src/main/res/values/strings_untranslatable.xml
+++ b/app/src/main/res/values/strings_untranslatable.xml
@@ -1,5 +1,4 @@
-
+
Authenticator
- ••••••••
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 2d49003..a1898f6 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -22,12 +22,4 @@
- true
-
-
\ No newline at end of file
diff --git a/logo/build.gradle.kts b/brand/build.gradle.kts
similarity index 78%
rename from logo/build.gradle.kts
rename to brand/build.gradle.kts
index 67e7c3e..c119604 100644
--- a/logo/build.gradle.kts
+++ b/brand/build.gradle.kts
@@ -3,7 +3,7 @@ plugins {
}
android {
- namespace = "dev.sanmer.logo"
+ namespace = "dev.sanmer.brand"
}
dependencies {
diff --git a/brand/src/main/kotlin/dev/sanmer/brand/Brand.kt b/brand/src/main/kotlin/dev/sanmer/brand/Brand.kt
new file mode 100644
index 0000000..1d459f9
--- /dev/null
+++ b/brand/src/main/kotlin/dev/sanmer/brand/Brand.kt
@@ -0,0 +1,174 @@
+package dev.sanmer.brand
+
+import androidx.annotation.DrawableRes
+
+@Suppress("SpellCheckingInspection")
+enum class Brand(
+ @DrawableRes val id: Int,
+ private val regex: Lazy
+) {
+ Aliyun(
+ id = R.drawable.brand_aliyun,
+ regex = lazy { "(?i)Aliyun(.com)?|Alibaba\\s*Cloud".toRegex() }
+ ),
+ Apple(
+ id = R.drawable.brand_apple,
+ regex = lazy { "(?i)Apple(.com)?".toRegex() }
+ ),
+ AWS(
+ id = R.drawable.brand_aws,
+ regex = lazy { "(?i)AWS(.com|.Amazon.com)?|Amazon\\s*Web\\s*Service".toRegex() }
+ ),
+ Azure(
+ id = R.drawable.brand_azure,
+ regex = lazy { "(?i)Azure(.com|.Microsoft.com)?|Microsoft\\s*Azure".toRegex() }
+ ),
+ Binance(
+ id = R.drawable.brand_binance,
+ regex = lazy { "(?i)Binance(.com)?".toRegex() }
+ ),
+ Bybit(
+ id = R.drawable.brand_bybit,
+ regex = lazy { "(?i)Bybit(.com)?".toRegex() }
+ ),
+ Cloudflare(
+ id = R.drawable.brand_cloudflare,
+ regex = lazy { "(?i)Cloudflare(.com)?".toRegex() }
+ ),
+ Coinbase(
+ id = R.drawable.brand_coinbase,
+ regex = lazy { "(?i)Coinbase(.com)?".toRegex() }
+ ),
+ Crowdin(
+ id = R.drawable.brand_crowdin,
+ regex = lazy { "(?i)Crowdin(.com)?".toRegex() }
+ ),
+ DigitalOcean(
+ id = R.drawable.brand_digitalocean,
+ regex = lazy { "(?i)DigitalOcean(.com)?".toRegex() }
+ ),
+ Discord(
+ id = R.drawable.brand_discord,
+ regex = lazy { "(?i)Discord(.com)?".toRegex() }
+ ),
+ Docker(
+ id = R.drawable.brand_docker,
+ regex = lazy { "(?i).*Docker.*".toRegex() }
+ ),
+ Facebook(
+ id = R.drawable.brand_facebook,
+ regex = lazy { "(?i)Facebook(.com)?".toRegex() }
+ ),
+ Fansly(
+ id = R.drawable.brand_fansly,
+ regex = lazy { "(?i)Fansly(.com)?".toRegex() }
+ ),
+ Gitea(
+ id = R.drawable.brand_gitea,
+ regex = lazy { "(?i).*Gitea.*".toRegex() }
+ ),
+ GitHub(
+ id = R.drawable.brand_github,
+ regex = lazy { "(?i).*GitHub.*".toRegex() }
+ ),
+ GitLab(
+ id = R.drawable.brand_gitlab,
+ regex = lazy { "(?i).*GitLab.*".toRegex() }
+ ),
+ Google(
+ id = R.drawable.brand_google,
+ regex = lazy { "(?i)Google(.com)?".toRegex() }
+ ),
+ GoogleCloud(
+ id = R.drawable.brand_googlecloud,
+ regex = lazy { "(?i)cloud.Google.com|Google\\s*Cloud".toRegex() }
+ ),
+ Instagram(
+ id = R.drawable.brand_instagram,
+ regex = lazy { "(?i)Instagram(.com)?".toRegex() }
+ ),
+ JetBrains(
+ id = R.drawable.brand_jetbrains,
+ regex = lazy { "(?i)JetBrains(.com)?".toRegex() }
+ ),
+ Lark(
+ id = R.drawable.brand_lark,
+ regex = lazy { "(?i)Lark(suite.com)?|Feishu(.cn)?".toRegex() }
+ ),
+ Mega(
+ id = R.drawable.brand_mega,
+ regex = lazy { "(?i)Mega(.io)?".toRegex() }
+ ),
+ Meta(
+ id = R.drawable.brand_meta,
+ regex = lazy { "(?i)Meta(.com)?".toRegex() }
+ ),
+ Microsoft(
+ id = R.drawable.brand_microsoft,
+ regex = lazy { "(?i)Microsoft(.com)?".toRegex() }
+ ),
+ OKX(
+ id = R.drawable.brand_okx,
+ regex = lazy { "(?i)OKX(.com)?".toRegex() }
+ ),
+ OnlyFans(
+ id = R.drawable.brand_onlyfans,
+ regex = lazy { "(?i)OnlyFans(.com)?".toRegex() }
+ ),
+ OpenAI(
+ id = R.drawable.brand_openai,
+ regex = lazy { "(?i)(ChatGPT|OpenAI)(.com)?".toRegex() }
+ ),
+ ORCID(
+ id = R.drawable.brand_orcid,
+ regex = lazy { "(?i)ORCID(.org)?".toRegex() }
+ ),
+ Patreon(
+ id = R.drawable.brand_patreon,
+ regex = lazy { "(?i)Patreon(.com)?".toRegex() }
+ ),
+ PayPal(
+ id = R.drawable.brand_paypal,
+ regex = lazy { "(?i)PayPal(.com)?".toRegex() }
+ ),
+ Pixiv(
+ id = R.drawable.brand_pixiv,
+ regex = lazy { "(?i)Pixiv(.net|.com)?".toRegex() }
+ ),
+ Tencent(
+ id = R.drawable.brand_tencent,
+ regex = lazy { "(?i)Tencent(.com)?".toRegex() }
+ ),
+ TencentCloud(
+ id = R.drawable.brand_tencentcloud,
+ regex = lazy { "(?i)cloud.Tencent.com|Tencent\\s*Cloud".toRegex() }
+ ),
+ Threads(
+ id = R.drawable.brand_threads,
+ regex = lazy { "(?i)Threads(.com)?".toRegex() }
+ ),
+ WhatsApp(
+ id = R.drawable.brand_whatsapp,
+ regex = lazy { "(?i)WhatsApp(.com)?".toRegex() }
+ ),
+ Wise(
+ id = R.drawable.brand_wise,
+ regex = lazy { "(?i)Wise(.com)?".toRegex() }
+ ),
+ X(
+ id = R.drawable.brand_x,
+ regex = lazy { "(?i)X(.com)?".toRegex() }
+ );
+
+ companion object Default {
+ fun matches(name: String) = entries.firstOrNull {
+ it.regex.value.matches(name)
+ }
+
+ fun valueOfOrNull(name: String) = try {
+ valueOf(name)
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+}
\ No newline at end of file
diff --git a/logo/src/main/res/drawable-night/brand_apple.xml b/brand/src/main/res/drawable-night/brand_apple.xml
similarity index 88%
rename from logo/src/main/res/drawable-night/brand_apple.xml
rename to brand/src/main/res/drawable-night/brand_apple.xml
index bea619a..5ae256b 100644
--- a/logo/src/main/res/drawable-night/brand_apple.xml
+++ b/brand/src/main/res/drawable-night/brand_apple.xml
@@ -4,7 +4,7 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M522,282.4C559.4,282.4 606.2,256.3 634.1,221.6C659.3,190.1 677.7,146.2 677.7,102.3C677.7,96.4 677.2,90.4 676.1,85.5C634.6,87.1 584.6,114.2 554.6,150.6C530.9,178.2 509.4,221.6 509.4,266.1C509.4,272.6 510.4,279.1 511,281.2C513.6,281.8 517.8,282.4 522,282.4ZM390.5,938.5C441.5,938.5 464.1,903.3 527.8,903.3C592.5,903.3 606.7,937.4 663.5,937.4C719.3,937.4 756.6,884.3 791.9,832.2C831.3,772.6 847.7,714 848.7,711.3C845,710.2 738.2,665.2 738.2,538.8C738.2,429.3 822.4,380 827.2,376.1C771.4,293.7 686.7,291.6 663.5,291.6C600.9,291.6 549.9,330.6 517.8,330.6C483,330.6 437.3,293.7 383.1,293.7C280,293.7 175.3,381.6 175.3,547.5C175.3,650.5 214.2,759.5 262.1,830C303.1,889.7 338.9,938.5 390.5,938.5Z" />
diff --git a/logo/src/main/res/drawable-night/brand_aws.xml b/brand/src/main/res/drawable-night/brand_aws.xml
similarity index 95%
rename from logo/src/main/res/drawable-night/brand_aws.xml
rename to brand/src/main/res/drawable-night/brand_aws.xml
index eb48515..cd43f7d 100644
--- a/logo/src/main/res/drawable-night/brand_aws.xml
+++ b/brand/src/main/res/drawable-night/brand_aws.xml
@@ -1,12 +1,10 @@
+ android:pathData="M244.2,256.4C205.6,256.4 166.3,271.5 159.8,279.7C158.1,283.1 154.1,310.7 163.5,310.7C166.6,310.7 168,311.3 177.1,307C211.3,293.7 232.9,293.9 236,293.9C274.4,290.3 296.6,316.4 293.2,350.3L293.2,370.2C260.7,362.5 242.3,362.2 233.2,362.2C186,359.4 142.3,384.3 142.3,439C142.3,499 195.9,511.8 216.6,510.9C247.6,511.2 277.2,497.3 296.8,473.1C312.5,508.1 322.4,505.8 322.7,505.8C325.5,505.8 327.8,504.7 330.1,503.2L346.3,491.9C349.1,490.2 351.4,487.3 351.7,483.9C351.4,475.6 336.6,462.9 337.8,434.1L337.8,345.4C339.6,321 331.4,296.9 315,278.6C295.3,262.1 269.8,254.2 244.2,256.4ZM795.1,256.4C738.2,256.4 705.5,292 706.4,328.1C706.4,377.6 756.4,393.2 762.1,394.9C810.2,410 816.7,410.5 830.1,421.9C841.5,433.6 840,456.3 823.3,466.3C818.4,469.1 797.7,481.6 750.8,472C735.1,468.8 726.9,465.1 714.1,459.7C710.7,458.6 702.7,456.6 702.7,467.1L702.7,481.1C702.7,487.6 706.7,493.6 712.7,496.4C742.5,511.5 778.3,512 786,512C787.2,512 852.6,512.1 874.4,468C878.9,458.9 890.7,425.6 868.8,397.2C850.6,375.8 834.9,373.6 788.3,359.4C784.3,358.2 749.9,349.4 750.2,325.2C748.5,294.2 790.6,292.5 799.4,293.1C834.9,292.5 852.6,305.9 862.2,306.7C866.5,306.7 868.5,304.2 868.5,298.5L868.5,285.4C868.6,282.3 867.7,279.2 865.9,276.6C854.5,261.8 811,256.4 795.1,256.4ZM363.5,263.5C360.3,264.1 358.1,267.2 358.6,270.4C359.2,274 359.8,277.7 361.2,281.4L424.9,491.6C426.3,498.4 430.9,505.8 440.8,504.7L464.1,504.7C478.3,506.1 480.3,492.4 480.6,491L522.4,315.8L564.8,491.3C565.1,492.7 567.1,506.4 581,504.9L604.6,504.9C614.8,506.1 619.7,498.7 621.1,491.9C692.8,261.3 687.9,276.9 688.5,274.6C689.6,262.7 682.8,263.5 681.7,263.8L656.4,263.8C643.6,262.4 641,274 640.4,276.9L593.2,459.2L550.6,276.9C548.6,263 537.2,263.5 534.4,263.8L512.5,263.8C500,262.7 496.8,272.6 496,276.9L453.6,456.6L408.1,276.9C407,271.2 403.3,262.4 392.2,263.5L363.5,263.5ZM242.5,395.2C263,395.5 280.7,398.6 292.9,401.4C292.9,415.6 293.4,423.6 290.3,436.4C286.3,450.1 268.7,474.8 227.2,475.4C203.3,476.5 187.7,457.7 189.1,436.4C187.7,402.3 222.9,393.8 242.5,395.2L242.5,395.2ZM882.8,569C856.3,569.3 824.9,575.3 801.2,592C793.8,597.1 795.1,604.1 803.3,603.2C830,600 889.5,592.7 900.1,606.3C910.8,619.9 888.4,676 878.4,701.1C875.4,708.6 881.8,711.7 888.7,706C933.2,668.7 944.7,590.6 935.6,579.4C931.1,573.8 909.3,568.6 882.8,569ZM91.6,597.4C85.4,598.3 82.8,606.1 89.2,612.3C200.6,716.8 347.8,768 511.2,768C627.8,768 763.1,729.9 856.5,658.3C871.9,646.3 858.7,628.5 842.9,635.5C738.2,681.8 624.4,704 520.9,704C367.5,704 218.9,672 98.8,599.2C96.7,597.8 94.2,597.2 91.6,597.4Z" />
diff --git a/logo/src/main/res/drawable-night/brand_bybit.xml b/brand/src/main/res/drawable-night/brand_bybit.xml
similarity index 81%
rename from logo/src/main/res/drawable-night/brand_bybit.xml
rename to brand/src/main/res/drawable-night/brand_bybit.xml
index 56d38b5..c0ae66e 100644
--- a/logo/src/main/res/drawable-night/brand_bybit.xml
+++ b/brand/src/main/res/drawable-night/brand_bybit.xml
@@ -4,23 +4,23 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M693.5,369.8l44.1,0l0,219.2l-44.1,0z" />
+ android:fillType="nonZero"
+ android:pathData="M180,654.1L85.5,654.1L85.5,434.9L176.2,434.9C220.2,434.9 245.9,459 245.9,496.5C245.9,520.8 229.4,536.6 218,541.8C231.6,547.9 249.1,561.8 249.1,591C249.1,632 220.2,654.1 180,654.1ZM172.7,473.1L129.6,473.1L129.6,523.6L172.7,523.6C191.4,523.6 201.8,513.5 201.8,498.4C201.8,483.3 191.4,473.1 172.7,473.1ZM175.5,562.1L129.6,562.1L129.6,616L175.5,616C195.5,616 205,603.7 205,588.9C205,574.1 195.5,562.1 175.5,562.1Z" />
+ android:fillType="nonZero"
+ android:pathData="M383.4,564.2L383.4,654.1L339.7,654.1L339.7,564.2L271.9,434.9L319.7,434.9L361.9,523.3L403.4,434.9L451.3,434.9L383.4,564.2Z" />
+ android:fillType="nonZero"
+ android:pathData="M576.2,654.1L481.7,654.1L481.7,434.9L572.4,434.9C616.4,434.9 642.1,459 642.1,496.5C642.1,520.8 625.6,536.6 614.2,541.8C627.8,547.9 645.3,561.8 645.3,591C645.3,632 616.4,654.1 576.2,654.1ZM568.9,473.1L525.8,473.1L525.8,523.6L568.9,523.6C587.6,523.6 598.1,513.5 598.1,498.4C598.1,483.3 587.6,473.1 568.9,473.1ZM571.7,562.1L525.8,562.1L525.8,616L571.7,616C591.7,616 601.2,603.7 601.2,588.9C601.2,574.1 591.7,562.1 571.7,562.1Z" />
+ android:fillType="nonZero"
+ android:pathData="M879.5,473.1L879.5,654.2L835.5,654.2L835.5,473.1L776.5,473.1L776.5,434.9L938.5,434.9L938.5,473.1L879.5,473.1Z" />
diff --git a/brand/src/main/res/drawable-night/brand_crowdin.xml b/brand/src/main/res/drawable-night/brand_crowdin.xml
new file mode 100644
index 0000000..da9dd7a
--- /dev/null
+++ b/brand/src/main/res/drawable-night/brand_crowdin.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/logo/src/main/res/drawable/brand_github.xml b/brand/src/main/res/drawable-night/brand_github.xml
similarity index 84%
rename from logo/src/main/res/drawable/brand_github.xml
rename to brand/src/main/res/drawable-night/brand_github.xml
index 7102668..081806b 100644
--- a/logo/src/main/res/drawable/brand_github.xml
+++ b/brand/src/main/res/drawable-night/brand_github.xml
@@ -1,12 +1,10 @@
+ android:pathData="M512,96C276.4,96 85.5,286.9 85.5,522.5C85.5,711.2 207.6,870.7 377.1,927.2C398.4,930.9 406.4,918.1 406.4,906.9C406.4,896.8 405.9,863.2 405.9,827.5C298.8,847.2 271,801.3 262.5,777.3C257.7,765.1 236.9,727.2 218.8,717.1C203.8,709.1 182.5,689.4 218.2,688.9C251.8,688.3 275.8,719.8 283.8,732.6C322.2,797.1 383.5,778.9 408,767.8C411.8,740 423,721.4 435.2,710.7C340.3,700.1 241.2,663.3 241.2,500.1C241.2,453.7 257.7,415.4 284.9,385.5C280.6,374.9 265.7,331.1 289.2,272.5C289.2,272.5 324.9,261.3 406.4,316.2C440.6,306.6 476.8,301.8 513.1,301.8C549.3,301.8 585.6,306.6 619.7,316.2C701.2,260.7 737,272.5 737,272.5C760.4,331.1 745.5,374.9 741.2,385.5C768.4,415.4 785,453.2 785,500.1C785,663.8 685.3,700.1 590.4,710.7C605.8,724 619.2,749.6 619.2,789.6C619.2,846.7 618.6,892.5 618.6,906.9C618.6,918.1 626.6,931.4 647.9,927.2C816.4,870.7 938.5,710.7 938.5,522.5C938.5,286.9 747.6,96 512,96Z" />
diff --git a/logo/src/main/res/drawable/brand_okx.xml b/brand/src/main/res/drawable-night/brand_okx.xml
similarity index 57%
rename from logo/src/main/res/drawable/brand_okx.xml
rename to brand/src/main/res/drawable-night/brand_okx.xml
index 6c7cd5c..bbf3b3b 100644
--- a/logo/src/main/res/drawable/brand_okx.xml
+++ b/brand/src/main/res/drawable-night/brand_okx.xml
@@ -4,23 +4,23 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M635.1,379L388.7,379C378.2,379 369.7,386.9 369.7,396.7L369.7,627.3C369.7,637.1 378.2,645 388.7,645L635.1,645C645.6,645 654,637.1 654,627.3L654,396.7C654,386.9 645.6,379 635.1,379Z" />
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M350.8,113L104.5,113C94,113 85.5,120.9 85.5,130.7L85.5,361.2C85.5,371 94,379 104.5,379L350.8,379C361.3,379 369.8,371 369.8,361.2L369.8,130.7C369.8,120.9 361.3,113 350.8,113Z" />
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M919.5,113L673.1,113C662.7,113 654.2,120.9 654.2,130.7L654.2,361.2C654.2,371 662.7,379 673.1,379L919.5,379C930,379 938.5,371 938.5,361.2L938.5,130.7C938.5,120.9 930,113 919.5,113Z" />
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M350.8,645L104.5,645C94,645 85.5,653 85.5,662.8L85.5,893.3C85.5,903.1 94,911 104.5,911L350.8,911C361.3,911 369.8,903.1 369.8,893.3L369.8,662.8C369.8,653 361.3,645 350.8,645Z" />
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M919.5,645L673.1,645C662.7,645 654.2,653 654.2,662.8L654.2,893.3C654.2,903.1 662.7,911 673.1,911L919.5,911C930,911 938.5,903.1 938.5,893.3L938.5,662.8C938.5,653 930,645 919.5,645Z" />
diff --git a/logo/src/main/res/drawable-night/brand_openai.xml b/brand/src/main/res/drawable-night/brand_openai.xml
similarity index 92%
rename from logo/src/main/res/drawable-night/brand_openai.xml
rename to brand/src/main/res/drawable-night/brand_openai.xml
index ab78069..8896c0b 100644
--- a/logo/src/main/res/drawable-night/brand_openai.xml
+++ b/brand/src/main/res/drawable-night/brand_openai.xml
@@ -1,12 +1,10 @@
+ android:pathData="M412.7,397L412.7,316.7C412.7,310 415.2,304.9 421.1,301.5L582.6,208.5C604.6,195.8 630.8,189.9 657.8,189.9C759.3,189.9 823.5,268.5 823.5,352.2C823.5,358.1 823.5,364.9 822.7,371.7L655.3,273.6C645.1,267.7 635,267.7 624.8,273.6L412.7,397ZM789.7,709.8L789.7,517.9C789.7,506.1 784.6,497.6 774.5,491.7L562.3,368.3L631.6,328.5C637.5,325.2 642.6,325.2 648.5,328.5L810,421.5C856.5,448.6 887.8,506.1 887.8,561.9C887.8,626.1 849.7,685.3 789.7,709.8L789.7,709.8ZM362.8,540.8L293.5,500.2C287.5,496.8 285,491.7 285,484.9L285,299C285,208.5 354.3,140 448.2,140C483.7,140 516.6,151.9 544.6,173L378,269.4C367.9,275.3 362.8,283.8 362.8,295.6L362.8,540.8L362.8,540.8ZM512,627L412.7,571.2L412.7,452.8L512,397L611.3,452.8L611.3,571.2L512,627ZM575.8,884C540.3,884 507.3,872.1 479.5,851L646,754.6C656.1,748.7 661.2,740.3 661.2,728.4L661.2,483.3L731.4,523.8C737.3,527.2 739.8,532.3 739.8,539.1L739.8,725C739.8,815.5 669.6,884 575.8,884L575.8,884ZM375.5,695.4L214,602.5C167.5,575.4 136.2,517.9 136.2,462.1C136.2,397 175.1,338.7 235.1,314.2L235.1,506.9C235.1,518.8 240.2,527.2 250.3,533.1L461.7,655.7L392.4,695.4C386.5,698.8 381.4,698.8 375.5,695.4ZM366.2,834.1C270.6,834.1 200.5,762.2 200.5,673.5C200.5,666.7 201.3,659.9 202.2,653.2L368.7,749.6C378.8,755.5 389,755.5 399.1,749.6L611.3,627L611.3,707.3C611.3,714.1 608.8,719.1 602.9,722.5L441.4,815.5C419.4,828.2 393.2,834.1 366.2,834.1L366.2,834.1ZM575.8,934.7C678.1,934.7 763.5,862 782.9,765.6C877.6,741.1 938.5,652.3 938.5,561.9C938.5,502.7 913.1,445.2 867.5,403.8C871.7,386 874.2,368.3 874.2,350.5C874.2,229.6 776.2,139.2 662.9,139.2C640.1,139.2 618.1,142.5 596.1,150.2C558.1,113 505.7,89.3 448.2,89.3C345.9,89.3 260.5,162 241,258.4C146.4,282.9 85.5,371.7 85.5,462.1C85.5,521.3 110.9,578.8 156.5,620.2C152.3,638 149.7,655.7 149.7,673.5C149.7,794.3 247.8,884.8 361.1,884.8C383.9,884.8 405.9,881.4 427.9,873.8C465.9,911 518.3,934.7 575.8,934.7Z" />
diff --git a/brand/src/main/res/drawable-night/brand_patreon.xml b/brand/src/main/res/drawable-night/brand_patreon.xml
new file mode 100644
index 0000000..d4cc925
--- /dev/null
+++ b/brand/src/main/res/drawable-night/brand_patreon.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/brand/src/main/res/drawable-night/brand_threads.xml b/brand/src/main/res/drawable-night/brand_threads.xml
new file mode 100644
index 0000000..1ea55cb
--- /dev/null
+++ b/brand/src/main/res/drawable-night/brand_threads.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/brand/src/main/res/drawable-night/brand_wise.xml b/brand/src/main/res/drawable-night/brand_wise.xml
new file mode 100644
index 0000000..6e91983
--- /dev/null
+++ b/brand/src/main/res/drawable-night/brand_wise.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/logo/src/main/res/drawable-night/brand_x.xml b/brand/src/main/res/drawable-night/brand_x.xml
similarity index 76%
rename from logo/src/main/res/drawable-night/brand_x.xml
rename to brand/src/main/res/drawable-night/brand_x.xml
index bce8579..32c2ad6 100644
--- a/logo/src/main/res/drawable-night/brand_x.xml
+++ b/brand/src/main/res/drawable-night/brand_x.xml
@@ -4,7 +4,7 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M757.3,126.5L888.1,126.5L602.3,453.1L938.5,897.5L675.3,897.5L469.1,628L233.2,897.5L102.3,897.5L408,548.2L85.5,126.5L355.4,126.5L541.8,372.9L757.3,126.5ZM711.4,819.2L783.9,819.2L316,200.7L238.2,200.7L711.4,819.2Z" />
diff --git a/brand/src/main/res/drawable/brand_aliyun.xml b/brand/src/main/res/drawable/brand_aliyun.xml
new file mode 100644
index 0000000..963f1fe
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_aliyun.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/logo/src/main/res/drawable/brand_apple.xml b/brand/src/main/res/drawable/brand_apple.xml
similarity index 84%
rename from logo/src/main/res/drawable/brand_apple.xml
rename to brand/src/main/res/drawable/brand_apple.xml
index abc3c38..c381842 100644
--- a/logo/src/main/res/drawable/brand_apple.xml
+++ b/brand/src/main/res/drawable/brand_apple.xml
@@ -4,7 +4,7 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#FF000000"
+ android:fillType="nonZero"
+ android:pathData="M522,282.4C559.4,282.4 606.2,256.3 634.1,221.6C659.3,190.1 677.7,146.2 677.7,102.3C677.7,96.4 677.2,90.4 676.1,85.5C634.6,87.1 584.6,114.2 554.6,150.6C530.9,178.2 509.4,221.6 509.4,266.1C509.4,272.6 510.4,279.1 511,281.2C513.6,281.8 517.8,282.4 522,282.4ZM390.5,938.5C441.5,938.5 464.1,903.3 527.8,903.3C592.5,903.3 606.7,937.4 663.5,937.4C719.3,937.4 756.6,884.3 791.9,832.2C831.3,772.6 847.7,714 848.7,711.3C845,710.2 738.2,665.2 738.2,538.8C738.2,429.3 822.4,380 827.2,376.1C771.4,293.7 686.7,291.6 663.5,291.6C600.9,291.6 549.9,330.6 517.8,330.6C483,330.6 437.3,293.7 383.1,293.7C280,293.7 175.3,381.6 175.3,547.5C175.3,650.5 214.2,759.5 262.1,830C303.1,889.7 338.9,938.5 390.5,938.5Z" />
diff --git a/logo/src/main/res/drawable/brand_aws.xml b/brand/src/main/res/drawable/brand_aws.xml
similarity index 94%
rename from logo/src/main/res/drawable/brand_aws.xml
rename to brand/src/main/res/drawable/brand_aws.xml
index 8129975..794bce0 100644
--- a/logo/src/main/res/drawable/brand_aws.xml
+++ b/brand/src/main/res/drawable/brand_aws.xml
@@ -1,12 +1,10 @@
+ android:pathData="M244.2,256.4C205.6,256.4 166.3,271.5 159.8,279.7C158.1,283.1 154.1,310.7 163.5,310.7C166.6,310.7 168,311.3 177.1,307C211.3,293.7 232.9,293.9 236,293.9C274.4,290.3 296.6,316.4 293.2,350.3L293.2,370.2C260.7,362.5 242.3,362.2 233.2,362.2C186,359.4 142.3,384.3 142.3,439C142.3,499 195.9,511.8 216.6,510.9C247.6,511.2 277.2,497.3 296.8,473.1C312.5,508.1 322.4,505.8 322.7,505.8C325.5,505.8 327.8,504.7 330.1,503.2L346.3,491.9C349.1,490.2 351.4,487.3 351.7,483.9C351.4,475.6 336.6,462.9 337.8,434.1L337.8,345.4C339.6,321 331.4,296.9 315,278.6C295.3,262.1 269.8,254.2 244.2,256.4ZM795.1,256.4C738.2,256.4 705.5,292 706.4,328.1C706.4,377.6 756.4,393.2 762.1,394.9C810.2,410 816.7,410.5 830.1,421.9C841.5,433.6 840,456.3 823.3,466.3C818.4,469.1 797.7,481.6 750.8,472C735.1,468.8 726.9,465.1 714.1,459.7C710.7,458.6 702.7,456.6 702.7,467.1L702.7,481.1C702.7,487.6 706.7,493.6 712.7,496.4C742.5,511.5 778.3,512 786,512C787.2,512 852.6,512.1 874.4,468C878.9,458.9 890.7,425.6 868.8,397.2C850.6,375.8 834.9,373.6 788.3,359.4C784.3,358.2 749.9,349.4 750.2,325.2C748.5,294.2 790.6,292.5 799.4,293.1C834.9,292.5 852.6,305.9 862.2,306.7C866.5,306.7 868.5,304.2 868.5,298.5L868.5,285.4C868.6,282.3 867.7,279.2 865.9,276.6C854.5,261.8 811,256.4 795.1,256.4ZM363.5,263.5C360.3,264.1 358.1,267.2 358.6,270.4C359.2,274 359.8,277.7 361.2,281.4L424.9,491.6C426.3,498.4 430.9,505.8 440.8,504.7L464.1,504.7C478.3,506.1 480.3,492.4 480.6,491L522.4,315.8L564.8,491.3C565.1,492.7 567.1,506.4 581,504.9L604.6,504.9C614.8,506.1 619.7,498.7 621.1,491.9C692.8,261.3 687.9,276.9 688.5,274.6C689.6,262.7 682.8,263.5 681.7,263.8L656.4,263.8C643.6,262.4 641,274 640.4,276.9L593.2,459.2L550.6,276.9C548.6,263 537.2,263.5 534.4,263.8L512.5,263.8C500,262.7 496.8,272.6 496,276.9L453.6,456.6L408.1,276.9C407,271.2 403.3,262.4 392.2,263.5L363.5,263.5ZM242.5,395.2C263,395.5 280.7,398.6 292.9,401.4C292.9,415.6 293.4,423.6 290.3,436.4C286.3,450.1 268.7,474.8 227.2,475.4C203.3,476.5 187.7,457.7 189.1,436.4C187.7,402.3 222.9,393.8 242.5,395.2L242.5,395.2ZM882.8,569C856.3,569.3 824.9,575.3 801.2,592C793.8,597.1 795.1,604.1 803.3,603.2C830,600 889.5,592.7 900.1,606.3C910.8,619.9 888.4,676 878.4,701.1C875.4,708.6 881.8,711.7 888.7,706C933.2,668.7 944.7,590.6 935.6,579.4C931.1,573.8 909.3,568.6 882.8,569ZM91.6,597.4C85.4,598.3 82.8,606.1 89.2,612.3C200.6,716.8 347.8,768 511.2,768C627.8,768 763.1,729.9 856.5,658.3C871.9,646.3 858.7,628.5 842.9,635.5C738.2,681.8 624.4,704 520.9,704C367.5,704 218.9,672 98.8,599.2C96.7,597.8 94.2,597.2 91.6,597.4Z" />
diff --git a/logo/src/main/res/drawable/brand_azure.xml b/brand/src/main/res/drawable/brand_azure.xml
similarity index 54%
rename from logo/src/main/res/drawable/brand_azure.xml
rename to brand/src/main/res/drawable/brand_azure.xml
index e9c3239..38b83f1 100644
--- a/logo/src/main/res/drawable/brand_azure.xml
+++ b/brand/src/main/res/drawable/brand_azure.xml
@@ -5,72 +5,72 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M369.9,110.1L622.3,110.1L360.3,886.5C354.8,902.8 339.3,913.9 322.1,913.9C322.1,913.9 125.7,913.9 125.7,913.9C103.6,913.9 85.5,895.7 85.5,873.7C85.5,869.3 86.2,865 87.6,860.8L331.7,137.5C337.2,121.2 352.6,110.1 369.9,110.1Z">
+ android:color="#FF114A8B"
+ android:offset="0" />
+ android:color="#FF0669BC"
+ android:offset="1" />
+ android:fillType="nonZero"
+ android:pathData="M736.7,630.9L336.4,630.9C336.4,630.9 336.4,630.9 336.4,630.9C326.2,630.9 317.9,639.2 317.9,649.4C317.9,654.5 320,659.4 323.7,662.9L581,903C588.4,910 598.3,913.9 608.5,913.9L835.2,913.9L736.7,630.9Z" />
+ android:fillType="nonZero"
+ android:pathData="M369.9,110.1C352.4,110.1 336.9,121.4 331.6,138L87.9,860.2C86.4,864.5 85.6,869.1 85.6,873.7C85.6,895.7 103.7,913.9 125.7,913.9C125.7,913.9 125.8,913.9 125.8,913.9L327.3,913.9C342.6,911.1 355.2,900.4 360.4,885.7L409,742.5L582.6,904.4C589.9,910.5 599,913.8 608.4,913.9L834.2,913.9L735.2,630.9L446.5,630.9L623.2,110.1L369.9,110.1Z">
+ android:color="#4C000000"
+ android:offset="0" />
+ android:color="#33000000"
+ android:offset="0.1" />
+ android:color="#19000000"
+ android:offset="0.3" />
+ android:color="#0C000000"
+ android:offset="0.6" />
+ android:color="#00000000"
+ android:offset="1" />
+ android:fillType="nonZero"
+ android:pathData="M692.3,137.5C686.8,121.2 671.4,110.1 654.2,110.1L372.9,110.1C390.1,110.1 405.5,121.2 411,137.5L655.1,860.8C656.5,865 657.2,869.3 657.2,873.7C657.2,895.7 639.1,913.9 617,913.9L898.3,913.9C920.4,913.9 938.5,895.7 938.5,873.7C938.5,869.3 937.8,865 936.4,860.8L692.3,137.5Z">
+ android:color="#FF3CCBF4"
+ android:offset="0" />
+ android:color="#FF2892DF"
+ android:offset="1" />
diff --git a/logo/src/main/res/drawable/brand_binance.xml b/brand/src/main/res/drawable/brand_binance.xml
similarity index 50%
rename from logo/src/main/res/drawable/brand_binance.xml
rename to brand/src/main/res/drawable/brand_binance.xml
index 909018b..0823b89 100644
--- a/logo/src/main/res/drawable/brand_binance.xml
+++ b/brand/src/main/res/drawable/brand_binance.xml
@@ -4,23 +4,23 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#F0B90A"
+ android:fillType="nonZero"
+ android:pathData="M346.4,443.9L512.1,278.3L677.8,444.1L774.2,347.7L512.1,85.5L250,347.6L346.4,443.9Z" />
+ android:fillColor="#F0B90A"
+ android:fillType="nonZero"
+ android:pathData="M85.5,512.1L181.8,415.7L278.3,512.1L181.8,608.4L85.5,512.1Z" />
+ android:fillColor="#F0B90A"
+ android:fillType="nonZero"
+ android:pathData="M346.4,580.1L512.1,745.8L677.8,580.1L774.2,676.3L512.2,938.5L250,676.5L346.4,580.1Z" />
+ android:fillColor="#F0B90A"
+ android:fillType="nonZero"
+ android:pathData="M745.7,512.1L842.1,415.7L938.5,512L842.1,608.5L745.7,512.1Z" />
+ android:fillColor="#F0B90A"
+ android:fillType="nonZero"
+ android:pathData="M609.9,512L512.1,414.2L439.8,486.5L431.5,494.8L414.3,511.9L512.1,609.6L609.9,512.1L609.9,512Z" />
diff --git a/logo/src/main/res/drawable/brand_bybit.xml b/brand/src/main/res/drawable/brand_bybit.xml
similarity index 81%
rename from logo/src/main/res/drawable/brand_bybit.xml
rename to brand/src/main/res/drawable/brand_bybit.xml
index ab324c7..66c5365 100644
--- a/logo/src/main/res/drawable/brand_bybit.xml
+++ b/brand/src/main/res/drawable/brand_bybit.xml
@@ -4,23 +4,23 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M693.5,369.8l44.1,0l0,219.2l-44.1,0z" />
+ android:fillType="nonZero"
+ android:pathData="M180,654.1L85.5,654.1L85.5,434.9L176.2,434.9C220.2,434.9 245.9,459 245.9,496.5C245.9,520.8 229.4,536.6 218,541.8C231.6,547.9 249.1,561.8 249.1,591C249.1,632 220.2,654.1 180,654.1ZM172.7,473.1L129.6,473.1L129.6,523.6L172.7,523.6C191.4,523.6 201.8,513.5 201.8,498.4C201.8,483.3 191.4,473.1 172.7,473.1ZM175.5,562.1L129.6,562.1L129.6,616L175.5,616C195.5,616 205,603.7 205,588.9C205,574.1 195.5,562.1 175.5,562.1Z" />
+ android:fillType="nonZero"
+ android:pathData="M383.4,564.2L383.4,654.1L339.7,654.1L339.7,564.2L271.9,434.9L319.7,434.9L361.9,523.3L403.4,434.9L451.3,434.9L383.4,564.2Z" />
+ android:fillType="nonZero"
+ android:pathData="M576.2,654.1L481.7,654.1L481.7,434.9L572.4,434.9C616.4,434.9 642.1,459 642.1,496.5C642.1,520.8 625.6,536.6 614.2,541.8C627.8,547.9 645.3,561.8 645.3,591C645.3,632 616.4,654.1 576.2,654.1ZM568.9,473.1L525.8,473.1L525.8,523.6L568.9,523.6C587.6,523.6 598.1,513.5 598.1,498.4C598.1,483.3 587.6,473.1 568.9,473.1ZM571.7,562.1L525.8,562.1L525.8,616L571.7,616C591.7,616 601.2,603.7 601.2,588.9C601.2,574.1 591.7,562.1 571.7,562.1Z" />
+ android:fillType="nonZero"
+ android:pathData="M879.5,473.1L879.5,654.2L835.5,654.2L835.5,473.1L776.5,473.1L776.5,434.9L938.5,434.9L938.5,473.1L879.5,473.1Z" />
diff --git a/brand/src/main/res/drawable/brand_cloudflare.xml b/brand/src/main/res/drawable/brand_cloudflare.xml
new file mode 100644
index 0000000..0976bf7
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_cloudflare.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/brand/src/main/res/drawable/brand_coinbase.xml b/brand/src/main/res/drawable/brand_coinbase.xml
new file mode 100644
index 0000000..0c86b6b
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_coinbase.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/brand/src/main/res/drawable/brand_crowdin.xml b/brand/src/main/res/drawable/brand_crowdin.xml
new file mode 100644
index 0000000..a081d40
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_crowdin.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/logo/src/main/res/drawable/brand_digitalocean.xml b/brand/src/main/res/drawable/brand_digitalocean.xml
similarity index 88%
rename from logo/src/main/res/drawable/brand_digitalocean.xml
rename to brand/src/main/res/drawable/brand_digitalocean.xml
index 4678713..c065374 100644
--- a/logo/src/main/res/drawable/brand_digitalocean.xml
+++ b/brand/src/main/res/drawable/brand_digitalocean.xml
@@ -4,9 +4,9 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#0069FF"
+ android:pathData="M914.7,376C875.3,248.4 773.9,146.8 647.1,106.7C356.4,15.3 88.3,232.9 88.3,511.9L252.1,511.9C252.1,336.9 424.6,201.5 607.6,268.2C675.4,292.9 729.7,347.5 754.2,415.2C820.9,599.3 686.2,772.8 512.6,773.1L512.6,609.2L348.7,609.2L348.7,774.1L512.1,774.1L512.1,938.5C789.3,938.5 1005.5,668.6 914.7,376Z" />
+ android:fillColor="#0069FF"
+ android:pathData="M223.3,905.1L348.7,905.1L348.7,778.9L223.4,778.9L223.4,673L118.1,673L118.1,778.9L223.3,778.9L223.3,905.1Z" />
diff --git a/logo/src/main/res/drawable/brand_discord.xml b/brand/src/main/res/drawable/brand_discord.xml
similarity index 85%
rename from logo/src/main/res/drawable/brand_discord.xml
rename to brand/src/main/res/drawable/brand_discord.xml
index 578abdf..7a91cbf 100644
--- a/logo/src/main/res/drawable/brand_discord.xml
+++ b/brand/src/main/res/drawable/brand_discord.xml
@@ -1,16 +1,14 @@
+ android:fillType="nonZero"
+ android:pathData="M938.5,512C938.5,747.6 747.6,938.5 512,938.5C276.4,938.5 85.5,747.6 85.5,512C85.5,276.4 276.4,85.5 512,85.5C747.6,85.5 938.5,276.4 938.5,512Z" />
+ android:pathData="M597.7,282.6C591.8,293.1 586.5,304 581.6,315.1C535.8,308.2 489.1,308.2 443.1,315.1C438.4,304 433,293.1 427,282.6C383.9,290 341.9,302.9 302.1,321.1C223.2,438.1 201.8,552 212.4,664.4C258.7,698.5 310.4,724.6 365.6,741.3C378,724.6 389,706.9 398.4,688.4C380.6,681.8 363.3,673.4 346.8,663.8C351.1,660.6 355.3,657.4 359.4,654.2C456.4,699.9 568.6,699.9 665.6,654.2C669.8,657.6 674,660.9 678.3,663.8C661.8,673.5 644.5,681.8 626.5,688.5C636,707 646.9,724.7 659.4,741.4C714.5,724.7 766.3,698.8 812.5,664.6C825.1,534.3 791,421.3 722.6,321.2C682.9,303 640.9,290.1 597.8,282.9L597.7,282.6ZM411.9,595.2C382.1,595.2 357.4,568.2 357.4,534.8C357.4,501.3 381.2,474.2 411.8,474.2C442.5,474.2 466.9,501.4 466.4,534.8C465.9,568.1 442.4,595.2 411.9,595.2ZM613,595.2C583.1,595.2 558.6,568.2 558.6,534.8C558.6,501.3 582.4,474.2 613,474.2C643.7,474.2 667.9,501.4 667.5,534.8C667,568.1 643.4,595.2 613,595.2Z" />
diff --git a/brand/src/main/res/drawable/brand_docker.xml b/brand/src/main/res/drawable/brand_docker.xml
new file mode 100644
index 0000000..b0c5489
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_docker.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/brand/src/main/res/drawable/brand_facebook.xml b/brand/src/main/res/drawable/brand_facebook.xml
new file mode 100644
index 0000000..53ae0f1
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_facebook.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/brand/src/main/res/drawable/brand_fansly.xml b/brand/src/main/res/drawable/brand_fansly.xml
new file mode 100644
index 0000000..81b22a8
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_fansly.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/logo/src/main/res/drawable/brand_gitea.xml b/brand/src/main/res/drawable/brand_gitea.xml
similarity index 79%
rename from logo/src/main/res/drawable/brand_gitea.xml
rename to brand/src/main/res/drawable/brand_gitea.xml
index 1ee92c3..c64afb3 100644
--- a/logo/src/main/res/drawable/brand_gitea.xml
+++ b/brand/src/main/res/drawable/brand_gitea.xml
@@ -1,21 +1,18 @@
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M615,712L442.8,629.2C425.9,621 418.5,600.4 426.8,583.3L509.6,411.1C517.7,394.2 538.3,386.8 555.4,395.1C578.8,406.4 592.2,412.8 592.2,412.8L592.1,264.6L614.7,264.4L614.9,423.3C614.9,423.3 692.8,456.2 727.6,477.8C732.7,480.9 741.5,487 745.1,497.3C748,505.6 747.9,515.1 743.8,523.5L661,695.7C652.6,712.9 632,720.2 615,712Z" />
+ android:pathData="M922.8,258.2C917.2,252.6 909.7,252.8 909.7,252.8C909.7,252.8 750.7,261.7 668.3,263.6C650.3,264 632.4,264.4 614.6,264.6L614.6,423.6C607.1,420.1 599.5,416.4 592.1,412.9C592.1,363.5 591.9,264.7 591.9,264.7C552.6,265.3 470.9,261.7 470.9,261.7C470.9,261.7 279,252.1 258.1,250.2C244.8,249.4 227.6,247.4 205.2,252.2C193.4,254.7 159.8,262.3 132.2,288.7C71.2,343.1 86.8,429.7 88.7,442.7C91,458.6 98,502.7 131.7,541.1C193.8,617.2 327.6,615.5 327.6,615.5C327.6,615.5 344,654.7 369.1,690.8C403.1,735.7 437.9,770.7 471.9,774.9C557.3,774.9 728.2,774.8 728.2,774.8C728.2,774.8 744.5,774.9 766.6,760.8C785.6,749.3 802.5,729.1 802.5,729.1C802.5,729.1 820,710.3 844.5,667.6C851.9,654.4 858.2,641.7 863.6,629.6C863.6,629.6 938.5,470.7 938.5,316C937,269.2 925.5,260.9 922.8,258.2ZM248.2,535.2C213.1,523.6 198.2,509.8 198.2,509.8C198.2,509.8 172.2,491.6 159.2,455.8C136.8,395.8 157.3,359.2 157.3,359.2C157.3,359.2 168.7,328.6 209.6,318.5C228.3,313.4 251.6,314.2 251.6,314.2C251.6,314.2 261.3,394.8 272.9,442.1C282.7,481.7 306.6,547.5 306.6,547.5C306.6,547.5 271.2,543.3 248.2,535.2ZM655.7,681.2C655.7,681.2 647.4,700.8 629.1,702.1C621.3,702.6 615.1,700.4 615.1,700.4C615.1,700.4 614.7,700.3 608,697.6L454.8,622.9C454.8,622.9 440,615.2 437.4,601.8C434.4,590.8 441.1,577.2 441.1,577.2L514.7,425.4C514.7,425.4 521.2,412.2 531.3,407.7C532.1,407.3 534.4,406.4 537.4,405.7C548.4,402.9 561.8,409.5 561.8,409.5L712,482.4C712,482.4 729.1,490.1 732.8,504.4C735.4,514.4 732.1,523.3 730.3,527.7C721.8,548.6 655.7,681.2 655.7,681.2Z" />
+ android:pathData="M521.2,570.7C510.1,570.8 500.4,578.6 497.8,589.4C495.2,600.3 500.5,611.5 510.1,616.6C520.6,622 533.9,619 540.9,609.2C547.8,599.6 546.8,586.3 538.5,577.9L571,511.3C573.1,511.4 576.1,511.5 579.5,510.6C585,509.4 589.1,505.7 589.1,505.7C594.8,508.1 600.8,510.9 607,514C613.5,517.2 619.6,520.6 625.2,523.9C626.4,524.6 627.6,525.4 629,526.5C631.2,528.2 633.6,530.7 635.4,533.9C637.9,541.4 632.8,554.1 632.8,554.1C629.7,564.5 607.8,609.2 607.8,609.2C596.8,609 587.1,616 583.8,626.2C580.3,637.2 585.3,649.7 595.9,655.1C606.5,660.5 619.5,657.4 626.4,647.9C633.2,638.7 632.7,625.8 624.9,617.2C627.5,612.2 629.9,607.2 632.5,601.9C639.3,587.8 650.8,560.7 650.8,560.7C652.1,558.4 658.6,546.7 654.5,531.8C651.1,516.3 637.4,509.1 637.4,509.1C620.8,498.4 597.8,488.5 597.8,488.5C597.8,488.5 597.8,482.9 596.3,478.8C594.8,474.6 592.5,471.9 591,470.3C597.4,457.1 603.7,444.1 610.1,430.9C604.6,428.2 599.1,425.5 593.6,422.7C587.1,436 580.4,449.4 573.9,462.7C564.8,462.6 556.4,467.4 552.1,475.4C547.4,484 548.4,494.6 554.6,502.3C543.5,525.1 532.4,547.9 521.2,570.7Z" />
diff --git a/logo/src/main/res/drawable-night/brand_github.xml b/brand/src/main/res/drawable/brand_github.xml
similarity index 84%
rename from logo/src/main/res/drawable-night/brand_github.xml
rename to brand/src/main/res/drawable/brand_github.xml
index f515d91..d6b4a1d 100644
--- a/logo/src/main/res/drawable-night/brand_github.xml
+++ b/brand/src/main/res/drawable/brand_github.xml
@@ -1,12 +1,10 @@
+ android:pathData="M512,96C276.4,96 85.5,286.9 85.5,522.5C85.5,711.2 207.6,870.7 377.1,927.2C398.4,930.9 406.4,918.1 406.4,906.9C406.4,896.8 405.9,863.2 405.9,827.5C298.8,847.2 271,801.3 262.5,777.3C257.7,765.1 236.9,727.2 218.8,717.1C203.8,709.1 182.5,689.4 218.2,688.9C251.8,688.3 275.8,719.8 283.8,732.6C322.2,797.1 383.5,778.9 408,767.8C411.8,740 423,721.4 435.2,710.7C340.3,700.1 241.2,663.3 241.2,500.1C241.2,453.7 257.7,415.4 284.9,385.5C280.6,374.9 265.7,331.1 289.2,272.5C289.2,272.5 324.9,261.3 406.4,316.2C440.6,306.6 476.8,301.8 513.1,301.8C549.3,301.8 585.6,306.6 619.7,316.2C701.2,260.7 737,272.5 737,272.5C760.4,331.1 745.5,374.9 741.2,385.5C768.4,415.4 785,453.2 785,500.1C785,663.8 685.3,700.1 590.4,710.7C605.8,724 619.2,749.6 619.2,789.6C619.2,846.7 618.6,892.5 618.6,906.9C618.6,918.1 626.6,931.4 647.9,927.2C816.4,870.7 938.5,710.7 938.5,522.5C938.5,286.9 747.6,96 512,96Z" />
diff --git a/logo/src/main/res/drawable/brand_gitlab.xml b/brand/src/main/res/drawable/brand_gitlab.xml
similarity index 70%
rename from logo/src/main/res/drawable/brand_gitlab.xml
rename to brand/src/main/res/drawable/brand_gitlab.xml
index a1714e4..53eef08 100644
--- a/logo/src/main/res/drawable/brand_gitlab.xml
+++ b/brand/src/main/res/drawable/brand_gitlab.xml
@@ -4,19 +4,19 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M924.2,426.4L923,423.3L806.9,120.4C804.6,114.4 800.4,109.4 795,106C789.6,102.5 783.2,100.9 776.8,101.3C770.4,101.6 764.2,103.9 759.2,108C754.2,112 750.6,117.5 749,123.7L670.7,363.5L353.5,363.5L275,123.7C273.3,117.6 269.7,112.1 264.7,108.1C259.7,104.1 253.5,101.7 247.1,101.4C240.7,101.1 234.3,102.7 228.9,106.1C223.5,109.6 219.3,114.6 217,120.6L100.9,423.3L99.8,426.4C83,470.1 81,518 93.9,562.9C106.8,607.9 133.9,647.4 171.3,675.6L171.6,675.8L172.7,676.6L349.6,809.1L437.1,875.3L490.3,915.5C496.5,920.2 504.1,922.8 512,922.8C519.8,922.8 527.4,920.2 533.7,915.5L586.9,875.3L674.4,809.1L852.2,676L852.7,675.7C890.1,647.5 917.2,607.9 930.1,563C943,518 941,470.1 924.2,426.4Z" />
+ android:fillType="nonZero"
+ android:pathData="M924.2,426.4L923,423.3C866.5,435 813.2,458.9 766.9,493.6L512,686.3L674.4,809.1L852.3,676L852.8,675.7C890.1,647.5 917.2,607.9 930.1,563C943.1,518 941,470.1 924.2,426.4Z" />
+ android:fillType="nonZero"
+ android:pathData="M349.6,809.1L437.1,875.3L490.3,915.5C496.5,920.2 504.1,922.8 512,922.8C519.8,922.8 527.4,920.2 533.7,915.5L586.9,875.3L674.4,809.1L512,686.3L349.6,809.1Z" />
+ android:fillType="nonZero"
+ android:pathData="M257,493.6C210.7,458.9 157.5,435 100.9,423.3L99.8,426.4C83,470.1 81,518 93.9,562.9C106.8,607.9 133.9,647.4 171.3,675.6L171.6,675.8L172.7,676.6L349.6,809.1L512,686.3L257,493.6Z" />
diff --git a/logo/src/main/res/drawable/brand_google.xml b/brand/src/main/res/drawable/brand_google.xml
similarity index 70%
rename from logo/src/main/res/drawable/brand_google.xml
rename to brand/src/main/res/drawable/brand_google.xml
index 879db67..229bc35 100644
--- a/logo/src/main/res/drawable/brand_google.xml
+++ b/brand/src/main/res/drawable/brand_google.xml
@@ -4,19 +4,19 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M275.5,595.3C344.2,783.1 545.8,812.9 667.2,732.7L809.2,732.7L809.2,842.7C729.6,910.8 628.2,948.3 523.4,948.3C358,948.3 206.1,855 131.2,707.5" />
+ android:fillType="nonZero"
+ android:pathData="M809.2,842.7C910.8,748.2 958.5,587 930.6,432.7L520.6,432.7L520.6,602.2L754.3,602.2C743.6,658.7 714.5,702.2 667.2,732.7" />
+ android:fillType="nonZero"
+ android:pathData="M131.2,707.5C70.3,583.6 70.3,439.8 131.2,315.9L275.5,428.1C257.2,484.6 257.2,540.3 275.5,595.3" />
+ android:fillType="nonZero"
+ android:pathData="M275.5,428.1C325.9,270 541.2,178.4 685.5,313.6L811.5,189.9C632.8,18.1 284.7,24.9 131.2,315.9" />
diff --git a/brand/src/main/res/drawable/brand_googlecloud.xml b/brand/src/main/res/drawable/brand_googlecloud.xml
new file mode 100644
index 0000000..20c8fc5
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_googlecloud.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/brand/src/main/res/drawable/brand_instagram.xml b/brand/src/main/res/drawable/brand_instagram.xml
new file mode 100644
index 0000000..e4e4930
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_instagram.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/logo/src/main/res/drawable/brand_jetbrains.xml b/brand/src/main/res/drawable/brand_jetbrains.xml
similarity index 65%
rename from logo/src/main/res/drawable/brand_jetbrains.xml
rename to brand/src/main/res/drawable/brand_jetbrains.xml
index b0d5574..9a90ce7 100644
--- a/logo/src/main/res/drawable/brand_jetbrains.xml
+++ b/brand/src/main/res/drawable/brand_jetbrains.xml
@@ -5,36 +5,36 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M356.6,134.3L134.3,356.6C103.1,387.8 85.5,430.2 85.5,474.4L85.5,871.9C85.5,908.6 115.4,938.5 152.1,938.5L549.6,938.5C593.8,938.5 636.1,920.9 667.4,889.7L889.7,667.4C920.9,636.2 938.5,593.8 938.5,549.6L938.5,152.1C938.5,115.4 908.6,85.5 871.9,85.5L474.4,85.5C430.2,85.5 387.9,103.1 356.6,134.3Z">
+ android:color="#FFFF9419"
+ android:offset="0" />
+ android:color="#FFFF021D"
+ android:offset="0.4" />
+ android:color="#FFE600FF"
+ android:offset="1" />
+ android:color="#FFE600FF"
+ android:offset="1" />
+ android:fillType="nonZero"
+ android:pathData="M192.1,298.8l533.1,0l0,533.1l-533.1,0z" />
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M258.8,711.9l226.6,0l0,53.3l-226.6,0z" />
diff --git a/brand/src/main/res/drawable/brand_lark.xml b/brand/src/main/res/drawable/brand_lark.xml
new file mode 100644
index 0000000..737a42c
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_lark.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/brand/src/main/res/drawable/brand_mega.xml b/brand/src/main/res/drawable/brand_mega.xml
new file mode 100644
index 0000000..8fc0dfd
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_mega.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/brand/src/main/res/drawable/brand_meta.xml b/brand/src/main/res/drawable/brand_meta.xml
new file mode 100644
index 0000000..948770d
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_meta.xml
@@ -0,0 +1,316 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/logo/src/main/res/drawable/brand_microsoft.xml b/brand/src/main/res/drawable/brand_microsoft.xml
similarity index 54%
rename from logo/src/main/res/drawable/brand_microsoft.xml
rename to brand/src/main/res/drawable/brand_microsoft.xml
index 82f9379..51dec15 100644
--- a/logo/src/main/res/drawable/brand_microsoft.xml
+++ b/brand/src/main/res/drawable/brand_microsoft.xml
@@ -4,19 +4,19 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#F25022"
+ android:fillType="nonZero"
+ android:pathData="M85.5,85.5l406.2,0l0,406.2l-406.2,0z" />
+ android:fillColor="#7FBA00"
+ android:fillType="nonZero"
+ android:pathData="M532.3,85.5l406.2,0l0,406.2l-406.2,0z" />
+ android:fillColor="#00A4EF"
+ android:fillType="nonZero"
+ android:pathData="M85.5,532.3l406.2,0l0,406.2l-406.2,0z" />
+ android:fillColor="#FFB900"
+ android:fillType="nonZero"
+ android:pathData="M532.3,532.3l406.2,0l0,406.2l-406.2,0z" />
diff --git a/logo/src/main/res/drawable-night/brand_okx.xml b/brand/src/main/res/drawable/brand_okx.xml
similarity index 57%
rename from logo/src/main/res/drawable-night/brand_okx.xml
rename to brand/src/main/res/drawable/brand_okx.xml
index 78eb10c..812379f 100644
--- a/logo/src/main/res/drawable-night/brand_okx.xml
+++ b/brand/src/main/res/drawable/brand_okx.xml
@@ -4,23 +4,23 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#FF000000"
+ android:fillType="nonZero"
+ android:pathData="M635.1,379L388.7,379C378.2,379 369.7,386.9 369.7,396.7L369.7,627.3C369.7,637.1 378.2,645 388.7,645L635.1,645C645.6,645 654,637.1 654,627.3L654,396.7C654,386.9 645.6,379 635.1,379Z" />
+ android:fillColor="#FF000000"
+ android:fillType="nonZero"
+ android:pathData="M350.8,113L104.5,113C94,113 85.5,120.9 85.5,130.7L85.5,361.2C85.5,371 94,379 104.5,379L350.8,379C361.3,379 369.8,371 369.8,361.2L369.8,130.7C369.8,120.9 361.3,113 350.8,113Z" />
+ android:fillColor="#FF000000"
+ android:fillType="nonZero"
+ android:pathData="M919.5,113L673.1,113C662.7,113 654.2,120.9 654.2,130.7L654.2,361.2C654.2,371 662.7,379 673.1,379L919.5,379C930,379 938.5,371 938.5,361.2L938.5,130.7C938.5,120.9 930,113 919.5,113Z" />
+ android:fillColor="#FF000000"
+ android:fillType="nonZero"
+ android:pathData="M350.8,645L104.5,645C94,645 85.5,653 85.5,662.8L85.5,893.3C85.5,903.1 94,911 104.5,911L350.8,911C361.3,911 369.8,903.1 369.8,893.3L369.8,662.8C369.8,653 361.3,645 350.8,645Z" />
+ android:fillColor="#FF000000"
+ android:fillType="nonZero"
+ android:pathData="M919.5,645L673.1,645C662.7,645 654.2,653 654.2,662.8L654.2,893.3C654.2,903.1 662.7,911 673.1,911L919.5,911C930,911 938.5,903.1 938.5,893.3L938.5,662.8C938.5,653 930,645 919.5,645Z" />
diff --git a/logo/src/main/res/drawable/brand_onlyfans.xml b/brand/src/main/res/drawable/brand_onlyfans.xml
similarity index 68%
rename from logo/src/main/res/drawable/brand_onlyfans.xml
rename to brand/src/main/res/drawable/brand_onlyfans.xml
index 7261279..564ba8f 100644
--- a/logo/src/main/res/drawable/brand_onlyfans.xml
+++ b/brand/src/main/res/drawable/brand_onlyfans.xml
@@ -4,11 +4,11 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M369.8,226.9C407.2,226.9 444.1,234.2 478.6,248.6C513.1,262.9 544.5,283.9 570.9,310.4C597.3,336.8 618.2,368.3 632.5,402.9C646.8,437.5 654.2,474.6 654.2,512C654.2,627.3 584.9,731.3 478.6,775.4C372.4,819.6 250.1,795.2 168.8,713.6C87.5,632.1 63.1,509.4 107.1,402.9C151.2,296.3 254.8,226.9 369.8,226.9ZM369.8,597.5C381,597.5 392.1,595.3 402.5,591.1C412.9,586.8 422.3,580.5 430.2,572.5C438.1,564.6 444.4,555.1 448.7,544.7C452.9,534.4 455.1,523.2 455.1,512C455.1,477.4 434.4,446.2 402.5,433C370.6,419.7 333.9,427 309.5,451.5C285.1,476 277.8,512.8 291,544.7C304.2,576.7 335.3,597.5 369.8,597.5L369.8,597.5Z" />
+ android:fillType="nonZero"
+ android:pathData="M689.4,440.7C761.7,461.6 846.9,440.7 846.9,440.7C822.2,549.1 743.7,616.9 630.5,625.2C619.6,650.6 605.1,674.3 587.4,695.5C569.6,716.6 548.9,735.1 525.9,750.3C502.8,765.5 477.7,777.3 451.3,785.2C424.8,793.1 397.4,797.1 369.8,797.1L455.1,525.3C542.8,245.8 587.8,226.9 795.7,226.9L938.5,226.9C914.6,332.4 832.3,413 689.4,440.7L689.4,440.7Z" />
diff --git a/logo/src/main/res/drawable/brand_openai.xml b/brand/src/main/res/drawable/brand_openai.xml
similarity index 94%
rename from logo/src/main/res/drawable/brand_openai.xml
rename to brand/src/main/res/drawable/brand_openai.xml
index 42114bc..8b760e3 100644
--- a/logo/src/main/res/drawable/brand_openai.xml
+++ b/brand/src/main/res/drawable/brand_openai.xml
@@ -1,12 +1,10 @@
+ android:pathData="M412.7,397L412.7,316.7C412.7,310 415.2,304.9 421.1,301.5L582.6,208.5C604.6,195.8 630.8,189.9 657.8,189.9C759.3,189.9 823.5,268.5 823.5,352.2C823.5,358.1 823.5,364.9 822.7,371.7L655.3,273.6C645.1,267.7 635,267.7 624.8,273.6L412.7,397ZM789.7,709.8L789.7,517.9C789.7,506.1 784.6,497.6 774.5,491.7L562.3,368.3L631.6,328.5C637.5,325.2 642.6,325.2 648.5,328.5L810,421.5C856.5,448.6 887.8,506.1 887.8,561.9C887.8,626.1 849.7,685.3 789.7,709.8L789.7,709.8ZM362.8,540.8L293.5,500.2C287.5,496.8 285,491.7 285,484.9L285,299C285,208.5 354.3,140 448.2,140C483.7,140 516.6,151.9 544.6,173L378,269.4C367.9,275.3 362.8,283.8 362.8,295.6L362.8,540.8L362.8,540.8ZM512,627L412.7,571.2L412.7,452.8L512,397L611.3,452.8L611.3,571.2L512,627ZM575.8,884C540.3,884 507.3,872.1 479.5,851L646,754.6C656.1,748.7 661.2,740.3 661.2,728.4L661.2,483.3L731.4,523.8C737.3,527.2 739.8,532.3 739.8,539.1L739.8,725C739.8,815.5 669.6,884 575.8,884L575.8,884ZM375.5,695.4L214,602.5C167.5,575.4 136.2,517.9 136.2,462.1C136.2,397 175.1,338.7 235.1,314.2L235.1,506.9C235.1,518.8 240.2,527.2 250.3,533.1L461.7,655.7L392.4,695.4C386.5,698.8 381.4,698.8 375.5,695.4ZM366.2,834.1C270.6,834.1 200.5,762.2 200.5,673.5C200.5,666.7 201.3,659.9 202.2,653.2L368.7,749.6C378.8,755.5 389,755.5 399.1,749.6L611.3,627L611.3,707.3C611.3,714.1 608.8,719.1 602.9,722.5L441.4,815.5C419.4,828.2 393.2,834.1 366.2,834.1L366.2,834.1ZM575.8,934.7C678.1,934.7 763.5,862 782.9,765.6C877.6,741.1 938.5,652.3 938.5,561.9C938.5,502.7 913.1,445.2 867.5,403.8C871.7,386 874.2,368.3 874.2,350.5C874.2,229.6 776.2,139.2 662.9,139.2C640.1,139.2 618.1,142.5 596.1,150.2C558.1,113 505.7,89.3 448.2,89.3C345.9,89.3 260.5,162 241,258.4C146.4,282.9 85.5,371.7 85.5,462.1C85.5,521.3 110.9,578.8 156.5,620.2C152.3,638 149.7,655.7 149.7,673.5C149.7,794.3 247.8,884.8 361.1,884.8C383.9,884.8 405.9,881.4 427.9,873.8C465.9,911 518.3,934.7 575.8,934.7Z" />
diff --git a/logo/src/main/res/drawable/brand_orcid.xml b/brand/src/main/res/drawable/brand_orcid.xml
similarity index 71%
rename from logo/src/main/res/drawable/brand_orcid.xml
rename to brand/src/main/res/drawable/brand_orcid.xml
index 3b880bd..09807ba 100644
--- a/logo/src/main/res/drawable/brand_orcid.xml
+++ b/brand/src/main/res/drawable/brand_orcid.xml
@@ -4,19 +4,19 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M938.5,512C938.5,747.6 747.6,938.5 512,938.5C276.4,938.5 85.5,747.6 85.5,512C85.5,276.4 276.4,85.5 512,85.5C747.6,85.5 938.5,276.4 938.5,512Z" />
+ android:fillType="nonZero"
+ android:pathData="M321.7,349.1l51.3,0l0,356.9l-51.3,0z" />
+ android:fillType="nonZero"
+ android:pathData="M448.3,349.1L587,349.1C718.9,349.1 776.9,443.3 776.9,527.7C776.9,619.3 705.3,706.3 587.7,706.3L448.3,706.3L448.3,349.1ZM499.7,659.9L581.3,659.9C697.6,659.9 724.2,571.7 724.2,527.7C724.2,456 678.6,395.4 578.6,395.4L499.7,395.4L499.7,659.9Z" />
+ android:fillType="nonZero"
+ android:pathData="M381.1,274.8C381.1,293.1 366.1,308.4 347.4,308.4C328.8,308.4 313.7,293.1 313.7,274.8C313.7,256.1 328.8,241.1 347.4,241.1C366.1,241.1 381.1,256.4 381.1,274.8Z" />
diff --git a/brand/src/main/res/drawable/brand_patreon.xml b/brand/src/main/res/drawable/brand_patreon.xml
new file mode 100644
index 0000000..6d02d82
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_patreon.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/brand/src/main/res/drawable/brand_paypal.xml b/brand/src/main/res/drawable/brand_paypal.xml
new file mode 100644
index 0000000..1ebb6a7
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_paypal.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/logo/src/main/res/drawable/brand_pixiv.xml b/brand/src/main/res/drawable/brand_pixiv.xml
similarity index 82%
rename from logo/src/main/res/drawable/brand_pixiv.xml
rename to brand/src/main/res/drawable/brand_pixiv.xml
index 4bb724f..309bc15 100644
--- a/logo/src/main/res/drawable/brand_pixiv.xml
+++ b/brand/src/main/res/drawable/brand_pixiv.xml
@@ -4,31 +4,31 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M325.2,435.5L361.2,435.5C364.6,435.5 367.2,438.2 367.2,441.5L367.2,630.6C367.2,633.9 364.5,636.6 361.2,636.6L325.2,636.6C321.9,636.6 319.2,633.9 319.2,630.6L319.2,441.5C319.2,438.2 321.9,435.5 325.2,435.5Z" />
+ android:fillType="nonZero"
+ android:pathData="M548.3,537.8C547.6,536.7 547.6,535.3 548.3,534.3L614.2,440.2C615.6,438.2 614.2,435.5 611.7,435.5L568.2,435.5C562.3,435.5 556.8,438.4 553.4,443.2L506.1,510.8C504.9,512.5 502.4,512.5 501.2,510.8L453.8,443.2C450.4,438.4 444.9,435.5 439,435.5L395.5,435.5C393.1,435.5 391.6,438.2 393,440.2L458.9,534.3C459.6,535.3 459.6,536.7 458.9,537.8L393,631.9C391.6,633.9 393,636.6 395.5,636.6L439,636.6C444.9,636.6 450.4,633.7 453.8,628.9L501.2,561.2C502.4,559.5 504.9,559.5 506.1,561.2L553.5,628.9C556.8,633.7 562.3,636.6 568.2,636.6L611.8,636.6C614.2,636.6 615.6,633.9 614.2,631.9L548.3,537.8Z" />
+ android:fillType="nonZero"
+ android:pathData="M85.5,654.6C85.5,657.9 88.2,660.6 91.5,660.6L127.6,660.6C130.9,660.6 133.6,657.9 133.6,654.6L133.6,621.8C148.8,631.2 166.8,636.6 186,636.6C241.6,636.6 286.6,591.6 286.6,536C286.6,480.5 241.6,435.5 186,435.5C162.4,435.5 140.7,443.7 123.6,457.3L111.3,438.3C110.2,436.5 108.3,435.5 106.3,435.5L91.5,435.5C88.2,435.5 85.5,438.2 85.5,441.5L85.5,654.6L85.5,654.6ZM186,588.5C157.1,588.5 133.6,565 133.6,536C133.6,507.1 157.1,483.6 186,483.6C215,483.6 238.5,507.1 238.5,536C238.5,565 215,588.5 186,588.5Z" />
+ android:fillType="nonZero"
+ android:pathData="M343.2,411.5C356.5,411.5 367.2,400.7 367.2,387.4C367.2,374.2 356.5,363.4 343.2,363.4C329.9,363.4 319.2,374.2 319.2,387.4C319.2,400.7 329.9,411.5 343.2,411.5Z" />
+ android:fillType="nonZero"
+ android:pathData="M645.9,435.5L682,435.5C685.3,435.5 688,438.2 688,441.5L688,630.6C688,633.9 685.3,636.6 682,636.6L645.9,636.6C642.6,636.6 639.9,633.9 639.9,630.6L639.9,441.5C639.9,438.2 642.6,435.5 645.9,435.5Z" />
+ android:fillType="nonZero"
+ android:pathData="M663.9,411.5C677.2,411.5 688,400.7 688,387.4C688,374.2 677.2,363.4 663.9,363.4C650.7,363.4 639.9,374.2 639.9,387.4C639.9,400.7 650.7,411.5 663.9,411.5Z" />
+ android:fillType="nonZero"
+ android:pathData="M883.9,447.1L827.4,594.4C826.4,597 822.8,597 821.8,594.4L765.2,447.1C762.5,440.1 755.8,435.5 748.4,435.5L713.6,435.5C711.5,435.5 710.1,437.6 710.9,439.6L785,632.7C785.9,635.1 788.1,636.6 790.6,636.6L858.5,636.6C861,636.6 863.3,635.1 864.1,632.7L938.3,439.6C939.1,437.6 937.6,435.5 935.5,435.5L900.8,435.5C893.3,435.5 886.6,440.1 883.9,447.1L883.9,447.1Z" />
diff --git a/brand/src/main/res/drawable/brand_tencent.xml b/brand/src/main/res/drawable/brand_tencent.xml
new file mode 100644
index 0000000..b5681b2
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_tencent.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/logo/src/main/res/drawable/brand_tencent_cloud.xml b/brand/src/main/res/drawable/brand_tencentcloud.xml
similarity index 85%
rename from logo/src/main/res/drawable/brand_tencent_cloud.xml
rename to brand/src/main/res/drawable/brand_tencentcloud.xml
index ad9b984..ef0448e 100644
--- a/logo/src/main/res/drawable/brand_tencent_cloud.xml
+++ b/brand/src/main/res/drawable/brand_tencentcloud.xml
@@ -4,15 +4,15 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M824,705.3C810.3,718.9 782.9,739.2 734.9,739.2L440,739.2C529.1,654.4 604.6,583.2 611.4,576.4C618.3,569.6 635.4,552.7 652.6,539.1C686.9,508.6 714.3,505.2 738.3,505.2C772.6,505.2 800,518.8 824,539.1C872,583.2 872,661.2 824,705.3ZM882.4,484.9C848.1,447.6 796.6,423.8 741.8,423.8C693.8,423.8 652.6,440.8 614.9,467.9C601.2,481.5 580.6,495 563.4,515.4C549.7,529 254.8,813.8 254.8,813.8C272,817.2 292.5,817.2 309.7,817.2L683.5,817.2C710.9,817.2 731.5,817.2 752,813.8C796.6,810.4 841.2,793.4 878.9,759.5C957.8,684.9 957.8,559.5 882.4,484.9Z" />
+ android:fillType="nonZero"
+ android:pathData="M402.3,464.5C364.6,437.4 326.8,423.8 282.2,423.8C227.4,423.8 176,447.6 141.7,484.9C66.2,562.9 66.2,684.9 145.1,762.9C179.4,793.4 213.7,810.4 254.8,813.8L333.7,739.2L289.1,739.2C244.5,735.8 217.1,722.2 199.9,705.3C151.9,657.8 151.9,583.2 196.5,535.7C220.5,512 247.9,501.8 282.2,501.8C302.8,501.8 333.7,505.2 364.6,535.7C378.3,549.3 416,576.4 429.7,590L433.1,590L484.6,539.1L484.6,535.7C460.6,512 422.8,481.5 402.3,464.5" />
+ android:fillType="nonZero"
+ android:pathData="M762.3,379.8C724.6,278 625.1,206.8 512,206.8C378.3,206.8 272,305.1 251.4,427.2C261.7,427.2 272,423.8 285.7,423.8C299.4,423.8 316.6,427.2 330.3,427.2C347.4,342.4 422.8,281.4 512,281.4C587.4,281.4 652.6,325.5 683.5,389.9C683.5,389.9 686.9,393.3 686.9,389.9C710.9,386.5 738.3,379.8 762.3,379.8C762.3,383.2 762.3,383.2 762.3,379.8" />
diff --git a/brand/src/main/res/drawable/brand_threads.xml b/brand/src/main/res/drawable/brand_threads.xml
new file mode 100644
index 0000000..9ffe2d8
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_threads.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/brand/src/main/res/drawable/brand_whatsapp.xml b/brand/src/main/res/drawable/brand_whatsapp.xml
new file mode 100644
index 0000000..c95d3ca
--- /dev/null
+++ b/brand/src/main/res/drawable/brand_whatsapp.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/logo/src/main/res/drawable/brand_wise.xml b/brand/src/main/res/drawable/brand_wise.xml
similarity index 91%
rename from logo/src/main/res/drawable/brand_wise.xml
rename to brand/src/main/res/drawable/brand_wise.xml
index d8e79c9..cc82698 100644
--- a/logo/src/main/res/drawable/brand_wise.xml
+++ b/brand/src/main/res/drawable/brand_wise.xml
@@ -4,7 +4,7 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillType="nonZero"
+ android:pathData="M316.1,350.9L85.5,620.4L497.2,620.4L543.5,493.4L367.1,493.4L474.9,368.7L475.2,365.4L405.1,244.8L720.5,244.8L476,917.4L643.3,917.4L938.5,106.6L175.9,106.6L316.1,350.9Z" />
diff --git a/logo/src/main/res/drawable/brand_x.xml b/brand/src/main/res/drawable/brand_x.xml
similarity index 76%
rename from logo/src/main/res/drawable/brand_x.xml
rename to brand/src/main/res/drawable/brand_x.xml
index b4ed7ab..e2314b8 100644
--- a/logo/src/main/res/drawable/brand_x.xml
+++ b/brand/src/main/res/drawable/brand_x.xml
@@ -4,7 +4,7 @@
android:viewportWidth="1024"
android:viewportHeight="1024">
+ android:fillColor="#FF000000"
+ android:fillType="nonZero"
+ android:pathData="M757.3,126.5L888.1,126.5L602.3,453.1L938.5,897.5L675.3,897.5L469.1,628L233.2,897.5L102.3,897.5L408,548.2L85.5,126.5L355.4,126.5L541.8,372.9L757.3,126.5ZM711.4,819.2L783.9,819.2L316,200.7L238.2,200.7L711.4,819.2Z" />
diff --git a/build-logic/plugins/build.gradle.kts b/build-logic/plugins/build.gradle.kts
index 17f3f72..53f4202 100644
--- a/build-logic/plugins/build.gradle.kts
+++ b/build-logic/plugins/build.gradle.kts
@@ -25,10 +25,5 @@ gradlePlugin {
id = "self.compose"
implementationClass = "ComposeConventionPlugin"
}
-
- register("self.room") {
- id = "self.room"
- implementationClass = "RoomConventionPlugin"
- }
}
}
\ No newline at end of file
diff --git a/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt
index a0b402d..0c047fd 100644
--- a/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt
+++ b/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt
@@ -16,7 +16,7 @@ class ApplicationConventionPlugin : Plugin {
buildToolsVersion = "37.0.0"
defaultConfig {
- minSdk = 33
+ minSdk = 30
targetSdk = compileSdk
}
@@ -33,7 +33,10 @@ class ApplicationConventionPlugin : Plugin {
extensions.configure {
compilerOptions {
languageVersion.set(KotlinVersion.KOTLIN_2_4)
- optIn.add("kotlinx.serialization.ExperimentalSerializationApi")
+ optIn.addAll(
+ "kotlinx.coroutines.FlowPreview",
+ "kotlinx.serialization.ExperimentalSerializationApi",
+ )
}
}
}
diff --git a/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt
index 114815c..106ac5d 100644
--- a/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt
+++ b/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt
@@ -21,7 +21,10 @@ class ComposeConventionPlugin : Plugin {
extensions.configure {
compilerOptions {
- optIn.add("androidx.compose.material3.ExperimentalMaterial3Api")
+ optIn.addAll(
+ "androidx.compose.material3.ExperimentalMaterial3Api",
+ "androidx.compose.foundation.layout.ExperimentalLayoutApi"
+ )
}
}
diff --git a/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt
index e98cb6e..08bbd7d 100644
--- a/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt
+++ b/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt
@@ -16,7 +16,7 @@ class LibraryConventionPlugin : Plugin {
buildToolsVersion = "37.0.0"
defaultConfig {
- minSdk = 33
+ minSdk = 30
}
compileOptions {
diff --git a/build-logic/plugins/src/main/kotlin/RoomConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/RoomConventionPlugin.kt
deleted file mode 100644
index d8a1741..0000000
--- a/build-logic/plugins/src/main/kotlin/RoomConventionPlugin.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-import com.google.devtools.ksp.gradle.KspExtension
-import org.gradle.api.Plugin
-import org.gradle.api.Project
-import org.gradle.api.artifacts.VersionCatalogsExtension
-import org.gradle.kotlin.dsl.apply
-import org.gradle.kotlin.dsl.configure
-import org.gradle.kotlin.dsl.dependencies
-import org.gradle.kotlin.dsl.getByType
-
-class RoomConventionPlugin : Plugin {
- override fun apply(target: Project) = with(target) {
- apply(plugin = "com.google.devtools.ksp")
-
- extensions.configure {
- arg("room.incremental", "true")
- arg("room.expandProjection", "true")
- arg("room.schemaLocation", "$projectDir/schemas")
- }
-
- val libs = extensions.getByType().named("libs")
- dependencies {
- "implementation"(libs.findLibrary("androidx.room.ktx").get())
- "implementation"(libs.findLibrary("androidx.room.runtime").get())
- "ksp"(libs.findLibrary("androidx.room.compiler").get())
- }
- }
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index c9698b6..43879c3 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.androidx.room) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index 2f78c3d..d6bea6b 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -3,7 +3,7 @@ plugins {
}
android {
- namespace = "dev.sanmer.core"
+ namespace = "dev.sanmer.auth"
}
dependencies {
diff --git a/core/src/main/kotlin/dev/sanmer/encoding/Base32.kt b/core/src/main/kotlin/dev/sanmer/auth/Base32.kt
similarity index 96%
rename from core/src/main/kotlin/dev/sanmer/encoding/Base32.kt
rename to core/src/main/kotlin/dev/sanmer/auth/Base32.kt
index 1e116ea..d4c5fe5 100644
--- a/core/src/main/kotlin/dev/sanmer/encoding/Base32.kt
+++ b/core/src/main/kotlin/dev/sanmer/auth/Base32.kt
@@ -1,6 +1,6 @@
@file:Suppress("NOTHING_TO_INLINE")
-package dev.sanmer.encoding
+package dev.sanmer.auth
import io.matthewnelson.encoding.base32.Base32
import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray
diff --git a/core/src/main/kotlin/dev/sanmer/encoding/Base64.kt b/core/src/main/kotlin/dev/sanmer/auth/Base64.kt
similarity index 96%
rename from core/src/main/kotlin/dev/sanmer/encoding/Base64.kt
rename to core/src/main/kotlin/dev/sanmer/auth/Base64.kt
index fa388a1..0dedcfe 100644
--- a/core/src/main/kotlin/dev/sanmer/encoding/Base64.kt
+++ b/core/src/main/kotlin/dev/sanmer/auth/Base64.kt
@@ -1,6 +1,6 @@
@file:Suppress("NOTHING_TO_INLINE")
-package dev.sanmer.encoding
+package dev.sanmer.auth
import io.matthewnelson.encoding.base64.Base64
import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray
diff --git a/core/src/main/kotlin/dev/sanmer/otp/Otp.kt b/core/src/main/kotlin/dev/sanmer/auth/Otp.kt
similarity index 98%
rename from core/src/main/kotlin/dev/sanmer/otp/Otp.kt
rename to core/src/main/kotlin/dev/sanmer/auth/Otp.kt
index 3a1b84e..5288713 100644
--- a/core/src/main/kotlin/dev/sanmer/otp/Otp.kt
+++ b/core/src/main/kotlin/dev/sanmer/auth/Otp.kt
@@ -1,4 +1,4 @@
-package dev.sanmer.otp
+package dev.sanmer.auth
import java.nio.ByteBuffer
import java.nio.ByteOrder
diff --git a/core/src/main/kotlin/dev/sanmer/auth/OtpUri.kt b/core/src/main/kotlin/dev/sanmer/auth/OtpUri.kt
new file mode 100644
index 0000000..c885ab7
--- /dev/null
+++ b/core/src/main/kotlin/dev/sanmer/auth/OtpUri.kt
@@ -0,0 +1,64 @@
+package dev.sanmer.auth
+
+import android.net.Uri
+
+data class OtpUri(
+ val type: String,
+ val name: String,
+ val issuer: String,
+ val secret: String,
+ val algorithm: String?,
+ val digits: Int?,
+ val period: Long?
+) {
+ fun toUri(): Uri {
+ val builder = Uri.Builder()
+ builder.scheme(SCHEME)
+ builder.authority(type)
+ builder.appendQueryParameter(Query.SECRET, secret)
+ algorithm?.let { builder.appendQueryParameter(Query.ALGORITHM, it) }
+ digits?.let { builder.appendQueryParameter(Query.DIGITS, it.toString()) }
+ period?.let { builder.appendQueryParameter(Query.PERIOD, it.toString()) }
+ if (issuer.isNotEmpty()) {
+ builder.appendQueryParameter(Query.ISSUER, issuer)
+ builder.path(if (name.isNotEmpty()) "${issuer}:${name}" else issuer)
+ } else if (name.isNotEmpty()) {
+ builder.path(name)
+ }
+ return builder.build()
+ }
+
+ private object Query {
+ const val SECRET = "secret"
+ const val ALGORITHM = "algorithm"
+ const val DIGITS = "digits"
+ const val PERIOD = "period"
+ const val ISSUER = "issuer"
+ }
+
+ companion object Default {
+ const val SCHEME = "otpauth"
+
+ fun Uri.isOtpUri() = scheme == SCHEME
+
+ fun Uri.toOtpUri(): OtpUri {
+ require(isOtpUri()) { "Expect scheme = $SCHEME" }
+ val label = path?.substring(1).orEmpty()
+ val (issuer, name) = if (label.contains(":")) {
+ val values = label.split(":".toRegex(), limit = 2)
+ values[0] to values[1]
+ } else {
+ getQueryParameter(Query.ISSUER).orEmpty() to label
+ }
+ return OtpUri(
+ type = requireNotNull(host) { "Expect type" },
+ name = name,
+ issuer = issuer,
+ secret = requireNotNull(getQueryParameter(Query.SECRET)) { "Expect secret" },
+ algorithm = getQueryParameter(Query.ALGORITHM)?.uppercase(),
+ digits = getQueryParameter(Query.DIGITS)?.let(String::toInt),
+ period = getQueryParameter(Query.PERIOD)?.let(String::toLong)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt b/core/src/main/kotlin/dev/sanmer/auth/QRCode.kt
similarity index 77%
rename from core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt
rename to core/src/main/kotlin/dev/sanmer/auth/QRCode.kt
index d5f2cec..7094e25 100644
--- a/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt
+++ b/core/src/main/kotlin/dev/sanmer/auth/QRCode.kt
@@ -1,4 +1,4 @@
-package dev.sanmer.qrcode
+package dev.sanmer.auth
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -11,7 +11,6 @@ import com.google.zxing.LuminanceSource
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.RGBLuminanceSource
-import com.google.zxing.common.BitMatrix
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import com.google.zxing.qrcode.QRCodeWriter
@@ -30,13 +29,11 @@ object QRCode {
private fun decode(source: LuminanceSource): String {
val reader = QRCodeReader()
- val result = try {
+ return try {
reader.decode(BinaryBitmap(HybridBinarizer(source)))
} catch (_: NotFoundException) {
- val inverted = source.invert()
- reader.decode(BinaryBitmap(HybridBinarizer(inverted)))
- }
- return result.text.trim()
+ reader.decode(BinaryBitmap(HybridBinarizer(source.invert())))
+ }.text.trim()
}
fun decodeFromYuv(
@@ -46,7 +43,7 @@ object QRCode {
width: Int,
height: Int
) = decode(
- source = PlanarYUVLuminanceSource(
+ PlanarYUVLuminanceSource(
yuvData,
dataWidth,
dataHeight,
@@ -62,10 +59,8 @@ object QRCode {
if (maxHeight <= 0 || maxWidth <= 0) {
return this
}
-
val maxRatio = maxWidth.toFloat() / maxHeight
val ratio = width.toFloat() / height
-
var width = maxWidth
var height = maxHeight
if (maxRatio > 1) {
@@ -73,15 +68,15 @@ object QRCode {
} else {
height = (maxWidth.toFloat() / ratio).toInt()
}
-
return Bitmap.createScaledBitmap(this, width, height, true)
}
fun decodeFromStream(stream: InputStream): String? {
- var bitmap = requireNotNull(
- BitmapFactory.decodeStream(stream, null, BitmapFactory.Options())
- ) { "Unable to decode stream to bitmap" }
-
+ var bitmap = BitmapFactory.decodeStream(
+ stream,
+ null,
+ BitmapFactory.Options()
+ ) ?: return null
for (i in 0..2) {
if (i != 0) {
bitmap = bitmap.resize(bitmap.width / (i * 2), bitmap.height / (i * 2))
@@ -89,18 +84,20 @@ object QRCode {
try {
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
- return decode(source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels))
+ return decode(RGBLuminanceSource(bitmap.width, bitmap.height, pixels))
} catch (_: NotFoundException) {
}
}
return null
}
- private fun createBitmap(
- bitMatrix: BitMatrix,
- @ColorInt foregroundColor: Int,
- @ColorInt backgroundColor: Int
+ fun encodeToBitmap(
+ content: String,
+ size: Int,
+ @ColorInt foregroundColor: Int = Color.WHITE,
+ @ColorInt backgroundColor: Int = Color.BLACK,
): Bitmap {
+ val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val width = bitMatrix.width
val height = bitMatrix.height
val pixels = IntArray(width * height)
@@ -114,15 +111,4 @@ object QRCode {
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}
-
- fun encodeToBitmap(
- contents: String,
- size: Int,
- @ColorInt foregroundColor: Int = Color.WHITE,
- @ColorInt backgroundColor: Int = Color.BLACK,
- ) = createBitmap(
- bitMatrix = QRCodeWriter().encode(contents, BarcodeFormat.QR_CODE, size, size, hints),
- foregroundColor = foregroundColor,
- backgroundColor = backgroundColor
- )
}
\ No newline at end of file
diff --git a/core/src/main/kotlin/dev/sanmer/crypto/Crypto.kt b/core/src/main/kotlin/dev/sanmer/auth/crypto/Crypto.kt
similarity index 72%
rename from core/src/main/kotlin/dev/sanmer/crypto/Crypto.kt
rename to core/src/main/kotlin/dev/sanmer/auth/crypto/Crypto.kt
index 828b1cb..47dd514 100644
--- a/core/src/main/kotlin/dev/sanmer/crypto/Crypto.kt
+++ b/core/src/main/kotlin/dev/sanmer/auth/crypto/Crypto.kt
@@ -1,7 +1,7 @@
-package dev.sanmer.crypto
+package dev.sanmer.auth.crypto
-import dev.sanmer.encoding.decodeBase64
-import dev.sanmer.encoding.encodeBase64
+import dev.sanmer.auth.decodeBase64
+import dev.sanmer.auth.encodeBase64
import java.security.SecureRandom
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
@@ -11,30 +11,15 @@ import javax.crypto.spec.SecretKeySpec
interface Crypto {
suspend fun encrypt(input: ByteArray): ByteArray
suspend fun encrypt(input: String) = encrypt(input.toByteArray(Charsets.UTF_8)).encodeBase64()
-
suspend fun decrypt(input: ByteArray): ByteArray
suspend fun decrypt(input: String) = decrypt(input.decodeBase64()).toString(Charsets.UTF_8)
- object None : Crypto {
- override suspend fun encrypt(input: ByteArray): ByteArray {
- return input
- }
-
- override suspend fun encrypt(input: String): String {
- return input
- }
-
- override suspend fun decrypt(input: ByteArray): ByteArray {
- return input
- }
-
- override suspend fun decrypt(input: String): String {
- return input
- }
-
- }
+ companion object Default : Crypto {
+ override suspend fun encrypt(input: ByteArray) = input
+ override suspend fun encrypt(input: String) = input
+ override suspend fun decrypt(input: ByteArray) = input
+ override suspend fun decrypt(input: String) = input
- companion object Default {
const val FACTORY_ALGORITHM = "PBKDF2WithHmacSHA256"
const val ALGORITHM = "AES/GCM/NoPadding"
const val KEY_ALGORITHM = "AES"
diff --git a/core/src/main/kotlin/dev/sanmer/crypto/PasswordKey.kt b/core/src/main/kotlin/dev/sanmer/auth/crypto/PasswordKey.kt
similarity index 80%
rename from core/src/main/kotlin/dev/sanmer/crypto/PasswordKey.kt
rename to core/src/main/kotlin/dev/sanmer/auth/crypto/PasswordKey.kt
index fb0d550..c2bafa6 100644
--- a/core/src/main/kotlin/dev/sanmer/crypto/PasswordKey.kt
+++ b/core/src/main/kotlin/dev/sanmer/auth/crypto/PasswordKey.kt
@@ -1,8 +1,8 @@
-package dev.sanmer.crypto
+package dev.sanmer.auth.crypto
-import dev.sanmer.crypto.Crypto.Default.generateSecretKey
-import dev.sanmer.crypto.Crypto.Default.randomIv
-import dev.sanmer.crypto.Crypto.Default.randomSalt
+import dev.sanmer.auth.crypto.Crypto.Default.generateSecretKey
+import dev.sanmer.auth.crypto.Crypto.Default.randomIv
+import dev.sanmer.auth.crypto.Crypto.Default.randomSalt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.crypto.Cipher
@@ -41,6 +41,7 @@ class PasswordKey private constructor(
}
companion object Default {
- fun new(password: String) = PasswordKey(password.toCharArray())
+ private fun CharSequence.toCharArray() = CharArray(length) { get(it) }
+ fun new(password: CharSequence) = PasswordKey(password.toCharArray())
}
}
diff --git a/core/src/main/kotlin/dev/sanmer/crypto/SessionKey.kt b/core/src/main/kotlin/dev/sanmer/auth/crypto/SessionKey.kt
similarity index 89%
rename from core/src/main/kotlin/dev/sanmer/crypto/SessionKey.kt
rename to core/src/main/kotlin/dev/sanmer/auth/crypto/SessionKey.kt
index 2d2d122..7e9ad53 100644
--- a/core/src/main/kotlin/dev/sanmer/crypto/SessionKey.kt
+++ b/core/src/main/kotlin/dev/sanmer/auth/crypto/SessionKey.kt
@@ -1,6 +1,6 @@
-package dev.sanmer.crypto
+package dev.sanmer.auth.crypto
-import dev.sanmer.crypto.Crypto.Default.randomIv
+import dev.sanmer.auth.crypto.Crypto.Default.randomIv
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.crypto.Cipher
@@ -28,7 +28,7 @@ class SessionKey(
cipher.doFinal(data)
}
- suspend fun getKeyEncryptedByPassword(password: String) =
+ suspend fun getKeyEncryptedByPassword(password: CharSequence) =
PasswordKey.new(password).encrypt(key.encoded)
companion object Default {
@@ -41,7 +41,7 @@ class SessionKey(
}.generateKey()
)
- suspend fun decryptKeyByPassword(key: ByteArray, password: String) = SessionKey(
+ suspend fun decryptKeyByPassword(key: ByteArray, password: CharSequence) = SessionKey(
key = PasswordKey.new(password).decrypt(key).toSecretKey()
)
}
diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/BaseClock.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/BaseClock.kt
new file mode 100644
index 0000000..4f1cffc
--- /dev/null
+++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/BaseClock.kt
@@ -0,0 +1,19 @@
+package dev.sanmer.auth.ntp
+
+import kotlin.time.Clock
+import kotlin.time.Duration
+import kotlin.time.Instant
+import kotlin.time.TimeMark
+import kotlin.time.TimeSource
+
+internal class BaseClock(
+ val instant: Instant = Clock.System.now(),
+ val mark: TimeMark = TimeSource.Monotonic.markNow(),
+) : Clock {
+ override fun now() = instant + mark.elapsedNow()
+
+ fun sync(offset: Duration) = BaseClock(
+ instant = now() + offset,
+ mark = TimeSource.Monotonic.markNow()
+ )
+}
\ No newline at end of file
diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpClock.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpClock.kt
new file mode 100644
index 0000000..4766ad2
--- /dev/null
+++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpClock.kt
@@ -0,0 +1,11 @@
+package dev.sanmer.auth.ntp
+
+import kotlin.time.Clock
+import kotlin.time.Duration
+
+class NtpClock(
+ private val clock: Clock,
+ val message: NtpMessage,
+ val offset: Duration,
+ val rtt: Duration
+) : Clock by clock
\ No newline at end of file
diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpMessage.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpMessage.kt
new file mode 100644
index 0000000..b06c444
--- /dev/null
+++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpMessage.kt
@@ -0,0 +1,65 @@
+package dev.sanmer.auth.ntp
+
+import java.net.Inet4Address
+import java.net.InetAddress
+import kotlin.time.Duration
+import kotlin.time.Instant
+
+data class NtpMessage(
+ val leapIndicator: LeapIndicator,
+ val version: Int,
+ val stratum: Stratum,
+ val poll: Duration,
+ val precision: Duration,
+ val rootDelay: Duration,
+ val rootDispersion: Duration,
+ val referenceIdentifier: ReferenceIdentifier,
+ val referenceTimestamp: Instant
+) {
+ internal constructor(packet: NtpPacket) : this(
+ leapIndicator = packet.readLeapIndicator().also {
+ check(it != LeapIndicator.Unknown) { "Not synchronized" }
+ },
+ version = packet.readVersion().also {
+ check(it == 3 || it == 4) { "Not supported" }
+ },
+ stratum = packet.readStratum().also {
+ check(it != Stratum.Unknown) { "Not synchronized" }
+ },
+ poll = packet.readPoll(),
+ precision = packet.readPrecision(),
+ rootDelay = packet.readRootDelay(),
+ rootDispersion = packet.readRootDispersion(),
+ referenceIdentifier = packet.readReferenceIdentifier(),
+ referenceTimestamp = packet.readReferenceTimestamp()
+ )
+
+ enum class LeapIndicator {
+ None,
+ PlusOneSecond,
+ MinusOneSecond,
+ Unknown
+ }
+
+ sealed interface Stratum {
+ data object Unspecified : Stratum
+ data object Primary : Stratum
+
+ @JvmInline
+ value class Secondary(val stratum: Int) : Stratum {
+ override fun toString() = stratum.toString()
+ }
+
+ data object Unknown : Stratum
+ }
+
+ class ReferenceIdentifier internal constructor(
+ private val bytes: ByteArray
+ ) {
+ fun kissCode() = bytes.toString(Charsets.US_ASCII)
+
+ fun ipv4Address(): InetAddress = Inet4Address.getByAddress(bytes)
+
+ fun ipv6Hash() = bytes.toHexString()
+ }
+}
diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpPacket.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpPacket.kt
new file mode 100644
index 0000000..4a7a976
--- /dev/null
+++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpPacket.kt
@@ -0,0 +1,129 @@
+package dev.sanmer.auth.ntp
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import kotlin.math.log2
+import kotlin.math.roundToInt
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+import kotlin.time.Instant
+
+internal class NtpPacket(
+ val bytes: ByteArray
+) {
+ constructor(size: Int = PACKET_SIZE) : this(
+ bytes = ByteArray(size)
+ )
+
+ private val buffer = ByteBuffer.wrap(bytes)
+ .order(ByteOrder.BIG_ENDIAN)
+
+ fun readLeapIndicator(): NtpMessage.LeapIndicator {
+ val leap = (buffer.get(0).toInt() and 0xff) ushr 6
+ return when (leap) {
+ 0 -> NtpMessage.LeapIndicator.None
+ 1 -> NtpMessage.LeapIndicator.PlusOneSecond
+ 2 -> NtpMessage.LeapIndicator.MinusOneSecond
+ else -> NtpMessage.LeapIndicator.Unknown
+ }
+ }
+
+ fun writeVersion(value: Int = VERSION) {
+ val current = buffer.get(0).toInt() and 0xFF
+ val updated = (current and 0xC7) or (value shl 3)
+ buffer.put(0, updated.toByte())
+ }
+
+ fun readVersion(): Int {
+ return ((buffer.get(0).toInt() and 0xFF) ushr 3) and 0x07
+ }
+
+ fun writeMode(value: Int = MODE_CLIENT) {
+ val current = buffer.get(0).toInt() and 0xFF
+ val updated = (current and 0xF8) or value
+ buffer.put(0, updated.toByte())
+ }
+
+ fun readMode(): Int {
+ return buffer.get(0).toInt() and 0x07
+ }
+
+ fun readStratum(): NtpMessage.Stratum {
+ val stratum = buffer.get(1).toInt() and 0xFF
+ return when {
+ stratum == 0 -> NtpMessage.Stratum.Unspecified
+ stratum == 1 -> NtpMessage.Stratum.Primary
+ stratum <= 15 -> NtpMessage.Stratum.Secondary(stratum)
+ else -> NtpMessage.Stratum.Unknown
+ }
+ }
+
+ fun writePoll(value: Duration) {
+ val value = log2(value.toDouble(DurationUnit.SECONDS))
+ buffer.put(2, value.roundToInt().toByte())
+ }
+
+ fun readPoll(): Duration {
+ val value = buffer.get(2).toInt()
+ return Math.scalb(1.0, value).seconds
+ }
+
+ fun writePrecision(value: Duration) {
+ val value = log2(value.toDouble(DurationUnit.SECONDS))
+ buffer.put(3, value.roundToInt().toByte())
+ }
+
+ fun readPrecision(): Duration {
+ val value = buffer.get(3).toInt()
+ return Math.scalb(1.0, value).seconds
+ }
+
+ fun readRootDelay(): Duration {
+ val value = buffer.getInt(4)
+ return (value / FIXED_POINT_SCALE).seconds
+ }
+
+ fun readRootDispersion(): Duration {
+ val value = buffer.getInt(8).toUInt().toLong()
+ return (value / FIXED_POINT_SCALE).seconds
+ }
+
+ fun readReferenceIdentifier(): NtpMessage.ReferenceIdentifier {
+ return NtpMessage.ReferenceIdentifier(bytes.copyOfRange(12, 16))
+ }
+
+ fun writeTimestamp(index: Int, value: Instant) {
+ val seconds = value.epochSeconds + NTP_OFFSET
+ val fraction = (value.nanosecondsOfSecond.toLong() shl 32) / 1_000_000_000L
+ buffer.putInt(index, seconds.toInt())
+ buffer.putInt(index + 4, fraction.toInt())
+ }
+
+ fun writeTransmitTimestamp(value: Instant) = writeTimestamp(40, value)
+
+ fun readTimestamp(index: Int): Instant {
+ val seconds = buffer.getInt(index).toUInt()
+ val epochSeconds = seconds.toLong() - NTP_OFFSET
+ val fraction = buffer.getInt(index + 4).toUInt()
+ val nanosecondsOfSecond = ((fraction.toLong() * 1_000_000_000L) ushr 32).toInt()
+ return Instant.fromEpochSeconds(epochSeconds, nanosecondsOfSecond)
+ }
+
+ fun readReferenceTimestamp() = readTimestamp(16)
+
+ fun readOriginTimestamp() = readTimestamp(24)
+
+ fun readReceiveTimestamp() = readTimestamp(32)
+
+ fun readTransmitTimestamp() = readTimestamp(40)
+
+ companion object Default {
+ const val VERSION = 4
+ const val MODE_CLIENT = 3
+ const val MODE_SERVER = 4
+ const val PACKET_SIZE = 48
+ const val NTP_OFFSET = 2208988800L
+ const val FIXED_POINT_SCALE = 65536.0
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpServer.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpServer.kt
new file mode 100644
index 0000000..1890a5f
--- /dev/null
+++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpServer.kt
@@ -0,0 +1,81 @@
+package dev.sanmer.auth.ntp
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.microseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+
+interface NtpServer {
+ val address: String
+ val port: Int get() = 123
+ val timeout: Duration get() = 1.seconds
+
+ suspend fun sync() = withContext(Dispatchers.IO) {
+ val address = InetAddress.getByName(address)
+ val socket = DatagramSocket()
+ socket.soTimeout = timeout.toInt(DurationUnit.MILLISECONDS)
+
+ val clock = BaseClock()
+ val packet = NtpPacket()
+ packet.writeVersion()
+ packet.writeMode()
+ packet.writePoll(64.seconds)
+ packet.writePrecision(1.microseconds)
+
+ val p = DatagramPacket(packet.bytes, NtpPacket.PACKET_SIZE, address, port)
+ val t1 = clock.now()
+ packet.writeTransmitTimestamp(t1)
+ socket.send(p)
+ socket.receive(p)
+ val t4 = clock.now()
+ socket.close()
+
+ val message = NtpMessage(packet)
+ val t2 = packet.readReceiveTimestamp()
+ val t3 = packet.readTransmitTimestamp()
+ val offset = (((t2 - t1) + (t3 - t4)) / 2)
+ val rtt = ((t4 - t1) - (t3 - t2))
+ NtpClock(clock.sync(offset), message, offset, rtt)
+ }
+
+ data class Custom(
+ override val address: String
+ ) : NtpServer
+
+ data object Alibaba : NtpServer {
+ override val address = "ntp.aliyun.com"
+ }
+
+ data object Apple : NtpServer {
+ override val address = "time.apple.com"
+ }
+
+ data object Amazon : NtpServer {
+ override val address = "time.aws.com"
+ }
+
+ data object Cloudflare : NtpServer {
+ override val address = "time.cloudflare.com"
+ }
+
+ data object Google : NtpServer {
+ override val address = "time.google.com"
+ }
+
+ data object Meta : NtpServer {
+ override val address = "time.facebook.com"
+ }
+
+ data object Microsoft : NtpServer {
+ override val address = "time.windows.com"
+ }
+
+ data object Tencent : NtpServer {
+ override val address = "ntp.tencent.com"
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/kotlin/dev/sanmer/ntp/NtpServer.kt b/core/src/main/kotlin/dev/sanmer/ntp/NtpServer.kt
deleted file mode 100644
index f9526e6..0000000
--- a/core/src/main/kotlin/dev/sanmer/ntp/NtpServer.kt
+++ /dev/null
@@ -1,138 +0,0 @@
-package dev.sanmer.ntp
-
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import java.net.DatagramPacket
-import java.net.DatagramSocket
-import java.net.InetAddress
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.DurationUnit
-
-interface NtpServer {
- val address: String
- val port: Int get() = NTP_PORT
- val timeout: Duration get() = 1.seconds
-
- suspend fun address() = address.toInetAddress()
- suspend fun sync() = sync(this)
-
- data class Custom(
- override val address: String
- ) : NtpServer
-
- data object Alibaba : NtpServer {
- override val address = "ntp.aliyun.com"
- }
-
- data object Apple : NtpServer {
- override val address = "time.apple.com"
- }
-
- data object Amazon : NtpServer {
- override val address = "time.aws.com"
- }
-
- data object Cloudflare : NtpServer {
- override val address = "time.cloudflare.com"
- }
-
- data object Google : NtpServer {
- override val address = "time.google.com"
- }
-
- data object Meta : NtpServer {
- override val address = "time.facebook.com"
- }
-
- data object Microsoft : NtpServer {
- override val address = "time.windows.com"
- }
-
- data object Tencent : NtpServer {
- override val address = "ntp.tencent.com"
- }
-
- data class NtpTime(
- val rtt: Duration = Duration.ZERO,
- val offset: Duration = Duration.ZERO
- ) {
- val offsetValue by lazy { offset.toLong(DurationUnit.MILLISECONDS) }
- val currentTimeMillis inline get() = System.currentTimeMillis() + offsetValue
- }
-
- private companion object Impl {
- const val NTP_PORT = 123
- const val NTP_MODE = 3
- const val NTP_VERSION = 3
- const val NTP_PACKET_SIZE = 48
- const val NTP_OFFSET = 2208988800L
-
- const val INDEX_VERSION = 0
- const val INDEX_RECEIVE_TIME = 32
- const val INDEX_TRANSMIT_TIME = 40
-
- suspend fun sync(server: NtpServer) = withContext(Dispatchers.IO) {
- val data = NtpData(NTP_PACKET_SIZE)
- data.writeNtpVersion(NTP_VERSION)
-
- DatagramSocket().use { socket ->
- socket.soTimeout = server.timeout.toInt(DurationUnit.MILLISECONDS)
-
- val request = DatagramPacket(data.bytes, data.size, server.address(), server.port)
- val t1 = System.currentTimeMillis()
- data.writeTransmitTimestamp(t1)
- socket.send(request)
-
- val response = DatagramPacket(data.bytes, data.size)
- socket.receive(response)
- val t4 = System.currentTimeMillis()
-
- val t2 = data.readTimestamp(INDEX_RECEIVE_TIME)
- val t3 = data.readTimestamp(INDEX_TRANSMIT_TIME)
-
- NtpTime(
- rtt = ((t4 - t1) - (t3 - t2)).milliseconds,
- offset = (((t2 - t1) + (t3 - t4)) / 2.0).milliseconds
- )
- }
- }
-
- suspend fun String.toInetAddress(): InetAddress = withContext(Dispatchers.IO) {
- InetAddress.getByName(this@toInetAddress)
- }
-
- class NtpData(
- val bytes: ByteArray
- ) {
- constructor(size: Int) : this(
- bytes = ByteArray(size)
- )
-
- private val buffer = ByteBuffer.wrap(bytes)
- .order(ByteOrder.BIG_ENDIAN)
-
- val size inline get() = bytes.size
-
- fun writeNtpVersion(value: Int) {
- buffer.put(INDEX_VERSION, (NTP_MODE or (value shl 3)).toByte())
- }
-
- fun writeTransmitTimestamp(value: Long) {
- val seconds = (value / 1000L) + NTP_OFFSET
- val fraction = ((value % 1000L) * 0x100000000L / 1000L)
- buffer.putInt(INDEX_TRANSMIT_TIME, seconds.toInt())
- buffer.putInt(INDEX_TRANSMIT_TIME + 4, fraction.toInt())
- }
-
- fun readTimestamp(index: Int): Long {
- val seconds = buffer.getInt(index).toLong() and 0xFFFFFFFFL
- val fraction = buffer.getInt(index + 4).toLong() and 0xFFFFFFFFL
- return ((seconds - NTP_OFFSET) * 1000) + (fraction * 1000) / 0x100000000L
- }
- }
- }
-}
\ No newline at end of file
diff --git a/core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt b/core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt
deleted file mode 100644
index e2db886..0000000
--- a/core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package dev.sanmer.otp
-
-import android.net.Uri
-
-class OtpUri private constructor() {
- constructor(
- issuer: String,
- name: String,
- type: String,
- secret: String,
- algorithm: String?,
- digits: Int?,
- counter: Long? = null,
- period: Long? = null
- ) : this() {
- this.issuer = issuer
- this.name = name
- this.type = type
- this.secret = secret
- this.algorithm = algorithm
- this.digits = digits
- this.counter = counter
- this.period = period
- }
-
- var issuer: String = ""
- private set
-
- var name: String = ""
- private set
-
- var type: String = ""
- private set
-
- var secret: String = ""
- private set
-
- var algorithm: String? = null
- private set
-
- var digits: Int? = null
- private set
-
- var counter: Long? = null
- private set
-
- var period: Long? = null
- private set
-
- private fun fromUri(uri: Uri): OtpUri {
- require(uri.isOtpUri()) { "Expected scheme = $SCHEME" }
- type = requireNotNull(uri.host) { "Expected type" }
- secret = requireNotNull(uri.getQueryParameter(Query.SECRET)) { "Expected secret" }
- algorithm = uri.getQueryParameter(Query.ALGORITHM)
- digits = uri.getQueryParameter(Query.DIGITS)?.let(String::toInt)
- counter = uri.getQueryParameter(Query.COUNTER)?.let(String::toLong)
- period = uri.getQueryParameter(Query.PERIOD)?.let(String::toLong)
-
- val label = uri.path?.substring(1).orEmpty()
- if (label.contains(":")) {
- val values = label.split(":".toRegex(), limit = 2)
- issuer = values[0]
- name = values[1]
- } else {
- issuer = uri.getQueryParameter(Query.ISSUER).orEmpty()
- name = label
- }
-
- return this
- }
-
- private fun toUri(): Uri {
- val builder = Uri.Builder()
- builder.scheme(SCHEME)
- builder.authority(type)
- builder.appendQueryParameter(Query.SECRET, secret)
- algorithm?.let { builder.appendQueryParameter(Query.ALGORITHM, it) }
- digits?.let { builder.appendQueryParameter(Query.DIGITS, it.toString()) }
- period?.let { builder.appendQueryParameter(Query.PERIOD, it.toString()) }
- counter?.let { builder.appendQueryParameter(Query.COUNTER, it.toString()) }
-
- if (issuer.isNotBlank()) {
- builder.appendQueryParameter(Query.ISSUER, issuer)
- builder.path(if (name.isNotBlank()) "${issuer}:${name}" else issuer)
- } else if (name.isNotBlank()) {
- builder.path(name)
- }
-
- return builder.build()
- }
-
- override fun toString(): String {
- return toUri().toString()
- }
-
- internal object Query {
- const val SECRET = "secret"
- const val ALGORITHM = "algorithm"
- const val DIGITS = "digits"
- const val PERIOD = "period"
- const val COUNTER = "counter"
- const val ISSUER = "issuer"
- }
-
- companion object Default {
- const val SCHEME = "otpauth"
-
- fun parse(uriString: String) = Uri.parse(uriString).let(OtpUri()::fromUri)
-
- fun String.toOtpUri() = parse(this)
-
- fun String.isOtpUri() = startsWith(SCHEME)
-
- fun Uri.isOtpUri() = scheme == SCHEME
- }
-}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e6f2619..edcd5f6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,12 +12,13 @@ androidxCoreSplashscreen = "1.2.0"
androidxDataStore = "1.2.1"
androidxLifecycle = "2.11.0"
androidxNavigation3 = "1.1.5"
-androidxRoom = "2.8.4"
+androidxRoom = "3.0.1"
encoding = "2.6.0"
googleZxing = "3.5.4"
koin = "4.2.2"
kotlin = "2.4.10"
kotlinxCoroutines = "1.11.0"
+kotlinxDatetime = "0.8.0"
kotlinxSerialization = "1.11.0"
ksp = "2.3.7"
@@ -47,9 +48,8 @@ androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmode
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycle" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" }
-androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidxRoom" }
-androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidxRoom" }
-androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidxRoom" }
+androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "androidxRoom" }
+androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "androidxRoom" }
encoding-base32 = { module = "io.matthewnelson.encoding:base32", version.ref = "encoding" }
encoding-base64 = { module = "io.matthewnelson.encoding:base64", version.ref = "encoding" }
google-zxing = { module = "com.google.zxing:core", version.ref = "googleZxing" }
@@ -57,6 +57,7 @@ koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" }
@@ -64,10 +65,10 @@ kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-seria
self-application = { id = "self.application", version = "unspecified" }
self-library = { id = "self.library", version = "unspecified" }
self-compose = { id = "self.compose", version = "unspecified" }
-self-room = { id = "self.room", version = "unspecified" }
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
+androidx-room = { id = "androidx.room3", version.ref = "androidxRoom" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/logo/afphoto/aliyun.afphoto b/logo/afphoto/aliyun.afphoto
deleted file mode 100644
index 23c9c36..0000000
Binary files a/logo/afphoto/aliyun.afphoto and /dev/null differ
diff --git a/logo/afphoto/apple.afphoto b/logo/afphoto/apple.afphoto
deleted file mode 100644
index 49601dd..0000000
Binary files a/logo/afphoto/apple.afphoto and /dev/null differ
diff --git a/logo/afphoto/aws.afphoto b/logo/afphoto/aws.afphoto
deleted file mode 100644
index 1d47cfb..0000000
Binary files a/logo/afphoto/aws.afphoto and /dev/null differ
diff --git a/logo/afphoto/azure.afphoto b/logo/afphoto/azure.afphoto
deleted file mode 100644
index 7128791..0000000
Binary files a/logo/afphoto/azure.afphoto and /dev/null differ
diff --git a/logo/afphoto/binance.afphoto b/logo/afphoto/binance.afphoto
deleted file mode 100644
index 85805ce..0000000
Binary files a/logo/afphoto/binance.afphoto and /dev/null differ
diff --git a/logo/afphoto/bybit.afphoto b/logo/afphoto/bybit.afphoto
deleted file mode 100644
index d174e27..0000000
Binary files a/logo/afphoto/bybit.afphoto and /dev/null differ
diff --git a/logo/afphoto/cloudflare.afphoto b/logo/afphoto/cloudflare.afphoto
deleted file mode 100644
index 07e6dec..0000000
Binary files a/logo/afphoto/cloudflare.afphoto and /dev/null differ
diff --git a/logo/afphoto/coinbase.afphoto b/logo/afphoto/coinbase.afphoto
deleted file mode 100644
index 8d7dd4c..0000000
Binary files a/logo/afphoto/coinbase.afphoto and /dev/null differ
diff --git a/logo/afphoto/crowdin.afphoto b/logo/afphoto/crowdin.afphoto
deleted file mode 100644
index 9f1a117..0000000
Binary files a/logo/afphoto/crowdin.afphoto and /dev/null differ
diff --git a/logo/afphoto/digitalocean.afphoto b/logo/afphoto/digitalocean.afphoto
deleted file mode 100644
index dbb563b..0000000
Binary files a/logo/afphoto/digitalocean.afphoto and /dev/null differ
diff --git a/logo/afphoto/discord.afphoto b/logo/afphoto/discord.afphoto
deleted file mode 100644
index 5bb29af..0000000
Binary files a/logo/afphoto/discord.afphoto and /dev/null differ
diff --git a/logo/afphoto/facebook.afphoto b/logo/afphoto/facebook.afphoto
deleted file mode 100644
index 842e44d..0000000
Binary files a/logo/afphoto/facebook.afphoto and /dev/null differ
diff --git a/logo/afphoto/gitea.afphoto b/logo/afphoto/gitea.afphoto
deleted file mode 100644
index 5aa49b5..0000000
Binary files a/logo/afphoto/gitea.afphoto and /dev/null differ
diff --git a/logo/afphoto/github.afphoto b/logo/afphoto/github.afphoto
deleted file mode 100644
index 1059517..0000000
Binary files a/logo/afphoto/github.afphoto and /dev/null differ
diff --git a/logo/afphoto/gitlab.afphoto b/logo/afphoto/gitlab.afphoto
deleted file mode 100644
index 82d3737..0000000
Binary files a/logo/afphoto/gitlab.afphoto and /dev/null differ
diff --git a/logo/afphoto/google.afphoto b/logo/afphoto/google.afphoto
deleted file mode 100644
index 8ca665c..0000000
Binary files a/logo/afphoto/google.afphoto and /dev/null differ
diff --git a/logo/afphoto/instagram.afphoto b/logo/afphoto/instagram.afphoto
deleted file mode 100644
index b732fdc..0000000
Binary files a/logo/afphoto/instagram.afphoto and /dev/null differ
diff --git a/logo/afphoto/jetbrains.afphoto b/logo/afphoto/jetbrains.afphoto
deleted file mode 100644
index f938ce1..0000000
Binary files a/logo/afphoto/jetbrains.afphoto and /dev/null differ
diff --git a/logo/afphoto/lark.afphoto b/logo/afphoto/lark.afphoto
deleted file mode 100644
index 9aa4e8c..0000000
Binary files a/logo/afphoto/lark.afphoto and /dev/null differ
diff --git a/logo/afphoto/mega.afphoto b/logo/afphoto/mega.afphoto
deleted file mode 100644
index 160d8a1..0000000
Binary files a/logo/afphoto/mega.afphoto and /dev/null differ
diff --git a/logo/afphoto/meta.afphoto b/logo/afphoto/meta.afphoto
deleted file mode 100644
index 0f2031f..0000000
Binary files a/logo/afphoto/meta.afphoto and /dev/null differ
diff --git a/logo/afphoto/microsoft.afphoto b/logo/afphoto/microsoft.afphoto
deleted file mode 100644
index e1a7068..0000000
Binary files a/logo/afphoto/microsoft.afphoto and /dev/null differ
diff --git a/logo/afphoto/okx.afphoto b/logo/afphoto/okx.afphoto
deleted file mode 100644
index 55e0dbe..0000000
Binary files a/logo/afphoto/okx.afphoto and /dev/null differ
diff --git a/logo/afphoto/onlyfans.afphoto b/logo/afphoto/onlyfans.afphoto
deleted file mode 100644
index cb11ee4..0000000
Binary files a/logo/afphoto/onlyfans.afphoto and /dev/null differ
diff --git a/logo/afphoto/openai.afphoto b/logo/afphoto/openai.afphoto
deleted file mode 100644
index 31ca347..0000000
Binary files a/logo/afphoto/openai.afphoto and /dev/null differ
diff --git a/logo/afphoto/orcid.afphoto b/logo/afphoto/orcid.afphoto
deleted file mode 100644
index cdc5cf8..0000000
Binary files a/logo/afphoto/orcid.afphoto and /dev/null differ
diff --git a/logo/afphoto/patreon.afphoto b/logo/afphoto/patreon.afphoto
deleted file mode 100644
index a4813fb..0000000
Binary files a/logo/afphoto/patreon.afphoto and /dev/null differ
diff --git a/logo/afphoto/paypal.afphoto b/logo/afphoto/paypal.afphoto
deleted file mode 100644
index ecf212a..0000000
Binary files a/logo/afphoto/paypal.afphoto and /dev/null differ
diff --git a/logo/afphoto/pixiv.afphoto b/logo/afphoto/pixiv.afphoto
deleted file mode 100644
index 3b748ab..0000000
Binary files a/logo/afphoto/pixiv.afphoto and /dev/null differ
diff --git a/logo/afphoto/tencent-cloud.afphoto b/logo/afphoto/tencent-cloud.afphoto
deleted file mode 100644
index 7d51888..0000000
Binary files a/logo/afphoto/tencent-cloud.afphoto and /dev/null differ
diff --git a/logo/afphoto/wise.afphoto b/logo/afphoto/wise.afphoto
deleted file mode 100644
index 0771177..0000000
Binary files a/logo/afphoto/wise.afphoto and /dev/null differ
diff --git a/logo/afphoto/x.afphoto b/logo/afphoto/x.afphoto
deleted file mode 100644
index b90f029..0000000
Binary files a/logo/afphoto/x.afphoto and /dev/null differ
diff --git a/logo/src/main/kotlin/dev/sanmer/logo/Brand.kt b/logo/src/main/kotlin/dev/sanmer/logo/Brand.kt
deleted file mode 100644
index 01ff592..0000000
--- a/logo/src/main/kotlin/dev/sanmer/logo/Brand.kt
+++ /dev/null
@@ -1,138 +0,0 @@
-package dev.sanmer.logo
-
-import androidx.annotation.DrawableRes
-
-@Suppress("SpellCheckingInspection")
-enum class Brand(
- @field:DrawableRes val res: Int,
- internal val regex: String
-) {
- Aliyun(
- res = R.drawable.brand_aliyun,
- regex = "(?i)Aliyun|Alibaba\\s*Cloud"
- ),
- Apple(
- res = R.drawable.brand_apple,
- regex = "(?i)Apple"
- ),
- AWS(
- res = R.drawable.brand_aws,
- regex = "(?i)AWS|Amazon\\s*Web\\s*Service"
- ),
- Azure(
- res = R.drawable.brand_azure,
- regex = "(?i)Azure"
- ),
- Binance(
- res = R.drawable.brand_binance,
- regex = "(?i)Binance"
- ),
- Bybit(
- res = R.drawable.brand_bybit,
- regex = "(?i)Bybit"
- ),
- Cloudflare(
- res = R.drawable.brand_cloudflare,
- regex = "(?i)Cloudflare"
- ),
- Coinbase(
- res = R.drawable.brand_coinbase,
- regex = "(?i)Coinbase"
- ),
- Crowdin(
- res = R.drawable.brand_crowdin,
- regex = "(?i)Crowdin"
- ),
- DigitalOcean(
- res = R.drawable.brand_digitalocean,
- regex = "(?i)Digital\\s*Ocean"
- ),
- Discord(
- res = R.drawable.brand_discord,
- regex = "(?i)Discord"
- ),
- Facebook(
- res = R.drawable.brand_facebook,
- regex = "(?i)Facebook"
- ),
- Gitea(
- res = R.drawable.brand_gitea,
- regex = "(?i)Gitea"
- ),
- Github(
- res = R.drawable.brand_github,
- regex = "(?i)GitHub"
- ),
- GitLab(
- res = R.drawable.brand_gitlab,
- regex = "(?i)GitLab"
- ),
- Google(
- res = R.drawable.brand_google,
- regex = "(?i)Google"
- ),
- Instagram(
- res = R.drawable.brand_instagram,
- regex = "(?i)Instagram"
- ),
- JetBrains(
- res = R.drawable.brand_jetbrains,
- regex = "(?i)JetBrains"
- ),
- Lark(
- res = R.drawable.brand_lark,
- regex = "(?i)Lark|Feishu"
- ),
- Mega(
- res = R.drawable.brand_mega,
- regex = "(?i)Mega"
- ),
- Meta(
- res = R.drawable.brand_meta,
- regex = "(?i)Meta"
- ),
- Microsoft(
- res = R.drawable.brand_microsoft,
- regex = "(?i)Microsoft"
- ),
- OKX(
- res = R.drawable.brand_okx,
- regex = "(?i)OKX"
- ),
- OnlyFans(
- res = R.drawable.brand_onlyfans,
- regex = "(?i)OnlyFans"
- ),
- OpenAI(
- res = R.drawable.brand_openai,
- regex = "(?i)OpenAI"
- ),
- ORCID(
- res = R.drawable.brand_orcid,
- regex = "(?i)ORCID"
- ),
- Patreon(
- res = R.drawable.brand_patreon,
- regex = "(?i)Patreon"
- ),
- PayPal(
- res = R.drawable.brand_paypal,
- regex = "(?i)PayPal"
- ),
- Pixiv(
- res = R.drawable.brand_pixiv,
- regex = "(?i)Pixiv"
- ),
- TencentCloud(
- res = R.drawable.brand_tencent_cloud,
- regex = "(?i)Tencent\\s*Cloud"
- ),
- Wise(
- res = R.drawable.brand_wise,
- regex = "(?i)Wise"
- ),
- X(
- res = R.drawable.brand_x,
- regex = "(?i)X|Twitter"
- )
-}
\ No newline at end of file
diff --git a/logo/src/main/kotlin/dev/sanmer/logo/Logo.kt b/logo/src/main/kotlin/dev/sanmer/logo/Logo.kt
deleted file mode 100644
index a0db010..0000000
--- a/logo/src/main/kotlin/dev/sanmer/logo/Logo.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package dev.sanmer.logo
-
-import androidx.annotation.DrawableRes
-
-data class Logo(
- @field:DrawableRes val res: Int,
- val name: String,
- val refillable: Boolean
-) {
- companion object Default {
- private val logos = hashMapOf()
-
- private fun getOrNull(name: String): Logo? {
- if (logos.contains(name)) return logos[name]
-
- val brand = Brand.entries.firstOrNull {
- it.regex.toRegex().matches(name)
- }
- if (brand != null) {
- return Logo(
- res = brand.res,
- name = brand.name,
- refillable = false
- ).also { logos[name] = it }
- }
-
- val normal = Normal.entries.firstOrNull {
- it.regex.toRegex().containsMatchIn(name)
- }
- if (normal != null) {
- return Logo(
- res = normal.res,
- name = normal.name,
- refillable = true
- ).also { logos[name] = it }
- }
-
- logos[name] = null
- return null
- }
-
- fun getOrDefault(name: String) =
- getOrNull(name) ?: Logo(
- res = R.drawable.normal_default,
- name = name,
- refillable = true
- )
-
- fun getOr(name: String, default: () -> Logo) =
- getOrNull(name) ?: default()
- }
-}
diff --git a/logo/src/main/kotlin/dev/sanmer/logo/Normal.kt b/logo/src/main/kotlin/dev/sanmer/logo/Normal.kt
deleted file mode 100644
index bd20200..0000000
--- a/logo/src/main/kotlin/dev/sanmer/logo/Normal.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package dev.sanmer.logo
-
-import androidx.annotation.DrawableRes
-
-enum class Normal(
- @field:DrawableRes val res: Int,
- internal val regex: String
-) {
- Cloud(
- res = R.drawable.normal_cloud,
- regex = "(?i)Cloud"
- )
-}
\ No newline at end of file
diff --git a/logo/src/main/res/drawable-night/brand_patreon.xml b/logo/src/main/res/drawable-night/brand_patreon.xml
deleted file mode 100644
index 9af431f..0000000
--- a/logo/src/main/res/drawable-night/brand_patreon.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/logo/src/main/res/drawable/brand_aliyun.xml b/logo/src/main/res/drawable/brand_aliyun.xml
deleted file mode 100644
index e6084a6..0000000
--- a/logo/src/main/res/drawable/brand_aliyun.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_cloudflare.xml b/logo/src/main/res/drawable/brand_cloudflare.xml
deleted file mode 100644
index 7cbc35d..0000000
--- a/logo/src/main/res/drawable/brand_cloudflare.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_coinbase.xml b/logo/src/main/res/drawable/brand_coinbase.xml
deleted file mode 100644
index 185e92c..0000000
--- a/logo/src/main/res/drawable/brand_coinbase.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/logo/src/main/res/drawable/brand_crowdin.xml b/logo/src/main/res/drawable/brand_crowdin.xml
deleted file mode 100644
index e786ebd..0000000
--- a/logo/src/main/res/drawable/brand_crowdin.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_facebook.xml b/logo/src/main/res/drawable/brand_facebook.xml
deleted file mode 100644
index 0f8e5b4..0000000
--- a/logo/src/main/res/drawable/brand_facebook.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_instagram.xml b/logo/src/main/res/drawable/brand_instagram.xml
deleted file mode 100644
index 3830773..0000000
--- a/logo/src/main/res/drawable/brand_instagram.xml
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_lark.xml b/logo/src/main/res/drawable/brand_lark.xml
deleted file mode 100644
index 8e6f6ca..0000000
--- a/logo/src/main/res/drawable/brand_lark.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_mega.xml b/logo/src/main/res/drawable/brand_mega.xml
deleted file mode 100644
index a5c9430..0000000
--- a/logo/src/main/res/drawable/brand_mega.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_meta.xml b/logo/src/main/res/drawable/brand_meta.xml
deleted file mode 100644
index b91df3f..0000000
--- a/logo/src/main/res/drawable/brand_meta.xml
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/logo/src/main/res/drawable/brand_patreon.xml b/logo/src/main/res/drawable/brand_patreon.xml
deleted file mode 100644
index 20114c0..0000000
--- a/logo/src/main/res/drawable/brand_patreon.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/logo/src/main/res/drawable/brand_paypal.xml b/logo/src/main/res/drawable/brand_paypal.xml
deleted file mode 100644
index acb6e8e..0000000
--- a/logo/src/main/res/drawable/brand_paypal.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
diff --git a/logo/src/main/res/drawable/normal_cloud.xml b/logo/src/main/res/drawable/normal_cloud.xml
deleted file mode 100644
index 6a94d4c..0000000
--- a/logo/src/main/res/drawable/normal_cloud.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/logo/svg/aliyun.svg b/logo/svg/aliyun.svg
deleted file mode 100644
index 263c7aa..0000000
--- a/logo/svg/aliyun.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/logo/svg/apple.svg b/logo/svg/apple.svg
deleted file mode 100644
index 6ea7f28..0000000
--- a/logo/svg/apple.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/logo/svg/aws.svg b/logo/svg/aws.svg
deleted file mode 100644
index 000d149..0000000
--- a/logo/svg/aws.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/logo/svg/azure.svg b/logo/svg/azure.svg
deleted file mode 100644
index 9a13a0f..0000000
--- a/logo/svg/azure.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
diff --git a/logo/svg/binance.svg b/logo/svg/binance.svg
deleted file mode 100644
index 969df9e..0000000
--- a/logo/svg/binance.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/logo/svg/bybit.svg b/logo/svg/bybit.svg
deleted file mode 100644
index e057c5e..0000000
--- a/logo/svg/bybit.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/logo/svg/cloudflare.svg b/logo/svg/cloudflare.svg
deleted file mode 100644
index 5d67a7c..0000000
--- a/logo/svg/cloudflare.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
diff --git a/logo/svg/coinbase.svg b/logo/svg/coinbase.svg
deleted file mode 100644
index ff85275..0000000
--- a/logo/svg/coinbase.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/logo/svg/crowdin.svg b/logo/svg/crowdin.svg
deleted file mode 100644
index 58e9282..0000000
--- a/logo/svg/crowdin.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/logo/svg/digitalocean.svg b/logo/svg/digitalocean.svg
deleted file mode 100644
index 61f6fc7..0000000
--- a/logo/svg/digitalocean.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
diff --git a/logo/svg/discord.svg b/logo/svg/discord.svg
deleted file mode 100644
index 2da100e..0000000
--- a/logo/svg/discord.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/logo/svg/facebook.svg b/logo/svg/facebook.svg
deleted file mode 100644
index 7ccc322..0000000
--- a/logo/svg/facebook.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/logo/svg/gitea.svg b/logo/svg/gitea.svg
deleted file mode 100644
index 4732506..0000000
--- a/logo/svg/gitea.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
diff --git a/logo/svg/github.svg b/logo/svg/github.svg
deleted file mode 100644
index 602bbd1..0000000
--- a/logo/svg/github.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/logo/svg/gitlab.svg b/logo/svg/gitlab.svg
deleted file mode 100644
index 9e0a44f..0000000
--- a/logo/svg/gitlab.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
diff --git a/logo/svg/google.svg b/logo/svg/google.svg
deleted file mode 100644
index c731559..0000000
--- a/logo/svg/google.svg
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
diff --git a/logo/svg/instagram.svg b/logo/svg/instagram.svg
deleted file mode 100644
index 59b967e..0000000
--- a/logo/svg/instagram.svg
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
diff --git a/logo/svg/jetbrains.svg b/logo/svg/jetbrains.svg
deleted file mode 100644
index c595b7c..0000000
--- a/logo/svg/jetbrains.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/logo/svg/lark.svg b/logo/svg/lark.svg
deleted file mode 100644
index 05e3abc..0000000
--- a/logo/svg/lark.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
diff --git a/logo/svg/mega.svg b/logo/svg/mega.svg
deleted file mode 100644
index e53a8a7..0000000
--- a/logo/svg/mega.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/logo/svg/meta.svg b/logo/svg/meta.svg
deleted file mode 100644
index 0608944..0000000
--- a/logo/svg/meta.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
diff --git a/logo/svg/microsoft.svg b/logo/svg/microsoft.svg
deleted file mode 100644
index 19d0465..0000000
--- a/logo/svg/microsoft.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
diff --git a/logo/svg/okx.svg b/logo/svg/okx.svg
deleted file mode 100644
index 28b4458..0000000
--- a/logo/svg/okx.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
diff --git a/logo/svg/onlyfans.svg b/logo/svg/onlyfans.svg
deleted file mode 100644
index 35a2a11..0000000
--- a/logo/svg/onlyfans.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/logo/svg/openai.svg b/logo/svg/openai.svg
deleted file mode 100644
index 5aa001c..0000000
--- a/logo/svg/openai.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/logo/svg/orcid.svg b/logo/svg/orcid.svg
deleted file mode 100644
index 62dc57d..0000000
--- a/logo/svg/orcid.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/logo/svg/patreon.svg b/logo/svg/patreon.svg
deleted file mode 100644
index db72c8d..0000000
--- a/logo/svg/patreon.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/logo/svg/paypal.svg b/logo/svg/paypal.svg
deleted file mode 100644
index e63cdb5..0000000
--- a/logo/svg/paypal.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
diff --git a/logo/svg/pixiv.svg b/logo/svg/pixiv.svg
deleted file mode 100644
index fe5c62c..0000000
--- a/logo/svg/pixiv.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
diff --git a/logo/svg/tencent-cloud.svg b/logo/svg/tencent-cloud.svg
deleted file mode 100644
index 4ead449..0000000
--- a/logo/svg/tencent-cloud.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/logo/svg/wise.svg b/logo/svg/wise.svg
deleted file mode 100644
index f096210..0000000
--- a/logo/svg/wise.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/logo/svg/x.svg b/logo/svg/x.svg
deleted file mode 100644
index 7bacac0..0000000
--- a/logo/svg/x.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 963f28a..d984b4c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -28,4 +28,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Authenticator"
-include(":core", ":logo", ":app")
+include(":brand", ":core", ":app")