├── .github ├── dependabot.yaml └── workflows │ └── android.yaml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── dev.sanmer.authenticator.database.AppDatabase │ │ ├── 1.json │ │ └── 2.json └── src │ ├── debug │ └── res │ │ └── drawable │ │ ├── launcher_foreground.xml │ │ └── launcher_splash.xml │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── dev │ │ └── sanmer │ │ ├── authenticator │ │ ├── App.kt │ │ ├── Const.kt │ │ ├── compat │ │ │ └── PermissionCompat.kt │ │ ├── database │ │ │ ├── AppDatabase.kt │ │ │ ├── dao │ │ │ │ └── TotpDao.kt │ │ │ └── entity │ │ │ │ └── TotpEntity.kt │ │ ├── datastore │ │ │ ├── PreferenceSerializer.kt │ │ │ └── model │ │ │ │ ├── Ntp.kt │ │ │ │ └── Preference.kt │ │ ├── ktx │ │ │ ├── ContextExt.kt │ │ │ ├── LocaleExt.kt │ │ │ ├── LocaleListCompatExt.kt │ │ │ └── StringExt.kt │ │ ├── model │ │ │ ├── AuthType.kt │ │ │ ├── impl │ │ │ │ └── TotpImpl.kt │ │ │ └── serializer │ │ │ │ ├── AuthJson.kt │ │ │ │ ├── AuthTxt.kt │ │ │ │ └── TotpAuth.kt │ │ ├── repository │ │ │ ├── DbRepository.kt │ │ │ ├── PreferenceRepository.kt │ │ │ ├── SecureRepository.kt │ │ │ └── TimeRepository.kt │ │ ├── ui │ │ │ ├── AuthorizeActivity.kt │ │ │ ├── CryptoActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── component │ │ │ │ ├── DropdownMenu.kt │ │ │ │ ├── LabelText.kt │ │ │ │ ├── Logo.kt │ │ │ │ ├── PageIndicator.kt │ │ │ │ └── Swipe.kt │ │ │ ├── ktx │ │ │ │ ├── ClipboardExt.kt │ │ │ │ ├── LazyListStateExt.kt │ │ │ │ ├── ModifierExt.kt │ │ │ │ ├── NavControllerExt.kt │ │ │ │ ├── PaddingValuesExt.kt │ │ │ │ └── ShapeExt.kt │ │ │ ├── main │ │ │ │ ├── LockScreen.kt │ │ │ │ └── MainScreen.kt │ │ │ ├── provider │ │ │ │ └── LocalPreference.kt │ │ │ ├── screens │ │ │ │ ├── authorize │ │ │ │ │ ├── AuthorizeScreen.kt │ │ │ │ │ └── component │ │ │ │ │ │ ├── ChangePasswordItem.kt │ │ │ │ │ │ ├── EnterPasswordItem.kt │ │ │ │ │ │ └── PasswordTextField.kt │ │ │ │ ├── crypto │ │ │ │ │ └── CryptoScreen.kt │ │ │ │ ├── edit │ │ │ │ │ ├── EditScreen.kt │ │ │ │ │ └── component │ │ │ │ │ │ ├── DigitsAndPeriodItem.kt │ │ │ │ │ │ ├── SecretItem.kt │ │ │ │ │ │ ├── TextFieldContent.kt │ │ │ │ │ │ ├── TextFieldItem.kt │ │ │ │ │ │ └── TypeAndHashItem.kt │ │ │ │ ├── encode │ │ │ │ │ └── EncodeScreen.kt │ │ │ │ ├── home │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ └── component │ │ │ │ │ │ ├── AuthItem.kt │ │ │ │ │ │ └── AuthList.kt │ │ │ │ ├── ntp │ │ │ │ │ ├── NtpScreen.kt │ │ │ │ │ └── component │ │ │ │ │ │ ├── NtpItem.kt │ │ │ │ │ │ └── NtpList.kt │ │ │ │ ├── scan │ │ │ │ │ └── ScanScreen.kt │ │ │ │ ├── security │ │ │ │ │ ├── SecurityScreen.kt │ │ │ │ │ └── component │ │ │ │ │ │ └── SecurityItem.kt │ │ │ │ ├── settings │ │ │ │ │ ├── SettingsScreen.kt │ │ │ │ │ └── component │ │ │ │ │ │ ├── DatabaseItem.kt │ │ │ │ │ │ ├── PreferenceItem.kt │ │ │ │ │ │ ├── SettingItem.kt │ │ │ │ │ │ ├── TokenItem.kt │ │ │ │ │ │ └── ToolItem.kt │ │ │ │ └── trash │ │ │ │ │ ├── TrashScreen.kt │ │ │ │ │ └── component │ │ │ │ │ ├── AuthItem.kt │ │ │ │ │ └── AuthList.kt │ │ │ └── theme │ │ │ │ ├── Shape.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ └── viewmodel │ │ │ ├── AuthorizeViewModel.kt │ │ │ ├── CryptoViewModel.kt │ │ │ ├── EditViewModel.kt │ │ │ ├── EncodeViewModel.kt │ │ │ ├── HomeViewModel.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── NtpViewModel.kt │ │ │ ├── ScanViewModel.kt │ │ │ ├── SettingsViewModel.kt │ │ │ └── TrashViewModel.kt │ │ └── crypto │ │ └── BiometricKey.kt │ └── res │ ├── drawable │ ├── a_b.xml │ ├── alert_triangle.xml │ ├── arrow_bar_down.xml │ ├── arrow_bar_up.xml │ ├── arrow_left.xml │ ├── brand_github_2.xml │ ├── camera_off.xml │ ├── caret_down.xml │ ├── caret_up.xml │ ├── check.xml │ ├── circle_check_filled.xml │ ├── clipboard_text.xml │ ├── database.xml │ ├── database_export.xml │ ├── database_import.xml │ ├── device_floppy.xml │ ├── edit.xml │ ├── eye.xml │ ├── eye_closed.xml │ ├── file_export.xml │ ├── file_import.xml │ ├── fingerprint.xml │ ├── key.xml │ ├── launcher_foreground.xml │ ├── launcher_splash.xml │ ├── lock.xml │ ├── lock_off.xml │ ├── lock_open.xml │ ├── math_function.xml │ ├── mood_heart.xml │ ├── pencil_plus.xml │ ├── photo.xml │ ├── qrcode.xml │ ├── refresh.xml │ ├── restore.xml │ ├── scan.xml │ ├── search.xml │ ├── settings_2.xml │ ├── shield.xml │ ├── shield_off.xml │ ├── timezone.xml │ ├── tool.xml │ ├── trash.xml │ ├── trash_x.xml │ ├── user.xml │ └── x.xml │ ├── mipmap-anydpi-v26 │ └── launcher.xml │ ├── values-night-v31 │ └── colors.xml │ ├── values-night │ ├── colors.xml │ └── themes.xml │ ├── values-v31 │ └── colors.xml │ ├── values-zh-rCN │ └── strings.xml │ ├── values │ ├── colors.xml │ ├── colors_material.xml │ ├── strings.xml │ ├── strings_untranslatable.xml │ └── themes.xml │ └── xml │ └── locales_config.xml ├── build-logic ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ ├── ApplicationConventionPlugin.kt │ ├── ComposeConventionPlugin.kt │ ├── HiltConventionPlugin.kt │ ├── LibraryConventionPlugin.kt │ ├── ProjectExt.kt │ └── RoomConventionPlugin.kt ├── build.gradle.kts ├── core ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── dev │ └── sanmer │ ├── crypto │ ├── Crypto.kt │ ├── PasswordKey.kt │ └── SessionKey.kt │ ├── encoding │ ├── Base32.kt │ └── Base64.kt │ ├── ntp │ └── NtpServer.kt │ ├── otp │ ├── ByteArray.kt │ ├── HOTP.kt │ ├── OtpUri.kt │ └── TOTP.kt │ └── qrcode │ └── QRCode.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── logo ├── afphoto │ ├── aliyun.afphoto │ ├── apple.afphoto │ ├── aws.afphoto │ ├── azure.afphoto │ ├── binance.afphoto │ ├── bybit.afphoto │ ├── cloudflare.afphoto │ ├── coinbase.afphoto │ ├── crowdin.afphoto │ ├── digitalocean.afphoto │ ├── discord.afphoto │ ├── facebook.afphoto │ ├── gitea.afphoto │ ├── github.afphoto │ ├── gitlab.afphoto │ ├── google.afphoto │ ├── instagram.afphoto │ ├── jetbrains.afphoto │ ├── lark.afphoto │ ├── mega.afphoto │ ├── meta.afphoto │ ├── microsoft.afphoto │ ├── okx.afphoto │ ├── onlyfans.afphoto │ ├── openai.afphoto │ ├── orcid.afphoto │ ├── patreon.afphoto │ ├── paypal.afphoto │ ├── pixiv.afphoto │ ├── tencent-cloud.afphoto │ ├── wise.afphoto │ └── x.afphoto ├── build.gradle.kts ├── src │ └── main │ │ ├── kotlin │ │ └── dev │ │ │ └── sanmer │ │ │ └── logo │ │ │ ├── Brand.kt │ │ │ ├── Logo.kt │ │ │ └── Normal.kt │ │ └── res │ │ ├── drawable-night │ │ ├── brand_apple.xml │ │ ├── brand_aws.xml │ │ ├── brand_bybit.xml │ │ ├── brand_github.xml │ │ ├── brand_okx.xml │ │ ├── brand_openai.xml │ │ ├── brand_patreon.xml │ │ └── brand_x.xml │ │ └── drawable │ │ ├── brand_aliyun.xml │ │ ├── brand_apple.xml │ │ ├── brand_aws.xml │ │ ├── brand_azure.xml │ │ ├── brand_binance.xml │ │ ├── brand_bybit.xml │ │ ├── brand_cloudflare.xml │ │ ├── brand_coinbase.xml │ │ ├── brand_crowdin.xml │ │ ├── brand_digitalocean.xml │ │ ├── brand_discord.xml │ │ ├── brand_facebook.xml │ │ ├── brand_gitea.xml │ │ ├── brand_github.xml │ │ ├── brand_gitlab.xml │ │ ├── brand_google.xml │ │ ├── brand_instagram.xml │ │ ├── brand_jetbrains.xml │ │ ├── brand_lark.xml │ │ ├── brand_mega.xml │ │ ├── brand_meta.xml │ │ ├── brand_microsoft.xml │ │ ├── brand_okx.xml │ │ ├── brand_onlyfans.xml │ │ ├── brand_openai.xml │ │ ├── brand_orcid.xml │ │ ├── brand_patreon.xml │ │ ├── brand_paypal.xml │ │ ├── brand_pixiv.xml │ │ ├── brand_tencent_cloud.xml │ │ ├── brand_wise.xml │ │ ├── brand_x.xml │ │ ├── normal_cloud.xml │ │ └── normal_default.xml └── svg │ ├── aliyun.svg │ ├── apple.svg │ ├── aws.svg │ ├── azure.svg │ ├── binance.svg │ ├── bybit.svg │ ├── cloudflare.svg │ ├── coinbase.svg │ ├── crowdin.svg │ ├── digitalocean.svg │ ├── discord.svg │ ├── facebook.svg │ ├── gitea.svg │ ├── github.svg │ ├── gitlab.svg │ ├── google.svg │ ├── instagram.svg │ ├── jetbrains.svg │ ├── lark.svg │ ├── mega.svg │ ├── meta.svg │ ├── microsoft.svg │ ├── okx.svg │ ├── onlyfans.svg │ ├── openai.svg │ ├── orcid.svg │ ├── patreon.svg │ ├── paypal.svg │ ├── pixiv.svg │ ├── tencent-cloud.svg │ ├── wise.svg │ └── x.svg └── settings.gradle.kts /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "21:00" 8 | labels: [ "github_actions" ] 9 | 10 | - package-ecosystem: gradle 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | time: "21:00" 15 | labels: [ "dependencies" ] 16 | registries: "*" 17 | ignore: 18 | - dependency-name: "self.*" 19 | groups: 20 | kotlin-ksp: 21 | patterns: 22 | - "org.jetbrains.kotlin:*" 23 | - "org.jetbrains.kotlin.jvm" 24 | - "com.google.devtools.ksp" 25 | - "com.google.devtools.ksp.gradle.plugin" 26 | 27 | registries: 28 | maven-google: 29 | type: "maven-repository" 30 | url: "https://maven.google.com" 31 | replaces-base: true 32 | -------------------------------------------------------------------------------- /.github/workflows/android.yaml: -------------------------------------------------------------------------------- 1 | name: Android 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up signing key 20 | if: github.ref == 'refs/heads/main' 21 | run: | 22 | if [ ! -z "${{ secrets.KEY_STORE }}" ]; then 23 | echo keyStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> signing.properties 24 | echo keyAlias='${{ secrets.KEY_ALIAS }}' >> signing.properties 25 | echo keyPassword='${{ secrets.KEY_PASSWORD }}' >> signing.properties 26 | echo keyStore='${{ github.workspace }}/key.jks' >> signing.properties 27 | echo ${{ secrets.KEY_STORE }} | base64 --decode > ${{ github.workspace }}/key.jks 28 | fi 29 | 30 | - name: Set up JDK 31 | uses: actions/setup-java@v4 32 | with: 33 | distribution: 'jetbrains' 34 | java-version: 21 35 | 36 | - name: Set up Gradle 37 | uses: gradle/actions/setup-gradle@v4 38 | 39 | - name: Build with Gradle 40 | run: ./gradlew assembleRelease 41 | 42 | - name: Get release name 43 | if: success() && github.ref == 'refs/heads/main' 44 | id: release-name 45 | run: | 46 | name=`ls app/build/outputs/apk/release/*.apk | awk -F '(/|.apk)' '{print $6}'` && echo "name=${name}" >> $GITHUB_OUTPUT 47 | 48 | - name: Upload apk 49 | if: success() && github.ref == 'refs/heads/main' 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: ${{ steps.release-name.outputs.name }} 53 | path: app/build/outputs/apk/release/*.apk* 54 | 55 | - name: Upload mapping 56 | if: success() && github.ref == 'refs/heads/main' 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: ${{ steps.release-name.outputs.name }}-mapping 60 | path: app/build/outputs/mapping/release 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | .gradle/ 3 | build/ 4 | 5 | # Kotlin 6 | .kotlin 7 | 8 | # Local configuration 9 | local.properties 10 | signing.properties 11 | 12 | # Android Studio 13 | captures/ 14 | release/ 15 | .externalNativeBuild/ 16 | .cxx/ 17 | 18 | # IntelliJ 19 | *.iml 20 | .idea/ 21 | 22 | # Keystore 23 | *.jks 24 | *.keystore 25 | 26 | # MacOS 27 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Authenticator 2 | [![release](https://img.shields.io/github/v/release/SanmerApps/Authenticator?label=release&color=red)](https://github.com/SanmerApps/Authenticator/releases) [![download](https://shields.io/github/downloads/SanmerApps/Authenticator/total?label=download)](https://github.com/SanmerApps/Authenticator/releases/latest) 3 | 4 | ## Supported Versions 5 | Android 13 ~ 15 6 | 7 | ## Credits 8 | - [tabler/tabler-icons](https://github.com/tabler/tabler-icons.git) 9 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -repackageclasses dev.sanmer.authenticator 2 | -------------------------------------------------------------------------------- /app/src/debug/res/drawable/launcher_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/App.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator 2 | 3 | import android.app.Application 4 | import android.util.Log 5 | import androidx.camera.camera2.Camera2Config 6 | import androidx.camera.core.CameraXConfig 7 | import dagger.hilt.android.HiltAndroidApp 8 | import timber.log.Timber 9 | 10 | @HiltAndroidApp 11 | class App : Application(), CameraXConfig.Provider { 12 | init { 13 | Timber.plant(Timber.DebugTree()) 14 | } 15 | 16 | override fun getCameraXConfig() = 17 | CameraXConfig.Builder.fromConfig(Camera2Config.defaultConfig()) 18 | .apply { 19 | if (BuildConfig.DEBUG) { 20 | setMinimumLoggingLevel(Log.DEBUG) 21 | } else { 22 | setMinimumLoggingLevel(Log.INFO) 23 | } 24 | }.build() 25 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/Const.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator 2 | 3 | object Const { 4 | const val GITHUB_URL = "https://github.com/SanmerApps/Authenticator" 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.compat 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager 5 | import androidx.activity.result.ActivityResultRegistryOwner 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.core.content.ContextCompat 8 | import dev.sanmer.authenticator.ktx.findActivity 9 | import java.util.UUID 10 | 11 | object PermissionCompat { 12 | @JvmInline 13 | value class PermissionState( 14 | private val state: Map 15 | ) { 16 | val allGranted get() = state.all { it.value } 17 | } 18 | 19 | fun checkPermissions( 20 | context: Context, 21 | permissions: List 22 | ) = PermissionState( 23 | permissions.associateWith { 24 | ContextCompat.checkSelfPermission( 25 | context, it 26 | ) == PackageManager.PERMISSION_GRANTED 27 | } 28 | ) 29 | 30 | fun checkPermission( 31 | context: Context, 32 | permission: String 33 | ) = checkPermissions( 34 | context = context, 35 | permissions = listOf(permission) 36 | ).allGranted 37 | 38 | fun requestPermissions( 39 | context: Context, 40 | permissions: List, 41 | callback: (PermissionState) -> Unit = {} 42 | ) { 43 | val activity = context.findActivity() 44 | if (activity !is ActivityResultRegistryOwner) return 45 | 46 | val activityResultRegistry = activity.activityResultRegistry 47 | val launcher = activityResultRegistry.register( 48 | key = UUID.randomUUID().toString(), 49 | contract = ActivityResultContracts.RequestMultiplePermissions(), 50 | callback = { callback(PermissionState(it)) } 51 | ) 52 | 53 | launcher.launch(permissions.toTypedArray()) 54 | } 55 | 56 | fun requestPermission( 57 | context: Context, 58 | permission: String, 59 | callback: (Boolean) -> Unit = {} 60 | ) = requestPermissions( 61 | context = context, 62 | permissions = listOf(permission), 63 | callback = { callback(it.allGranted) } 64 | ) 65 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import androidx.room.Update 8 | import dev.sanmer.authenticator.database.entity.TotpEntity 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | @Dao 12 | interface TotpDao { 13 | @Query("SELECT * FROM totp WHERE deletedAt != 0") 14 | suspend fun getAllTrashed(): List 15 | 16 | @Query("SELECT * FROM totp WHERE deletedAt = 0") 17 | fun getAllEnabledAsFlow(): Flow> 18 | 19 | @Query("SELECT * FROM totp WHERE deletedAt != 0") 20 | fun getAllTrashedAsFlow(): Flow> 21 | 22 | @Query("SELECT * FROM totp WHERE id = :id") 23 | fun getByIdAsFlow(id: Long): Flow 24 | 25 | @Query("SELECT * FROM totp") 26 | suspend fun getAll(): List 27 | 28 | @Insert 29 | suspend fun insert(entity: TotpEntity) 30 | 31 | @Insert 32 | suspend fun insert(entities: List) 33 | 34 | @Update 35 | suspend fun update(entity: TotpEntity) 36 | 37 | @Update 38 | suspend fun update(entities: List) 39 | 40 | @Delete 41 | suspend fun delete(entity: TotpEntity) 42 | 43 | @Delete 44 | suspend fun delete(entities: List) 45 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.database.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import dev.sanmer.authenticator.model.serializer.TotpAuth 6 | import dev.sanmer.otp.HOTP 7 | import kotlin.time.Duration.Companion.days 8 | import kotlin.time.Duration.Companion.milliseconds 9 | 10 | @Entity(tableName = "totp") 11 | data class TotpEntity( 12 | @PrimaryKey(autoGenerate = true) 13 | val id: Long = 0, 14 | val deletedAt: Long = 0, 15 | val issuer: String, 16 | val name: String, 17 | val secret: String, 18 | val hash: HOTP.Hash, 19 | val digits: Int, 20 | val period: Long 21 | ) { 22 | val lifetime inline get() = (System.currentTimeMillis() - deletedAt).milliseconds 23 | 24 | val displayName inline get() = "$issuer ($name)" 25 | 26 | constructor( 27 | auth: TotpAuth 28 | ) : this( 29 | issuer = auth.issuer, 30 | name = auth.name, 31 | secret = auth.secret, 32 | hash = auth.hash, 33 | digits = auth.digits, 34 | period = auth.period 35 | ) 36 | 37 | val totp get() = TotpAuth( 38 | issuer = issuer, 39 | name = name, 40 | secret = secret, 41 | hash = hash, 42 | digits = digits, 43 | period = period 44 | ) 45 | 46 | companion object Default { 47 | val LIFETIME_MAX = 7.days 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.datastore 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.CorruptionException 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.core.DataStoreFactory 7 | import androidx.datastore.core.Serializer 8 | import androidx.datastore.dataStoreFile 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import dagger.hilt.components.SingletonComponent 14 | import dev.sanmer.authenticator.datastore.model.Preference 15 | import dev.sanmer.authenticator.ktx.deviceProtectedContext 16 | import kotlinx.serialization.SerializationException 17 | import java.io.InputStream 18 | import java.io.OutputStream 19 | import javax.inject.Inject 20 | import javax.inject.Singleton 21 | 22 | class PreferenceSerializer @Inject constructor() : Serializer { 23 | override val defaultValue = Preference() 24 | 25 | override suspend fun readFrom(input: InputStream) = 26 | try { 27 | Preference.decodeFromStream(input) 28 | } catch (e: SerializationException) { 29 | throw CorruptionException("Failed to read proto", e) 30 | } 31 | 32 | override suspend fun writeTo(t: Preference, output: OutputStream) { 33 | t.encodeToStream(output) 34 | } 35 | 36 | @Module 37 | @InstallIn(SingletonComponent::class) 38 | object Impl { 39 | @Provides 40 | @Singleton 41 | fun dataStore( 42 | @ApplicationContext context: Context, 43 | serializer: PreferenceSerializer 44 | ): DataStore = 45 | DataStoreFactory.create( 46 | serializer = serializer 47 | ) { 48 | context.deviceProtectedContext.dataStoreFile("preference.pb") 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Ntp.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.datastore.model 2 | 3 | enum class Ntp { 4 | Custom, 5 | Alibaba, 6 | Apple, 7 | Amazon, 8 | Cloudflare, 9 | Google, 10 | Meta, 11 | Microsoft, 12 | Tencent; 13 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.datastore.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.decodeFromByteArray 5 | import kotlinx.serialization.encodeToByteArray 6 | import kotlinx.serialization.protobuf.ProtoBuf 7 | import kotlinx.serialization.protobuf.ProtoNumber 8 | import java.io.InputStream 9 | import java.io.OutputStream 10 | 11 | @Serializable 12 | data class Preference( 13 | @ProtoNumber(1) 14 | val keyEncryptedByPassword: String = "", 15 | @ProtoNumber(2) 16 | val keyEncryptedByBiometric: String = "", 17 | @ProtoNumber(3) 18 | val ntpAddress: String = "", 19 | @ProtoNumber(4) 20 | val ntp: Ntp = Ntp.Cloudflare 21 | ) { 22 | val isEncrypted inline get() = keyEncryptedByPassword.isNotEmpty() 23 | val isBiometric inline get() = keyEncryptedByBiometric.isNotEmpty() 24 | 25 | fun encodeToStream(output: OutputStream) = output.write( 26 | ProtoBuf.encodeToByteArray(this) 27 | ) 28 | 29 | companion object Default { 30 | fun decodeFromStream(input: InputStream): Preference = 31 | ProtoBuf.decodeFromByteArray(input.readBytes()) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ktx 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.ContextWrapper 6 | import android.content.Intent 7 | import androidx.core.app.LocaleManagerCompat 8 | import java.util.Locale 9 | 10 | val Context.applicationLocale: Locale? 11 | inline get() = LocaleManagerCompat.getApplicationLocales(applicationContext) 12 | .toList().firstOrNull() 13 | 14 | val Context.deviceProtectedContext: Context 15 | inline get() = createDeviceProtectedStorageContext() 16 | 17 | fun Context.viewUrl(url: String) { 18 | startActivity( 19 | Intent.parseUri(url, Intent.URI_INTENT_SCHEME) 20 | ) 21 | } 22 | 23 | fun Context.findActivity(): Activity? { 24 | var context = this 25 | while (context is ContextWrapper) { 26 | if (context is Activity) return context 27 | context = context.baseContext 28 | } 29 | 30 | return null 31 | } 32 | 33 | fun Context.finishActivity() { 34 | if (this is Activity) finish() 35 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ktx 2 | 3 | import java.util.Locale 4 | 5 | val Locale.localizedDisplayName: String 6 | inline get() = getDisplayName(this) 7 | .replaceFirstChar { 8 | if (it.isLowerCase()) { 9 | it.titlecase(this) 10 | } else { 11 | it.toString() 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ktx 2 | 3 | import androidx.core.os.LocaleListCompat 4 | import java.util.Locale 5 | 6 | fun LocaleListCompat.toList(): List = List(size()) { this[it]!! } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ktx 2 | 3 | import kotlin.math.min 4 | 5 | fun String.hidden(limit: Int = 6, mask: Char = '\u2022'): String { 6 | val showLength = min(length / 2, limit) 7 | val hiddenLength = min(length - showLength, limit) 8 | val sb = StringBuilder() 9 | sb.appendRange(this, 0, showLength) 10 | repeat(hiddenLength) { sb.append(mask) } 11 | return sb.toString() 12 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/model/AuthType.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.model 2 | 3 | enum class AuthType { 4 | TOTP 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/model/impl/TotpImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.model.impl 2 | 3 | import dev.sanmer.authenticator.database.entity.TotpEntity 4 | import dev.sanmer.encoding.decodeBase32 5 | import dev.sanmer.otp.HOTP 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.map 8 | 9 | data class TotpImpl( 10 | val entity: TotpEntity, 11 | val epochSeconds: StateFlow 12 | ) { 13 | val secret by lazy { entity.secret.decodeBase32() } 14 | 15 | val otp = epochSeconds.map { 16 | HOTP.otp( 17 | hash = entity.hash, 18 | secret = secret, 19 | digits = entity.digits, 20 | counter = it / entity.period 21 | ) 22 | } 23 | 24 | fun now() = HOTP.otp( 25 | hash = entity.hash, 26 | secret = secret, 27 | digits = entity.digits, 28 | counter = epochSeconds.value / entity.period 29 | ) 30 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.model.serializer 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.json.Json 5 | import kotlinx.serialization.json.decodeFromStream 6 | import kotlinx.serialization.json.encodeToStream 7 | import java.io.InputStream 8 | import java.io.OutputStream 9 | 10 | @Serializable 11 | data class AuthJson( 12 | val totp: List 13 | ) { 14 | fun encodeTo(output: OutputStream) { 15 | endpointJson.encodeToStream(this, output) 16 | } 17 | 18 | companion object Default { 19 | private val endpointJson = Json { 20 | prettyPrint = true 21 | } 22 | 23 | const val MIME_TYPE = "application/json" 24 | const val FILE_NAME = "auth.json" 25 | 26 | fun decodeFrom(input: InputStream): AuthJson = 27 | Json.decodeFromStream(input) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthTxt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.model.serializer 2 | 3 | import dev.sanmer.authenticator.model.AuthType 4 | import dev.sanmer.otp.HOTP 5 | import dev.sanmer.otp.OtpUri.Default.toOtpUri 6 | import java.io.InputStream 7 | import java.io.OutputStream 8 | import kotlin.streams.toList 9 | 10 | data class AuthTxt( 11 | val totp: List 12 | ) { 13 | fun encodeTo(output: OutputStream) { 14 | val uris = totp.map { it.uri.toString() } 15 | val content = uris.joinToString(separator = "\n") 16 | output.write(content.toByteArray()) 17 | } 18 | 19 | companion object Default { 20 | const val MIME_TYPE = "text/plain" 21 | const val FILE_NAME = "auth.txt" 22 | 23 | fun parse(uriString: String): TotpAuth { 24 | val uri = uriString.toOtpUri() 25 | AuthType.valueOf(uri.type) 26 | 27 | return TotpAuth( 28 | issuer = uri.issuer, 29 | name = uri.name, 30 | secret = uri.secret, 31 | hash = uri.algorithm?.let(HOTP.Hash::valueOf) ?: HOTP.Hash.SHA1, 32 | digits = uri.digits ?: 6, 33 | period = uri.period ?: 30, 34 | ) 35 | } 36 | 37 | fun decodeFrom(input: InputStream): AuthTxt { 38 | return AuthTxt( 39 | input.bufferedReader().lines().toList() 40 | .mapNotNull { 41 | runCatching { 42 | parse(it) 43 | }.getOrNull() 44 | } 45 | ) 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpAuth.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.model.serializer 2 | 3 | import dev.sanmer.authenticator.model.AuthType 4 | import dev.sanmer.otp.HOTP 5 | import dev.sanmer.otp.OtpUri 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | data class TotpAuth( 10 | val issuer: String, 11 | val name: String, 12 | val secret: String, 13 | val hash: HOTP.Hash, 14 | val digits: Int, 15 | val period: Long 16 | ) { 17 | val uri get() = OtpUri( 18 | type = AuthType.TOTP.name, 19 | name = name, 20 | issuer = issuer, 21 | secret = secret, 22 | algorithm = hash.name, 23 | digits = digits, 24 | period = period 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.repository 2 | 3 | import androidx.datastore.core.DataStore 4 | import dev.sanmer.authenticator.datastore.model.Ntp 5 | import dev.sanmer.authenticator.datastore.model.Preference 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class PreferenceRepository @Inject constructor( 13 | private val dataStore: DataStore 14 | ) { 15 | val data get() = dataStore.data 16 | 17 | suspend fun setKeyEncryptedByPassword(value: String) { 18 | withContext(Dispatchers.IO) { 19 | dataStore.updateData { 20 | it.copy(keyEncryptedByPassword = value) 21 | } 22 | } 23 | } 24 | 25 | suspend fun setKeyEncryptedByBiometric(value: String) { 26 | withContext(Dispatchers.IO) { 27 | dataStore.updateData { 28 | it.copy(keyEncryptedByBiometric = value) 29 | } 30 | } 31 | } 32 | 33 | suspend fun setNtpAddress(value: String) { 34 | withContext(Dispatchers.IO) { 35 | dataStore.updateData { 36 | it.copy(ntpAddress = value) 37 | } 38 | } 39 | } 40 | 41 | suspend fun setNtp(value: Ntp) { 42 | withContext(Dispatchers.IO) { 43 | dataStore.updateData { 44 | it.copy(ntp = value) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/repository/SecureRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.repository 2 | 3 | import dev.sanmer.authenticator.database.dao.TotpDao 4 | import dev.sanmer.crypto.Crypto 5 | import dev.sanmer.crypto.SessionKey 6 | import dev.sanmer.encoding.decodeBase64 7 | import dev.sanmer.encoding.encodeBase64 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.withContext 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Singleton 14 | class SecureRepository @Inject constructor( 15 | private val totp: TotpDao, 16 | ) { 17 | private var key: Crypto? = null 18 | 19 | fun setSessionKey(sessionKey: SessionKey?) { 20 | key = sessionKey 21 | } 22 | 23 | suspend fun encrypt(input: String) = key?.encrypt(input) ?: input 24 | 25 | suspend fun decrypt(input: String) = key?.decrypt(input) ?: input 26 | 27 | suspend fun encryptSecret(key: Crypto) = withContext(Dispatchers.IO) { 28 | totp.update( 29 | totp.getAll().map { it.copy(secret = key.encrypt(it.secret)) } 30 | ) 31 | } 32 | 33 | suspend fun decryptSecret(key: Crypto) = withContext(Dispatchers.IO) { 34 | totp.update( 35 | totp.getAll().map { it.copy(secret = key.decrypt(it.secret)) } 36 | ) 37 | } 38 | 39 | suspend fun encryptSecretByNewKey(current: Crypto, new: Crypto) = withContext(Dispatchers.IO) { 40 | totp.update( 41 | totp.getAll().map { 42 | val decrypted = current.decrypt(it.secret.decodeBase64()) 43 | it.copy(secret = new.encrypt(decrypted).encodeBase64()) 44 | } 45 | ) 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.ColumnScope 5 | import androidx.compose.foundation.shape.CornerBasedShape 6 | import androidx.compose.material3.DropdownMenu 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.DpOffset 12 | import androidx.compose.ui.window.PopupProperties 13 | 14 | @Composable 15 | fun DropdownMenu( 16 | expanded: Boolean, 17 | onDismissRequest: () -> Unit, 18 | modifier: Modifier = Modifier, 19 | shape: CornerBasedShape = MaterialTheme.shapes.small, 20 | contentAlignment: Alignment = Alignment.TopStart, 21 | offset: DpOffset = DpOffset.Zero, 22 | properties: PopupProperties = PopupProperties(focusable = true), 23 | surface: @Composable () -> Unit, 24 | content: @Composable ColumnScope.() -> Unit 25 | ) = Box( 26 | modifier = modifier 27 | ) { 28 | surface() 29 | 30 | Box( 31 | modifier = Modifier.align(contentAlignment), 32 | contentAlignment = contentAlignment 33 | ) { 34 | DropdownMenu( 35 | expanded = expanded, 36 | onDismissRequest = onDismissRequest, 37 | offset = offset, 38 | properties = properties, 39 | shape = shape, 40 | content = content 41 | ) 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.component 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.text.TextStyle 12 | import androidx.compose.ui.unit.dp 13 | 14 | @Composable 15 | fun LabelText( 16 | text: String, 17 | modifier: Modifier = Modifier, 18 | style: TextStyle = MaterialTheme.typography.labelMedium, 19 | color: Color = MaterialTheme.colorScheme.onSecondaryContainer, 20 | containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, 21 | ) = Text( 22 | text = text, 23 | style = style, 24 | color = color, 25 | modifier = modifier 26 | .background( 27 | color = containerColor, 28 | shape = CircleShape 29 | ) 30 | .padding(horizontal = 8.dp, vertical = 2.dp) 31 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.component 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.shape.CircleShape 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.LocalContentColor 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.graphics.Shape 16 | import androidx.compose.ui.res.painterResource 17 | 18 | @Composable 19 | fun Logo( 20 | @DrawableRes icon: Int, 21 | modifier: Modifier = Modifier, 22 | shape: Shape = CircleShape, 23 | contentColor: Color = MaterialTheme.colorScheme.onPrimary, 24 | containerColor: Color = MaterialTheme.colorScheme.primary, 25 | fraction: Float = 0.6f 26 | ) = Surface( 27 | modifier = modifier, 28 | shape = shape, 29 | color = containerColor, 30 | contentColor = contentColor 31 | ) { 32 | Box( 33 | contentAlignment = Alignment.Center 34 | ) { 35 | Icon( 36 | modifier = Modifier.fillMaxSize(fraction), 37 | painter = painterResource(id = icon), 38 | contentDescription = null, 39 | tint = LocalContentColor.current 40 | ) 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ClipboardExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.ktx 2 | 3 | import android.content.ClipData 4 | import android.content.ClipDescription 5 | import android.os.PersistableBundle 6 | import androidx.compose.ui.platform.Clipboard 7 | import androidx.compose.ui.platform.toClipEntry 8 | 9 | suspend fun Clipboard.setSensitiveText( 10 | content: String 11 | ) { 12 | val data = ClipData.newPlainText("plain text", content).apply { 13 | description.extras = PersistableBundle().apply { 14 | putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) 15 | } 16 | } 17 | 18 | setClipEntry(data.toClipEntry()) 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListStateExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.ktx 2 | 3 | import androidx.compose.foundation.lazy.LazyListState 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.derivedStateOf 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableIntStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.setValue 11 | 12 | @Composable 13 | fun LazyListState.isScrollingUp(): State { 14 | var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } 15 | var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } 16 | return remember(this) { 17 | derivedStateOf { 18 | if (previousIndex != firstVisibleItemIndex) { 19 | previousIndex > firstVisibleItemIndex 20 | } else { 21 | previousScrollOffset >= firstVisibleItemScrollOffset 22 | }.also { 23 | previousIndex = firstVisibleItemIndex 24 | previousScrollOffset = firstVisibleItemScrollOffset 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ModifierExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.ktx 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.border 6 | import androidx.compose.runtime.Stable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.draw.clip 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.Shape 11 | 12 | @Stable 13 | fun Modifier.surface( 14 | shape: Shape, 15 | backgroundColor: Color, 16 | border: BorderStroke? = null 17 | ) = then(if (border != null) Modifier.border(border = border, shape = shape) else Modifier) 18 | .background(color = backgroundColor, shape = shape) 19 | .clip(shape = shape) -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/NavControllerExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.ktx 2 | 3 | import androidx.navigation.NavController 4 | import androidx.navigation.NavOptionsBuilder 5 | 6 | fun NavController.navigateSingleTopTo( 7 | route: String, 8 | builder: NavOptionsBuilder.() -> Unit = {} 9 | ) = navigate( 10 | route = route 11 | ) { 12 | launchSingleTop = true 13 | restoreState = true 14 | builder() 15 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValuesExt.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package dev.sanmer.authenticator.ui.ktx 4 | 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.ui.unit.Dp 7 | import androidx.compose.ui.unit.LayoutDirection 8 | import javax.annotation.concurrent.Immutable 9 | 10 | inline operator fun PaddingValues.plus(other: PaddingValues): PaddingValues = 11 | OperatorPaddingValues(this, other, Dp::plus) 12 | 13 | inline operator fun PaddingValues.minus(other: PaddingValues): PaddingValues = 14 | OperatorPaddingValues(this, other, Dp::minus) 15 | 16 | @Immutable 17 | class OperatorPaddingValues( 18 | private val that: PaddingValues, 19 | private val other: PaddingValues, 20 | private val operator: Dp.(Dp) -> Dp, 21 | ) : PaddingValues { 22 | override fun calculateBottomPadding(): Dp = 23 | operator( 24 | that.calculateBottomPadding(), 25 | other.calculateBottomPadding() 26 | ) 27 | 28 | override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 29 | operator( 30 | that.calculateLeftPadding(layoutDirection), 31 | other.calculateLeftPadding(layoutDirection) 32 | ) 33 | 34 | override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 35 | operator( 36 | that.calculateRightPadding(layoutDirection), 37 | other.calculateRightPadding(layoutDirection) 38 | ) 39 | 40 | override fun calculateTopPadding(): Dp = 41 | operator( 42 | that.calculateTopPadding(), 43 | other.calculateTopPadding() 44 | ) 45 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.ktx 2 | 3 | import androidx.compose.foundation.shape.CornerBasedShape 4 | import androidx.compose.foundation.shape.CornerSize 5 | import androidx.compose.ui.unit.Dp 6 | 7 | fun CornerBasedShape.bottom(size: Dp) = 8 | copy(bottomStart = CornerSize(size), bottomEnd = CornerSize(size)) 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/provider/LocalPreference.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.provider 2 | 3 | import androidx.compose.runtime.staticCompositionLocalOf 4 | import dev.sanmer.authenticator.datastore.model.Preference 5 | 6 | val LocalPreference = staticCompositionLocalOf { Preference() } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.authorize.component 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.text.input.ImeAction 12 | import dev.sanmer.authenticator.R 13 | import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet 14 | 15 | @Composable 16 | fun ChangePasswordItem( 17 | onDismiss: () -> Unit, 18 | isPasswordError: Boolean, 19 | onChange: (String, String) -> Unit 20 | ) { 21 | var currentPassword by remember { mutableStateOf("") } 22 | var newPassword by remember { mutableStateOf("") } 23 | 24 | SettingBottomSheet( 25 | onDismiss = onDismiss, 26 | title = stringResource(id = R.string.security_change_password), 27 | ) { 28 | PasswordTextField( 29 | password = currentPassword, 30 | onPasswordChange = { currentPassword = it }, 31 | imeAction = ImeAction.Next, 32 | isError = isPasswordError, 33 | title = stringResource(id = R.string.security_current_password), 34 | modifier = Modifier.fillMaxWidth() 35 | ) 36 | 37 | PasswordTextField( 38 | password = newPassword, 39 | onPasswordChange = { newPassword = it }, 40 | onImeDone = { onChange(currentPassword, newPassword) }, 41 | title = stringResource(id = R.string.security_new_password), 42 | modifier = Modifier.fillMaxWidth() 43 | ) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.authorize.component 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import dev.sanmer.authenticator.R 12 | import dev.sanmer.authenticator.ui.AuthorizeActivity.Action 13 | import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet 14 | 15 | @Composable 16 | fun EnterPasswordItem( 17 | onDismiss: () -> Unit, 18 | action: Action, 19 | isPasswordError: Boolean, 20 | onEnter: (String) -> Unit, 21 | enableBiometric: Boolean = false, 22 | onBiometric: () -> Unit = {} 23 | ) { 24 | var currentPassword by remember { mutableStateOf("") } 25 | 26 | SettingBottomSheet( 27 | onDismiss = onDismiss, 28 | title = stringResource( 29 | id = when (action) { 30 | Action.SetupPassword -> R.string.security_set_password 31 | Action.RemovePassword -> R.string.security_remove_password 32 | else -> R.string.security_enter_password 33 | } 34 | ), 35 | ) { 36 | PasswordTextField( 37 | password = currentPassword, 38 | onPasswordChange = { currentPassword = it }, 39 | onImeDone = { onEnter(currentPassword) }, 40 | isError = isPasswordError, 41 | title = stringResource( 42 | id = when (action) { 43 | Action.SetupPassword -> R.string.security_new_password 44 | else -> R.string.security_current_password 45 | } 46 | ), 47 | modifier = Modifier.fillMaxWidth(), 48 | actionIcon = if (enableBiometric) R.drawable.fingerprint else null, 49 | onActionClick = if (enableBiometric) onBiometric else null 50 | ) 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.crypto 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.DisposableEffect 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.platform.LocalContext 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.hilt.navigation.compose.hiltViewModel 10 | import dev.sanmer.authenticator.R 11 | import dev.sanmer.authenticator.ktx.finishActivity 12 | import dev.sanmer.authenticator.ui.screens.authorize.component.PasswordTextField 13 | import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet 14 | import dev.sanmer.authenticator.viewmodel.CryptoViewModel 15 | 16 | @Composable 17 | fun CryptoScreen( 18 | viewModel: CryptoViewModel = hiltViewModel() 19 | ) { 20 | val context = LocalContext.current 21 | 22 | DisposableEffect(viewModel.state) { 23 | if (viewModel.state.isSucceed) context.finishActivity() 24 | onDispose {} 25 | } 26 | 27 | SettingBottomSheet( 28 | onDismiss = context::finishActivity, 29 | title = stringResource( 30 | id = if (viewModel.isEncrypt) { 31 | R.string.crypto_encrypt 32 | } else { 33 | R.string.crypto_decrypt 34 | } 35 | ) 36 | ) { 37 | PasswordTextField( 38 | password = viewModel.password, 39 | onPasswordChange = viewModel::updatePassword, 40 | onImeDone = viewModel::crypto, 41 | isError = viewModel.state.isFailed, 42 | title = stringResource(id = R.string.security_password), 43 | modifier = Modifier.fillMaxWidth(), 44 | actionIcon = if (viewModel.isSkip) R.drawable.lock_off else null, 45 | onActionClick = if (viewModel.isSkip) viewModel::crypto else null 46 | ) 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.edit.component 2 | 3 | import androidx.compose.foundation.layout.Spacer 4 | import androidx.compose.foundation.layout.width 5 | import androidx.compose.foundation.text.KeyboardOptions 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.compose.ui.text.input.ImeAction 10 | import androidx.compose.ui.text.input.KeyboardType 11 | import androidx.compose.ui.unit.dp 12 | import dev.sanmer.authenticator.R 13 | import dev.sanmer.authenticator.model.AuthType 14 | 15 | @Composable 16 | fun DigitsAndPeriodItem( 17 | type: AuthType, 18 | digits: String, 19 | onDigitsChange: (String) -> Unit, 20 | period: String, 21 | onPeriodChange: (String) -> Unit, 22 | readOnly: Boolean 23 | ) = TextFieldContent { 24 | TextField( 25 | value = digits, 26 | onValueChange = onDigitsChange, 27 | label = stringResource(id = R.string.edit_digits), 28 | modifier = Modifier.weight(1f), 29 | keyboardOptions = KeyboardOptions( 30 | keyboardType = KeyboardType.Number, 31 | imeAction = ImeAction.Next 32 | ), 33 | readOnly = readOnly 34 | ) 35 | 36 | Spacer(modifier = Modifier.width(15.dp)) 37 | 38 | TextField( 39 | value = when (type) { 40 | AuthType.TOTP -> period 41 | }, 42 | onValueChange = when (type) { 43 | AuthType.TOTP -> onPeriodChange 44 | }, 45 | label = when (type) { 46 | AuthType.TOTP -> stringResource(id = R.string.edit_period) 47 | }, 48 | modifier = Modifier.weight(1f), 49 | keyboardOptions = KeyboardOptions( 50 | keyboardType = KeyboardType.Number, 51 | imeAction = ImeAction.Done 52 | ), 53 | readOnly = readOnly 54 | ) 55 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.edit.component 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.foundation.text.KeyboardOptions 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.text.input.ImeAction 9 | import androidx.compose.ui.text.input.KeyboardType 10 | import androidx.compose.ui.text.input.PasswordVisualTransformation 11 | import androidx.compose.ui.text.input.VisualTransformation 12 | 13 | @Composable 14 | fun TextFieldItem( 15 | value: String, 16 | onValueChange: (String) -> Unit, 17 | label: String, 18 | modifier: Modifier = Modifier, 19 | @DrawableRes leadingIcon: Int? = null, 20 | trailingIcon: @Composable (() -> Unit)? = null, 21 | hidden: Boolean = false, 22 | isError: Boolean = false, 23 | keyboardOptions: KeyboardOptions = KeyboardOptions( 24 | keyboardType = KeyboardType.Text, 25 | imeAction = ImeAction.Next 26 | ) 27 | ) = TextFieldContent( 28 | modifier = modifier, 29 | leading = leadingIcon?.let { { TextFieldContentIcon(icon = it) } }, 30 | trailing = trailingIcon 31 | ) { 32 | val passwordVisualTransformation = remember { PasswordVisualTransformation() } 33 | 34 | TextField( 35 | value = value, 36 | onValueChange = onValueChange, 37 | label = label, 38 | modifier = Modifier.weight(1f), 39 | readOnly = hidden, 40 | isError = isError, 41 | keyboardOptions = keyboardOptions, 42 | visualTransformation = if (hidden) { 43 | passwordVisualTransformation 44 | } else { 45 | VisualTransformation.None 46 | }, 47 | ) 48 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.edit.component 2 | 3 | import androidx.compose.foundation.layout.Spacer 4 | import androidx.compose.foundation.layout.width 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.res.stringResource 8 | import androidx.compose.ui.unit.dp 9 | import dev.sanmer.authenticator.R 10 | import dev.sanmer.authenticator.model.AuthType 11 | import dev.sanmer.otp.HOTP 12 | 13 | @Composable 14 | fun TypeAndHashItem( 15 | type: AuthType, 16 | onTypeChange: (AuthType) -> Unit, 17 | hash: HOTP.Hash, 18 | onHashChange: (HOTP.Hash) -> Unit, 19 | readOnly: Boolean 20 | ) = TextFieldContent( 21 | leading = { TextFieldContentIcon(icon = R.drawable.math_function) } 22 | ) { 23 | TextFieldDropdownMenu( 24 | value = type, 25 | values = AuthType.entries, 26 | onValueChange = onTypeChange, 27 | label = stringResource(id = R.string.edit_type), 28 | readOnly = readOnly, 29 | modifier = Modifier.weight(1f) 30 | ) 31 | 32 | Spacer(modifier = Modifier.width(15.dp)) 33 | 34 | TextFieldDropdownMenu( 35 | value = hash, 36 | values = HOTP.Hash.entries, 37 | onValueChange = onHashChange, 38 | label = stringResource(id = R.string.edit_hash), 39 | readOnly = readOnly, 40 | modifier = Modifier.weight(1f) 41 | ) 42 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.home.component 2 | 3 | import androidx.compose.animation.animateContentSize 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.lazy.LazyListState 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import androidx.navigation.NavController 15 | import dev.sanmer.authenticator.database.entity.TotpEntity 16 | import dev.sanmer.authenticator.model.impl.TotpImpl 17 | import dev.sanmer.authenticator.ui.ktx.navigateSingleTopTo 18 | import dev.sanmer.authenticator.ui.ktx.plus 19 | import dev.sanmer.authenticator.ui.main.Screen 20 | 21 | @Composable 22 | fun AuthList( 23 | state: LazyListState, 24 | navController: NavController, 25 | totp: List, 26 | recycle: (TotpEntity) -> Unit, 27 | contentPadding: PaddingValues = PaddingValues(0.dp) 28 | ) { 29 | LazyColumn( 30 | modifier = Modifier 31 | .fillMaxWidth() 32 | .animateContentSize(), 33 | state = state, 34 | contentPadding = contentPadding + PaddingValues(15.dp), 35 | verticalArrangement = Arrangement.spacedBy(15.dp), 36 | horizontalAlignment = Alignment.CenterHorizontally 37 | ) { 38 | items(totp) { 39 | AuthItem( 40 | auth = it, 41 | enabled = false, 42 | onEdit = { navController.navigateSingleTopTo(Screen.Edit(it.entity.id)) }, 43 | onDelete = { recycle(it.entity) } 44 | ) 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.ntp.component 2 | 3 | import androidx.compose.animation.animateContentSize 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.lazy.LazyListState 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import dev.sanmer.authenticator.datastore.model.Ntp 15 | import dev.sanmer.authenticator.ui.ktx.plus 16 | import dev.sanmer.authenticator.ui.provider.LocalPreference 17 | import dev.sanmer.authenticator.viewmodel.NtpViewModel 18 | 19 | @Composable 20 | fun NtpList( 21 | state: LazyListState, 22 | ntps: List, 23 | setNtp: (Ntp) -> Unit, 24 | contentPadding: PaddingValues = PaddingValues(0.dp) 25 | ) { 26 | val preference = LocalPreference.current 27 | 28 | LazyColumn( 29 | modifier = Modifier 30 | .fillMaxWidth() 31 | .animateContentSize(), 32 | state = state, 33 | contentPadding = contentPadding + PaddingValues(15.dp), 34 | verticalArrangement = Arrangement.spacedBy(15.dp), 35 | horizontalAlignment = Alignment.CenterHorizontally 36 | ) { 37 | items(ntps) { 38 | NtpItem( 39 | ntp = it, 40 | selected = preference.ntp == it.ntp, 41 | onClick = { setNtp(it.ntp) } 42 | ) 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.settings.component 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import androidx.navigation.NavController 6 | import dev.sanmer.authenticator.R 7 | import dev.sanmer.authenticator.ui.ktx.navigateSingleTopTo 8 | import dev.sanmer.authenticator.ui.main.Screen 9 | 10 | @Composable 11 | fun PreferenceItem( 12 | onDismiss: () -> Unit, 13 | navController: NavController 14 | ) { 15 | SettingBottomSheet( 16 | onDismiss = onDismiss, 17 | title = stringResource(id = R.string.settings_preference) 18 | ) { 19 | SettingItem( 20 | icon = R.drawable.shield, 21 | title = stringResource(id = R.string.settings_security), 22 | onClick = { 23 | navController.navigateSingleTopTo(Screen.Security()) 24 | onDismiss() 25 | } 26 | ) 27 | 28 | SettingItem( 29 | icon = R.drawable.timezone, 30 | title = stringResource(id = R.string.settings_ntp_server), 31 | onClick = { 32 | navController.navigateSingleTopTo(Screen.Ntp()) 33 | onDismiss() 34 | } 35 | ) 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.settings.component 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import androidx.navigation.NavController 6 | import dev.sanmer.authenticator.R 7 | import dev.sanmer.authenticator.ui.ktx.navigateSingleTopTo 8 | import dev.sanmer.authenticator.ui.main.Screen 9 | 10 | @Composable 11 | fun TokenItem( 12 | onDismiss: () -> Unit, 13 | navController: NavController, 14 | ) { 15 | SettingBottomSheet( 16 | onDismiss = onDismiss, 17 | title = stringResource(id = R.string.settings_token) 18 | ) { 19 | SettingItem( 20 | icon = R.drawable.pencil_plus, 21 | title = stringResource(id = R.string.settings_enter), 22 | onClick = { 23 | navController.navigateSingleTopTo(Screen.Edit()) 24 | onDismiss() 25 | } 26 | ) 27 | 28 | SettingItem( 29 | icon = R.drawable.scan, 30 | title = stringResource(id = R.string.settings_scan), 31 | onClick = { 32 | navController.navigateSingleTopTo(Screen.Scan()) 33 | onDismiss() 34 | } 35 | ) 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.settings.component 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.activity.compose.rememberLauncherForActivityResult 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.navigation.NavController 11 | import dev.sanmer.authenticator.R 12 | import dev.sanmer.authenticator.model.serializer.AuthJson 13 | import dev.sanmer.authenticator.ui.ktx.navigateSingleTopTo 14 | import dev.sanmer.authenticator.ui.main.Screen 15 | 16 | @Composable 17 | fun ToolItem( 18 | onDismiss: () -> Unit, 19 | navController: NavController, 20 | decryptFromJson: (Context, Uri, () -> Unit) -> Unit, 21 | decryptedToJson: (Context, Uri) -> Unit 22 | ) { 23 | val context = LocalContext.current 24 | val jsonDecrypt = rememberLauncherForActivityResult( 25 | contract = ActivityResultContracts.CreateDocument(AuthJson.MIME_TYPE), 26 | onResult = { if (it != null) decryptedToJson(context, it) } 27 | ) 28 | val jsonEncrypt = rememberLauncherForActivityResult( 29 | contract = ActivityResultContracts.GetContent(), 30 | onResult = { 31 | if (it != null) decryptFromJson(context, it) { 32 | jsonDecrypt.launch(AuthJson.FILE_NAME) 33 | } 34 | } 35 | ) 36 | 37 | SettingBottomSheet( 38 | onDismiss = onDismiss, 39 | title = stringResource(id = R.string.settings_tools), 40 | ) { 41 | SettingItem( 42 | icon = R.drawable.lock_open, 43 | title = stringResource(id = R.string.settings_decrypt), 44 | onClick = { jsonEncrypt.launch(AuthJson.MIME_TYPE) } 45 | ) 46 | 47 | SettingItem( 48 | icon = R.drawable.a_b, 49 | title = stringResource(id = R.string.settings_encode_decode), 50 | onClick = { 51 | navController.navigateSingleTopTo(Screen.Encode()) 52 | onDismiss() 53 | } 54 | ) 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.screens.trash.component 2 | 3 | import androidx.compose.animation.animateContentSize 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.lazy.LazyListState 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import dev.sanmer.authenticator.database.entity.TotpEntity 15 | import dev.sanmer.authenticator.ui.ktx.plus 16 | 17 | @Composable 18 | fun AuthList( 19 | state: LazyListState, 20 | totp: List, 21 | restore: (TotpEntity) -> Unit, 22 | delete: (TotpEntity) -> Unit, 23 | contentPadding: PaddingValues = PaddingValues(0.dp) 24 | ) = LazyColumn( 25 | modifier = Modifier 26 | .fillMaxWidth() 27 | .animateContentSize(), 28 | state = state, 29 | contentPadding = contentPadding + PaddingValues(15.dp), 30 | verticalArrangement = Arrangement.spacedBy(15.dp), 31 | horizontalAlignment = Alignment.CenterHorizontally 32 | ) { 33 | items(totp) { 34 | AuthItem( 35 | entity = it, 36 | onRestore = { restore(it) }, 37 | onDelete = { delete(it) } 38 | ) 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.theme 2 | 3 | import androidx.compose.material3.Shapes 4 | 5 | val Shapes = Shapes() -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | val Typography = Typography( 10 | titleLarge = TextStyle( 11 | fontFamily = FontFamily.SansSerif, 12 | fontWeight = FontWeight.Medium, 13 | fontSize = 22.sp, 14 | lineHeight = 28.sp, 15 | letterSpacing = 0.sp, 16 | ) 17 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/viewmodel/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.viewmodel 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import dev.sanmer.authenticator.datastore.model.Preference 10 | import dev.sanmer.authenticator.repository.PreferenceRepository 11 | import kotlinx.coroutines.launch 12 | import timber.log.Timber 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class MainViewModel @Inject constructor( 17 | private val preferenceRepository: PreferenceRepository 18 | ) : ViewModel() { 19 | var loadState by mutableStateOf(LoadState.Pending) 20 | private set 21 | 22 | val isPending inline get() = loadState.isPending 23 | val isLocked inline get() = loadState.isLocked 24 | val preference inline get() = loadState.preference 25 | 26 | init { 27 | Timber.d("MainViewModel init") 28 | preferenceObserver() 29 | } 30 | 31 | private fun preferenceObserver() { 32 | viewModelScope.launch { 33 | preferenceRepository.data.collect { 34 | loadState = if (loadState.isReady || !it.isEncrypted ) { 35 | LoadState.Ready(it) 36 | } else { 37 | LoadState.Locked(it) 38 | } 39 | } 40 | } 41 | } 42 | 43 | fun setUnlocked() { 44 | loadState = LoadState.Ready(preference) 45 | } 46 | 47 | sealed class LoadState { 48 | abstract val preference: Preference 49 | 50 | data object Pending : LoadState() { 51 | override val preference = Preference() 52 | } 53 | 54 | data class Locked( 55 | override val preference: Preference 56 | ) : LoadState() 57 | 58 | data class Ready( 59 | override val preference: Preference 60 | ) : LoadState() 61 | 62 | val isPending inline get() = this is Pending 63 | val isLocked inline get() = this is Locked 64 | val isReady inline get() = this is Ready 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/dev/sanmer/authenticator/viewmodel/TrashViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.authenticator.viewmodel 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import dev.sanmer.authenticator.database.entity.TotpEntity 10 | import dev.sanmer.authenticator.repository.DbRepository 11 | import kotlinx.coroutines.launch 12 | import timber.log.Timber 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class TrashViewModel @Inject constructor( 17 | private val dbRepository: DbRepository 18 | ) : ViewModel() { 19 | var loadState by mutableStateOf(LoadState.Pending) 20 | private set 21 | val totp inline get() = loadState.totp 22 | val isPending inline get() = loadState.isPending 23 | 24 | init { 25 | Timber.d("TrashViewModel init") 26 | dataObserver() 27 | } 28 | 29 | private fun dataObserver() { 30 | viewModelScope.launch { 31 | dbRepository.getTotpAllDecryptedTrashedAsFlow() 32 | .collect { totp -> 33 | loadState = LoadState.Ready(totp) 34 | } 35 | } 36 | } 37 | 38 | fun restoreAll() { 39 | viewModelScope.launch { 40 | dbRepository.updateTotp(totp.map { it.copy(deletedAt = 0) }) 41 | } 42 | } 43 | 44 | fun restore(entity: TotpEntity) { 45 | viewModelScope.launch { 46 | dbRepository.updateTotp(entity.copy(deletedAt = 0)) 47 | } 48 | } 49 | 50 | fun delete(entity: TotpEntity) { 51 | viewModelScope.launch { 52 | dbRepository.deleteTotp(entity) 53 | } 54 | } 55 | 56 | sealed class LoadState { 57 | abstract val totp: List 58 | 59 | data object Pending : LoadState() { 60 | override val totp = emptyList() 61 | } 62 | 63 | data class Ready( 64 | override val totp: List 65 | ) : LoadState() 66 | 67 | val isPending inline get() = this is Pending 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/a_b.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/alert_triangle.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/arrow_bar_down.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/arrow_bar_up.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/arrow_left.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/brand_github_2.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/camera_off.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/caret_down.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/caret_up.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/check.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle_check_filled.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/clipboard_text.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/database.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/database_export.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/database_import.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/device_floppy.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/edit.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/eye.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/eye_closed.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/file_export.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/file_import.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/fingerprint.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/key.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 17 | 23 | 29 | 35 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/launcher_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/lock.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/lock_off.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/lock_open.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/math_function.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/mood_heart.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/pencil_plus.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/photo.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/refresh.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/restore.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/scan.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/search.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/settings_2.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shield.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shield_off.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/timezone.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 42 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/tool.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/trash.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/trash_x.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/user.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/x.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values-night-v31/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @android:color/system_accent1_200 4 | @android:color/system_accent1_800 5 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FBFCFD 4 | #0A0C10 5 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 15 | 16 | 24 | 25 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/res/xml/locales_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /build-logic/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | dependencies { 6 | compileOnly(libs.android.gradle) 7 | compileOnly(libs.compose.gradle) 8 | compileOnly(libs.kotlin.gradle) 9 | compileOnly(libs.ksp.gradle) 10 | } 11 | 12 | gradlePlugin { 13 | plugins { 14 | register("self.application") { 15 | id = "self.application" 16 | implementationClass = "ApplicationConventionPlugin" 17 | } 18 | 19 | register("self.library") { 20 | id = "self.library" 21 | implementationClass = "LibraryConventionPlugin" 22 | } 23 | 24 | register("self.compose") { 25 | id = "self.compose" 26 | implementationClass = "ComposeConventionPlugin" 27 | } 28 | 29 | register("self.hilt") { 30 | id = "self.hilt" 31 | implementationClass = "HiltConventionPlugin" 32 | } 33 | 34 | register("self.room") { 35 | id = "self.room" 36 | implementationClass = "RoomConventionPlugin" 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | 7 | versionCatalogs { 8 | create("libs") { 9 | from(files("../gradle/libs.versions.toml")) 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/ApplicationConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.ApplicationExtension 2 | import org.gradle.api.JavaVersion 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.Project 5 | import org.gradle.api.plugins.JavaPluginExtension 6 | import org.gradle.jvm.toolchain.JavaLanguageVersion 7 | import org.gradle.kotlin.dsl.apply 8 | import org.gradle.kotlin.dsl.configure 9 | import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension 10 | 11 | class ApplicationConventionPlugin : Plugin { 12 | override fun apply(target: Project) = with(target) { 13 | apply(plugin = "com.android.application") 14 | apply(plugin = "org.jetbrains.kotlin.android") 15 | 16 | extensions.configure { 17 | compileSdk = 35 18 | buildToolsVersion = "35.0.1" 19 | 20 | defaultConfig { 21 | minSdk = 33 22 | targetSdk = compileSdk 23 | } 24 | 25 | compileOptions { 26 | sourceCompatibility = JavaVersion.VERSION_21 27 | targetCompatibility = JavaVersion.VERSION_21 28 | } 29 | } 30 | 31 | extensions.configure { 32 | toolchain { 33 | languageVersion.set(JavaLanguageVersion.of(21)) 34 | } 35 | } 36 | 37 | extensions.configure { 38 | jvmToolchain(21) 39 | 40 | sourceSets.all { 41 | languageSettings { 42 | optIn("kotlinx.serialization.ExperimentalSerializationApi") 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/ComposeConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.ApplicationExtension 2 | import org.gradle.api.Plugin 3 | import org.gradle.api.Project 4 | import org.gradle.api.artifacts.VersionCatalogsExtension 5 | import org.gradle.kotlin.dsl.apply 6 | import org.gradle.kotlin.dsl.configure 7 | import org.gradle.kotlin.dsl.dependencies 8 | import org.gradle.kotlin.dsl.getByType 9 | import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension 10 | 11 | class ComposeConventionPlugin : Plugin { 12 | override fun apply(target: Project) = with(target) { 13 | apply(plugin = "com.android.application") 14 | apply(plugin = "org.jetbrains.kotlin.android") 15 | apply(plugin = "org.jetbrains.kotlin.plugin.compose") 16 | 17 | extensions.configure { 18 | buildFeatures { 19 | compose = true 20 | } 21 | } 22 | 23 | extensions.configure { 24 | sourceSets.all { 25 | languageSettings { 26 | optIn("androidx.compose.material3.ExperimentalMaterial3Api") 27 | } 28 | } 29 | } 30 | 31 | val libs = extensions.getByType().named("libs") 32 | dependencies { 33 | "implementation"(libs.findLibrary("androidx.compose.material3").get()) 34 | "implementation"(libs.findLibrary("androidx.compose.ui").get()) 35 | "implementation"(libs.findLibrary("androidx.compose.ui.tooling.preview").get()) 36 | "debugImplementation"(libs.findLibrary("androidx.compose.ui.tooling").get()) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/HiltConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.api.Plugin 2 | import org.gradle.api.Project 3 | import org.gradle.api.artifacts.VersionCatalogsExtension 4 | import org.gradle.kotlin.dsl.apply 5 | import org.gradle.kotlin.dsl.dependencies 6 | import org.gradle.kotlin.dsl.getByType 7 | 8 | class HiltConventionPlugin : Plugin { 9 | override fun apply(target: Project) = with(target) { 10 | apply(plugin = "dagger.hilt.android.plugin") 11 | apply(plugin = "com.google.devtools.ksp") 12 | 13 | val libs = extensions.getByType().named("libs") 14 | dependencies { 15 | "implementation"(libs.findLibrary("hilt.android").get()) 16 | "ksp"(libs.findLibrary("hilt.compiler").get()) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/LibraryConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.LibraryExtension 2 | import org.gradle.api.JavaVersion 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.Project 5 | import org.gradle.api.plugins.JavaPluginExtension 6 | import org.gradle.jvm.toolchain.JavaLanguageVersion 7 | import org.gradle.kotlin.dsl.apply 8 | import org.gradle.kotlin.dsl.configure 9 | import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension 10 | 11 | class LibraryConventionPlugin : Plugin { 12 | override fun apply(target: Project) = with(target) { 13 | apply(plugin = "com.android.library") 14 | apply(plugin = "org.jetbrains.kotlin.android") 15 | 16 | extensions.configure { 17 | compileSdk = 35 18 | buildToolsVersion = "35.0.1" 19 | 20 | defaultConfig { 21 | minSdk = 31 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility = JavaVersion.VERSION_21 26 | targetCompatibility = JavaVersion.VERSION_21 27 | } 28 | } 29 | 30 | extensions.configure { 31 | toolchain { 32 | languageVersion.set(JavaLanguageVersion.of(21)) 33 | } 34 | } 35 | 36 | extensions.configure { 37 | jvmToolchain(21) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/ProjectExt.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.api.Project 2 | import org.gradle.kotlin.dsl.extra 3 | import java.io.File 4 | import java.util.Properties 5 | 6 | val Project.commitSha: String get() = exec("git rev-parse HEAD") 7 | val Project.commitCount: Int get() = exec("git rev-list --count HEAD").toInt() 8 | 9 | @Suppress("UnstableApiUsage") 10 | fun Project.exec(command: String) = providers.exec { 11 | commandLine(command.split(" ")) 12 | }.standardOutput.asText.get().trim() 13 | 14 | val Project.releaseKeyStore: File get() = File(extra["keyStore"] as String) 15 | val Project.releaseKeyStorePassword: String get() = extra["keyStorePassword"] as String 16 | val Project.releaseKeyAlias: String get() = extra["keyAlias"] as String 17 | val Project.releaseKeyPassword: String get() = extra["keyPassword"] as String 18 | val Project.hasReleaseKeyStore: Boolean get() { 19 | signingProperties(rootDir).forEach { key, value -> 20 | extra[key as String] = value 21 | } 22 | 23 | return extra.has("keyStore") 24 | } 25 | 26 | private fun signingProperties(rootDir: File): Properties { 27 | val properties = Properties() 28 | val signingProperties = rootDir.resolve("signing.properties") 29 | if (signingProperties.isFile) { 30 | signingProperties.inputStream().use(properties::load) 31 | } 32 | 33 | return properties 34 | } -------------------------------------------------------------------------------- /build-logic/src/main/kotlin/RoomConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | import com.google.devtools.ksp.gradle.KspExtension 2 | import org.gradle.api.Plugin 3 | import org.gradle.api.Project 4 | import org.gradle.api.artifacts.VersionCatalogsExtension 5 | import org.gradle.kotlin.dsl.apply 6 | import org.gradle.kotlin.dsl.configure 7 | import org.gradle.kotlin.dsl.dependencies 8 | import org.gradle.kotlin.dsl.getByType 9 | 10 | class RoomConventionPlugin : Plugin { 11 | override fun apply(target: Project) = with(target) { 12 | apply(plugin = "com.google.devtools.ksp") 13 | 14 | extensions.configure { 15 | arg("room.incremental", "true") 16 | arg("room.expandProjection", "true") 17 | arg("room.schemaLocation", "$projectDir/schemas") 18 | } 19 | 20 | val libs = extensions.getByType().named("libs") 21 | dependencies { 22 | "implementation"(libs.findLibrary("androidx.room.ktx").get()) 23 | "implementation"(libs.findLibrary("androidx.room.runtime").get()) 24 | "ksp"(libs.findLibrary("androidx.room.compiler").get()) 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) apply false 3 | alias(libs.plugins.android.library) apply false 4 | alias(libs.plugins.compose.compiler) apply false 5 | alias(libs.plugins.kotlin.jvm) apply false 6 | alias(libs.plugins.kotlin.serialization) apply false 7 | alias(libs.plugins.hilt) apply false 8 | alias(libs.plugins.ksp) apply false 9 | } 10 | 11 | tasks.register("clean") { 12 | delete(layout.buildDirectory) 13 | } 14 | -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.self.library) 3 | } 4 | 5 | android { 6 | namespace = "dev.sanmer.core" 7 | } 8 | 9 | dependencies { 10 | implementation(libs.androidx.annotation) 11 | implementation(libs.encoding.base32) 12 | implementation(libs.encoding.base64) 13 | implementation(libs.google.zxing) 14 | implementation(libs.kotlinx.coroutines.android) 15 | } 16 | -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/crypto/Crypto.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.crypto 2 | 3 | import dev.sanmer.encoding.decodeBase64 4 | import dev.sanmer.encoding.encodeBase64 5 | import java.security.SecureRandom 6 | import javax.crypto.SecretKey 7 | import javax.crypto.SecretKeyFactory 8 | import javax.crypto.spec.PBEKeySpec 9 | import javax.crypto.spec.SecretKeySpec 10 | 11 | interface Crypto { 12 | suspend fun encrypt(input: ByteArray): ByteArray 13 | suspend fun encrypt(input: String) = encrypt(input.toByteArray(Charsets.UTF_8)).encodeBase64() 14 | 15 | suspend fun decrypt(input: ByteArray): ByteArray 16 | suspend fun decrypt(input: String) = decrypt(input.decodeBase64()).toString(Charsets.UTF_8) 17 | 18 | companion object Default { 19 | const val FACTORY_ALGORITHM = "PBKDF2WithHmacSHA256" 20 | const val ALGORITHM = "AES/GCM/NoPadding" 21 | const val KEY_ALGORITHM = "AES" 22 | const val ITERATION_COUNT = 102400 23 | const val KEY_LENGTH = 256 24 | const val SALT_LENGTH = 16 25 | const val IV_LENGTH = 12 26 | const val TAG_LENGTH = 128 27 | 28 | val randomSalt: ByteArray 29 | inline get() = ByteArray(SALT_LENGTH).apply { 30 | SecureRandom().nextBytes(this) 31 | } 32 | 33 | val randomIv: ByteArray 34 | inline get() = ByteArray(IV_LENGTH).apply { 35 | SecureRandom().nextBytes(this) 36 | } 37 | 38 | fun CharArray.generateSecretKey(salt: ByteArray): SecretKey { 39 | val factory = SecretKeyFactory.getInstance(FACTORY_ALGORITHM) 40 | val spec = PBEKeySpec(this, salt, ITERATION_COUNT, KEY_LENGTH) 41 | val secret = factory.generateSecret(spec) 42 | return SecretKeySpec(secret.encoded, KEY_ALGORITHM) 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/crypto/PasswordKey.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.crypto 2 | 3 | import dev.sanmer.crypto.Crypto.Default.generateSecretKey 4 | import dev.sanmer.crypto.Crypto.Default.randomIv 5 | import dev.sanmer.crypto.Crypto.Default.randomSalt 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.withContext 8 | import javax.crypto.Cipher 9 | import javax.crypto.SecretKey 10 | import javax.crypto.spec.GCMParameterSpec 11 | 12 | class PasswordKey private constructor( 13 | private val password: CharArray 14 | ) : Crypto { 15 | private val salt by lazy { randomSalt } 16 | 17 | private val keys = hashMapOf() 18 | private fun generateSecretKey(salt: ByteArray): SecretKey { 19 | return keys.getOrPut(salt.contentHashCode()) { 20 | password.generateSecretKey(salt) 21 | } 22 | } 23 | 24 | override suspend fun encrypt(input: ByteArray) = withContext(Dispatchers.IO) { 25 | val iv = randomIv 26 | val key = generateSecretKey(salt) 27 | val cipher = Cipher.getInstance(Crypto.ALGORITHM) 28 | cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(Crypto.TAG_LENGTH, iv)) 29 | salt + iv + cipher.doFinal(input) 30 | } 31 | 32 | override suspend fun decrypt(input: ByteArray) = withContext(Dispatchers.IO) { 33 | val salt = input.copyOfRange(0, Crypto.SALT_LENGTH) 34 | val iv = input.copyOfRange(Crypto.SALT_LENGTH, Crypto.SALT_LENGTH + Crypto.IV_LENGTH) 35 | val data = input.copyOfRange(Crypto.SALT_LENGTH + Crypto.IV_LENGTH, input.size) 36 | 37 | val key = generateSecretKey(salt) 38 | val cipher = Cipher.getInstance(Crypto.ALGORITHM) 39 | cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(Crypto.TAG_LENGTH, iv)) 40 | cipher.doFinal(data) 41 | } 42 | 43 | companion object Default { 44 | fun new(password: String) = PasswordKey(password.toCharArray()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/crypto/SessionKey.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.crypto 2 | 3 | import dev.sanmer.crypto.Crypto.Default.randomIv 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import javax.crypto.Cipher 7 | import javax.crypto.KeyGenerator 8 | import javax.crypto.SecretKey 9 | import javax.crypto.spec.GCMParameterSpec 10 | import javax.crypto.spec.SecretKeySpec 11 | 12 | class SessionKey( 13 | val key: SecretKey 14 | ) : Crypto { 15 | override suspend fun encrypt(input: ByteArray) = withContext(Dispatchers.IO) { 16 | val iv = randomIv 17 | val cipher = Cipher.getInstance(Crypto.ALGORITHM) 18 | cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(Crypto.TAG_LENGTH, iv)) 19 | iv + cipher.doFinal(input) 20 | } 21 | 22 | override suspend fun decrypt(input: ByteArray) = withContext(Dispatchers.IO) { 23 | val iv = input.copyOfRange(0, Crypto.IV_LENGTH) 24 | val data = input.copyOfRange(Crypto.IV_LENGTH, input.size) 25 | 26 | val cipher = Cipher.getInstance(Crypto.ALGORITHM) 27 | cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(Crypto.TAG_LENGTH, iv)) 28 | cipher.doFinal(data) 29 | } 30 | 31 | suspend fun getKeyEncryptedByPassword(password: String) = 32 | PasswordKey.new(password).encrypt(key.encoded) 33 | 34 | companion object Default { 35 | fun ByteArray.toSecretKey() = 36 | SecretKeySpec(this, 0, size, Crypto.KEY_ALGORITHM) 37 | 38 | fun new() = SessionKey( 39 | key = KeyGenerator.getInstance(Crypto.KEY_ALGORITHM).apply { 40 | init(Crypto.KEY_LENGTH) 41 | }.generateKey() 42 | ) 43 | 44 | suspend fun decryptKeyByPassword(key: ByteArray, password: String) = SessionKey( 45 | key = PasswordKey.new(password).decrypt(key).toSecretKey() 46 | ) 47 | } 48 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/encoding/Base32.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package dev.sanmer.encoding 4 | 5 | import io.matthewnelson.encoding.base32.Base32 6 | import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArrayOrNull 7 | import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString 8 | import io.matthewnelson.encoding.core.EncodingException 9 | import java.nio.charset.Charset 10 | 11 | inline fun String.isBase32(): Boolean { 12 | return isNotBlank() && (decodeToByteArrayOrNull(Base32.Default) != null 13 | || decodeToByteArrayOrNull(Base32.Hex) != null) 14 | } 15 | 16 | inline fun String.decodeBase32(): ByteArray { 17 | return decodeToByteArrayOrNull(Base32.Default) 18 | ?: decodeToByteArrayOrNull(Base32.Hex) 19 | ?: throw EncodingException(this) 20 | } 21 | 22 | inline fun String.decodeBase32(charset: Charset = Charsets.UTF_8) = 23 | decodeBase32().toString(charset) 24 | 25 | inline fun ByteArray.encodeBase32Default() = encodeToString(Base32.Default) 26 | 27 | inline fun ByteArray.encodeBase32Hex() = encodeToString(Base32.Hex) 28 | 29 | inline fun String.encodeBase32Default(charset: Charset = Charsets.UTF_8) = 30 | toByteArray(charset).encodeBase32Default() 31 | 32 | inline fun String.encodeBase32Hex(charset: Charset = Charsets.UTF_8) = 33 | toByteArray(charset).encodeBase32Hex() -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/encoding/Base64.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package dev.sanmer.encoding 4 | 5 | import io.matthewnelson.encoding.base64.Base64 6 | import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray 7 | import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArrayOrNull 8 | import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString 9 | import java.nio.charset.Charset 10 | 11 | inline fun String.isBase64() = isNotBlank() && decodeToByteArrayOrNull(Base64.Default) != null 12 | 13 | inline fun String.decodeBase64() = decodeToByteArray(Base64.Default) 14 | 15 | inline fun String.decodeBase64(charset: Charset = Charsets.UTF_8) = 16 | decodeToByteArray(Base64.Default).toString(charset) 17 | 18 | inline fun ByteArray.encodeBase64() = encodeToString(Base64.Default) 19 | 20 | inline fun String.encodeBase64(charset: Charset = Charsets.UTF_8) = 21 | toByteArray(charset).encodeBase64() -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/otp/ByteArray.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package dev.sanmer.otp 4 | 5 | import java.nio.ByteBuffer 6 | import java.nio.ByteOrder 7 | import javax.crypto.Mac 8 | import javax.crypto.spec.SecretKeySpec 9 | import kotlin.math.pow 10 | 11 | internal inline fun Long.toByteArray() = 12 | ByteBuffer.allocate(Long.SIZE_BYTES) 13 | .order(ByteOrder.BIG_ENDIAN) 14 | .putLong(this) 15 | .array() 16 | 17 | internal inline fun ByteArray.hmac(key: ByteArray, algorithm: String): ByteArray = 18 | Mac.getInstance(algorithm).let { 19 | it.init(SecretKeySpec(key, algorithm)) 20 | it.doFinal(this) 21 | } 22 | 23 | internal inline fun ByteArray.hmacSha1(key: ByteArray) = hmac(key, "HmacSHA1") 24 | 25 | internal inline fun ByteArray.hmacSha256(key: ByteArray) = hmac(key, "HmacSHA256") 26 | 27 | internal inline fun ByteArray.hmacSha512(key: ByteArray) = hmac(key, "HmacSHA512") 28 | 29 | internal inline fun ByteArray.otp(digits: Int): Int { 30 | val offset = this[size - 1].toInt() and 0x0F 31 | val code = ((this[offset].toInt() and 0x7F shl 24) 32 | or (this[offset + 1].toInt() and 0xFF shl 16) 33 | or (this[offset + 2].toInt() and 0xFF shl 8) 34 | or (this[offset + 3].toInt() and 0xFF)) 35 | 36 | return code % 10.0.pow(digits).toInt() 37 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/otp/HOTP.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.otp 2 | 3 | import dev.sanmer.encoding.decodeBase32 4 | 5 | object HOTP { 6 | enum class Hash { 7 | SHA1, 8 | SHA256, 9 | SHA512 10 | } 11 | 12 | private fun otp( 13 | hash: Hash, 14 | secret: ByteArray, 15 | counter: ByteArray, 16 | digits: Int 17 | ) = when (hash) { 18 | Hash.SHA1 -> counter.hmacSha1(secret) 19 | Hash.SHA256 -> counter.hmacSha256(secret) 20 | Hash.SHA512 -> counter.hmacSha512(secret) 21 | }.otp(digits) 22 | 23 | fun otp( 24 | hash: Hash, 25 | secret: ByteArray, 26 | counter: Long, 27 | digits: Int 28 | ) = otp( 29 | hash = hash, 30 | secret = secret, 31 | counter = counter.toByteArray(), 32 | digits = digits 33 | ).toString().padStart(digits, '0') 34 | 35 | fun otp( 36 | hash: Hash, 37 | secret: String, 38 | counter: Long, 39 | digits: Int 40 | ) = otp( 41 | hash = hash, 42 | secret = secret.decodeBase32(), 43 | counter = counter.toByteArray(), 44 | digits = digits 45 | ).toString().padStart(digits, '0') 46 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/dev/sanmer/otp/TOTP.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.otp 2 | 3 | object TOTP { 4 | val epochSeconds: Long 5 | inline get() = System.currentTimeMillis() / 1000 6 | 7 | fun otp( 8 | hash: HOTP.Hash, 9 | secret: ByteArray, 10 | digits: Int, 11 | period: Long, 12 | seconds: Long = epochSeconds 13 | ) = HOTP.otp( 14 | hash = hash, 15 | secret = secret, 16 | counter = seconds / period, 17 | digits = digits 18 | ) 19 | 20 | fun otp( 21 | hash: HOTP.Hash, 22 | secret: String, 23 | digits: Int, 24 | period: Long, 25 | seconds: Long = epochSeconds 26 | ) = HOTP.otp( 27 | hash = hash, 28 | secret = secret, 29 | counter = seconds / period, 30 | digits = digits 31 | ) 32 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | org.gradle.caching=true 3 | org.gradle.configuration-cache=true 4 | 5 | android.useAndroidX=true 6 | android.nonTransitiveRClass=true 7 | 8 | kapt.include.compile.classpath=false 9 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /logo/afphoto/aliyun.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/aliyun.afphoto -------------------------------------------------------------------------------- /logo/afphoto/apple.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/apple.afphoto -------------------------------------------------------------------------------- /logo/afphoto/aws.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/aws.afphoto -------------------------------------------------------------------------------- /logo/afphoto/azure.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/azure.afphoto -------------------------------------------------------------------------------- /logo/afphoto/binance.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/binance.afphoto -------------------------------------------------------------------------------- /logo/afphoto/bybit.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/bybit.afphoto -------------------------------------------------------------------------------- /logo/afphoto/cloudflare.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/cloudflare.afphoto -------------------------------------------------------------------------------- /logo/afphoto/coinbase.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/coinbase.afphoto -------------------------------------------------------------------------------- /logo/afphoto/crowdin.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/crowdin.afphoto -------------------------------------------------------------------------------- /logo/afphoto/digitalocean.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/digitalocean.afphoto -------------------------------------------------------------------------------- /logo/afphoto/discord.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/discord.afphoto -------------------------------------------------------------------------------- /logo/afphoto/facebook.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/facebook.afphoto -------------------------------------------------------------------------------- /logo/afphoto/gitea.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/gitea.afphoto -------------------------------------------------------------------------------- /logo/afphoto/github.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/github.afphoto -------------------------------------------------------------------------------- /logo/afphoto/gitlab.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/gitlab.afphoto -------------------------------------------------------------------------------- /logo/afphoto/google.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/google.afphoto -------------------------------------------------------------------------------- /logo/afphoto/instagram.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/instagram.afphoto -------------------------------------------------------------------------------- /logo/afphoto/jetbrains.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/jetbrains.afphoto -------------------------------------------------------------------------------- /logo/afphoto/lark.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/lark.afphoto -------------------------------------------------------------------------------- /logo/afphoto/mega.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/mega.afphoto -------------------------------------------------------------------------------- /logo/afphoto/meta.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/meta.afphoto -------------------------------------------------------------------------------- /logo/afphoto/microsoft.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/microsoft.afphoto -------------------------------------------------------------------------------- /logo/afphoto/okx.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/okx.afphoto -------------------------------------------------------------------------------- /logo/afphoto/onlyfans.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/onlyfans.afphoto -------------------------------------------------------------------------------- /logo/afphoto/openai.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/openai.afphoto -------------------------------------------------------------------------------- /logo/afphoto/orcid.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/orcid.afphoto -------------------------------------------------------------------------------- /logo/afphoto/patreon.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/patreon.afphoto -------------------------------------------------------------------------------- /logo/afphoto/paypal.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/paypal.afphoto -------------------------------------------------------------------------------- /logo/afphoto/pixiv.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/pixiv.afphoto -------------------------------------------------------------------------------- /logo/afphoto/tencent-cloud.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/tencent-cloud.afphoto -------------------------------------------------------------------------------- /logo/afphoto/wise.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/wise.afphoto -------------------------------------------------------------------------------- /logo/afphoto/x.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SanmerApps/Authenticator/a90e9f5d15a3ed75399f6ad841260adb6f103d5f/logo/afphoto/x.afphoto -------------------------------------------------------------------------------- /logo/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.self.library) 3 | } 4 | 5 | android { 6 | namespace = "dev.sanmer.logo" 7 | } 8 | 9 | dependencies { 10 | implementation(libs.androidx.annotation) 11 | } 12 | -------------------------------------------------------------------------------- /logo/src/main/kotlin/dev/sanmer/logo/Logo.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.logo 2 | 3 | import androidx.annotation.DrawableRes 4 | 5 | data class Logo( 6 | @DrawableRes 7 | val res: Int, 8 | val name: String, 9 | val refillable: Boolean 10 | ) { 11 | companion object Default { 12 | private val logos = hashMapOf() 13 | 14 | private fun getOrNull(name: String): Logo? { 15 | if (logos.contains(name)) return logos[name] 16 | 17 | val brand = Brand.entries.firstOrNull { 18 | it.regex.toRegex().matches(name) 19 | } 20 | if (brand != null) { 21 | return Logo( 22 | res = brand.res, 23 | name = brand.name, 24 | refillable = false 25 | ).also { logos[name] = it } 26 | } 27 | 28 | val normal = Normal.entries.firstOrNull { 29 | it.regex.toRegex().containsMatchIn(name) 30 | } 31 | if (normal != null) { 32 | return Logo( 33 | res = normal.res, 34 | name = normal.name, 35 | refillable = true 36 | ).also { logos[name] = it } 37 | } 38 | 39 | logos[name] = null 40 | return null 41 | } 42 | 43 | fun getOrDefault(name: String) = 44 | getOrNull(name) ?: Logo( 45 | res = R.drawable.normal_default, 46 | name = name, 47 | refillable = true 48 | ) 49 | 50 | fun getOr(name: String, default: () -> Logo) = 51 | getOrNull(name) ?: default() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /logo/src/main/kotlin/dev/sanmer/logo/Normal.kt: -------------------------------------------------------------------------------- 1 | package dev.sanmer.logo 2 | 3 | import androidx.annotation.DrawableRes 4 | 5 | enum class Normal( 6 | @DrawableRes 7 | val res: Int, 8 | internal val regex: String 9 | ) { 10 | Cloud( 11 | res = R.drawable.normal_cloud, 12 | regex = "(?i)Cloud" 13 | ) 14 | } -------------------------------------------------------------------------------- /logo/src/main/res/drawable-night/brand_apple.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable-night/brand_bybit.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 26 | 27 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable-night/brand_github.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable-night/brand_okx.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 26 | 27 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable-night/brand_x.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_aliyun.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_apple.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_binance.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 26 | 27 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_bybit.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 26 | 27 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_cloudflare.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_coinbase.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_digitalocean.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_discord.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 16 | 17 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_facebook.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_github.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_gitlab.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_google.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_jetbrains.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | 17 | 20 | 23 | 26 | 29 | 30 | 31 | 32 | 36 | 40 | 41 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_mega.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_microsoft.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_okx.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 26 | 27 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_onlyfans.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_orcid.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_tencent_cloud.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_wise.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/brand_x.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/normal_cloud.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /logo/src/main/res/drawable/normal_default.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /logo/svg/aliyun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /logo/svg/apple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /logo/svg/binance.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /logo/svg/bybit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /logo/svg/coinbase.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /logo/svg/digitalocean.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /logo/svg/discord.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /logo/svg/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /logo/svg/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /logo/svg/gitlab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /logo/svg/google.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /logo/svg/jetbrains.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /logo/svg/mega.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /logo/svg/microsoft.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /logo/svg/okx.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /logo/svg/onlyfans.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /logo/svg/orcid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /logo/svg/tencent-cloud.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /logo/svg/wise.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /logo/svg/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | enableFeaturePreview("STABLE_CONFIGURATION_CACHE") 2 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 3 | 4 | dependencyResolutionManagement { 5 | repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS 6 | repositories { 7 | google() 8 | mavenCentral() 9 | } 10 | } 11 | 12 | pluginManagement { 13 | includeBuild("build-logic") 14 | repositories { 15 | google() 16 | mavenCentral() 17 | gradlePluginPortal() 18 | } 19 | } 20 | 21 | rootProject.name = "Authenticator" 22 | include(":core", ":logo", ":app") 23 | --------------------------------------------------------------------------------