From d6e72fda347e67e5b6e440b2a6c6c36ec65047ad Mon Sep 17 00:00:00 2001 From: SanmerDev Date: Sun, 9 Aug 2026 23:09:48 +0800 Subject: [PATCH 1/6] Bump version to v1 --- app/build.gradle.kts | 12 +- .../3.json | 87 +++++ .../res/drawable/launcher_foreground.xml | 52 +-- .../debug/res/drawable/launcher_splash.xml | 3 - app/src/main/AndroidManifest.xml | 26 +- .../kotlin/dev/sanmer/authenticator/App.kt | 2 +- .../kotlin/dev/sanmer/authenticator/Const.kt | 19 ++ .../kotlin/dev/sanmer/authenticator/Logo.kt | 119 +++++++ .../authenticator/compat/BuildCompat.kt | 12 + .../authenticator/compat/PermissionCompat.kt | 12 +- .../crypto/BiometricKey.kt | 57 ++-- .../authenticator/database/AppDatabase.kt | 71 ++-- .../authenticator/database/dao/AuthDao.kt | 71 ++++ .../authenticator/database/dao/TotpDao.kt | 45 --- .../database/entity/TotpEntity.kt | 29 -- .../authenticator/database/model/Auth.kt | 23 ++ .../database/model/AuthProperties.kt | 41 +++ .../database/model/AuthProperty.kt | 17 + .../datastore/PreferenceSerializer.kt | 22 +- .../datastore/model/Preference.kt | 27 +- .../authenticator/di/DataStoreModule.kt | 3 +- .../sanmer/authenticator/di/DatabaseModule.kt | 5 +- .../{ui => }/di/NavigationModule.kt | 67 ++-- .../authenticator/di/RepositoriesModule.kt | 3 + .../sanmer/authenticator/ktx/ContextExt.kt | 35 -- .../dev/sanmer/authenticator/ktx/Flow.kt | 15 + .../dev/sanmer/authenticator/ktx/LocaleExt.kt | 13 - .../authenticator/ktx/LocaleListCompatExt.kt | 6 - .../dev/sanmer/authenticator/ktx/StringExt.kt | 12 - .../sanmer/authenticator/model/LoadData.kt | 56 ++++ .../authenticator/model/auth/AuthType.kt | 5 - .../authenticator/model/auth/TotpAuth.kt | 30 -- .../sanmer/authenticator/model/otp/Totp.kt | 61 ++++ .../model/serializable/AuthSet.kt | 69 ++++ .../model/serializable/UriSerializer.kt | 26 ++ .../model/serializer/AuthJson.kt | 38 --- .../authenticator/model/serializer/AuthUri.kt | 59 ---- .../model/serializer/TotpJson.kt | 33 -- .../authenticator/repository/DbRepository.kt | 31 +- .../repository/DbRepositoryImpl.kt | 138 ++++---- .../authenticator/repository/OtpRepository.kt | 14 + .../repository/OtpRepositoryImpl.kt | 35 ++ .../repository/PreferenceRepository.kt | 1 + .../repository/PreferenceRepositoryImpl.kt | 8 + .../repository/TimeRepository.kt | 16 +- .../repository/TimeRepositoryImpl.kt | 123 +++---- .../authenticator/ui/AuthorizeActivity.kt | 129 ------- .../sanmer/authenticator/ui/CryptoActivity.kt | 177 ---------- .../sanmer/authenticator/ui/MainActivity.kt | 85 +++-- .../ui/component/DropdownMenu.kt | 43 --- .../authenticator/ui/component/IconRow.kt | 56 ++++ .../authenticator/ui/component/LabelText.kt | 3 + .../sanmer/authenticator/ui/component/Logo.kt | 42 --- .../ui/component/PageIndicator.kt | 145 -------- .../authenticator/ui/component/Placeholder.kt | 76 +++++ .../authenticator/ui/component/Point.kt | 81 +++++ .../ui/component/SegmentedButton.kt | 22 ++ .../authenticator/ui/component/Swipe.kt | 163 --------- .../authenticator/ui/di/ViewModelsModule.kt | 27 -- .../ui/ktx/{ClipboardExt.kt => Clipboard.kt} | 8 +- .../{LazyListStateExt.kt => LazyListState.kt} | 0 .../ui/ktx/{ModifierExt.kt => Modifier.kt} | 8 +- .../{PaddingValuesExt.kt => PaddingValues.kt} | 8 +- .../dev/sanmer/authenticator/ui/ktx/Shape.kt | 29 ++ .../sanmer/authenticator/ui/ktx/ShapeExt.kt | 8 - .../ui/{screens => screen}/Screen.kt | 20 +- .../ui/screen/brand/BrandScreen.kt | 110 ++++++ .../ui/screen/edit/EditScreen.kt | 275 +++++++++++++++ .../ui/screen/edit/EditViewModel.kt | 269 +++++++++++++++ .../ui/screen/edit/component/DigitsItem.kt | 72 ++++ .../ui/screen/edit/component/IssuerItem.kt | 65 ++++ .../ui/screen/edit/component/NameItem.kt | 42 +++ .../edit/component/PreviewBottomSheet.kt | 81 +++++ .../edit/component/QrcodeBottomSheet.kt | 101 ++++++ .../ui/screen/edit/component/SecretItem.kt | 76 +++++ .../ui/screen/edit/component/TypeItem.kt | 123 +++++++ .../ui/screen/export/ExportScreen.kt | 167 +++++++++ .../ui/screen/export/ExportViewModel.kt | 198 +++++++++++ .../ui/screen/export/component/AuthItem.kt | 53 +++ .../ui/screen/export/component/ButtonsItem.kt | 141 ++++++++ .../ui/screen/export/component/ErrorItem.kt | 29 ++ .../ui/screen/home/HomeScreen.kt | 158 +++++++++ .../ui/screen/home/HomeViewModel.kt | 73 ++++ .../ui/screen/home/component/AuthItem.kt | 176 ++++++++++ .../ui/screen/home/component/AuthList.kt | 39 +++ .../ui/{screens => screen}/main/MainScreen.kt | 4 +- .../ui/screen/main/MainViewModel.kt | 87 +++++ .../ui/screen/main/UnlockScreen.kt | 84 +++++ .../authenticator/ui/screen/ntp/NtpScreen.kt | 97 ++++++ .../ui/screen/ntp/NtpViewModel.kt | 141 ++++++++ .../screen/ntp/component/CustomBottomSheet.kt | 87 +++++ .../ui/screen/ntp/component/NtpItem.kt | 201 +++++++++++ .../ui/screen/ntp/component/NtpList.kt | 52 +++ .../ntp/component/NtpMessageBottomSheet.kt | 98 ++++++ .../ui/screen/ntp/component/ValueItem.kt | 38 +++ .../ui/screen/scan/ScanScreen.kt | 214 ++++++++++++ .../ui/screen/scan/ScanViewModel.kt | 170 ++++++++++ .../ui/screen/setting/SettingScreen.kt | 263 +++++++++++++++ .../ui/screen/setting/SettingViewModel.kt | 101 ++++++ .../setting/component/PasswordBottomSheet.kt | 129 +++++++ .../screen/setting/component/SettingColumn.kt | 29 ++ .../screen/setting/component/SettingItem.kt | 62 ++++ .../ui/screen/trash/TrashScreen.kt | 141 ++++++++ .../ui/screen/trash/TrashViewModel.kt | 81 +++++ .../ui/screen/trash/component/AuthItem.kt | 107 ++++++ .../ui/screen/trash/component/AuthList.kt | 41 +++ .../ui/screens/authorize/AuthorizeScreen.kt | 73 ---- .../screens/authorize/AuthorizeViewModel.kt | 212 ------------ .../authorize/component/ChangePasswordItem.kt | 45 --- .../authorize/component/EnterPasswordItem.kt | 52 --- .../authorize/component/PasswordTextField.kt | 96 ------ .../ui/screens/crypto/CryptoScreen.kt | 47 --- .../ui/screens/crypto/CryptoViewModel.kt | 84 ----- .../ui/screens/edit/EditScreen.kt | 173 ---------- .../ui/screens/edit/EditViewModel.kt | 128 ------- .../edit/component/DigitsAndPeriodItem.kt | 55 --- .../ui/screens/edit/component/SecretItem.kt | 135 -------- .../edit/component/TextFieldContent.kt | 169 ---------- .../screens/edit/component/TextFieldItem.kt | 48 --- .../screens/edit/component/TypeAndHashItem.kt | 42 --- .../ui/screens/encode/EncodeScreen.kt | 172 ---------- .../ui/screens/encode/EncodeViewModel.kt | 87 ----- .../ui/screens/home/HomeScreen.kt | 122 ------- .../ui/screens/home/HomeViewModel.kt | 91 ----- .../ui/screens/home/component/AuthItem.kt | 216 ------------ .../ui/screens/home/component/AuthList.kt | 44 --- .../ui/screens/main/LockScreen.kt | 59 ---- .../ui/screens/main/MainViewModel.kt | 65 ---- .../authenticator/ui/screens/ntp/NtpScreen.kt | 122 ------- .../ui/screens/ntp/NtpViewModel.kt | 186 ----------- .../ui/screens/ntp/component/NtpItem.kt | 144 -------- .../ui/screens/ntp/component/NtpList.kt | 45 --- .../ui/screens/scan/ScanScreen.kt | 233 ------------- .../ui/screens/scan/ScanViewModel.kt | 157 --------- .../ui/screens/security/SecurityScreen.kt | 122 ------- .../security/component/SecurityItem.kt | 75 ----- .../ui/screens/settings/SettingsScreen.kt | 219 ------------ .../ui/screens/settings/SettingsViewModel.kt | 257 -------------- .../settings/component/DatabaseItem.kt | 70 ---- .../settings/component/PreferenceItem.kt | 35 -- .../screens/settings/component/SettingItem.kt | 129 ------- .../screens/settings/component/TokenItem.kt | 35 -- .../ui/screens/settings/component/ToolItem.kt | 53 --- .../ui/screens/trash/TrashScreen.kt | 94 ------ .../ui/screens/trash/TrashViewModel.kt | 68 ---- .../ui/screens/trash/component/AuthItem.kt | 150 --------- .../ui/screens/trash/component/AuthList.kt | 40 --- .../sanmer/authenticator/ui/theme/Color.kt | 106 ++++++ .../sanmer/authenticator/ui/theme/Theme.kt | 16 +- app/src/main/res/drawable/arrow_bar_down.xml | 30 -- app/src/main/res/drawable/arrow_bar_up.xml | 30 -- .../res/drawable/{a_b.xml => arrow_right.xml} | 18 +- app/src/main/res/drawable/arrows_shuffle.xml | 30 ++ app/src/main/res/drawable/bug.xml | 60 ---- app/src/main/res/drawable/caret_down.xml | 9 - app/src/main/res/drawable/caret_up.xml | 9 - app/src/main/res/drawable/chevron_right.xml | 9 + app/src/main/res/drawable/clear_all.xml | 24 ++ app/src/main/res/drawable/clipboard_text.xml | 30 -- .../main/res/drawable/clock_record_thin.xml | 24 ++ .../res/drawable/{eye.xml => copyright.xml} | 12 +- app/src/main/res/drawable/database_export.xml | 42 --- app/src/main/res/drawable/download.xml | 9 + app/src/main/res/drawable/edit.xml | 24 -- app/src/main/res/drawable/eye_closed.xml | 36 -- app/src/main/res/drawable/file_export.xml | 18 - app/src/main/res/drawable/file_import.xml | 18 - .../main/res/drawable/fingerprint_thin.xml | 0 app/src/main/res/drawable/lock.xml | 24 -- app/src/main/res/drawable/lock_off.xml | 30 -- app/src/main/res/drawable/lock_open.xml | 24 -- app/src/main/res/drawable/mood_heart.xml | 36 -- app/src/main/res/drawable/plus.xml | 9 + app/src/main/res/drawable/scan.xml | 36 -- .../res/drawable/{refresh.xml => search.xml} | 12 +- app/src/main/res/drawable/shield_off.xml | 18 - app/src/main/res/drawable/sparkles_2.xml | 18 + app/src/main/res/drawable/sparkles_2_off.xml | 27 ++ app/src/main/res/drawable/spy.xml | 36 ++ app/src/main/res/drawable/tool.xml | 12 - app/src/main/res/drawable/upload.xml | 24 ++ app/src/main/res/values-zh-rCN/strings.xml | 95 ++---- app/src/main/res/values/colors_material.xml | 300 ----------------- app/src/main/res/values/strings.xml | 95 ++---- .../res/values/strings_untranslatable.xml | 3 +- app/src/main/res/values/themes.xml | 8 - {logo => brand}/build.gradle.kts | 2 +- .../src/main/kotlin/dev/sanmer/brand/Brand.kt | 174 ++++++++++ .../main/res/drawable-night/brand_apple.xml | 4 +- .../src/main/res/drawable-night/brand_aws.xml | 4 +- .../main/res/drawable-night/brand_bybit.xml | 20 +- .../main/res/drawable-night/brand_crowdin.xml | 34 ++ .../main/res/drawable-night}/brand_github.xml | 6 +- .../main/res/drawable-night}/brand_okx.xml | 30 +- .../main/res/drawable-night/brand_openai.xml | 6 +- .../main/res/drawable-night/brand_patreon.xml | 10 + .../main/res/drawable-night/brand_threads.xml | 10 + .../main/res/drawable-night/brand_wise.xml | 10 + .../src/main/res/drawable-night/brand_x.xml | 6 +- brand/src/main/res/drawable/brand_aliyun.xml | 14 + .../src/main/res/drawable/brand_apple.xml | 6 +- .../src/main/res/drawable/brand_aws.xml | 6 +- .../src/main/res/drawable/brand_azure.xml | 64 ++-- .../src/main/res/drawable/brand_binance.xml | 30 +- .../src/main/res/drawable/brand_bybit.xml | 20 +- .../main/res/drawable/brand_cloudflare.xml | 14 + .../src/main/res/drawable/brand_coinbase.xml | 10 + brand/src/main/res/drawable/brand_crowdin.xml | 34 ++ .../main/res/drawable/brand_digitalocean.xml | 8 +- .../src/main/res/drawable/brand_discord.xml | 10 +- brand/src/main/res/drawable/brand_docker.xml | 14 + .../src/main/res/drawable/brand_facebook.xml | 14 + brand/src/main/res/drawable/brand_fansly.xml | 10 + .../src/main/res/drawable/brand_gitea.xml | 13 +- .../src/main/res/drawable}/brand_github.xml | 6 +- .../src/main/res/drawable/brand_gitlab.xml | 16 +- .../src/main/res/drawable/brand_google.xml | 16 +- .../main/res/drawable/brand_googlecloud.xml | 22 ++ .../src/main/res/drawable/brand_instagram.xml | 91 +++++ .../src/main/res/drawable/brand_jetbrains.xml | 34 +- brand/src/main/res/drawable/brand_lark.xml | 18 + brand/src/main/res/drawable/brand_mega.xml | 12 + brand/src/main/res/drawable/brand_meta.xml | 316 ++++++++++++++++++ .../src/main/res/drawable/brand_microsoft.xml | 24 +- .../src/main/res/drawable}/brand_okx.xml | 30 +- .../src/main/res/drawable/brand_onlyfans.xml | 8 +- .../src/main/res/drawable/brand_openai.xml | 4 +- .../src/main/res/drawable/brand_orcid.xml | 16 +- brand/src/main/res/drawable/brand_patreon.xml | 10 + brand/src/main/res/drawable/brand_paypal.xml | 18 + .../src/main/res/drawable/brand_pixiv.xml | 28 +- brand/src/main/res/drawable/brand_tencent.xml | 9 + .../main/res/drawable/brand_tencentcloud.xml | 12 +- brand/src/main/res/drawable/brand_threads.xml | 10 + .../src/main/res/drawable/brand_whatsapp.xml | 14 + .../src/main/res/drawable/brand_wise.xml | 4 +- .../src/main/res/drawable/brand_x.xml | 6 +- build-logic/plugins/build.gradle.kts | 5 - .../kotlin/ApplicationConventionPlugin.kt | 7 +- .../main/kotlin/ComposeConventionPlugin.kt | 5 +- .../main/kotlin/LibraryConventionPlugin.kt | 2 +- .../src/main/kotlin/RoomConventionPlugin.kt | 27 -- build.gradle.kts | 1 + core/build.gradle.kts | 2 +- .../dev/sanmer/{encoding => auth}/Base32.kt | 2 +- .../dev/sanmer/{encoding => auth}/Base64.kt | 2 +- .../kotlin/dev/sanmer/{otp => auth}/Otp.kt | 2 +- .../src/main/kotlin/dev/sanmer/auth/OtpUri.kt | 64 ++++ .../dev/sanmer/{qrcode => auth}/QRCode.kt | 48 +-- .../dev/sanmer/{ => auth}/crypto/Crypto.kt | 31 +- .../sanmer/{ => auth}/crypto/PasswordKey.kt | 11 +- .../sanmer/{ => auth}/crypto/SessionKey.kt | 8 +- .../kotlin/dev/sanmer/auth/ntp/BaseClock.kt | 19 ++ .../kotlin/dev/sanmer/auth/ntp/NtpClock.kt | 11 + .../kotlin/dev/sanmer/auth/ntp/NtpMessage.kt | 65 ++++ .../kotlin/dev/sanmer/auth/ntp/NtpPacket.kt | 129 +++++++ .../kotlin/dev/sanmer/auth/ntp/NtpServer.kt | 81 +++++ .../main/kotlin/dev/sanmer/ntp/NtpServer.kt | 138 -------- core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt | 116 ------- gradle/libs.versions.toml | 11 +- logo/afphoto/aliyun.afphoto | Bin 13449 -> 0 bytes logo/afphoto/apple.afphoto | Bin 14913 -> 0 bytes logo/afphoto/aws.afphoto | Bin 33792 -> 0 bytes logo/afphoto/azure.afphoto | Bin 31762 -> 0 bytes logo/afphoto/binance.afphoto | Bin 15112 -> 0 bytes logo/afphoto/bybit.afphoto | Bin 12003 -> 0 bytes logo/afphoto/cloudflare.afphoto | Bin 15589 -> 0 bytes logo/afphoto/coinbase.afphoto | Bin 17531 -> 0 bytes logo/afphoto/crowdin.afphoto | Bin 29994 -> 0 bytes logo/afphoto/digitalocean.afphoto | Bin 16244 -> 0 bytes logo/afphoto/discord.afphoto | Bin 24894 -> 0 bytes logo/afphoto/facebook.afphoto | Bin 17782 -> 0 bytes logo/afphoto/gitea.afphoto | Bin 23789 -> 0 bytes logo/afphoto/github.afphoto | Bin 19168 -> 0 bytes logo/afphoto/gitlab.afphoto | Bin 23216 -> 0 bytes logo/afphoto/google.afphoto | Bin 20409 -> 0 bytes logo/afphoto/instagram.afphoto | Bin 76520 -> 0 bytes logo/afphoto/jetbrains.afphoto | Bin 24243 -> 0 bytes logo/afphoto/lark.afphoto | Bin 23382 -> 0 bytes logo/afphoto/mega.afphoto | Bin 19479 -> 0 bytes logo/afphoto/meta.afphoto | Bin 24077 -> 0 bytes logo/afphoto/microsoft.afphoto | Bin 7719 -> 0 bytes logo/afphoto/okx.afphoto | Bin 9602 -> 0 bytes logo/afphoto/onlyfans.afphoto | Bin 18028 -> 0 bytes logo/afphoto/openai.afphoto | Bin 27120 -> 0 bytes logo/afphoto/orcid.afphoto | Bin 20018 -> 0 bytes logo/afphoto/patreon.afphoto | Bin 18476 -> 0 bytes logo/afphoto/paypal.afphoto | Bin 19504 -> 0 bytes logo/afphoto/pixiv.afphoto | Bin 16811 -> 0 bytes logo/afphoto/tencent-cloud.afphoto | Bin 21487 -> 0 bytes logo/afphoto/wise.afphoto | Bin 15134 -> 0 bytes logo/afphoto/x.afphoto | Bin 17117 -> 0 bytes logo/src/main/kotlin/dev/sanmer/logo/Brand.kt | 138 -------- logo/src/main/kotlin/dev/sanmer/logo/Logo.kt | 52 --- .../src/main/kotlin/dev/sanmer/logo/Normal.kt | 13 - .../main/res/drawable-night/brand_patreon.xml | 12 - logo/src/main/res/drawable/brand_aliyun.xml | 18 - .../main/res/drawable/brand_cloudflare.xml | 18 - logo/src/main/res/drawable/brand_coinbase.xml | 10 - logo/src/main/res/drawable/brand_crowdin.xml | 20 -- logo/src/main/res/drawable/brand_facebook.xml | 14 - .../src/main/res/drawable/brand_instagram.xml | 94 ------ logo/src/main/res/drawable/brand_lark.xml | 21 -- logo/src/main/res/drawable/brand_mega.xml | 12 - logo/src/main/res/drawable/brand_meta.xml | 57 ---- logo/src/main/res/drawable/brand_patreon.xml | 12 - logo/src/main/res/drawable/brand_paypal.xml | 20 -- logo/src/main/res/drawable/normal_cloud.xml | 12 - logo/svg/aliyun.svg | 10 - logo/svg/apple.svg | 8 - logo/svg/aws.svg | 8 - logo/svg/azure.svg | 24 -- logo/svg/binance.svg | 12 - logo/svg/bybit.svg | 12 - logo/svg/cloudflare.svg | 16 - logo/svg/coinbase.svg | 8 - logo/svg/crowdin.svg | 12 - logo/svg/digitalocean.svg | 11 - logo/svg/discord.svg | 13 - logo/svg/facebook.svg | 13 - logo/svg/gitea.svg | 14 - logo/svg/github.svg | 8 - logo/svg/gitlab.svg | 11 - logo/svg/google.svg | 19 -- logo/svg/instagram.svg | 26 -- logo/svg/jetbrains.svg | 13 - logo/svg/lark.svg | 16 - logo/svg/mega.svg | 13 - logo/svg/meta.svg | 20 -- logo/svg/microsoft.svg | 11 - logo/svg/okx.svg | 12 - logo/svg/onlyfans.svg | 9 - logo/svg/openai.svg | 8 - logo/svg/orcid.svg | 13 - logo/svg/patreon.svg | 8 - logo/svg/paypal.svg | 11 - logo/svg/pixiv.svg | 14 - logo/svg/tencent-cloud.svg | 10 - logo/svg/wise.svg | 8 - logo/svg/x.svg | 8 - settings.gradle.kts | 2 +- 341 files changed, 7745 insertions(+), 8337 deletions(-) create mode 100644 app/schemas/dev.sanmer.authenticator.database.AppDatabase/3.json delete mode 100644 app/src/debug/res/drawable/launcher_splash.xml create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/Logo.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/compat/BuildCompat.kt rename app/src/main/kotlin/dev/sanmer/{ => authenticator}/crypto/BiometricKey.kt (75%) create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/database/dao/AuthDao.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/database/model/Auth.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperties.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperty.kt rename app/src/main/kotlin/dev/sanmer/authenticator/{ui => }/di/NavigationModule.kt (54%) delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ktx/Flow.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/LoadData.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/auth/AuthType.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/auth/TotpAuth.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/otp/Totp.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/AuthSet.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/UriSerializer.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthUri.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpJson.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepository.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepositoryImpl.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/AuthorizeActivity.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/CryptoActivity.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/IconRow.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/PageIndicator.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Placeholder.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Point.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/SegmentedButton.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Swipe.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/di/ViewModelsModule.kt rename app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/{ClipboardExt.kt => Clipboard.kt} (67%) rename app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/{LazyListStateExt.kt => LazyListState.kt} (100%) rename app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/{ModifierExt.kt => Modifier.kt} (70%) rename app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/{PaddingValuesExt.kt => PaddingValues.kt} (86%) create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Shape.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt rename app/src/main/kotlin/dev/sanmer/authenticator/ui/{screens => screen}/Screen.kt (53%) create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/brand/BrandScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/DigitsItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/IssuerItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/NameItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/PreviewBottomSheet.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/QrcodeBottomSheet.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/SecretItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/TypeItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/AuthItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ButtonsItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ErrorItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthList.kt rename app/src/main/kotlin/dev/sanmer/authenticator/ui/{screens => screen}/main/MainScreen.kt (94%) create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/UnlockScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/CustomBottomSheet.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpList.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpMessageBottomSheet.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/ValueItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/PasswordBottomSheet.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingColumn.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashScreen.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashViewModel.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthItem.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthList.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/PasswordTextField.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/SecretItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldContent.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/LockScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/SecurityScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/component/SecurityItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/DatabaseItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/SettingItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashScreen.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashViewModel.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthItem.kt delete mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt create mode 100644 app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Color.kt delete mode 100644 app/src/main/res/drawable/arrow_bar_down.xml delete mode 100644 app/src/main/res/drawable/arrow_bar_up.xml rename app/src/main/res/drawable/{a_b.xml => arrow_right.xml} (50%) create mode 100644 app/src/main/res/drawable/arrows_shuffle.xml delete mode 100644 app/src/main/res/drawable/bug.xml delete mode 100644 app/src/main/res/drawable/caret_down.xml delete mode 100644 app/src/main/res/drawable/caret_up.xml create mode 100644 app/src/main/res/drawable/chevron_right.xml create mode 100644 app/src/main/res/drawable/clear_all.xml delete mode 100644 app/src/main/res/drawable/clipboard_text.xml create mode 100644 app/src/main/res/drawable/clock_record_thin.xml rename app/src/main/res/drawable/{eye.xml => copyright.xml} (50%) delete mode 100644 app/src/main/res/drawable/database_export.xml create mode 100644 app/src/main/res/drawable/download.xml delete mode 100644 app/src/main/res/drawable/edit.xml delete mode 100644 app/src/main/res/drawable/eye_closed.xml delete mode 100644 app/src/main/res/drawable/file_export.xml delete mode 100644 app/src/main/res/drawable/file_import.xml rename logo/src/main/res/drawable/normal_default.xml => app/src/main/res/drawable/fingerprint_thin.xml (100%) delete mode 100644 app/src/main/res/drawable/lock.xml delete mode 100644 app/src/main/res/drawable/lock_off.xml delete mode 100644 app/src/main/res/drawable/lock_open.xml delete mode 100644 app/src/main/res/drawable/mood_heart.xml create mode 100644 app/src/main/res/drawable/plus.xml delete mode 100644 app/src/main/res/drawable/scan.xml rename app/src/main/res/drawable/{refresh.xml => search.xml} (54%) delete mode 100644 app/src/main/res/drawable/shield_off.xml create mode 100644 app/src/main/res/drawable/sparkles_2.xml create mode 100644 app/src/main/res/drawable/sparkles_2_off.xml create mode 100644 app/src/main/res/drawable/spy.xml delete mode 100644 app/src/main/res/drawable/tool.xml create mode 100644 app/src/main/res/drawable/upload.xml delete mode 100644 app/src/main/res/values/colors_material.xml rename {logo => brand}/build.gradle.kts (78%) create mode 100644 brand/src/main/kotlin/dev/sanmer/brand/Brand.kt rename {logo => brand}/src/main/res/drawable-night/brand_apple.xml (88%) rename {logo => brand}/src/main/res/drawable-night/brand_aws.xml (95%) rename {logo => brand}/src/main/res/drawable-night/brand_bybit.xml (81%) create mode 100644 brand/src/main/res/drawable-night/brand_crowdin.xml rename {logo/src/main/res/drawable => brand/src/main/res/drawable-night}/brand_github.xml (84%) rename {logo/src/main/res/drawable => brand/src/main/res/drawable-night}/brand_okx.xml (57%) rename {logo => brand}/src/main/res/drawable-night/brand_openai.xml (92%) create mode 100644 brand/src/main/res/drawable-night/brand_patreon.xml create mode 100644 brand/src/main/res/drawable-night/brand_threads.xml create mode 100644 brand/src/main/res/drawable-night/brand_wise.xml rename {logo => brand}/src/main/res/drawable-night/brand_x.xml (76%) create mode 100644 brand/src/main/res/drawable/brand_aliyun.xml rename {logo => brand}/src/main/res/drawable/brand_apple.xml (84%) rename {logo => brand}/src/main/res/drawable/brand_aws.xml (94%) rename {logo => brand}/src/main/res/drawable/brand_azure.xml (54%) rename {logo => brand}/src/main/res/drawable/brand_binance.xml (50%) rename {logo => brand}/src/main/res/drawable/brand_bybit.xml (81%) create mode 100644 brand/src/main/res/drawable/brand_cloudflare.xml create mode 100644 brand/src/main/res/drawable/brand_coinbase.xml create mode 100644 brand/src/main/res/drawable/brand_crowdin.xml rename {logo => brand}/src/main/res/drawable/brand_digitalocean.xml (88%) rename {logo => brand}/src/main/res/drawable/brand_discord.xml (85%) create mode 100644 brand/src/main/res/drawable/brand_docker.xml create mode 100644 brand/src/main/res/drawable/brand_facebook.xml create mode 100644 brand/src/main/res/drawable/brand_fansly.xml rename {logo => brand}/src/main/res/drawable/brand_gitea.xml (79%) rename {logo/src/main/res/drawable-night => brand/src/main/res/drawable}/brand_github.xml (84%) rename {logo => brand}/src/main/res/drawable/brand_gitlab.xml (70%) rename {logo => brand}/src/main/res/drawable/brand_google.xml (70%) create mode 100644 brand/src/main/res/drawable/brand_googlecloud.xml create mode 100644 brand/src/main/res/drawable/brand_instagram.xml rename {logo => brand}/src/main/res/drawable/brand_jetbrains.xml (65%) create mode 100644 brand/src/main/res/drawable/brand_lark.xml create mode 100644 brand/src/main/res/drawable/brand_mega.xml create mode 100644 brand/src/main/res/drawable/brand_meta.xml rename {logo => brand}/src/main/res/drawable/brand_microsoft.xml (54%) rename {logo/src/main/res/drawable-night => brand/src/main/res/drawable}/brand_okx.xml (57%) rename {logo => brand}/src/main/res/drawable/brand_onlyfans.xml (68%) rename {logo => brand}/src/main/res/drawable/brand_openai.xml (94%) rename {logo => brand}/src/main/res/drawable/brand_orcid.xml (71%) create mode 100644 brand/src/main/res/drawable/brand_patreon.xml create mode 100644 brand/src/main/res/drawable/brand_paypal.xml rename {logo => brand}/src/main/res/drawable/brand_pixiv.xml (82%) create mode 100644 brand/src/main/res/drawable/brand_tencent.xml rename logo/src/main/res/drawable/brand_tencent_cloud.xml => brand/src/main/res/drawable/brand_tencentcloud.xml (85%) create mode 100644 brand/src/main/res/drawable/brand_threads.xml create mode 100644 brand/src/main/res/drawable/brand_whatsapp.xml rename {logo => brand}/src/main/res/drawable/brand_wise.xml (91%) rename {logo => brand}/src/main/res/drawable/brand_x.xml (76%) delete mode 100644 build-logic/plugins/src/main/kotlin/RoomConventionPlugin.kt rename core/src/main/kotlin/dev/sanmer/{encoding => auth}/Base32.kt (96%) rename core/src/main/kotlin/dev/sanmer/{encoding => auth}/Base64.kt (96%) rename core/src/main/kotlin/dev/sanmer/{otp => auth}/Otp.kt (98%) create mode 100644 core/src/main/kotlin/dev/sanmer/auth/OtpUri.kt rename core/src/main/kotlin/dev/sanmer/{qrcode => auth}/QRCode.kt (77%) rename core/src/main/kotlin/dev/sanmer/{ => auth}/crypto/Crypto.kt (72%) rename core/src/main/kotlin/dev/sanmer/{ => auth}/crypto/PasswordKey.kt (80%) rename core/src/main/kotlin/dev/sanmer/{ => auth}/crypto/SessionKey.kt (89%) create mode 100644 core/src/main/kotlin/dev/sanmer/auth/ntp/BaseClock.kt create mode 100644 core/src/main/kotlin/dev/sanmer/auth/ntp/NtpClock.kt create mode 100644 core/src/main/kotlin/dev/sanmer/auth/ntp/NtpMessage.kt create mode 100644 core/src/main/kotlin/dev/sanmer/auth/ntp/NtpPacket.kt create mode 100644 core/src/main/kotlin/dev/sanmer/auth/ntp/NtpServer.kt delete mode 100644 core/src/main/kotlin/dev/sanmer/ntp/NtpServer.kt delete mode 100644 core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt delete mode 100644 logo/afphoto/aliyun.afphoto delete mode 100644 logo/afphoto/apple.afphoto delete mode 100644 logo/afphoto/aws.afphoto delete mode 100644 logo/afphoto/azure.afphoto delete mode 100644 logo/afphoto/binance.afphoto delete mode 100644 logo/afphoto/bybit.afphoto delete mode 100644 logo/afphoto/cloudflare.afphoto delete mode 100644 logo/afphoto/coinbase.afphoto delete mode 100644 logo/afphoto/crowdin.afphoto delete mode 100644 logo/afphoto/digitalocean.afphoto delete mode 100644 logo/afphoto/discord.afphoto delete mode 100644 logo/afphoto/facebook.afphoto delete mode 100644 logo/afphoto/gitea.afphoto delete mode 100644 logo/afphoto/github.afphoto delete mode 100644 logo/afphoto/gitlab.afphoto delete mode 100644 logo/afphoto/google.afphoto delete mode 100644 logo/afphoto/instagram.afphoto delete mode 100644 logo/afphoto/jetbrains.afphoto delete mode 100644 logo/afphoto/lark.afphoto delete mode 100644 logo/afphoto/mega.afphoto delete mode 100644 logo/afphoto/meta.afphoto delete mode 100644 logo/afphoto/microsoft.afphoto delete mode 100644 logo/afphoto/okx.afphoto delete mode 100644 logo/afphoto/onlyfans.afphoto delete mode 100644 logo/afphoto/openai.afphoto delete mode 100644 logo/afphoto/orcid.afphoto delete mode 100644 logo/afphoto/patreon.afphoto delete mode 100644 logo/afphoto/paypal.afphoto delete mode 100644 logo/afphoto/pixiv.afphoto delete mode 100644 logo/afphoto/tencent-cloud.afphoto delete mode 100644 logo/afphoto/wise.afphoto delete mode 100644 logo/afphoto/x.afphoto delete mode 100644 logo/src/main/kotlin/dev/sanmer/logo/Brand.kt delete mode 100644 logo/src/main/kotlin/dev/sanmer/logo/Logo.kt delete mode 100644 logo/src/main/kotlin/dev/sanmer/logo/Normal.kt delete mode 100644 logo/src/main/res/drawable-night/brand_patreon.xml delete mode 100644 logo/src/main/res/drawable/brand_aliyun.xml delete mode 100644 logo/src/main/res/drawable/brand_cloudflare.xml delete mode 100644 logo/src/main/res/drawable/brand_coinbase.xml delete mode 100644 logo/src/main/res/drawable/brand_crowdin.xml delete mode 100644 logo/src/main/res/drawable/brand_facebook.xml delete mode 100644 logo/src/main/res/drawable/brand_instagram.xml delete mode 100644 logo/src/main/res/drawable/brand_lark.xml delete mode 100644 logo/src/main/res/drawable/brand_mega.xml delete mode 100644 logo/src/main/res/drawable/brand_meta.xml delete mode 100644 logo/src/main/res/drawable/brand_patreon.xml delete mode 100644 logo/src/main/res/drawable/brand_paypal.xml delete mode 100644 logo/src/main/res/drawable/normal_cloud.xml delete mode 100644 logo/svg/aliyun.svg delete mode 100644 logo/svg/apple.svg delete mode 100644 logo/svg/aws.svg delete mode 100644 logo/svg/azure.svg delete mode 100644 logo/svg/binance.svg delete mode 100644 logo/svg/bybit.svg delete mode 100644 logo/svg/cloudflare.svg delete mode 100644 logo/svg/coinbase.svg delete mode 100644 logo/svg/crowdin.svg delete mode 100644 logo/svg/digitalocean.svg delete mode 100644 logo/svg/discord.svg delete mode 100644 logo/svg/facebook.svg delete mode 100644 logo/svg/gitea.svg delete mode 100644 logo/svg/github.svg delete mode 100644 logo/svg/gitlab.svg delete mode 100644 logo/svg/google.svg delete mode 100644 logo/svg/instagram.svg delete mode 100644 logo/svg/jetbrains.svg delete mode 100644 logo/svg/lark.svg delete mode 100644 logo/svg/mega.svg delete mode 100644 logo/svg/meta.svg delete mode 100644 logo/svg/microsoft.svg delete mode 100644 logo/svg/okx.svg delete mode 100644 logo/svg/onlyfans.svg delete mode 100644 logo/svg/openai.svg delete mode 100644 logo/svg/orcid.svg delete mode 100644 logo/svg/patreon.svg delete mode 100644 logo/svg/paypal.svg delete mode 100644 logo/svg/pixiv.svg delete mode 100644 logo/svg/tencent-cloud.svg delete mode 100644 logo/svg/wise.svg delete mode 100644 logo/svg/x.svg diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9d74aa..0a03017 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,8 +4,9 @@ import java.time.Instant plugins { alias(libs.plugins.self.application) alias(libs.plugins.self.compose) - alias(libs.plugins.self.room) + alias(libs.plugins.androidx.room) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } val baseVersionName = "0.2.5" @@ -87,9 +88,13 @@ androidComponents.onVariants { variant -> } } +room3 { + schemaDirectory("$projectDir/schemas") +} + dependencies { + implementation(project(":brand")) implementation(project(":core")) - implementation(project(":logo")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) @@ -106,10 +111,13 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) implementation(libs.koin.android) implementation(libs.koin.compose) implementation(libs.koin.compose.navigation3) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.protobuf) } diff --git a/app/schemas/dev.sanmer.authenticator.database.AppDatabase/3.json b/app/schemas/dev.sanmer.authenticator.database.AppDatabase/3.json new file mode 100644 index 0000000..ed6a8ec --- /dev/null +++ b/app/schemas/dev.sanmer.authenticator.database.AppDatabase/3.json @@ -0,0 +1,87 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "e6844b57f2d0ab509a4ff97ac517ab00", + "entities": [ + { + "tableName": "Auth", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY NOT NULL, `name` TEXT NOT NULL, `issuer` TEXT NOT NULL, `type` TEXT NOT NULL, `trashedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "issuer", + "columnName": "issuer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trashedAt", + "columnName": "trashedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ], + "algorithm": "ROWID" + } + }, + { + "tableName": "AuthProperty", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authId` INTEGER NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`authId`, `key`))", + "fields": [ + { + "fieldPath": "authId", + "columnName": "authId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authId", + "key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e6844b57f2d0ab509a4ff97ac517ab00')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/drawable/launcher_foreground.xml b/app/src/debug/res/drawable/launcher_foreground.xml index d5df5ec..b9aa196 100644 --- a/app/src/debug/res/drawable/launcher_foreground.xml +++ b/app/src/debug/res/drawable/launcher_foreground.xml @@ -9,58 +9,10 @@ android:translateX="6.72" android:translateY="6.72"> - - - - - - - - - + \ No newline at end of file diff --git a/app/src/debug/res/drawable/launcher_splash.xml b/app/src/debug/res/drawable/launcher_splash.xml deleted file mode 100644 index 5c33747..0000000 --- a/app/src/debug/res/drawable/launcher_splash.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 09e2eac..340d35a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,39 +20,29 @@ - + + - - + + - + + diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/App.kt b/app/src/main/kotlin/dev/sanmer/authenticator/App.kt index e05e258..875ae49 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/App.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/App.kt @@ -6,8 +6,8 @@ import androidx.camera.camera2.Camera2Config import androidx.camera.core.CameraXConfig import dev.sanmer.authenticator.di.DataStore import dev.sanmer.authenticator.di.Database +import dev.sanmer.authenticator.di.Navigation import dev.sanmer.authenticator.di.Repositories -import dev.sanmer.authenticator.ui.di.Navigation import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt b/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt index ca78be4..6fe3d2e 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/Const.kt @@ -1,5 +1,24 @@ package dev.sanmer.authenticator +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.format.char +import kotlin.time.Instant + object Const { const val GITHUB_URL = "https://github.com/SanmerApps/Authenticator" + + val INSTANT_ZERO = Instant.fromEpochSeconds(0, 0) + inline val Instant.isZero get() = this == INSTANT_ZERO + + val TIME_DISPLAY = LocalTime.Format { + hour(); char(':'); minute(); char(':'); second() + } + + val DATETIME_DISPLAY = LocalDateTime.Format { + date(LocalDate.Formats.ISO) + char(' ') + time(TIME_DISPLAY) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/Logo.kt b/app/src/main/kotlin/dev/sanmer/authenticator/Logo.kt new file mode 100644 index 0000000..3674153 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/Logo.kt @@ -0,0 +1,119 @@ +package dev.sanmer.authenticator + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +val Aliyun = ImageVector.Builder( + name = "Aliyun", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 1608f, + viewportHeight = 1024f +).apply { + // 中间横条 + path( + fill = SolidColor(Color(0xFFFF6A00)) + ) { + moveTo(537.6f, 445.44f) + lineTo(1075.2f, 445.44f) + lineTo(1075.2f, 568.32f) + lineTo(537.6f, 568.32f) + close() + } + + // 阿里云主体图形 + path( + fill = SolidColor(Color(0xFFFF6A00)) + ) { + moveTo(1341.44f, 5.12f) + lineTo(988.16f, 5.12f) + lineTo(1075.2f, 128f) + lineTo(1331.2f, 209.92f) + + curveTo( + 1377.28f, 225.28f, + 1408f, 271.36f, + 1408f, 317.44f + ) + + lineTo(1408f, 706.56f) + + curveTo( + 1408f, 752.64f, + 1377.28f, 798.72f, + 1331.2f, 814.08f + ) + + lineTo(1075.2f, 896f) + lineTo(988.16f, 1018.88f) + lineTo(1341.44f, 1018.88f) + + curveTo( + 1489.92f, 1018.88f, + 1607.68f, 901.12f, + 1607.68f, 752.64f + ) + + lineTo(1607.68f, 276.48f) + + curveTo( + 1607.68f, 128f, + 1489.92f, 5.12f, + 1341.44f, 5.12f + ) + + moveTo(276.48f, 814.08f) + curveTo( + 230.4f, 798.72f, + 199.68f, 752.64f, + 199.68f, 706.56f + ) + + lineTo(199.68f, 317.44f) + + curveTo( + 199.68f, 271.36f, + 230.4f, 225.28f, + 276.48f, 209.92f + ) + + lineTo(532.48f, 128f) + lineTo(619.52f, 5.12f) + lineTo(266.24f, 5.12f) + + curveTo( + 117.76f, 5.12f, + 0f, 128f, + 0f, 276.48f + ) + + lineTo(0f, 747.52f) + + curveTo( + 0f, 896f, + 117.76f, 1013.76f, + 266.24f, 1013.76f + ) + + lineTo(619.52f, 1013.76f) + lineTo(532.48f, 890.88f) + lineTo(276.48f, 814.08f) + + close() + } +}.build() + +@Preview +@Composable +private fun Aliyun() { + Icon( + imageVector = Aliyun, + contentDescription = null + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/compat/BuildCompat.kt b/app/src/main/kotlin/dev/sanmer/authenticator/compat/BuildCompat.kt new file mode 100644 index 0000000..56ecc46 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/compat/BuildCompat.kt @@ -0,0 +1,12 @@ +package dev.sanmer.authenticator.compat + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast + +object BuildCompat { + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) + val atLeastT inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) + val atLeastS inline get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt b/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt index 8042c9d..799c708 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/compat/PermissionCompat.kt @@ -1,11 +1,12 @@ package dev.sanmer.authenticator.compat +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.content.pm.PackageManager import androidx.activity.result.ActivityResultRegistryOwner import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat -import dev.sanmer.authenticator.ktx.findActivity import java.util.UUID object PermissionCompat { @@ -35,6 +36,15 @@ object PermissionCompat { permissions = listOf(permission) ).allGranted + private fun Context.findActivity(): Activity? { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + return null + } + fun requestPermissions( context: Context, permissions: List, diff --git a/app/src/main/kotlin/dev/sanmer/crypto/BiometricKey.kt b/app/src/main/kotlin/dev/sanmer/authenticator/crypto/BiometricKey.kt similarity index 75% rename from app/src/main/kotlin/dev/sanmer/crypto/BiometricKey.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/crypto/BiometricKey.kt index ca40baa..e79aba1 100644 --- a/app/src/main/kotlin/dev/sanmer/crypto/BiometricKey.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/crypto/BiometricKey.kt @@ -1,8 +1,8 @@ -package dev.sanmer.crypto +package dev.sanmer.authenticator.crypto -import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties +import androidx.activity.ComponentActivity import androidx.biometric.AuthenticationRequest.Biometric import androidx.biometric.AuthenticationResult import androidx.biometric.AuthenticationResultLauncher @@ -10,31 +10,28 @@ import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators import androidx.biometric.BiometricPrompt import androidx.biometric.registerForAuthenticationResult -import androidx.fragment.app.FragmentActivity +import dev.sanmer.auth.crypto.Crypto +import dev.sanmer.auth.crypto.SessionKey import dev.sanmer.authenticator.BuildConfig import dev.sanmer.authenticator.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withContext +import java.security.Key import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator -import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -class BiometricKey(context: Context) : Crypto { - private val key by lazy { getSecretKey() } - - private val builder by lazy { - Biometric.Builder( - title = context.getString(R.string.biometric_title) - ).apply { - setIsConfirmationRequired(true) - } - } +class BiometricKey private constructor( + val key: Key +) : Crypto { + constructor() : this(getSecretKey()) private suspend fun Cipher.authenticated() = withContext(Dispatchers.Main) { val launcher = checkNotNull(launcher) { "BiometricKey uninitialized" } + val builder = checkNotNull(builder) { "BiometricKey uninitialized" } + val biometric = builder.setMinStrength( Biometric.Strength.Class3(BiometricPrompt.CryptoObject(this@authenticated)) ).build() @@ -67,15 +64,26 @@ class BiometricKey(context: Context) : Crypto { } companion object Default { - fun canAuthenticate(context: Context) = BiometricManager.from(context) - .canAuthenticate(Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - private var launcher: AuthenticationResultLauncher? = null + private var builder: Biometric.Builder? = null private val channel = Channel() - fun init(activity: FragmentActivity) { + + val isInitialized get() = launcher != null && builder != null + + fun init(activity: ComponentActivity) { + if (BiometricManager.from(activity) + .canAuthenticate(Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS + ) { + return + } launcher = activity.registerForAuthenticationResult { result -> channel.trySend(result) } + builder = Biometric.Builder( + title = activity.getString(R.string.setting_biometric_verify) + ).apply { + setIsConfirmationRequired(true) + } } private fun generateSecretKey(keyGenParameterSpec: KeyGenParameterSpec) = @@ -88,7 +96,7 @@ class BiometricKey(context: Context) : Crypto { .getKey(BuildConfig.APPLICATION_ID, null) .let { requireNotNull(it) { "Expect key(alias=${BuildConfig.APPLICATION_ID})" } } - fun new(): SecretKey = generateSecretKey( + fun new() = generateSecretKey( KeyGenParameterSpec.Builder( BuildConfig.APPLICATION_ID, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT @@ -99,16 +107,15 @@ class BiometricKey(context: Context) : Crypto { .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG) .setInvalidatedByBiometricEnrollment(true) .build() - ) + ).let(::BiometricKey) - suspend fun SessionKey.getKeyEncryptedByBiometric(context: Context) = - BiometricKey(context).encrypt(key.encoded) + suspend fun SessionKey.getKeyEncryptedByBiometric() = + new().encrypt(key.encoded) suspend fun SessionKey.Default.decryptKeyByBiometric( - key: ByteArray, - context: Context + key: ByteArray ) = SessionKey( - key = BiometricKey(context).decrypt(key).toSecretKey() + key = BiometricKey().decrypt(key).toSecretKey() ) } diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt index 4fe00f6..f988a0c 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/AppDatabase.kt @@ -1,44 +1,65 @@ package dev.sanmer.authenticator.database import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import androidx.room.migration.Migration -import dev.sanmer.authenticator.database.dao.TotpDao -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.otp.Otp - -@Database(version = 2, entities = [TotpEntity::class]) -@TypeConverters(AppDatabase.Default::class) +import androidx.room3.ColumnTypeConverter +import androidx.room3.ColumnTypeConverters +import androidx.room3.Database +import androidx.room3.Room +import androidx.room3.RoomDatabase +import androidx.room3.migration.Migration +import androidx.sqlite.execSQL +import dev.sanmer.authenticator.database.dao.AuthDao +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.database.model.AuthProperty +import kotlin.time.Instant + +@Database( + entities = [ + Auth::class, + AuthProperty::class + ], + version = 3 +) +@ColumnTypeConverters(AppDatabase.Default::class) abstract class AppDatabase : RoomDatabase() { - abstract fun totp(): TotpDao + abstract fun auth(): AuthDao companion object Default { - @TypeConverter - fun nameToHash(value: String) = Otp.Hash.valueOf(value) + @ColumnTypeConverter + fun fromEpochMilliseconds(value: Long) = Instant.fromEpochMilliseconds(value) - @TypeConverter - fun hashToName(value: Otp.Hash) = value.name + @ColumnTypeConverter + fun toEpochMilliseconds(value: Instant) = value.toEpochMilliseconds() fun build(context: Context) = Room.databaseBuilder( context = context, name = "auth" ).addMigrations( - MIGRATION_1_2 + MIGRATION_1_2, + MIGRATION_2_3 ).build() - private val MIGRATION_1_2 = Migration(1, 2) { - it.execSQL("DROP TABLE trash") - it.execSQL("DROP TABLE hotp") + private val MIGRATION_1_2 = Migration(1, 2) { connection -> + connection.execSQL("DROP TABLE trash") + connection.execSQL("DROP TABLE hotp") + + connection.execSQL("CREATE TABLE totp_new (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, deletedAt INTEGER NOT NULL, issuer TEXT NOT NULL, name TEXT NOT NULL, secret TEXT NOT NULL, hash TEXT NOT NULL, digits INTEGER NOT NULL, period INTEGER NOT NULL)") + connection.execSQL("INSERT INTO totp_new (deletedAt, issuer, name, secret, hash, digits, period) SELECT 0, issuer, name, secret, hash, digits, period FROM totp") + connection.execSQL("DROP TABLE totp") + connection.execSQL("ALTER TABLE totp_new RENAME TO totp") + } + + private val MIGRATION_2_3 = Migration(2, 3) { connection -> + connection.execSQL("CREATE TABLE Auth (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, issuer TEXT NOT NULL, type TEXT NOT NULL, trashedAt INTEGER NOT NULL)") + connection.execSQL("INSERT INTO Auth (id, name, issuer, type, trashedAt) SELECT id, name, issuer, 'TOTP', deletedAt FROM totp") - it.execSQL("CREATE TABLE IF NOT EXISTS totp_2 (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, deletedAt INTEGER NOT NULL, issuer TEXT NOT NULL, name TEXT NOT NULL, secret TEXT NOT NULL, hash TEXT NOT NULL, digits INTEGER NOT NULL, period INTEGER NOT NULL)") - it.execSQL("INSERT INTO totp_2 (deletedAt, issuer, name, secret, hash, digits, period) SELECT 0, issuer, name, secret, hash, digits, period FROM totp") - it.execSQL("DROP TABLE totp") - it.execSQL("ALTER TABLE totp_2 RENAME TO totp") + connection.execSQL("CREATE TABLE AuthProperty (authId INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(authId, key))") + connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Secret', secret FROM totp") + connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Hash', hash FROM totp") + connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Digits', CAST(digits AS TEXT) FROM totp") + connection.execSQL("INSERT INTO AuthProperty (authId, key, value) SELECT id, 'Period', CAST(period AS TEXT) FROM totp") + connection.execSQL("DROP TABLE totp") } } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/AuthDao.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/AuthDao.kt new file mode 100644 index 0000000..3fcc5d7 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/AuthDao.kt @@ -0,0 +1,71 @@ +package dev.sanmer.authenticator.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Transaction +import androidx.room3.Upsert +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.database.model.AuthProperty +import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant + +@Dao +interface AuthDao { + @Transaction + @Query("SELECT * FROM Auth") + suspend fun getAllAuthProperties(): List + + @Transaction + @Query("SELECT * FROM Auth WHERE trashedAt = 0") + suspend fun getUntrashedAuthProperties(): List + + @Query("SELECT * FROM AuthProperty WHERE key IN (:key)") + suspend fun getProperties(vararg key: AuthProperty.Key): List + + @Transaction + @Query("SELECT * FROM Auth") + fun getAllAuthPropertiesAsFlow(): Flow> + + @Transaction + @Query("SELECT * FROM Auth WHERE trashedAt = 0") + fun getUntrashedAuthPropertiesAsFlow(): Flow> + + @Transaction + @Query("SELECT * FROM Auth WHERE trashedAt != 0") + fun getTrashedAuthAsFlow(): Flow> + + @Query("SELECT COUNT(*) FROM Auth WHERE trashedAt != 0") + fun getTrashedCountAsFlow(): Flow + + @Transaction + @Query("SELECT * FROM Auth WHERE id = :id") + fun getAuthPropertiesAsFlow(id: Long): Flow + + @Upsert + suspend fun upsertAuth(auth: Auth): Long + + @Query("UPDATE Auth SET trashedAt = :trashedAt WHERE id = :id") + suspend fun trashAuth(id: Long, trashedAt: Instant) + + @Query("DELETE FROM Auth WHERE id = :id") + suspend fun deleteAuth(id: Long) + + @Upsert + suspend fun upsertProperties(properties: List) + + @Query("DELETE FROM AuthProperty WHERE authId = :authId") + suspend fun deleteProperties(authId: Long) + + @Transaction + suspend fun upsert(auth: Auth, properties: List) { + val authId = upsertAuth(auth).coerceAtLeast(auth.id) + upsertProperties(properties.map { it.copy(authId = authId) }) + } + + @Transaction + suspend fun delete(authId: Long) { + deleteAuth(authId) + deleteProperties(authId) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt deleted file mode 100644 index 8e31ce3..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/database/dao/TotpDao.kt +++ /dev/null @@ -1,45 +0,0 @@ -package dev.sanmer.authenticator.database.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Update -import dev.sanmer.authenticator.database.entity.TotpEntity -import kotlinx.coroutines.flow.Flow - -@Dao -interface TotpDao { - @Query("SELECT * FROM totp WHERE deletedAt != 0") - suspend fun getAllTrashed(): List - - @Query("SELECT * FROM totp WHERE deletedAt = 0") - fun getAllEnabledAsFlow(): Flow> - - @Query("SELECT * FROM totp WHERE deletedAt != 0") - fun getAllTrashedAsFlow(): Flow> - - @Query("SELECT * FROM totp WHERE id = :id") - fun getByIdAsFlow(id: Long): Flow - - @Query("SELECT * FROM totp") - suspend fun getAll(): List - - @Insert - suspend fun insert(entity: TotpEntity) - - @Insert - suspend fun insert(entities: List) - - @Update - suspend fun update(entity: TotpEntity) - - @Update - suspend fun update(entities: List) - - @Delete - suspend fun delete(entity: TotpEntity) - - @Delete - suspend fun delete(entities: List) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt deleted file mode 100644 index 519fca2..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/database/entity/TotpEntity.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.sanmer.authenticator.database.entity - -import androidx.room.Entity -import androidx.room.PrimaryKey -import dev.sanmer.otp.Otp -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.milliseconds - -@Entity(tableName = "totp") -data class TotpEntity( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - val deletedAt: Long = 0, - val issuer: String, - val name: String, - val secret: String, - val hash: Otp.Hash, - val digits: Int, - val period: Long -) { - val lifetime by lazy { (System.currentTimeMillis() - deletedAt).milliseconds } - val displayName by lazy { "$issuer ($name)" } - - fun toTrash() = copy(deletedAt = System.currentTimeMillis()) - - companion object Default { - val LIFETIME_MAX = 7.days - } -} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/model/Auth.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/Auth.kt new file mode 100644 index 0000000..c3836ed --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/Auth.kt @@ -0,0 +1,23 @@ +package dev.sanmer.authenticator.database.model + +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import dev.sanmer.authenticator.Const.INSTANT_ZERO +import kotlin.time.Instant + +@Entity +data class Auth( + @PrimaryKey( + autoGenerate = true, + algorithm = PrimaryKey.Algorithm.ROWID + ) + val id: Long = 0, + val name: String, + val issuer: String, + val type: Type, + val trashedAt: Instant = INSTANT_ZERO +) { + enum class Type { + TOTP + } +} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperties.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperties.kt new file mode 100644 index 0000000..42630f3 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperties.kt @@ -0,0 +1,41 @@ +package dev.sanmer.authenticator.database.model + +import androidx.room3.Embedded +import androidx.room3.Relation + +data class AuthProperties( + @Embedded + val auth: Auth, + @Relation(parentColumns = ["id"], entityColumns = ["authId"]) + val properties: List, +) { + private val propertyMap by lazy { properties.associateBy { it.key } } + + fun getValue(key: AuthProperty.Key, transform: (String) -> T) = + transform(requireNotNull(propertyMap[key]) { "Expect $key" }.value) + + fun getValue(key: AuthProperty.Key, default: T, transform: (String) -> T) = + propertyMap[key]?.let { transform(it.value) } ?: default + + inline fun protectValue(transform: (String) -> String) = copy( + auth = auth, + properties = properties.map { + when (it.key) { + AuthProperty.Key.Secret -> it.copy(value = transform(it.value)) + else -> it + } + } + ) + + companion object Default { + fun build( + auth: Auth, + properties: List> + ) = AuthProperties( + auth = auth, + properties = properties.map { (key, value) -> + AuthProperty(authId = auth.id, key = key, value = value) + } + ) + } +} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperty.kt b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperty.kt new file mode 100644 index 0000000..7f8ff1f --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/database/model/AuthProperty.kt @@ -0,0 +1,17 @@ +package dev.sanmer.authenticator.database.model + +import androidx.room3.Entity + +@Entity(primaryKeys = ["authId", "key"]) +data class AuthProperty( + val authId: Long = 0, + val key: Key, + val value: String +) { + enum class Key { + Secret, + Hash, + Digits, + Period + } +} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt index 8f0f734..81fdace 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/PreferenceSerializer.kt @@ -3,21 +3,33 @@ package dev.sanmer.authenticator.datastore import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import dev.sanmer.authenticator.datastore.model.Preference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf import java.io.InputStream import java.io.OutputStream class PreferenceSerializer : Serializer { override val defaultValue = Preference() - override suspend fun readFrom(input: InputStream) = + override suspend fun readFrom(input: InputStream) = withContext(Dispatchers.IO) { try { - Preference.decodeFromStream(input) + val bytes = input.use { it.readBytes() } + ProtoBuf.decodeFromByteArray(bytes) } catch (e: SerializationException) { throw CorruptionException("Failed to read proto", e) } - - override suspend fun writeTo(t: Preference, output: OutputStream) { - t.encodeToStream(output) } + + override suspend fun writeTo(t: Preference, output: OutputStream) = + withContext(Dispatchers.IO) { + output.use { + it.write( + ProtoBuf.encodeToByteArray(t) + ) + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt index 4a3371d..ce75620 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/datastore/model/Preference.kt @@ -1,12 +1,8 @@ package dev.sanmer.authenticator.datastore.model +import dev.sanmer.auth.ntp.NtpServer import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.encodeToByteArray -import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoNumber -import java.io.InputStream -import java.io.OutputStream @Serializable data class Preference( @@ -17,17 +13,22 @@ data class Preference( @ProtoNumber(3) val ntpAddress: String = "", @ProtoNumber(4) - val ntp: Ntp = Ntp.Cloudflare + val ntp: Ntp = Ntp.Cloudflare, + @ProtoNumber(5) + val secureWindow: Boolean = false ) { val isEncrypted inline get() = keyEncryptedByPassword.isNotEmpty() val isBiometric inline get() = keyEncryptedByBiometric.isNotEmpty() - fun encodeToStream(output: OutputStream) = output.write( - ProtoBuf.encodeToByteArray(this) - ) - - companion object Default { - fun decodeFromStream(input: InputStream): Preference = - ProtoBuf.decodeFromByteArray(input.readBytes()) + fun ntpServer() = when (ntp) { + Ntp.Custom -> NtpServer.Custom(ntpAddress) + Ntp.Alibaba -> NtpServer.Alibaba + Ntp.Apple -> NtpServer.Apple + Ntp.Amazon -> NtpServer.Amazon + Ntp.Cloudflare -> NtpServer.Cloudflare + Ntp.Google -> NtpServer.Google + Ntp.Meta -> NtpServer.Meta + Ntp.Microsoft -> NtpServer.Microsoft + Ntp.Tencent -> NtpServer.Tencent } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt index 81388c5..f4f47d4 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/DataStoreModule.kt @@ -7,7 +7,6 @@ import androidx.datastore.core.Serializer import androidx.datastore.dataStoreFile import dev.sanmer.authenticator.datastore.PreferenceSerializer import dev.sanmer.authenticator.datastore.model.Preference -import dev.sanmer.authenticator.ktx.deviceProtectedContext import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module @@ -19,7 +18,7 @@ val DataStore = module { DataStoreFactory.create( serializer = get() ) { - get().deviceProtectedContext.dataStoreFile("preference.pb") + get().createDeviceProtectedStorageContext().dataStoreFile("preference.pb") } } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt index 6846984..e3469b6 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/DatabaseModule.kt @@ -2,15 +2,14 @@ package dev.sanmer.authenticator.di import android.content.Context import dev.sanmer.authenticator.database.AppDatabase -import dev.sanmer.authenticator.ktx.deviceProtectedContext import org.koin.dsl.module val Database = module { single { - AppDatabase.build(get().deviceProtectedContext) + AppDatabase.build(get().createDeviceProtectedStorageContext()) } single { - get().totp() + get().auth() } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/NavigationModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/NavigationModule.kt similarity index 54% rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/di/NavigationModule.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/di/NavigationModule.kt index 7c8445c..ce1e5f4 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/NavigationModule.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/NavigationModule.kt @@ -1,25 +1,40 @@ -package dev.sanmer.authenticator.ui.di +package dev.sanmer.authenticator.di import androidx.navigation3.runtime.NavBackStack -import dev.sanmer.authenticator.ui.screens.Screen -import dev.sanmer.authenticator.ui.screens.edit.EditScreen -import dev.sanmer.authenticator.ui.screens.encode.EncodeScreen -import dev.sanmer.authenticator.ui.screens.home.HomeScreen -import dev.sanmer.authenticator.ui.screens.ntp.NtpScreen -import dev.sanmer.authenticator.ui.screens.scan.ScanScreen -import dev.sanmer.authenticator.ui.screens.security.SecurityScreen -import dev.sanmer.authenticator.ui.screens.settings.SettingsScreen -import dev.sanmer.authenticator.ui.screens.trash.TrashScreen +import dev.sanmer.authenticator.ui.screen.Screen +import dev.sanmer.authenticator.ui.screen.brand.BrandScreen +import dev.sanmer.authenticator.ui.screen.edit.EditScreen +import dev.sanmer.authenticator.ui.screen.edit.EditViewModel +import dev.sanmer.authenticator.ui.screen.export.ExportScreen +import dev.sanmer.authenticator.ui.screen.export.ExportViewModel +import dev.sanmer.authenticator.ui.screen.home.HomeScreen +import dev.sanmer.authenticator.ui.screen.home.HomeViewModel +import dev.sanmer.authenticator.ui.screen.main.MainViewModel +import dev.sanmer.authenticator.ui.screen.ntp.NtpScreen +import dev.sanmer.authenticator.ui.screen.ntp.NtpViewModel +import dev.sanmer.authenticator.ui.screen.scan.ScanViewModel +import dev.sanmer.authenticator.ui.screen.setting.SettingScreen +import dev.sanmer.authenticator.ui.screen.setting.SettingViewModel +import dev.sanmer.authenticator.ui.screen.trash.TrashScreen +import dev.sanmer.authenticator.ui.screen.trash.TrashViewModel import org.koin.androidx.compose.koinViewModel import org.koin.androidx.scope.dsl.activityRetainedScope import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.dsl.viewModelOf import org.koin.core.parameter.parametersOf import org.koin.dsl.module import org.koin.dsl.navigation3.navigation @OptIn(KoinExperimentalAPI::class) val Navigation = module { - includes(ViewModels) + viewModelOf(::MainViewModel) + viewModelOf(::HomeViewModel) + viewModelOf(::EditViewModel) + viewModelOf(::ScanViewModel) + viewModelOf(::SettingViewModel) + viewModelOf(::TrashViewModel) + viewModelOf(::NtpViewModel) + viewModelOf(::ExportViewModel) activityRetainedScope { scoped { NavBackStack(Screen.Home) } @@ -32,36 +47,28 @@ val Navigation = module { ) } - navigation { - val backStack = get>() - SettingsScreen( - viewModel = koinViewModel(), - goTo = backStack::add, - goBack = backStack::removeLastOrNull - ) - } - navigation { val backStack = get>() EditScreen( - viewModel = koinViewModel { parametersOf(it.id, it.uri) }, + viewModel = koinViewModel { parametersOf(it.authId, it.otpUri) }, goBack = backStack::removeLastOrNull ) } - navigation { + navigation { val backStack = get>() - ScanScreen( + SettingScreen( viewModel = koinViewModel(), goTo = backStack::add, goBack = backStack::removeLastOrNull ) } - navigation { + navigation { val backStack = get>() - EncodeScreen( + TrashScreen( viewModel = koinViewModel(), + goTo = backStack::add, goBack = backStack::removeLastOrNull ) } @@ -74,17 +81,17 @@ val Navigation = module { ) } - navigation { + navigation { val backStack = get>() - SecurityScreen( + ExportScreen( + viewModel = koinViewModel(), goBack = backStack::removeLastOrNull ) } - navigation { + navigation { val backStack = get>() - TrashScreen( - viewModel = koinViewModel(), + BrandScreen( goBack = backStack::removeLastOrNull ) } diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt index 8f426e1..9baf5f2 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/di/RepositoriesModule.kt @@ -2,6 +2,8 @@ package dev.sanmer.authenticator.di import dev.sanmer.authenticator.repository.DbRepository import dev.sanmer.authenticator.repository.DbRepositoryImpl +import dev.sanmer.authenticator.repository.OtpRepository +import dev.sanmer.authenticator.repository.OtpRepositoryImpl import dev.sanmer.authenticator.repository.PreferenceRepository import dev.sanmer.authenticator.repository.PreferenceRepositoryImpl import dev.sanmer.authenticator.repository.TimeRepository @@ -14,4 +16,5 @@ val Repositories = module { singleOf(::PreferenceRepositoryImpl) { bind() } singleOf(::DbRepositoryImpl) { bind() } singleOf(::TimeRepositoryImpl) { bind() } + singleOf(::OtpRepositoryImpl) { bind() } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt deleted file mode 100644 index df85cfa..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/ContextExt.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.sanmer.authenticator.ktx - -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import androidx.core.app.LocaleManagerCompat -import java.util.Locale - -val Context.applicationLocale: Locale? - inline get() = LocaleManagerCompat.getApplicationLocales(applicationContext) - .toList().firstOrNull() - -val Context.deviceProtectedContext: Context - inline get() = createDeviceProtectedStorageContext() - -fun Context.viewUrl(url: String) { - startActivity( - Intent.parseUri(url, Intent.URI_INTENT_SCHEME) - ) -} - -fun Context.findActivity(): Activity? { - var context = this - while (context is ContextWrapper) { - if (context is Activity) return context - context = context.baseContext - } - - return null -} - -fun Context.finishActivity() { - if (this is Activity) finish() -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/Flow.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/Flow.kt new file mode 100644 index 0000000..acdda0a --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/Flow.kt @@ -0,0 +1,15 @@ +package dev.sanmer.authenticator.ktx + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn + +fun Pair>.stateIn( + scope: CoroutineScope, + started: SharingStarted, +) = second.stateIn( + scope = scope, + started = started, + initialValue = first +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt deleted file mode 100644 index d7dba8d..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleExt.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.sanmer.authenticator.ktx - -import java.util.Locale - -val Locale.localizedDisplayName: String - inline get() = getDisplayName(this) - .replaceFirstChar { - if (it.isLowerCase()) { - it.titlecase(this) - } else { - it.toString() - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt deleted file mode 100644 index 2a85d89..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/LocaleListCompatExt.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.sanmer.authenticator.ktx - -import androidx.core.os.LocaleListCompat -import java.util.Locale - -fun LocaleListCompat.toList(): List = List(size()) { this[it]!! } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt deleted file mode 100644 index 539b068..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ktx/StringExt.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.sanmer.authenticator.ktx - -import kotlin.math.min - -fun String.hidden(limit: Int = 6, mask: Char = '\u2022'): String { - val showLength = min(length / 2, limit) - val hiddenLength = min(length - showLength, limit) - val sb = StringBuilder() - sb.appendRange(this, 0, showLength) - repeat(hiddenLength) { sb.append(mask) } - return sb.toString() -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/LoadData.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/LoadData.kt new file mode 100644 index 0000000..aceaff6 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/LoadData.kt @@ -0,0 +1,56 @@ +package dev.sanmer.authenticator.model + +sealed class LoadData { + data object Pending : LoadData() + data object Loading : LoadData() + data class Success(val value: V) : LoadData() + data class Failure(val error: Throwable) : LoadData() + + val isPending inline get() = this == Pending + val isLoading inline get() = this == Loading + val isSuccess inline get() = this is Success + val isFailure inline get() = this is Failure + + fun getOrThrow(): V { + return when (this) { + Pending -> throw IllegalStateException("Pending") + Loading -> throw IllegalStateException("Loading") + is Success -> value + is Failure -> throw error + } + } + + inline fun getOrElse(transform: (V) -> R?, defaultValue: () -> R): R { + return (this as? Success)?.value?.let(transform) ?: defaultValue() + } + + inline fun onPending(action: () -> Unit): LoadData { + if (this == Pending) action() + return this + } + + inline fun onLoading(action: () -> Unit): LoadData { + if (this == Loading) action() + return this + } + + inline fun onSuccess(action: (V) -> Unit): LoadData { + (this as? Success)?.value?.let(action) + return this + } + + inline fun onFailure(action: (Throwable) -> Unit): LoadData { + (this as? Failure)?.error?.let(action) + return this + } + + companion object Default { + inline fun T.loadData(block: T.() -> R): LoadData { + return try { + Success(block()) + } catch (e: Throwable) { + Failure(e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/AuthType.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/AuthType.kt deleted file mode 100644 index 955acd1..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/AuthType.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.sanmer.authenticator.model.auth - -enum class AuthType { - TOTP -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/TotpAuth.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/TotpAuth.kt deleted file mode 100644 index 1a8d583..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/model/auth/TotpAuth.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.sanmer.authenticator.model.auth - -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.encoding.decodeBase32 -import dev.sanmer.otp.Otp -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map - -data class TotpAuth( - val entity: TotpEntity, - val epochSeconds: StateFlow -) { - val secret by lazy { entity.secret.decodeBase32() } - - val otp = epochSeconds.map { - Otp.otp( - hash = entity.hash, - secret = secret, - digits = entity.digits, - counter = it / entity.period - ) - } - - fun otp() = Otp.otp( - hash = entity.hash, - secret = secret, - digits = entity.digits, - counter = epochSeconds.value / entity.period - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/otp/Totp.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/otp/Totp.kt new file mode 100644 index 0000000..e0ace24 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/otp/Totp.kt @@ -0,0 +1,61 @@ +package dev.sanmer.authenticator.model.otp + +import dev.sanmer.auth.Otp +import dev.sanmer.auth.OtpUri +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.database.model.AuthProperty +import kotlinx.serialization.Serializable + +@Serializable +data class Totp( + val name: String, + val issuer: String, + val secret: String, + val hash: Otp.Hash, + val digits: Int, + val period: Long +) { + constructor(auth: AuthProperties) : this( + name = auth.auth.name, + issuer = auth.auth.issuer, + secret = auth.getValue(AuthProperty.Key.Secret) { it }, + hash = auth.getValue(AuthProperty.Key.Hash, Otp.Hash::valueOf), + digits = auth.getValue(AuthProperty.Key.Digits, String::toInt), + period = auth.getValue(AuthProperty.Key.Period, String::toLong) + ) + + constructor(uri: OtpUri) : this( + name = uri.name, + issuer = uri.issuer, + secret = uri.secret, + hash = uri.algorithm?.let(Otp.Hash::valueOf) ?: Otp.Hash.SHA1, + digits = uri.digits ?: 6, + period = uri.period ?: 30 + ) + + fun toAuth(id: Long = 0) = AuthProperties.build( + auth = Auth( + id = id, + name = name, + issuer = issuer, + type = Auth.Type.TOTP + ), + properties = listOf( + AuthProperty.Key.Secret to secret, + AuthProperty.Key.Hash to hash.name, + AuthProperty.Key.Digits to digits.toString(), + AuthProperty.Key.Period to period.toString() + ) + ) + + fun toUri() = OtpUri( + type = "totp", + name = name, + issuer = issuer, + secret = secret, + algorithm = hash.name, + digits = digits, + period = period + ).toUri() +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/AuthSet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/AuthSet.kt new file mode 100644 index 0000000..b3d00b2 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/AuthSet.kt @@ -0,0 +1,69 @@ +package dev.sanmer.authenticator.model.serializable + +import android.net.Uri +import dev.sanmer.auth.OtpUri.Default.toOtpUri +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.model.otp.Totp +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import java.io.InputStream +import java.io.OutputStream + +@Serializable +data class AuthSet( + val totp: List +) { + inline fun map(action: (AuthProperties) -> T): List { + val list = ArrayList() + totp.mapTo(list) { action(it.toAuth()) } + return list + } + + fun encodeToJson(output: OutputStream) { + endpointJson.encodeToStream(this, output) + } + + fun encodeToUri(output: OutputStream) { + val writer = output.bufferedWriter() + totp.forEach { writer.appendLine(it.toUri().toString()) } + writer.flush() + } + + companion object Default { + private val endpointJson = Json { + prettyPrint = true + } + + fun List.toAuthSet(): AuthSet { + val totp = ArrayList() + forEach { + when (it.auth.type) { + Auth.Type.TOTP -> totp.add(Totp(it)) + } + } + return AuthSet( + totp = totp + ) + } + + fun decodeFromJson(input: InputStream) = endpointJson.decodeFromStream(input) + + fun decodeFromUri(input: InputStream): AuthSet { + val totp = ArrayList() + input.bufferedReader().forEachLine { + runCatching { + val uri = Uri.parse(it).toOtpUri() + if (uri.type.equals("totp", ignoreCase = true)) { + totp.add(Totp(uri)) + } + } + } + return AuthSet( + totp = totp + ) + } + } +} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/UriSerializer.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/UriSerializer.kt new file mode 100644 index 0000000..440f81e --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializable/UriSerializer.kt @@ -0,0 +1,26 @@ +package dev.sanmer.authenticator.model.serializable + +import android.net.Uri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.serialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object UriSerializer : KSerializer { + override val descriptor = SerialDescriptor( + serialName = "android.net.Uri", + original = serialDescriptor() + ) + + override fun serialize( + encoder: Encoder, + value: Uri + ) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri { + return Uri.parse(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt deleted file mode 100644 index d3eba46..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthJson.kt +++ /dev/null @@ -1,38 +0,0 @@ -package dev.sanmer.authenticator.model.serializer - -import dev.sanmer.authenticator.database.entity.TotpEntity -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.encodeToStream -import java.io.InputStream -import java.io.OutputStream - -@Serializable -data class AuthJson( - val totp: List -) { - fun entities() = totp.map(TotpJson::entity) - - fun encodeTo(output: OutputStream) { - endpointJson.encodeToStream(this, output) - } - - companion object Default { - private val endpointJson = Json { - prettyPrint = true - } - - const val MIME_TYPE = "application/json" - const val FILE_NAME = "auth.json" - - fun entities( - totp: List - ) = AuthJson( - totp = totp.map(::TotpJson) - ) - - fun decodeFrom(input: InputStream): AuthJson = - Json.decodeFromStream(input) - } -} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthUri.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthUri.kt deleted file mode 100644 index 2c97d76..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/AuthUri.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.sanmer.authenticator.model.serializer - -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.otp.Otp -import dev.sanmer.otp.OtpUri -import dev.sanmer.otp.OtpUri.Default.toOtpUri -import java.io.InputStream -import java.io.OutputStream - -data class AuthUri( - val totp: List -) { - fun encodeTo(output: OutputStream) { - val uris = totp.map { it.uri().toString() } - val content = uris.joinToString(separator = "\n") - output.write(content.toByteArray()) - } - - companion object Default { - const val MIME_TYPE = "text/plain" - const val FILE_NAME = "auth.txt" - - fun TotpEntity.uri() = OtpUri( - type = "totp", - name = name, - issuer = issuer, - secret = secret, - algorithm = hash.name, - digits = digits, - period = period - ) - - fun OtpUri.toTotpEntity(): TotpEntity { - require(type.equals("totp", ignoreCase = true)) { "Expected type = totp" } - val hash = algorithm?.let { Otp.Hash.valueOf(it.uppercase()) } - return TotpEntity( - issuer = issuer, - name = name, - secret = secret, - hash = hash ?: Otp.Hash.SHA1, - digits = digits ?: 6, - period = period ?: 30, - ) - } - - fun decodeFrom(input: InputStream): AuthUri { - val totp = input.bufferedReader() - .lineSequence() - .mapNotNull { - runCatching { - it.toOtpUri().toTotpEntity() - }.getOrNull() - } - return AuthUri( - totp = totp.toList() - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpJson.kt b/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpJson.kt deleted file mode 100644 index a5bef28..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/model/serializer/TotpJson.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.sanmer.authenticator.model.serializer - -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.otp.Otp -import kotlinx.serialization.Serializable - -@Serializable -data class TotpJson( - val issuer: String, - val name: String, - val secret: String, - val hash: Otp.Hash, - val digits: Int, - val period: Long -) { - constructor(entity: TotpEntity) : this( - issuer = entity.issuer, - name = entity.name, - secret = entity.secret, - hash = entity.hash, - digits = entity.digits, - period = entity.period - ) - - fun entity() = TotpEntity( - issuer = issuer, - name = name, - secret = secret, - hash = hash, - digits = digits, - period = period - ) -} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt index 56026f7..6fb3896 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepository.kt @@ -1,23 +1,24 @@ package dev.sanmer.authenticator.repository -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.crypto.Crypto +import dev.sanmer.auth.crypto.Crypto +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.database.model.AuthProperties import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant interface DbRepository { - fun setSessionKey(key: Crypto) + suspend fun setSessionKey(key: Crypto) + suspend fun getSessionKey(): Crypto suspend fun encrypt(key: Crypto) - suspend fun decrypt(key: Crypto) - suspend fun reEncrypt(old: Crypto, new: Crypto) + suspend fun decrypt() - suspend fun getTotpAllDecryptedAsFlow(): Flow> - suspend fun getTotpDecryptedByIdAsFlow(id: Long): Flow - suspend fun getTotpAllDecryptedTrashedAsFlow(): Flow> - suspend fun getTotpAllTrashed(dead: Boolean): List - suspend fun insertTotp(entity: TotpEntity) - suspend fun insertTotp(entities: List) - suspend fun updateTotp(entity: TotpEntity) - suspend fun updateTotp(entities: List) - suspend fun deleteTotp(entity: TotpEntity) - suspend fun deleteTotp(entities: List) + suspend fun getAllAuthProperties(): List + suspend fun getUntrashedAuthProperties(): List + suspend fun getUntrashedAuthPropertiesAsFlow(): Flow> + fun getTrashedAuthAsFlow(): Flow> + fun getTrashedCountAsFlow(): Flow + suspend fun getAuthPropertiesAsFlow(id: Long): Flow + suspend fun upsert(auth: AuthProperties) + suspend fun trash(authId: Long, trashedAt: Instant) + suspend fun delete(authId: Long) } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt index 5f8d13a..63466ff 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/DbRepositoryImpl.kt @@ -1,99 +1,107 @@ package dev.sanmer.authenticator.repository -import dev.sanmer.authenticator.database.dao.TotpDao -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.crypto.Crypto -import dev.sanmer.encoding.decodeBase64 -import dev.sanmer.encoding.encodeBase64 +import dev.sanmer.auth.crypto.Crypto +import dev.sanmer.authenticator.database.dao.AuthDao +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.database.model.AuthProperty import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlin.time.Instant class DbRepositoryImpl( - private val totp: TotpDao + private val authDao: AuthDao ) : DbRepository { - private var key: Crypto = Crypto.None + private val mutex = Mutex() + private var _key: Crypto = Crypto.Default - private suspend inline fun String.toEncrypted() = key.encrypt(this) - - private suspend inline fun String.toDecrypted() = key.decrypt(this) + private suspend inline fun withKey(block: (Crypto) -> T) = mutex.withLock { + block(_key) + } - override fun setSessionKey(key: Crypto) { - this.key = key + override suspend fun setSessionKey(key: Crypto) = mutex.withLock { + _key = key } + override suspend fun getSessionKey() = withKey { it } + override suspend fun encrypt(key: Crypto) = withContext(Dispatchers.IO) { - totp.update( - totp.getAll().map { - val encrypted = key.encrypt(it.secret) - it.copy(secret = encrypted) - } - ) + val secrets = authDao.getProperties(AuthProperty.Key.Secret) + val encrypted = secrets.map { + it.copy(value = key.encrypt(it.value)) + } + setSessionKey(key) + authDao.upsertProperties(encrypted) } - override suspend fun decrypt(key: Crypto) = withContext(Dispatchers.IO) { - totp.update( - totp.getAll().map { - val decrypted = key.decrypt(it.secret) - it.copy(secret = decrypted) + override suspend fun decrypt() = withContext(Dispatchers.IO) { + val secrets = authDao.getProperties(AuthProperty.Key.Secret) + val decrypted = withKey { current -> + secrets.map { + it.copy(value = current.decrypt(it.value)) } - ) + } + setSessionKey(Crypto.Default) + authDao.upsertProperties(decrypted) } - override suspend fun reEncrypt(old: Crypto, new: Crypto) = withContext(Dispatchers.IO) { - totp.update( - totp.getAll().map { - val decrypted = old.decrypt(it.secret.decodeBase64()) - val encrypted = new.encrypt(decrypted) - it.copy(secret = encrypted.encodeBase64()) + override suspend fun getAllAuthProperties() = withContext(Dispatchers.IO) { + withKey { current -> + authDao.getAllAuthProperties().map { auth -> + auth.protectValue { current.decrypt(it) } } - ) + } } - override suspend fun getTotpAllDecryptedAsFlow() = totp.getAllEnabledAsFlow() - .map { entries -> entries.map { it.copy(secret = it.secret.toDecrypted()) } } - - override suspend fun getTotpDecryptedByIdAsFlow(id: Long) = - totp.getByIdAsFlow(id).filterNotNull() - .map { it.copy(secret = it.secret.toDecrypted()) } - - override suspend fun getTotpAllDecryptedTrashedAsFlow() = totp.getAllTrashedAsFlow() - .map { entries -> entries.map { it.copy(secret = it.secret.toDecrypted()) } } - - override suspend fun getTotpAllTrashed(dead: Boolean) = withContext(Dispatchers.IO) { - totp.getAllTrashed().filter { if (dead) it.lifetime > TotpEntity.LIFETIME_MAX else true } + override suspend fun getUntrashedAuthProperties() = withContext(Dispatchers.IO) { + withKey { current -> + authDao.getUntrashedAuthProperties().map { auth -> + auth.protectValue { current.decrypt(it) } + } + } } - override suspend fun insertTotp(entity: TotpEntity) = withContext(Dispatchers.IO) { - totp.insert( - entity.copy(secret = entity.secret.toEncrypted()) - ) - } + override suspend fun getUntrashedAuthPropertiesAsFlow() = + authDao.getUntrashedAuthPropertiesAsFlow() + .map { list -> + withKey { current -> + list.map { auth -> + auth.protectValue { current.decrypt(it) } + } + } + } - override suspend fun insertTotp(entities: List) = withContext(Dispatchers.IO) { - totp.insert( - entities.map { it.copy(secret = it.secret.toEncrypted()) } - ) - } + override fun getTrashedAuthAsFlow() = authDao.getTrashedAuthAsFlow() - override suspend fun updateTotp(entity: TotpEntity) = withContext(Dispatchers.IO) { - totp.update( - entity.copy(secret = entity.secret.toEncrypted()) - ) - } + override fun getTrashedCountAsFlow() = authDao.getTrashedCountAsFlow() + + override suspend fun getAuthPropertiesAsFlow(id: Long) = + authDao.getAuthPropertiesAsFlow(id) + .filterNotNull() + .map { auth -> + withKey { current -> + auth.protectValue { current.decrypt(it) } + } + } - override suspend fun updateTotp(entities: List) = withContext(Dispatchers.IO) { - totp.update( - entities.map { it.copy(secret = it.secret.toEncrypted()) } + override suspend fun upsert(auth: AuthProperties) = withContext(Dispatchers.IO) { + val auth = withKey { current -> + auth.protectValue { current.encrypt(it) } + } + authDao.upsert( + auth = auth.auth, + properties = auth.properties ) } - override suspend fun deleteTotp(entity: TotpEntity) = withContext(Dispatchers.IO) { - totp.delete(entity) + override suspend fun trash(authId: Long, trashedAt: Instant) = withContext(Dispatchers.IO) { + authDao.trashAuth(authId, trashedAt) } - override suspend fun deleteTotp(entities: List) = withContext(Dispatchers.IO) { - totp.delete(entities) + override suspend fun delete(authId: Long) = withContext(Dispatchers.IO) { + authDao.delete(authId) } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepository.kt new file mode 100644 index 0000000..44170c8 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepository.kt @@ -0,0 +1,14 @@ +package dev.sanmer.authenticator.repository + +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.model.otp.Totp +import kotlinx.coroutines.flow.Flow + +interface OtpRepository { + suspend fun otp(auth: AuthProperties) = when (auth.auth.type) { + Auth.Type.TOTP -> totp(Totp(auth)) + } + + suspend fun totp(totp: Totp): Pair> +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepositoryImpl.kt new file mode 100644 index 0000000..32b31a2 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/OtpRepositoryImpl.kt @@ -0,0 +1,35 @@ +package dev.sanmer.authenticator.repository + +import dev.sanmer.auth.Otp +import dev.sanmer.auth.decodeBase32 +import dev.sanmer.authenticator.model.otp.Totp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class OtpRepositoryImpl( + private val timeRepository: TimeRepository +) : OtpRepository { + override suspend fun totp(totp: Totp) = withContext(Dispatchers.IO) { + val secret = totp.secret.decodeBase32() + val now = Otp.otp( + hash = totp.hash, + secret = secret, + counter = timeRepository.now().epochSeconds / totp.period, + digits = totp.digits + ) + val otp = timeRepository.now + .map { it.epochSeconds / totp.period } + .distinctUntilChanged() + .map { + Otp.otp( + hash = totp.hash, + secret = secret, + counter = it, + digits = totp.digits + ) + } + now to otp + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt index 266e353..cba5492 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepository.kt @@ -10,4 +10,5 @@ interface PreferenceRepository { suspend fun setKeyEncryptedByBiometric(value: String) suspend fun setNtpAddress(value: String) suspend fun setNtp(value: Ntp) + suspend fun setSecureWindow(value: Boolean) } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt index 943c47a..c121781 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/PreferenceRepositoryImpl.kt @@ -42,4 +42,12 @@ class PreferenceRepositoryImpl( } } } + + override suspend fun setSecureWindow(value: Boolean) { + withContext(Dispatchers.IO) { + dataStore.updateData { + it.copy(secureWindow = value) + } + } + } } diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt index 9c98a25..9a48f05 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepository.kt @@ -1,11 +1,15 @@ package dev.sanmer.authenticator.repository -import dev.sanmer.authenticator.datastore.model.Preference -import dev.sanmer.ntp.NtpServer +import dev.sanmer.auth.ntp.NtpClock +import dev.sanmer.auth.ntp.NtpServer +import dev.sanmer.authenticator.model.LoadData +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Clock +import kotlin.time.Instant -interface TimeRepository { - val ntpTime: StateFlow - val epochSeconds: StateFlow - suspend fun sync(preference: Preference, times: Int = 3): Result +interface TimeRepository : Clock { + val clock: StateFlow> + val now: Flow + suspend fun sync(server: NtpServer) } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt index a42ffb7..794090d 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/repository/TimeRepositoryImpl.kt @@ -1,98 +1,53 @@ package dev.sanmer.authenticator.repository +import dev.sanmer.auth.ntp.NtpClock +import dev.sanmer.auth.ntp.NtpServer import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.datastore.model.Ntp -import dev.sanmer.authenticator.datastore.model.Preference -import dev.sanmer.ntp.NtpServer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable +import dev.sanmer.authenticator.model.LoadData +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -class TimeRepositoryImpl( - private val preferenceRepository: PreferenceRepository -) : TimeRepository { - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private var job: Job = NonCancellable - - private val _ntpTime = MutableStateFlow(NtpServer.NtpTime()) - override val ntpTime get() = _ntpTime.asStateFlow() - - private val _epochSeconds = MutableStateFlow( System.currentTimeMillis() / 1000) - override val epochSeconds get() = _epochSeconds.asStateFlow() - - private val logger = Logger.Android("TimeRepositoryImpl") - - init { - preferenceObserver() - ntpObserver() - } - - private fun preferenceObserver() { - coroutineScope.launch { - preferenceRepository.data - .distinctUntilChanged { old, new -> - old.ntp == new.ntp && old.ntpAddress == new.ntpAddress - } - .collectLatest { - sync(it) - } +import java.net.SocketTimeoutException +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds + +class TimeRepositoryImpl : TimeRepository { + private val _clock = MutableStateFlow>(LoadData.Pending) + + override val clock = _clock.asStateFlow() + + override val now = flow { + while (currentCoroutineContext().isActive) { + val now = now() + emit(now) + delay(1.seconds - now.nanosecondsOfSecond.nanoseconds) } } - private fun ntpObserver() { - coroutineScope.launch { - _ntpTime.collect { - start(it) + private val logger = Logger.Android("NTP") + + override fun now() = clock.value.getOrElse({ it }) { Clock.System }.now() + + override suspend fun sync(server: NtpServer) { + _clock.update { LoadData.Loading } + var data: LoadData = LoadData.Pending + for (times in 1..3) { + try { + data = LoadData.Success(server.sync()) + } catch (e: SocketTimeoutException) { + data = LoadData.Failure(e) + delay(200.milliseconds * times) + } catch (e: Throwable) { + data = LoadData.Failure(e) + logger.e(e) } } - } - - private fun start(ntpTime: NtpServer.NtpTime) { - if (job.isActive) { - job.cancel() - } - job = coroutineScope.launch { - delay(1000 - (ntpTime.currentTimeMillis % 1000)) - while (isActive) { - _epochSeconds.value = ntpTime.currentTimeMillis / 1000 - delay(1000) - } - } - } - - override suspend fun sync(preference: Preference, times: Int): Result { - val server = when (preference.ntp) { - Ntp.Custom -> NtpServer.Custom(preference.ntpAddress) - Ntp.Alibaba -> NtpServer.Alibaba - Ntp.Apple -> NtpServer.Apple - Ntp.Amazon -> NtpServer.Amazon - Ntp.Cloudflare -> NtpServer.Cloudflare - Ntp.Google -> NtpServer.Google - Ntp.Meta -> NtpServer.Meta - Ntp.Microsoft -> NtpServer.Microsoft - Ntp.Tencent -> NtpServer.Tencent - } - - var result = Result.failure(Throwable()) - repeat(times) { - result = runCatching { - _ntpTime.updateAndGet { server.sync() } - }.onSuccess { - logger.i("NTP(${server.address}): $it") - return Result.success(it) - } - } - return result.onFailure { - logger.w(it) - } + _clock.update { data } } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/AuthorizeActivity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/AuthorizeActivity.kt deleted file mode 100644 index 5f77bd0..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/AuthorizeActivity.kt +++ /dev/null @@ -1,129 +0,0 @@ -package dev.sanmer.authenticator.ui - -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import androidx.activity.SystemBarStyle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.ActivityResultRegistryOwner -import androidx.activity.result.contract.ActivityResultContract -import androidx.fragment.app.FragmentActivity -import dev.sanmer.authenticator.ui.screens.authorize.AuthorizeScreen -import dev.sanmer.authenticator.ui.screens.authorize.AuthorizeViewModel -import dev.sanmer.authenticator.ui.theme.AppTheme -import dev.sanmer.crypto.BiometricKey -import kotlinx.coroutines.suspendCancellableCoroutine -import org.koin.androidx.viewmodel.ext.android.viewModel -import java.util.UUID -import kotlin.coroutines.resume - -class AuthorizeActivity : FragmentActivity() { - private val viewModel by viewModel() - - init { - BiometricKey.init(this) - } - - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge( - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) - ) - super.onCreate(savedInstanceState) - - viewModel.updateFromIntent(::getIntent) - - setContent { - AppTheme { - AuthorizeScreen() - } - } - } - - override fun finish() { - Intent().apply { - putExtra(EXTRA_SUCCEED, viewModel.type.isSucceed) - setResult(RESULT_OK, this) - } - super.finish() - } - - enum class Action( - val original: String - ) { - SetupPassword(ACTION_SETUP_PASSWORD), - ChangePassword(ACTION_CHANGE_PASSWORD), - RemovePassword(ACTION_REMOVE_PASSWORD), - SetupBiometric(ACTION_SETUP_BIOMETRIC), - RemoveBiometric(ACTION_REMOVE_BIOMETRIC), - Auth(ACTION_AUTH); - - companion object Default { - operator fun invoke(action: String?): Action { - return entries.first { it.original == action } - } - } - } - - companion object Default { - private const val ACTION_SETUP_PASSWORD = "dev.sanmer.authenticator.action.SETUP_PASSWORD" - private const val ACTION_CHANGE_PASSWORD = "dev.sanmer.authenticator.action.CHANGE_PASSWORD" - private const val ACTION_REMOVE_PASSWORD = "dev.sanmer.authenticator.action.REMOVE_PASSWORD" - private const val ACTION_SETUP_BIOMETRIC = "dev.sanmer.authenticator.action.SETUP_BIOMETRIC" - private const val ACTION_REMOVE_BIOMETRIC = - "dev.sanmer.authenticator.action.REMOVE_BIOMETRIC" - private const val ACTION_AUTH = "dev.sanmer.authenticator.action.AUTH" - const val EXTRA_SUCCEED = "dev.sanmer.authenticator.extra.SUCCEED" - - val Intent.isSucceed: Boolean - inline get() = getBooleanExtra(EXTRA_SUCCEED, false) - - private class Authorize( - val action: Action - ) : ActivityResultContract() { - override fun createIntent(context: Context, input: Unit): Intent { - return Intent(context, AuthorizeActivity::class.java).also { - it.action = action.original - } - } - - override fun parseResult(resultCode: Int, intent: Intent?): Boolean { - if (resultCode != RESULT_OK) return false - if (intent == null) return false - return intent.isSucceed - } - - } - - fun start( - context: Context, - action: Action, - callback: (Boolean) -> Unit - ) { - require(context is ActivityResultRegistryOwner) { "Expected ActivityResultRegistryOwner" } - - val activityResultRegistry = context.activityResultRegistry - val launcher = activityResultRegistry.register( - key = UUID.randomUUID().toString(), - contract = Authorize(action), - callback = callback - ) - - launcher.launch(Unit) - } - - fun auth( - context: Context, - callback: (Boolean) -> Unit - ) = start( - context = context, - action = Action.Auth, - callback = callback - ) - - suspend fun auth(context: Context) = suspendCancellableCoroutine { continuation -> - auth(context) { continuation.resume(it) } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/CryptoActivity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/CryptoActivity.kt deleted file mode 100644 index c923dce..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/CryptoActivity.kt +++ /dev/null @@ -1,177 +0,0 @@ -package dev.sanmer.authenticator.ui - -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.ActivityResultRegistryOwner -import androidx.activity.result.contract.ActivityResultContract -import dev.sanmer.authenticator.ui.screens.crypto.CryptoScreen -import dev.sanmer.authenticator.ui.screens.crypto.CryptoViewModel -import dev.sanmer.authenticator.ui.theme.AppTheme -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.suspendCancellableCoroutine -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -class CryptoActivity : ComponentActivity() { - private val viewModel by viewModel() - - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge( - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) - ) - super.onCreate(savedInstanceState) - - viewModel.updateFromIntent(::getIntent) - - setContent { - AppTheme { - CryptoScreen() - } - } - } - - override fun finish() { - Intent().apply { - putExtra(EXTRA_OUTPUT, viewModel.output.toTypedArray()) - setResult(RESULT_OK, this) - } - super.finish() - } - - enum class Action( - val original: String, - ) { - Encrypt(ACTION_ENCRYPT), - Decrypt(ACTION_DECRYPT); - - companion object Default { - operator fun invoke(action: String?): Action { - return entries.first { it.original == action } - } - } - } - - companion object Default { - private const val ACTION_ENCRYPT = "dev.sanmer.authenticator.action.ENCRYPT" - private const val ACTION_DECRYPT = "dev.sanmer.authenticator.action.DECRYPT" - const val EXTRA_BYPASS = "dev.sanmer.authenticator.extra.BYPASS" - const val EXTRA_INPUT = "dev.sanmer.authenticator.extra.INPUT" - const val EXTRA_OUTPUT = "dev.sanmer.authenticator.extra.OUTPUT" - - val Intent.bypass: Boolean - inline get() = getBooleanExtra(EXTRA_BYPASS, true) - - val Intent.input: Array - inline get() = checkNotNull(getStringArrayExtra(EXTRA_INPUT)) - - val Intent.output: Array - inline get() = checkNotNull(getStringArrayExtra(EXTRA_OUTPUT)) - - private class Crypto( - private val action: Action, - private val bypass: Boolean - ) : ActivityResultContract, List>() { - override fun createIntent(context: Context, input: List): Intent { - return Intent(context, CryptoActivity::class.java).also { - it.action = action.original - it.putExtra(EXTRA_BYPASS, bypass) - it.putExtra(EXTRA_INPUT, input.toTypedArray()) - } - } - - override fun parseResult(resultCode: Int, intent: Intent?): List { - if (resultCode != RESULT_OK) return emptyList() - if (intent == null) return emptyList() - return intent.output.toList() - } - } - - private fun start( - context: Context, - input: List, - action: Action, - bypass: Boolean, - callback: (List) -> Unit - ) { - require(context is ActivityResultRegistryOwner) { "Expected ActivityResultRegistryOwner" } - - val activityResultRegistry = context.activityResultRegistry - val launcher = activityResultRegistry.register( - key = input.toString(), - contract = Crypto(action, bypass), - callback = { callback(it) } - ) - - launcher.launch(input) - } - - fun encrypt( - context: Context, - input: List, - bypass: Boolean = true, - callback: (List) -> Unit - ) = start( - context = context, - input = input, - action = Action.Encrypt, - bypass = bypass, - callback = callback - ) - - suspend fun encrypt( - context: Context, - input: List, - bypass: Boolean = true, - ) = suspendCancellableCoroutine { continuation -> - encrypt( - context = context, - input = input, - bypass = bypass - ) { output -> - if (output.isNotEmpty()) { - continuation.resume(output) - } else { - continuation.resumeWithException(CancellationException()) - } - } - } - - fun decrypt( - context: Context, - input: List, - bypass: Boolean = true, - callback: (List) -> Unit - ) = start( - context = context, - input = input, - action = Action.Decrypt, - bypass = bypass, - callback = callback - ) - - suspend fun decrypt( - context: Context, - input: List, - bypass: Boolean = true, - ) = suspendCancellableCoroutine { continuation -> - decrypt( - context = context, - input = input, - bypass = bypass - ) { output -> - if (output.isNotEmpty()) { - continuation.resume(output) - } else { - continuation.resumeWithException(CancellationException()) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt index 7db4941..89f5596 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/MainActivity.kt @@ -1,25 +1,34 @@ package dev.sanmer.authenticator.ui +import android.app.ComponentCaller +import android.content.Intent import android.graphics.Color import android.os.Bundle +import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.animation.Crossfade +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.navigation3.runtime.NavBackStack +import dev.sanmer.authenticator.crypto.BiometricKey import dev.sanmer.authenticator.datastore.compose.LocalPreference -import dev.sanmer.authenticator.ui.screens.main.LockScreen -import dev.sanmer.authenticator.ui.screens.main.MainScreen -import dev.sanmer.authenticator.ui.screens.main.MainViewModel -import dev.sanmer.authenticator.ui.screens.main.MainViewModel.LoadState +import dev.sanmer.authenticator.ui.screen.Screen +import dev.sanmer.authenticator.ui.screen.main.MainScreen +import dev.sanmer.authenticator.ui.screen.main.MainViewModel +import dev.sanmer.authenticator.ui.screen.main.UnlockScreen import dev.sanmer.authenticator.ui.theme.AppTheme -import org.koin.android.ext.android.get +import org.koin.android.ext.android.inject import org.koin.android.scope.AndroidScopeComponent import org.koin.androidx.compose.navigation3.getEntryProvider import org.koin.androidx.scope.activityRetainedScope @@ -31,6 +40,25 @@ import org.koin.core.scope.Scope class MainActivity : ComponentActivity(), AndroidScopeComponent { override val scope: Scope by activityRetainedScope() private val viewModel by viewModel() + private val backStack by inject>() + + private fun fromIntent(intent: Intent) { + intent.data?.let { + backStack.add(Screen.Edit(otpUri = it)) + } + } + + private fun setSecureWindow(secure: Boolean) { + if (secure) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + override fun onNewIntent(intent: Intent, caller: ComponentCaller) { + fromIntent(intent) + } override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() @@ -39,29 +67,42 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent { ) super.onCreate(savedInstanceState) - splashScreen.setKeepOnScreenCondition { viewModel.isPending } + BiometricKey.init(this) + splashScreen.setKeepOnScreenCondition { viewModel.preference.isPending } + fromIntent(intent) setContent { - when (viewModel.loadState) { - LoadState.Pending -> {} - else -> CompositionLocalProvider( - LocalPreference provides viewModel.preference + viewModel.preference.onSuccess { preference -> + DisposableEffect(preference.secureWindow) { + setSecureWindow(preference.secureWindow) + onDispose {} + } + + CompositionLocalProvider( + LocalPreference provides preference ) { AppTheme { - Crossfade( - modifier = Modifier.background( - color = MaterialTheme.colorScheme.background - ), - targetState = viewModel.isLocked, - animationSpec = tween(400) - ) { isLocked -> - if (isLocked) { - LockScreen() - } else { + AnimatedContent( + modifier = Modifier.background(MaterialTheme.colorScheme.background), + targetState = viewModel.isDecrypted(preference), + transitionSpec = { + fadeIn( + animationSpec = tween(500) + ) togetherWith fadeOut( + animationSpec = tween(500) + ) + } + ) { isDecrypted -> + if (isDecrypted) { MainScreen( - backStack = get(), + backStack = backStack, entryProvider = getEntryProvider() ) + } else { + UnlockScreen( + viewModel = viewModel, + preference = preference + ) } } } diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt deleted file mode 100644 index ed9d5e3..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/DropdownMenu.kt +++ /dev/null @@ -1,43 +0,0 @@ -package dev.sanmer.authenticator.ui.component - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.window.PopupProperties - -@Composable -fun DropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - shape: CornerBasedShape = MaterialTheme.shapes.medium, - contentAlignment: Alignment = Alignment.TopStart, - offset: DpOffset = DpOffset.Zero, - properties: PopupProperties = PopupProperties(focusable = true), - surface: @Composable () -> Unit, - content: @Composable ColumnScope.() -> Unit -) = Box( - modifier = modifier -) { - surface() - - Box( - modifier = Modifier.align(contentAlignment), - contentAlignment = contentAlignment - ) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - offset = offset, - properties = properties, - shape = shape, - content = content - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/IconRow.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/IconRow.kt new file mode 100644 index 0000000..f23c397 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/IconRow.kt @@ -0,0 +1,56 @@ +package dev.sanmer.authenticator.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp + +@Composable +fun IconRow( + modifier: Modifier = Modifier, + leadingIcon: @Composable RowScope.() -> Unit = { FixedBox() }, + trailingIcon: @Composable RowScope.() -> Unit = { FixedBox() }, + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(5.dp), + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + content: @Composable RowScope.() -> Unit, +) = Row( + modifier = modifier, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment +) { + leadingIcon() + content() + trailingIcon() +} + +@Composable +fun FixedBox( + content: @Composable (BoxScope.() -> Unit) = {} +) = Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + content = content +) + +@Composable +fun FixedIcon( + painter: Painter, + contentDescription: String? = null, + tint: Color = LocalContentColor.current +) = FixedBox { + Icon( + painter = painter, + contentDescription = contentDescription, + tint = tint + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt index d8458c4..59ee7c3 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/LabelText.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable @@ -22,6 +23,8 @@ fun LabelText( text = text, style = style, color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = modifier .background( color = containerColor, diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt deleted file mode 100644 index 8941cd4..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Logo.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dev.sanmer.authenticator.ui.component - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.res.painterResource - -@Composable -fun Logo( - @DrawableRes icon: Int, - modifier: Modifier = Modifier, - shape: Shape = CircleShape, - contentColor: Color = MaterialTheme.colorScheme.onPrimary, - containerColor: Color = MaterialTheme.colorScheme.primary, - fraction: Float = 0.6f -) = Surface( - modifier = modifier, - shape = shape, - color = containerColor, - contentColor = contentColor -) { - Box( - contentAlignment = Alignment.Center - ) { - Icon( - modifier = Modifier.fillMaxSize(fraction), - painter = painterResource(id = icon), - contentDescription = null, - tint = LocalContentColor.current - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/PageIndicator.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/PageIndicator.kt deleted file mode 100644 index f2735f8..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/PageIndicator.kt +++ /dev/null @@ -1,145 +0,0 @@ -package dev.sanmer.authenticator.ui.component - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.unit.sp -import dev.sanmer.authenticator.R - -@Composable -fun PageIndicator( - icon: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - text: @Composable ColumnScope.() -> Unit = {}, - height: Dp = Dp.Unspecified -) = Column( - modifier = modifier then (if (height.isSpecified) { - Modifier - .height(height = height) - .fillMaxWidth() - } else { - Modifier.fillMaxSize() - }), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center -) { - icon() - Spacer(modifier = Modifier.height(20.dp)) - text() -} - -@Composable -fun PageIndicator( - @DrawableRes icon: Int, - @StringRes text: Int, - modifier: Modifier = Modifier, - height: Dp = Dp.Unspecified -) = PageIndicator( - icon = { - Icon( - painter = painterResource(icon), - contentDescription = null, - tint = PageIndicatorDefaults.ContentColor, - modifier = Modifier.size(PageIndicatorDefaults.IconSize) - ) - }, - text = { - Text( - text = stringResource(text), - style = PageIndicatorDefaults.TextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - modifier = modifier, - height = height -) - -@Composable -fun Loading( - modifier: Modifier = Modifier, - height: Dp = Dp.Unspecified -) = PageIndicator( - icon = { - CircularProgressIndicator( - modifier = Modifier.size(PageIndicatorDefaults.IconSize), - strokeWidth = 5.dp - ) - }, - modifier = modifier, - height = height -) - -@Composable -fun Failed( - message: String, - modifier: Modifier = Modifier, - height: Dp = Dp.Unspecified -) = PageIndicator( - icon = { - Icon( - painter = painterResource(R.drawable.bug), - contentDescription = null, - tint = PageIndicatorDefaults.ContentColor, - modifier = Modifier.size(PageIndicatorDefaults.IconSize) - ) - }, - text = { - Text( - text = message, - style = PageIndicatorDefaults.TextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - modifier = modifier, - height = height -) - -@Composable -fun Failed( - error: Throwable, - modifier: Modifier = Modifier, - height: Dp = Dp.Unspecified -) = Failed( - message = error.message ?: error.javaClass.name, - modifier = modifier, - height = height -) - -object PageIndicatorDefaults { - val IconSize = 48.dp - val ContentColor @Composable get() = MaterialTheme.colorScheme.outline.copy(0.5f) - - val TextStyle @Composable get() = TextStyle( - color = ContentColor, - fontSize = 20.sp, - fontFamily = FontFamily.SansSerif, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Placeholder.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Placeholder.kt new file mode 100644 index 0000000..0482fad --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Placeholder.kt @@ -0,0 +1,76 @@ +package dev.sanmer.authenticator.ui.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun Loading( + modifier: Modifier = Modifier +) = Box( + modifier = modifier, + contentAlignment = Alignment.Center +) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 4.dp + ) +} + +@Composable +fun Finished( + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.outlineVariant, + textStyle: TextStyle = MaterialTheme.typography.titleLarge +) = Box( + modifier = modifier, + contentAlignment = Alignment.Center +) { + CompositionLocalProvider( + LocalContentColor provides color, + ) { + ProvideTextStyle(textStyle) { + label() + } + } +} + +@Composable +fun Finished( + label: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.outlineVariant, + textStyle: TextStyle = MaterialTheme.typography.titleLarge +) = Finished( + label = { Text(text = label) }, + modifier = modifier, + color = color, + textStyle = textStyle +) + +@Composable +fun Finished( + @StringRes label: Int, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.outlineVariant, + textStyle: TextStyle = MaterialTheme.typography.titleLarge +) = Finished( + label = stringResource(label), + modifier = modifier, + color = color, + textStyle = textStyle +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Point.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Point.kt new file mode 100644 index 0000000..9cfe3e1 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Point.kt @@ -0,0 +1,81 @@ +package dev.sanmer.authenticator.ui.component + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateValue +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.isSpecified + +@Composable +fun Point( + modifier: Modifier, + color: Color, + ringColor: Color = Color.Unspecified, + ringScale: Float = 1f, + ringAlpha: Float = 1f +) = Canvas(modifier = modifier) { + val radius = size.minDimension / 2f + val innerRadius = radius * 0.6f + + drawCircle( + color = color, + radius = innerRadius, + center = center + ) + + if (ringColor.isSpecified) { + val ringRadius = (radius * ringScale + innerRadius) / 2 + val strokeWidth = radius * ringScale - innerRadius + + drawCircle( + color = ringColor, + alpha = ringAlpha, + radius = ringRadius, + center = center, + style = Stroke(width = strokeWidth) + ) + } +} + +@Composable +fun AnimatedPoint( + modifier: Modifier, + color: Color, + ringColor: Color = color, +) { + val transition = rememberInfiniteTransition() + val scale by transition.animateValue( + initialValue = 0.1f, + targetValue = 1f, + typeConverter = Float.VectorConverter, + animationSpec = infiniteRepeatable( + animation = tween(1200), + repeatMode = RepeatMode.Reverse + ) + ) + val alpha by transition.animateValue( + initialValue = 0.15f, + targetValue = 0.45f, + typeConverter = Float.VectorConverter, + animationSpec = infiniteRepeatable( + animation = tween(1200), + repeatMode = RepeatMode.Reverse + ) + ) + + Point( + modifier = modifier, + color = color, + ringColor = ringColor, + ringScale = scale, + ringAlpha = alpha + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/SegmentedButton.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/SegmentedButton.kt new file mode 100644 index 0000000..6fba7b5 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/SegmentedButton.kt @@ -0,0 +1,22 @@ +package dev.sanmer.authenticator.ui.component + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import dev.sanmer.authenticator.R + +@Composable +fun SegmentedButtonDefaults.Check( + active: Boolean +) = Icon( + active = active, + activeContent = { + androidx.compose.material3.Icon( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(IconSize) + ) + } +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Swipe.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Swipe.kt deleted file mode 100644 index 9d6d1be..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/component/Swipe.kt +++ /dev/null @@ -1,163 +0,0 @@ -package dev.sanmer.authenticator.ui.component - -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.LayoutDirection -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt - -@Composable -fun SwipeContent( - content: @Composable (() -> Unit) -> Unit, - surface: @Composable () -> Unit -) { - var deltaValue by remember { mutableFloatStateOf(0f) } - var offsetX by remember { mutableFloatStateOf(0f) } - var targetOffsetX = remember { 0f } - - var expanded by remember { mutableStateOf(false) } - LaunchedEffect(expanded) { - if (expanded) animate(offsetX, -targetOffsetX, animationSpec = tween(200)) { value, _ -> - offsetX = value - } - } - - var released by remember { mutableStateOf(false) } - LaunchedEffect(released) { - if (released) animate(offsetX, 0f, animationSpec = tween(200)) { value, _ -> - offsetX = value - } - } - - Box { - Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .onSizeChanged { targetOffsetX = it.width.toFloat() } - ) { - content { released = true } - } - - Box( - modifier = Modifier - .offset { IntOffset(offsetX.roundToInt(), 0) } - .draggable( - reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - if (targetOffsetX == 0f) return@rememberDraggableState - val p = 1f - abs(offsetX) / (targetOffsetX * 1.25f) - offsetX += delta * min(p, 1f) - deltaValue = delta - }, - onDragStarted = { - expanded = false - released = false - deltaValue = 0f - }, - onDragStopped = { - if (deltaValue > 0) { - released = true - } else { - if (abs(offsetX) / targetOffsetX > 0.35) { - expanded = true - } else { - released = true - } - } - } - ) - ) { - surface() - } - } -} - -@Composable -fun SwipeFloor( - onSwipe: (SwipeValue) -> Unit, - floor: @Composable () -> Unit, - content: @Composable () -> Unit -) { - val currentOnSwipe by rememberUpdatedState(newValue = onSwipe) - - var offsetX by remember { mutableFloatStateOf(0f) } - var triggerOffsetX = remember { 0f } - val swipeProgress by remember { - derivedStateOf { - when { - offsetX > 0 -> min(offsetX / triggerOffsetX, 1f) - offsetX < 0 -> max(offsetX / triggerOffsetX, -1f) - else -> 0f - } - } - } - - var released by remember { mutableStateOf(false) } - LaunchedEffect(released) { - if (released) animate(offsetX, 0f, animationSpec = tween(300)) { value, _ -> - offsetX = value - currentOnSwipe(SwipeValue.Closing(swipeProgress)) - } - } - - Box( - contentAlignment = Alignment.Center - ) { - if (offsetX != 0f) floor() - - Box( - modifier = Modifier - .onSizeChanged { triggerOffsetX = it.width / 5f * 2 } - .offset { IntOffset(offsetX.roundToInt(), 0) } - .draggable( - reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - if (triggerOffsetX == 0f) return@rememberDraggableState - val p = 1f - abs(offsetX) / (triggerOffsetX * 1.25f) - offsetX += delta * min(p, 1f) - currentOnSwipe(SwipeValue.Swiping(swipeProgress)) - }, - onDragStarted = { - currentOnSwipe(SwipeValue.Started()) - released = false - }, - onDragStopped = { - currentOnSwipe(SwipeValue.Stopped(swipeProgress)) - released = true - } - ) - ) { - content() - } - } -} - -sealed class SwipeValue(val progress: Float) { - class Started : SwipeValue(0f) - class Swiping(progress: Float) : SwipeValue(progress) - class Stopped(progress: Float) : SwipeValue(progress) - class Closing(progress: Float) : SwipeValue(progress) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/ViewModelsModule.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/ViewModelsModule.kt deleted file mode 100644 index fa30cfc..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/di/ViewModelsModule.kt +++ /dev/null @@ -1,27 +0,0 @@ -package dev.sanmer.authenticator.ui.di - -import dev.sanmer.authenticator.ui.screens.authorize.AuthorizeViewModel -import dev.sanmer.authenticator.ui.screens.crypto.CryptoViewModel -import dev.sanmer.authenticator.ui.screens.edit.EditViewModel -import dev.sanmer.authenticator.ui.screens.encode.EncodeViewModel -import dev.sanmer.authenticator.ui.screens.home.HomeViewModel -import dev.sanmer.authenticator.ui.screens.main.MainViewModel -import dev.sanmer.authenticator.ui.screens.ntp.NtpViewModel -import dev.sanmer.authenticator.ui.screens.scan.ScanViewModel -import dev.sanmer.authenticator.ui.screens.settings.SettingsViewModel -import dev.sanmer.authenticator.ui.screens.trash.TrashViewModel -import org.koin.core.module.dsl.viewModelOf -import org.koin.dsl.module - -val ViewModels = module { - viewModelOf(::MainViewModel) - viewModelOf(::AuthorizeViewModel) - viewModelOf(::CryptoViewModel) - viewModelOf(::HomeViewModel) - viewModelOf(::EditViewModel) - viewModelOf(::SettingsViewModel) - viewModelOf(::ScanViewModel) - viewModelOf(::NtpViewModel) - viewModelOf(::EncodeViewModel) - viewModelOf(::TrashViewModel) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ClipboardExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Clipboard.kt similarity index 67% rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ClipboardExt.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Clipboard.kt index b15ca71..dcefdbf 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ClipboardExt.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Clipboard.kt @@ -5,15 +5,13 @@ import android.content.ClipDescription import android.os.PersistableBundle import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.toClipEntry +import dev.sanmer.authenticator.compat.BuildCompat -suspend fun Clipboard.setSensitiveText( - content: String -) { +suspend fun Clipboard.setSensitiveText(content: String) { val data = ClipData.newPlainText("plain text", content).apply { description.extras = PersistableBundle().apply { - putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + if (BuildCompat.atLeastT) putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) } } - setClipEntry(data.toClipEntry()) } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListStateExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListState.kt similarity index 100% rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListStateExt.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/LazyListState.kt diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ModifierExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Modifier.kt similarity index 70% rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ModifierExt.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Modifier.kt index cbf7d6f..cc35b01 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ModifierExt.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Modifier.kt @@ -3,17 +3,15 @@ package dev.sanmer.authenticator.ui.ktx import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -@Stable fun Modifier.surface( shape: Shape, backgroundColor: Color, - border: BorderStroke? = null -) = then(if (border != null) Modifier.border(border = border, shape = shape) else Modifier) + border: BorderStroke? = null, +) = then(if (border != null) Modifier.border(border, shape) else Modifier) .background(color = backgroundColor, shape = shape) - .clip(shape = shape) \ No newline at end of file + .clip(shape) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValuesExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValues.kt similarity index 86% rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValuesExt.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValues.kt index 7597675..21007c1 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValuesExt.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/PaddingValues.kt @@ -1,5 +1,3 @@ -@file:Suppress("NOTHING_TO_INLINE") - package dev.sanmer.authenticator.ui.ktx import androidx.compose.foundation.layout.PaddingValues @@ -7,10 +5,10 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection -inline operator fun PaddingValues.plus(other: PaddingValues): PaddingValues = +operator fun PaddingValues.plus(other: PaddingValues): PaddingValues = OperatorPaddingValues(this, other, Dp::plus) -inline operator fun PaddingValues.minus(other: PaddingValues): PaddingValues = +operator fun PaddingValues.minus(other: PaddingValues): PaddingValues = OperatorPaddingValues(this, other, Dp::minus) @Immutable @@ -42,4 +40,4 @@ class OperatorPaddingValues( that.calculateTopPadding(), other.calculateTopPadding() ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Shape.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Shape.kt new file mode 100644 index 0000000..79c8a07 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/Shape.kt @@ -0,0 +1,29 @@ +package dev.sanmer.authenticator.ui.ktx + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.unit.Dp + +fun CornerBasedShape.top(size: Dp) = copy( + topStart = CornerSize(size), topEnd = CornerSize(size) +) + +fun CornerBasedShape.bottom(size: Dp) = copy( + bottomStart = CornerSize(size), bottomEnd = CornerSize(size) +) + +infix fun CornerBasedShape.topWith(other: CornerBasedShape) = copy( + topStart = other.topStart, topEnd = other.topEnd +) + +infix fun CornerBasedShape.bottomWith(other: CornerBasedShape) = copy( + bottomStart = other.bottomStart, bottomEnd = other.bottomEnd +) + +infix fun CornerBasedShape.startWith(other: CornerBasedShape) = copy( + topStart = other.topStart, bottomStart = other.bottomStart +) + +infix fun CornerBasedShape.endWith(other: CornerBasedShape) = copy( + topEnd = other.topEnd, bottomEnd = other.bottomEnd +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt deleted file mode 100644 index 9b3b521..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/ktx/ShapeExt.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.sanmer.authenticator.ui.ktx - -import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.ui.unit.Dp - -fun CornerBasedShape.bottom(size: Dp) = - copy(bottomStart = CornerSize(size), bottomEnd = CornerSize(size)) diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/Screen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/Screen.kt similarity index 53% rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/Screen.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/Screen.kt index 0c05554..8ec97a4 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/Screen.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/Screen.kt @@ -1,33 +1,33 @@ -package dev.sanmer.authenticator.ui.screens +package dev.sanmer.authenticator.ui.screen +import android.net.Uri import androidx.navigation3.runtime.NavKey +import dev.sanmer.authenticator.model.serializable.UriSerializer import kotlinx.serialization.Serializable sealed interface Screen : NavKey { @Serializable data object Home : Screen - @Serializable - data object Settings : Screen - @Serializable data class Edit( - val id: Long = Long.MAX_VALUE, - val uri: String = "" + val authId: Long = -1, + @Serializable(UriSerializer::class) + val otpUri: Uri = Uri.EMPTY ) : Screen @Serializable - data object Scan : Screen + data object Setting : Screen @Serializable - data object Encode : Screen + data object Trash : Screen @Serializable data object Ntp : Screen @Serializable - data object Security : Screen + data object Export : Screen @Serializable - data object Trash : Screen + data object Brand : Screen } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/brand/BrandScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/brand/BrandScreen.kt new file mode 100644 index 0000000..ddfb0b2 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/brand/BrandScreen.kt @@ -0,0 +1,110 @@ +package dev.sanmer.authenticator.ui.screen.brand + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.ktx.plus +import dev.sanmer.brand.Brand + +@Composable +fun BrandScreen( + goBack: () -> Unit +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + Scaffold( + topBar = { + TopBar( + onBack = goBack, + scrollBehavior = scrollBehavior + ) + } + ) { contentPadding -> + LazyVerticalGrid( + columns = GridCells.Adaptive(100.dp), + contentPadding = PaddingValues(15.dp) + contentPadding, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + items( + count = Brand.entries.size + ) { + BrandItem( + brand = Brand.entries[it] + ) + } + } + } +} + +@Composable +private fun BrandItem( + brand: Brand +) = Column( + modifier = Modifier + .border( + border = CardDefaults.outlinedCardBorder(false), + shape = MaterialTheme.shapes.large + ) + .size(100.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center +) { + Image( + painter = painterResource(brand.id), + contentDescription = null, + modifier = Modifier.size(45.dp) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = brand.name, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.outline + ) +} + +@Composable +private fun TopBar( + onBack: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) = TopAppBar( + title = { Text(text = stringResource(R.string.setting_brand)) }, + navigationIcon = { + IconButton( + onClick = onBack + ) { + Icon( + painter = painterResource(R.drawable.arrow_left), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditScreen.kt new file mode 100644 index 0000000..e496ef8 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditScreen.kt @@ -0,0 +1,275 @@ +package dev.sanmer.authenticator.ui.screen.edit + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.ktx.plus +import dev.sanmer.authenticator.ui.screen.edit.EditViewModel.BottomSheet +import dev.sanmer.authenticator.ui.screen.edit.component.DigitsItem +import dev.sanmer.authenticator.ui.screen.edit.component.IssuerItem +import dev.sanmer.authenticator.ui.screen.edit.component.NameItem +import dev.sanmer.authenticator.ui.screen.edit.component.PreviewBottomSheet +import dev.sanmer.authenticator.ui.screen.edit.component.QrcodeBottomSheet +import dev.sanmer.authenticator.ui.screen.edit.component.SecretItem +import dev.sanmer.authenticator.ui.screen.edit.component.TypeItem +import dev.sanmer.authenticator.ui.screen.scan.ScanScreen +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun EditScreen( + viewModel: EditViewModel, + goBack: () -> Unit +) = AnimatedContent( + targetState = viewModel.bottomSheet, + transitionSpec = { + fadeIn( + animationSpec = tween(500) + ) togetherWith fadeOut( + animationSpec = tween(500) + ) + }, + contentKey = { it == BottomSheet.Scan } +) { + when (it) { + BottomSheet.Scan -> ScanScreen( + viewModel = koinViewModel { parametersOf(viewModel::fromScan) }, + goBack = { viewModel.bottomSheet = BottomSheet.None } + ) + + else -> EditScreen( + viewModel = viewModel, + goBack = goBack, + onScan = { viewModel.bottomSheet = BottomSheet.Scan } + ) + } +} + +@Composable +fun EditScreen( + viewModel: EditViewModel, + goBack: () -> Unit, + onScan: () -> Unit +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + when (val bs = viewModel.bottomSheet) { + BottomSheet.None, BottomSheet.Scan -> {} + is BottomSheet.Preview -> PreviewBottomSheet( + onClose = { viewModel.bottomSheet = BottomSheet.None }, + preview = bs.preview, + onSave = { viewModel.save(it, goBack) } + ) + + is BottomSheet.Qrcode -> QrcodeBottomSheet( + onClose = { viewModel.bottomSheet = BottomSheet.None }, + qrcode = bs.qrcode + ) + } + + Scaffold( + modifier = Modifier.imePadding(), + topBar = { + TopBar( + isEdit = viewModel.isEdit, + isTrashed = viewModel.isTrashed, + onBack = goBack, + onTrash = viewModel::trash, + onDelete = { viewModel.delete(goBack) }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + ActionButton( + isTrashed = viewModel.isTrashed, + onRestore = viewModel::restore, + onSave = { viewModel.preview() }, + visible = viewModel.input.isNotEmpty + ) + } + ) { contentPadding -> + Column( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .verticalScroll(rememberScrollState()) + .padding(PaddingValues(horizontal = 5.dp, vertical = 15.dp) + contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + NameItem( + name = viewModel.input.name, + enabled = !viewModel.isTrashed + ) + + IssuerItem( + issuer = viewModel.input.issuer, + brand = viewModel.brand, + onMatchesBrand = viewModel::matchesBrand, + enabled = !viewModel.isTrashed + ) + + SecretItem( + secret = viewModel.input.secret, + hidden = viewModel.input.hidden, + isEdit = viewModel.isEdit, + enabled = !viewModel.isTrashed + ) + + TypeItem( + type = viewModel.input.type, + hash = viewModel.input.hash, + enabled = !viewModel.isTrashed + ) + + DigitsItem( + digits = viewModel.input.digits, + period = viewModel.input.period, + enabled = !viewModel.isTrashed + ) + + val keyboardController = LocalSoftwareKeyboardController.current + val density = LocalDensity.current + val color = MaterialTheme.colorScheme.onSurface + FilledTonalIconButton( + onClick = { + keyboardController?.hide() + if (viewModel.isEdit) { + viewModel.qrcode(density, color) + } else { + onScan() + } + } + ) { + Icon( + painter = painterResource(R.drawable.qrcode), + contentDescription = null + ) + } + } + } +} + +@Composable +private fun TopBar( + isEdit: Boolean, + isTrashed: Boolean, + onBack: () -> Unit, + onTrash: () -> Unit, + onDelete: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) = TopAppBar( + title = { + Text( + text = stringResource( + if (isEdit) R.string.token_edit_title + else R.string.token_add_title + ) + ) + }, + navigationIcon = { + val keyboardController = LocalSoftwareKeyboardController.current + IconButton( + onClick = { + keyboardController?.hide() + onBack() + } + ) { + Icon( + painter = painterResource(R.drawable.arrow_left), + contentDescription = null + ) + } + }, + actions = { + if (isEdit) AnimatedContent( + targetState = isTrashed, + transitionSpec = { + fadeIn() + scaleIn( + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) togetherWith scaleOut( + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + fadeOut() + } + ) { isTrashed -> + IconButton( + onClick = { + when { + transition.isRunning -> {} + else -> if (isTrashed) onDelete() else onTrash() + } + } + ) { + Icon( + painter = painterResource( + if (isTrashed) R.drawable.trash_x + else R.drawable.trash + ), + contentDescription = null + ) + } + } + }, + scrollBehavior = scrollBehavior +) + +@Composable +private fun ActionButton( + isTrashed: Boolean, + onRestore: () -> Unit, + onSave: () -> Unit, + visible: Boolean = true +) = AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = scaleOut() + fadeOut() +) { + val keyboardController = LocalSoftwareKeyboardController.current + FloatingActionButton( + onClick = { + keyboardController?.hide() + if (isTrashed) onRestore() else onSave() + }, + ) { + Icon( + painter = painterResource( + if (isTrashed) R.drawable.restore + else R.drawable.device_floppy + ), + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditViewModel.kt new file mode 100644 index 0000000..f7e156e --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/EditViewModel.kt @@ -0,0 +1,269 @@ +package dev.sanmer.authenticator.ui.screen.edit + +import android.net.Uri +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.sanmer.auth.Otp +import dev.sanmer.auth.OtpUri.Default.isOtpUri +import dev.sanmer.auth.OtpUri.Default.toOtpUri +import dev.sanmer.auth.QRCode +import dev.sanmer.authenticator.Const.INSTANT_ZERO +import dev.sanmer.authenticator.Const.isZero +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.database.model.AuthProperty +import dev.sanmer.authenticator.ktx.stateIn +import dev.sanmer.authenticator.model.otp.Totp +import dev.sanmer.authenticator.repository.DbRepository +import dev.sanmer.authenticator.repository.OtpRepository +import dev.sanmer.authenticator.repository.TimeRepository +import dev.sanmer.brand.Brand +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class EditViewModel( + private val authId: Long, + private val otpUri: Uri, + private val dbRepository: DbRepository, + private val otpRepository: OtpRepository, + private val timeRepository: TimeRepository +) : ViewModel() { + val isEdit = authId > 0 + var isTrashed by mutableStateOf(false) + private set + + val input = Input(hidden = isEdit) + + var brand by mutableStateOf(null) + private set + + var bottomSheet by mutableStateOf(BottomSheet.None) + + private val logger = Logger.Android("EditViewModel") + + init { + logger.d("init") + loadData() + } + + private fun loadData() { + viewModelScope.launch { + if (isEdit) { + dbRepository.getAuthPropertiesAsFlow(authId) + .collect { + input.update(it) + isTrashed = !it.auth.trashedAt.isZero + } + } else { + fromOtpUri(otpUri) + } + } + } + + private fun fromOtpUri(uri: Uri) { + if (!uri.isOtpUri()) return + runCatching { + val otpUri = uri.toOtpUri() + if (otpUri.type.equals("totp", ignoreCase = true)) { + input.update(Totp(otpUri).toAuth()) + } + }.onFailure { + logger.e(it) + } + } + + fun fromScan(uri: Uri) { + fromOtpUri(uri) + bottomSheet = BottomSheet.None + } + + fun matchesBrand() { + viewModelScope.launch { + brand = Brand.matches(input.issuerValue) + brand?.let { input.issuer.setTextAndPlaceCursorAtEnd(it.name) } + } + } + + fun preview() { + viewModelScope.launch { + val preview = runCatching { + val authId = authId.coerceAtLeast(0) + when (input.typeValue) { + Auth.Type.TOTP -> { + val totp = input.totp() + val auth = totp.toAuth(authId) + auth to otpRepository.totp(totp) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly + ) + } + } + }.onFailure { + logger.e(it) + } + bottomSheet = BottomSheet.Preview(preview) + } + } + + fun save(auth: AuthProperties, onBack: () -> Unit = {}) { + viewModelScope.launch { + runCatching { + dbRepository.upsert(auth) + if (!isEdit) onBack() + }.onFailure { + logger.e(it) + } + } + } + + fun trash() { + viewModelScope.launch { + runCatching { + runCatching { + dbRepository.trash(authId, timeRepository.now()) + }.onFailure { + logger.e(it) + } + } + } + } + + fun restore() { + viewModelScope.launch { + runCatching { + dbRepository.trash(authId, INSTANT_ZERO) + }.onFailure { + logger.e(it) + } + } + } + + fun delete(onBack: () -> Unit = {}) { + viewModelScope.launch { + runCatching { + dbRepository.delete(authId) + onBack() + }.onFailure { + logger.e(it) + } + } + } + + fun qrcode(density: Density, color: Color) { + viewModelScope.launch(Dispatchers.IO) { + val qrcode = runCatching { + val uri = when (input.typeValue) { + Auth.Type.TOTP -> input.totp().toUri() + } + val content = uri.toString() + content to QRCode.encodeToBitmap( + content = content, + size = with(density) { 360.dp.roundToPx() }, + foregroundColor = color.toArgb(), + backgroundColor = Color.Transparent.toArgb() + ).asImageBitmap() + }.onFailure { + logger.e(it) + } + bottomSheet = BottomSheet.Qrcode(qrcode) + } + } + + data class Input( + val name: TextFieldState, + val issuer: TextFieldState, + val secret: TextFieldState, + val hidden: MutableState, + val type: MutableState, + val hash: MutableState, + val digits: TextFieldState, + val period: TextFieldState + ) { + constructor( + hidden: Boolean, + name: String = "", + issuer: String = "", + secret: String = "", + type: Auth.Type = Auth.Type.TOTP, + hash: Otp.Hash = Otp.Hash.SHA1, + digits: Int = 6, + period: Long = 30 + ) : this( + hidden = mutableStateOf(hidden), + name = TextFieldState(name), + issuer = TextFieldState(issuer), + secret = TextFieldState(secret), + type = mutableStateOf(type), + hash = mutableStateOf(hash), + digits = TextFieldState(digits.toString()), + period = TextFieldState(period.toString()) + ) + + val nameValue inline get() = name.text.trim().toString() + val issuerValue inline get() = issuer.text.trim().toString() + val secretValue inline get() = secret.text.trim().toString() + var typeValue by type + var hashValue by hash + val digitsValue inline get() = digits.text.toString().toInt() + val periodValue inline get() = period.text.toString().toLong() + + val isNotEmpty by derivedStateOf { + secret.text.isNotEmpty() && digits.text.isNotEmpty() && period.text.isNotEmpty() + } + + fun update(auth: AuthProperties) { + name.setTextAndPlaceCursorAtEnd( + auth.auth.name + ) + issuer.setTextAndPlaceCursorAtEnd( + auth.auth.issuer + ) + secret.setTextAndPlaceCursorAtEnd( + auth.getValue(AuthProperty.Key.Secret, "") { it } + ) + typeValue = auth.auth.type + hashValue = auth.getValue(AuthProperty.Key.Hash, Otp.Hash.SHA1, Otp.Hash::valueOf) + digits.setTextAndPlaceCursorAtEnd( + auth.getValue(AuthProperty.Key.Digits, "6") { it } + ) + period.setTextAndPlaceCursorAtEnd( + auth.getValue(AuthProperty.Key.Period, "30") { it } + ) + } + + fun totp() = Totp( + name = nameValue, + issuer = issuerValue, + secret = secretValue, + hash = hashValue, + digits = digitsValue.coerceAtLeast(1), + period = periodValue.coerceAtLeast(1) + ) + } + + sealed interface BottomSheet { + data object None : BottomSheet + data object Scan : BottomSheet + data class Preview(val preview: Result>>) : + BottomSheet + + data class Qrcode(val qrcode: Result>) : BottomSheet + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/DigitsItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/DigitsItem.kt new file mode 100644 index 0000000..7a1a090 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/DigitsItem.kt @@ -0,0 +1,72 @@ +package dev.sanmer.authenticator.ui.screen.edit.component + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.then +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.component.IconRow + +@Composable +fun DigitsItem( + digits: TextFieldState, + period: TextFieldState, + enabled: Boolean = true +) = IconRow { + OutlinedTextField( + state = digits, + enabled = enabled, + inputTransformation = InputTransformation.maxLength(2).then { + if (!asCharSequence().isDigitsOnly()) { + revertAllChanges() + } + }, + label = { Text(text = stringResource(R.string.token_digits)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + shape = MaterialTheme.shapes.medium, + lineLimits = TextFieldLineLimits.SingleLine, + modifier = Modifier + .weight(1f) + .padding(bottom = 8.dp) + ) + + Spacer(modifier = Modifier.width(15.dp)) + + OutlinedTextField( + state = period, + enabled = enabled, + inputTransformation = InputTransformation.maxLength(2).then { + if (!asCharSequence().isDigitsOnly()) { + revertAllChanges() + } + }, + label = { Text(text = stringResource(R.string.token_period)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + shape = MaterialTheme.shapes.medium, + lineLimits = TextFieldLineLimits.SingleLine, + modifier = Modifier + .weight(1f) + .padding(bottom = 8.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/IssuerItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/IssuerItem.kt new file mode 100644 index 0000000..2113359 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/IssuerItem.kt @@ -0,0 +1,65 @@ +package dev.sanmer.authenticator.ui.screen.edit.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.component.FixedBox +import dev.sanmer.authenticator.ui.component.IconRow +import dev.sanmer.brand.Brand + +@Composable +fun IssuerItem( + issuer: TextFieldState, + brand: Brand?, + onMatchesBrand: () -> Unit, + enabled: Boolean = true +) = IconRow( + leadingIcon = { + FixedBox { + if (brand != null) Image( + painter = painterResource(brand.id), + contentDescription = null + ) + } + }, + trailingIcon = { + IconButton( + onClick = onMatchesBrand + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } +) { + OutlinedTextField( + state = issuer, + enabled = enabled, + label = { Text(text = stringResource(R.string.token_issuer)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + shape = MaterialTheme.shapes.medium, + lineLimits = TextFieldLineLimits.SingleLine, + modifier = Modifier + .weight(1f) + .padding(bottom = 8.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/NameItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/NameItem.kt new file mode 100644 index 0000000..d2d7436 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/NameItem.kt @@ -0,0 +1,42 @@ +package dev.sanmer.authenticator.ui.screen.edit.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.component.FixedIcon +import dev.sanmer.authenticator.ui.component.IconRow + +@Composable +fun NameItem( + name: TextFieldState, + enabled: Boolean = true +) = IconRow( + leadingIcon = { FixedIcon(painter = painterResource(R.drawable.user)) } +) { + OutlinedTextField( + state = name, + enabled = enabled, + label = { Text(text = stringResource(R.string.token_name)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + shape = MaterialTheme.shapes.medium, + lineLimits = TextFieldLineLimits.SingleLine, + modifier = Modifier + .weight(1f) + .padding(bottom = 8.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/PreviewBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/PreviewBottomSheet.kt new file mode 100644 index 0000000..fee00b1 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/PreviewBottomSheet.kt @@ -0,0 +1,81 @@ +package dev.sanmer.authenticator.ui.screen.edit.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.ui.component.DragHandle +import dev.sanmer.authenticator.ui.component.Finished +import dev.sanmer.authenticator.ui.ktx.bottom +import dev.sanmer.authenticator.ui.screen.home.component.AuthItem +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun PreviewBottomSheet( + onClose: () -> Unit, + preview: Result>>, + onSave: (AuthProperties) -> Unit +) = ModalBottomSheet( + onDismissRequest = onClose, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + shape = MaterialTheme.shapes.large.bottom(0.dp), + dragHandle = null +) { + DragHandle() + + Text( + text = stringResource(R.string.token_preview), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + preview.onSuccess { (auth, otp) -> + AuthItem( + auth = auth.auth, + otp = otp + ) + + Button( + onClick = { onSave(auth) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.token_save)) + } + }.onFailure { + Finished( + label = it.message ?: it.javaClass.name, + modifier = Modifier + .height(120.dp) + .fillMaxWidth() + ) + + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.token_close)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/QrcodeBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/QrcodeBottomSheet.kt new file mode 100644 index 0000000..0e98f4c --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/QrcodeBottomSheet.kt @@ -0,0 +1,101 @@ +package dev.sanmer.authenticator.ui.screen.edit.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.component.DragHandle +import dev.sanmer.authenticator.ui.component.Finished +import dev.sanmer.authenticator.ui.ktx.bottom +import dev.sanmer.authenticator.ui.ktx.setSensitiveText +import kotlinx.coroutines.launch + +@Composable +fun QrcodeBottomSheet( + onClose: () -> Unit, + qrcode: Result> +) = ModalBottomSheet( + onDismissRequest = onClose, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + shape = MaterialTheme.shapes.large.bottom(0.dp), + dragHandle = null +) { + DragHandle() + + Text( + text = stringResource(R.string.token_preview), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + qrcode.onSuccess { (content, bitmap) -> + Image( + bitmap = bitmap, + contentDescription = null, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier + .fillMaxWidth() + .border( + border = CardDefaults.outlinedCardBorder(false), + shape = MaterialTheme.shapes.medium + ) + .padding(15.dp) + ) + + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + Button( + onClick = { scope.launch { clipboard.setSensitiveText(content) } }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.token_copy)) + } + }.onFailure { + Finished( + label = it.message ?: it.javaClass.name, + modifier = Modifier + .height(120.dp) + .fillMaxWidth() + ) + + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.token_close)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/SecretItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/SecretItem.kt new file mode 100644 index 0000000..d786f6e --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/SecretItem.kt @@ -0,0 +1,76 @@ +package dev.sanmer.authenticator.ui.screen.edit.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.sanmer.auth.encodeBase32 +import dev.sanmer.authenticator.BuildConfig +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.component.FixedBox +import dev.sanmer.authenticator.ui.component.FixedIcon +import dev.sanmer.authenticator.ui.component.IconRow +import kotlin.random.Random + +@Composable +fun SecretItem( + secret: TextFieldState, + hidden: MutableState, + isEdit: Boolean, + enabled: Boolean = true +) = IconRow( + leadingIcon = { FixedIcon(painter = painterResource(R.drawable.key)) }, + trailingIcon = { + if (isEdit) IconButton( + onClick = { hidden.value = !hidden.value }, + enabled = enabled + ) { + Icon( + painter = painterResource( + if (hidden.value) R.drawable.sparkles_2 + else R.drawable.sparkles_2_off + ), + contentDescription = null + ) + } else if (BuildConfig.DEBUG) IconButton( + onClick = { + val value = ByteArray(16).let(Random::nextBytes) + secret.setTextAndPlaceCursorAtEnd(value.encodeBase32()) + } + ) { + Icon( + painter = painterResource(R.drawable.arrows_shuffle), + contentDescription = null + ) + } else FixedBox() + } +) { + OutlinedSecureTextField( + state = secret, + enabled = enabled, + label = { Text(text = stringResource(R.string.token_secret)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + shape = MaterialTheme.shapes.medium, + textObfuscationMode = with(TextObfuscationMode) { if (hidden.value) Hidden else Visible }, + modifier = Modifier + .weight(1f) + .padding(bottom = 8.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/TypeItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/TypeItem.kt new file mode 100644 index 0000000..5dc1c17 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/edit/component/TypeItem.kt @@ -0,0 +1,123 @@ +package dev.sanmer.authenticator.ui.screen.edit.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.auth.Otp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.ui.component.FixedIcon +import dev.sanmer.authenticator.ui.component.IconRow +import kotlin.enums.enumEntries + +@Composable +fun TypeItem( + type: MutableState, + hash: MutableState, + enabled: Boolean = true +) = IconRow( + leadingIcon = { FixedIcon(painter = painterResource(R.drawable.math_function)) } +) { + val (typeExpanded, onTypeExpandedChange) = remember { mutableStateOf(false) } + val (hashExpanded, onHashExpandedChange) = remember { mutableStateOf(false) } + + EnumTextField( + value = type.value, + onValueChange = { type.value = it }, + expanded = typeExpanded, + onExpandedChange = onTypeExpandedChange, + label = stringResource(R.string.token_type), + modifier = Modifier.weight(1f), + enabled = enabled + ) + + Spacer(modifier = Modifier.width(15.dp)) + + EnumTextField( + value = hash.value, + onValueChange = { hash.value = it }, + expanded = hashExpanded, + onExpandedChange = onHashExpandedChange, + label = stringResource(R.string.token_hash), + modifier = Modifier.weight(1f), + enabled = enabled + ) +} + +@Composable +private inline fun > EnumTextField( + value: T, + crossinline onValueChange: (T) -> Unit, + expanded: Boolean, + crossinline onExpandedChange: (Boolean) -> Unit, + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true +) = ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { onExpandedChange(it) }, + modifier = modifier +) { + OutlinedTextField( + value = value.name, + onValueChange = {}, + enabled = enabled, + readOnly = true, + label = { Text(text = label) }, + shape = MaterialTheme.shapes.medium, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = enabled + ) + .padding(bottom = 8.dp) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.padding(vertical = 0.dp) + ) { + enumEntries().forEachIndexed { index, value -> + if (index != 0) Spacer(modifier = Modifier.height(4.dp)) + + DropdownMenuItem( + text = { + Text( + text = value.name, + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + onValueChange(value) + onExpandedChange(false) + }, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier + .padding(horizontal = 8.dp) + .clip(shape = MaterialTheme.shapes.small) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportScreen.kt new file mode 100644 index 0000000..562dcfb --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportScreen.kt @@ -0,0 +1,167 @@ +package dev.sanmer.authenticator.ui.screen.export + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.ktx.isScrollingUp +import dev.sanmer.authenticator.ui.ktx.plus +import dev.sanmer.authenticator.ui.screen.export.component.AuthItem +import dev.sanmer.authenticator.ui.screen.export.component.ButtonsItem +import dev.sanmer.authenticator.ui.screen.export.component.ErrorItem +import dev.sanmer.authenticator.ui.screen.home.component.AuthItem + +@Composable +fun ExportScreen( + viewModel: ExportViewModel, + goBack: () -> Unit +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val listState = rememberLazyListState() + val isScrollingUp by listState.isScrollingUp() + + Scaffold( + topBar = { + TopBar( + onBack = goBack, + onClear = viewModel::clear, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + ActionButton( + onClick = if (viewModel.isNotEmpty) { + viewModel::dbExport + } else { + viewModel::dbImport + }, + isSave = viewModel.isNotEmpty, + visible = isScrollingUp, + ) + } + ) { contentPadding -> + LazyColumn( + state = listState, + contentPadding = PaddingValues(15.dp) + contentPadding, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(15.dp), + modifier = Modifier + .animateContentSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + item { + ButtonsItem( + password = viewModel.input.password, + hidden = viewModel.input.hidden, + type = viewModel.input.type, + onImport = viewModel::import, + onExport = viewModel::export, + isNotEmpty = viewModel.isNotEmpty + ) + } + + items( + items = viewModel.list + ) { (auth, otp) -> + otp.onSuccess { + AuthItem( + auth = auth.auth, + otp = it, + isSelected = viewModel.isSelected(auth), + onClick = { viewModel.pick(auth) } + ) + }.onFailure { + AuthItem( + auth = auth.auth, + error = it + ) + } + } + + item { + viewModel.data.onFailure { + ErrorItem( + error = it + ) + } + } + } + } +} + +@Composable +private fun TopBar( + onBack: () -> Unit, + onClear: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) = TopAppBar( + title = { Text(text = stringResource(R.string.setting_import_export)) }, + navigationIcon = { + IconButton( + onClick = onBack + ) { + Icon( + painter = painterResource(R.drawable.arrow_left), + contentDescription = null + ) + } + }, + actions = { + IconButton( + onClick = onClear + ) { + Icon( + painter = painterResource(R.drawable.clear_all), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior +) + +@Composable +private fun ActionButton( + onClick: () -> Unit, + isSave: Boolean, + visible: Boolean = true +) = AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = scaleOut() + fadeOut() +) { + FloatingActionButton( + onClick = onClick + ) { + Icon( + painter = painterResource( + if (isSave) R.drawable.device_floppy + else R.drawable.database_import + ), + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportViewModel.kt new file mode 100644 index 0000000..9be5d05 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/ExportViewModel.kt @@ -0,0 +1,198 @@ +package dev.sanmer.authenticator.ui.screen.export + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.sanmer.auth.crypto.Crypto +import dev.sanmer.auth.crypto.PasswordKey +import dev.sanmer.authenticator.Const.isZero +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.database.model.AuthProperties +import dev.sanmer.authenticator.ktx.stateIn +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.model.LoadData.Default.loadData +import dev.sanmer.authenticator.model.serializable.AuthSet +import dev.sanmer.authenticator.model.serializable.AuthSet.Default.toAuthSet +import dev.sanmer.authenticator.repository.DbRepository +import dev.sanmer.authenticator.repository.OtpRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class ExportViewModel( + private val dbRepository: DbRepository, + private val otpRepository: OtpRepository +) : ViewModel() { + val input = Input() + + var data by mutableStateOf>>>>>( + LoadData.Pending + ) + val list by derivedStateOf { + data.getOrElse({ it }, ::emptyList) + } + + private val _selected = mutableStateListOf() + val isNotEmpty get() = _selected.isNotEmpty() + + private val logger = Logger.Android("ExportViewModel") + + init { + logger.d("init") + } + + fun isSelected(auth: AuthProperties) = _selected.contains(auth) + + fun pick(auth: AuthProperties) = if (isSelected(auth)) { + _selected.remove(auth) + } else { + _selected.add(auth) + } + + fun clear() { + _selected.clear() + data = LoadData.Pending + } + + fun import(context: Context, uri: Uri) { + viewModelScope.launch { + val cr = context.contentResolver + val stream = cr.openInputStream(uri) ?: return@launch + data = loadData { + when (input.typeValue) { + Input.Type.Json -> { + val set = stream.use(AuthSet::decodeFromJson) + val password = input.passwordValue + val key = if (password.isNotEmpty()) { + PasswordKey.new(password) + } else { + Crypto.Default + } + set.map { auth -> + val auth = auth.protectValue { key.decrypt(it) } + auth to runCatching { + otpRepository.otp(auth) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly + ) + }.onSuccess { + _selected.add(auth) + } + } + } + + Input.Type.Uri -> { + val set = stream.use(AuthSet::decodeFromUri) + set.map { auth -> + auth to runCatching { + otpRepository.otp(auth) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly + ) + }.onSuccess { + _selected.add(auth) + } + } + } + } + } + } + } + + fun export(context: Context, uri: Uri) { + viewModelScope.launch { + val cr = context.contentResolver + val stream = cr.openOutputStream(uri) ?: return@launch + runCatching { + when (input.typeValue) { + Input.Type.Json -> { + val password = input.passwordValue + val key = if (password.isNotEmpty()) { + PasswordKey.new(password) + } else { + Crypto.Default + } + val set = _selected.map { auth -> + auth.protectValue { key.encrypt(it) } + }.toAuthSet() + stream.use(set::encodeToJson) + clear() + } + + Input.Type.Uri -> { + val set = _selected.toAuthSet() + stream.use(set::encodeToUri) + clear() + } + } + }.onFailure { + logger.e(it) + } + } + } + + fun dbImport() { + viewModelScope.launch { + data = loadData { + dbRepository.getAllAuthProperties() + .map { + if (it.auth.trashedAt.isZero) _selected.add(it) + it to runCatching { + otpRepository.otp(it) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly + ) + } + } + } + } + } + + fun dbExport() { + viewModelScope.launch { + _selected.forEach { auth -> + runCatching { + dbRepository.upsert(auth) + }.onFailure { + logger.e(it) + } + } + clear() + } + } + + data class Input( + val password: TextFieldState, + val hidden: MutableState, + val type: MutableState, + ) { + constructor( + password: String = "", + hidden: Boolean = true, + type: Type = Type.Json + ) : this( + password = TextFieldState(password), + hidden = mutableStateOf(hidden), + type = mutableStateOf(type) + ) + + val passwordValue inline get() = password.text.trim() + var typeValue by type + + enum class Type(val mimeType: String) { + Json("application/json"), + Uri("text/plain") + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/AuthItem.kt new file mode 100644 index 0000000..0c100d6 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/AuthItem.kt @@ -0,0 +1,53 @@ +package dev.sanmer.authenticator.ui.screen.export.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.ui.component.LabelText +import dev.sanmer.authenticator.ui.ktx.surface + +@Composable +fun AuthItem( + auth: Auth, + error: Throwable, + modifier: Modifier = Modifier +) = Column( + modifier = modifier + .fillMaxWidth() + .surface( + shape = MaterialTheme.shapes.large, + backgroundColor = MaterialTheme.colorScheme.surface, + border = CardDefaults.outlinedCardBorder(false) + ) + .padding(15.dp) +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (auth.name.isNotEmpty()) Text( + text = auth.name, + style = MaterialTheme.typography.titleMedium + ) + + if (auth.issuer.isNotEmpty()) LabelText( + text = auth.issuer + ) + } + + Text( + text = error.message ?: error.javaClass.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ButtonsItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ButtonsItem.kt new file mode 100644 index 0000000..93c0cd6 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ButtonsItem.kt @@ -0,0 +1,141 @@ +package dev.sanmer.authenticator.ui.screen.export.component + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.ktx.surface +import dev.sanmer.authenticator.ui.screen.export.ExportViewModel + +@Composable +fun ButtonsItem( + password: TextFieldState, + hidden: MutableState, + type: MutableState, + onImport: (Context, Uri) -> Unit, + onExport: (Context, Uri) -> Unit, + isNotEmpty: Boolean, + modifier: Modifier = Modifier +) = Column( + modifier = modifier + .surface( + shape = MaterialTheme.shapes.large, + backgroundColor = MaterialTheme.colorScheme.surface, + border = CardDefaults.outlinedCardBorder(false) + ) + .padding(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) +) { + val isJson by remember(type) { + derivedStateOf { + type.value == ExportViewModel.Input.Type.Json + } + } + + OutlinedSecureTextField( + state = password, + enabled = isJson, + placeholder = { Text(text = stringResource(R.string.setting_password)) }, + trailingIcon = { + IconButton( + onClick = { hidden.value = !hidden.value }, + enabled = isJson + ) { + Icon( + painter = painterResource( + if (hidden.value) R.drawable.sparkles_2 + else R.drawable.sparkles_2_off + ), + contentDescription = null + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + shape = MaterialTheme.shapes.medium, + textObfuscationMode = with(TextObfuscationMode) { if (hidden.value) Hidden else Visible }, + modifier = Modifier.fillMaxWidth() + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + SingleChoiceSegmentedButtonRow { + ExportViewModel.Input.Type.entries.forEachIndexed { index, value -> + SegmentedButton( + selected = type.value == value, + onClick = { type.value = value }, + shape = SegmentedButtonDefaults.itemShape( + index, + ExportViewModel.Input.Type.entries.size + ), + label = { Text(text = value.name) } + ) + } + } + + val context = LocalContext.current + val importer = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { if (it != null) onImport(context, it) } + ) + FilledTonalIconButton( + onClick = { importer.launch(type.value.mimeType) }, + enabled = !isNotEmpty + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + + val exporter = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(type.value.mimeType), + onResult = { if (it != null) onExport(context, it) } + ) + FilledTonalIconButton( + onClick = { exporter.launch("auth") }, + enabled = isNotEmpty + ) { + Icon( + painter = painterResource(R.drawable.upload), + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ErrorItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ErrorItem.kt new file mode 100644 index 0000000..49c25c7 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/export/component/ErrorItem.kt @@ -0,0 +1,29 @@ +package dev.sanmer.authenticator.ui.screen.export.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.ui.ktx.surface + +@Composable +fun ErrorItem( + error: Throwable, + modifier: Modifier = Modifier +) = Text( + text = error.message ?: error.javaClass.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + modifier = modifier + .fillMaxWidth() + .surface( + shape = MaterialTheme.shapes.large, + backgroundColor = MaterialTheme.colorScheme.surface, + border = CardDefaults.outlinedCardBorder(false) + ) + .padding(15.dp) +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeScreen.kt new file mode 100644 index 0000000..5072cd6 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeScreen.kt @@ -0,0 +1,158 @@ +package dev.sanmer.authenticator.ui.screen.home + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.sanmer.auth.ntp.NtpClock +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.datastore.compose.LocalPreference +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.ui.component.AnimatedPoint +import dev.sanmer.authenticator.ui.component.Point +import dev.sanmer.authenticator.ui.ktx.isScrollingUp +import dev.sanmer.authenticator.ui.screen.Screen +import dev.sanmer.authenticator.ui.screen.home.component.AuthList + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + goTo: (Screen) -> Unit +) { + val clock by viewModel.clock.collectAsStateWithLifecycle() + val time by viewModel.time.collectAsStateWithLifecycle("") + + val preference = LocalPreference.current + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val listState = rememberLazyListState() + val isScrollingUp by listState.isScrollingUp() + + Scaffold( + topBar = { + TopBar( + time = time, + clock = clock, + onSync = { viewModel.sync(preference.ntpServer()) }, + onSetting = { goTo(Screen.Setting) }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + ActionButton( + onClick = { goTo(Screen.Edit()) }, + visible = isScrollingUp, + ) + } + ) { contentPadding -> + viewModel.data.onSuccess { list -> + AuthList( + list = list, + state = listState, + onEdit = { goTo(Screen.Edit(authId = it.id)) }, + contentPadding = contentPadding, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) + } + } +} + +@Composable +private fun TopBar( + time: String, + clock: LoadData, + onSync: () -> Unit, + onSetting: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) = TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = time) + + AnimatedContent( + targetState = clock, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentAlignment = Alignment.Center + ) { + when (it) { + LoadData.Loading -> AnimatedPoint( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.secondaryContainer + ) + + is LoadData.Failure -> Point( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onSync), + color = MaterialTheme.colorScheme.errorContainer + ) + + else -> Spacer( + modifier = Modifier.size(24.dp) + ) + } + } + } + }, + actions = { + IconButton( + onClick = onSetting + ) { + Icon( + painter = painterResource(R.drawable.settings_2), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior +) + +@Composable +private fun ActionButton( + onClick: () -> Unit, + visible: Boolean = true +) = AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = scaleOut() + fadeOut() +) { + FloatingActionButton( + onClick = onClick + ) { + Icon( + painter = painterResource(R.drawable.plus), + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeViewModel.kt new file mode 100644 index 0000000..3842ea1 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/HomeViewModel.kt @@ -0,0 +1,73 @@ +package dev.sanmer.authenticator.ui.screen.home + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.sanmer.auth.ntp.NtpServer +import dev.sanmer.authenticator.Const.TIME_DISPLAY +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.ktx.stateIn +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.repository.DbRepository +import dev.sanmer.authenticator.repository.OtpRepository +import dev.sanmer.authenticator.repository.TimeRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime + +class HomeViewModel( + private val dbRepository: DbRepository, + private val otpRepository: OtpRepository, + private val timeRepository: TimeRepository +) : ViewModel() { + val clock = timeRepository.clock + + val time = timeRepository.now + .map { + it.toLocalDateTime(TimeZone.currentSystemDefault()).time + .format(TIME_DISPLAY) + } + + var data by mutableStateOf>>>>(LoadData.Loading) + private set + + private val logger = Logger.Android("HomeViewModel") + + init { + logger.d("init") + loadData() + } + + private fun loadData() { + viewModelScope.launch { + dbRepository.getUntrashedAuthPropertiesAsFlow() + .collectLatest { list -> + data = LoadData.Success( + list.sortedBy { it.auth.name.lowercase() } + .sortedBy { it.auth.issuer.lowercase() } + .map { + it.auth to otpRepository.otp(it) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly + ) + } + ) + } + } + } + + fun sync(server: NtpServer) { + viewModelScope.launch { + timeRepository.sync(server) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthItem.kt new file mode 100644 index 0000000..217a79f --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthItem.kt @@ -0,0 +1,176 @@ +package dev.sanmer.authenticator.ui.screen.home.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.ui.component.LabelText +import dev.sanmer.authenticator.ui.ktx.setSensitiveText +import dev.sanmer.authenticator.ui.ktx.surface +import dev.sanmer.brand.Brand +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@Composable +fun AuthItem( + auth: Auth, + otp: StateFlow, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + onClick: () -> Unit = {} +) { + val otp by otp.collectAsStateWithLifecycle() + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Row( + modifier = modifier + .fillMaxWidth() + .surface( + shape = MaterialTheme.shapes.large, + backgroundColor = MaterialTheme.colorScheme.surface, + border = CardDefaults.outlinedCardBorder(false) + ) + .combinedClickable( + onClick = onClick, + onLongClick = { + scope.launch { clipboard.setSensitiveText(otp) } + } + ) + .padding(15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp) + ) { + val brand by remember(auth.id) { + derivedStateOf { Brand.valueOfOrNull(auth.issuer) } + } + + Image( + painter = painterResource(brand?.id ?: R.drawable.fingerprint_thin), + contentDescription = null, + modifier = Modifier.size(45.dp), + colorFilter = if (brand != null) null else + ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OtpItem( + otp = otp + ) + + if (brand == null && auth.issuer.isNotEmpty()) LabelText( + text = auth.issuer + ) + } + + if (auth.name.isNotEmpty()) Text( + text = auth.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + + if (isSelected) Icon( + painter = painterResource(R.drawable.circle_check_filled), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(30.dp) + ) + } +} + +@Composable +private fun OtpItem( + otp: String, + modifier: Modifier = Modifier +) = Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically +) { + otp.forEachIndexed { index, char -> + AnimatedDigit( + digit = char, + position = index + ) + if ((index + 1) % 3 == 0 && index < otp.lastIndex) { + Spacer(modifier = Modifier.width(12.dp)) + } + } +} + +@Composable +private fun AnimatedDigit( + digit: Char, + position: Int, + isOddPosition: Boolean = position % 2 == 0, + enterDirection: Int = if (isOddPosition) -1 else 1, + exitDirection: Int = if (isOddPosition) 1 else -1 +) = AnimatedContent( + targetState = digit, + transitionSpec = { + slideIn( + animationSpec = tween(500) + ) { + IntOffset(0, enterDirection * it.height) + } + scaleIn( + animationSpec = tween(500) + ) + fadeIn( + animationSpec = tween(500) + ) togetherWith slideOut( + animationSpec = tween(500) + ) { + IntOffset(0, exitDirection * it.height) + } + scaleOut( + animationSpec = tween(500) + ) + fadeOut( + animationSpec = tween(500) + ) + } +) { digit -> + Text( + text = digit.toString(), + style = MaterialTheme.typography.headlineSmall, + fontFamily = FontFamily.Monospace + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthList.kt new file mode 100644 index 0000000..ce9042a --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/home/component/AuthList.kt @@ -0,0 +1,39 @@ +package dev.sanmer.authenticator.ui.screen.home.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.ui.ktx.plus +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun AuthList( + list: List>>, + onEdit: (Auth) -> Unit, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp) +) = LazyColumn( + modifier = modifier, + state = state, + contentPadding = PaddingValues(15.dp) + contentPadding, + verticalArrangement = Arrangement.spacedBy(15.dp) +) { + items( + items = list, + key = { (auth, _) -> auth.id } + ) { (auth, otp) -> + AuthItem( + auth = auth, + otp = otp, + onClick = { onEdit(auth) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainScreen.kt similarity index 94% rename from app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainScreen.kt rename to app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainScreen.kt index 11d4d75..963808b 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainScreen.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainScreen.kt @@ -1,4 +1,4 @@ -package dev.sanmer.authenticator.ui.screens.main +package dev.sanmer.authenticator.ui.screen.main import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -13,7 +13,7 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import dev.sanmer.authenticator.ui.screens.Screen +import dev.sanmer.authenticator.ui.screen.Screen @Composable fun MainScreen( diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainViewModel.kt new file mode 100644 index 0000000..e7bdbf6 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/MainViewModel.kt @@ -0,0 +1,87 @@ +package dev.sanmer.authenticator.ui.screen.main + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.sanmer.auth.crypto.SessionKey +import dev.sanmer.auth.decodeBase64 +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.crypto.BiometricKey.Default.decryptKeyByBiometric +import dev.sanmer.authenticator.datastore.model.Preference +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.repository.DbRepository +import dev.sanmer.authenticator.repository.PreferenceRepository +import dev.sanmer.authenticator.repository.TimeRepository +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class MainViewModel( + private val dbRepository: DbRepository, + private val preferenceRepository: PreferenceRepository, + private val timeRepository: TimeRepository +) : ViewModel() { + var preference by mutableStateOf>(LoadData.Pending) + private set + + var isEncrypted by mutableStateOf(false) + private set + + val password = TextFieldState() + var isError by mutableStateOf(false) + private set + + private val logger = Logger.Android("MainViewModel") + + init { + logger.d("init") + loadData() + } + + private fun loadData() { + viewModelScope.launch { + preferenceRepository.data + .onEach { + logger.d("$it") + preference = LoadData.Success(it) + } + .distinctUntilChanged { old, new -> + old.ntp == new.ntp && old.ntpAddress == new.ntpAddress + } + .collectLatest { + timeRepository.sync(it.ntpServer()) + } + } + } + + fun isDecrypted(preference: Preference): Boolean { + if (!preference.isEncrypted) isEncrypted = true + return isEncrypted + } + + fun decryptByPassword(key: String) { + viewModelScope.launch { + runCatching { + val key = SessionKey.decryptKeyByPassword(key.decodeBase64(), password.text) + dbRepository.setSessionKey(key) + isEncrypted = true + }.onFailure { + isError = true + } + } + } + + fun decryptByBiometric(key: String) { + viewModelScope.launch { + runCatching { + val key = SessionKey.decryptKeyByBiometric(key.decodeBase64()) + dbRepository.setSessionKey(key) + isEncrypted = true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/UnlockScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/UnlockScreen.kt new file mode 100644 index 0000000..07304e9 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/main/UnlockScreen.kt @@ -0,0 +1,84 @@ +package dev.sanmer.authenticator.ui.screen.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.datastore.model.Preference + +@Composable +fun UnlockScreen( + viewModel: MainViewModel, + preference: Preference +) = Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center +) { + val keyboardController = LocalSoftwareKeyboardController.current + + OutlinedSecureTextField( + state = viewModel.password, + isError = viewModel.isError, + textObfuscationMode = TextObfuscationMode.Hidden, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go + ), + onKeyboardAction = { + keyboardController?.hide() + viewModel.decryptByPassword(preference.keyEncryptedByPassword) + }, + shape = MaterialTheme.shapes.medium + ) + + Row( + modifier = Modifier.padding(top = 20.dp), + horizontalArrangement = Arrangement.spacedBy(15.dp) + ) { + if (preference.isBiometric) { + DisposableEffect(Unit) { + viewModel.decryptByBiometric(preference.keyEncryptedByBiometric) + onDispose {} + } + + FilledTonalIconButton( + onClick = { viewModel.decryptByBiometric(preference.keyEncryptedByBiometric) } + ) { + Icon( + painter = painterResource(R.drawable.fingerprint), + contentDescription = null + ) + } + } + + FilledTonalIconButton( + onClick = { + keyboardController?.hide() + viewModel.decryptByPassword(preference.keyEncryptedByPassword) + } + ) { + Icon( + painter = painterResource(R.drawable.arrow_right), + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpScreen.kt new file mode 100644 index 0000000..ec6f5e4 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpScreen.kt @@ -0,0 +1,97 @@ +package dev.sanmer.authenticator.ui.screen.ntp + +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.datastore.compose.LocalPreference +import dev.sanmer.authenticator.ui.screen.ntp.NtpViewModel.BottomSheet +import dev.sanmer.authenticator.ui.screen.ntp.component.CustomBottomSheet +import dev.sanmer.authenticator.ui.screen.ntp.component.NtpList +import dev.sanmer.authenticator.ui.screen.ntp.component.NtpMessageBottomSheet + +@Composable +fun NtpScreen( + viewModel: NtpViewModel, + goBack: () -> Unit +) { + val preference = LocalPreference.current + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + when (val bs = viewModel.bottomSheet) { + BottomSheet.None -> {} + BottomSheet.Custom -> CustomBottomSheet( + onClose = { viewModel.bottomSheet = BottomSheet.None }, + ntpAddress = viewModel.ntpAddress, + onSave = viewModel::setNtpAddress + ) + + is BottomSheet.NtpMsg -> NtpMessageBottomSheet( + onClose = { viewModel.bottomSheet = BottomSheet.None }, + ntp = bs.ntp, + message = bs.msg + ) + } + + Scaffold( + modifier = Modifier.imePadding(), + topBar = { + TopBar( + onBack = goBack, + onCustom = { viewModel.bottomSheet = BottomSheet.Custom }, + scrollBehavior = scrollBehavior + ) + }, + ) { contentPadding -> + NtpList( + list = viewModel.list, + clock = { viewModel.clock(it.address) }, + isSelected = { it == preference.ntp }, + onPick = viewModel::pick, + onView = { ntp, msg -> viewModel.bottomSheet = BottomSheet.NtpMsg(ntp, msg) }, + contentPadding = contentPadding, + state = viewModel.listState, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) + } +} + +@Composable +private fun TopBar( + onBack: () -> Unit, + onCustom: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) = TopAppBar( + title = { Text(text = stringResource(R.string.ntp_title)) }, + navigationIcon = { + IconButton( + onClick = onBack + ) { + Icon( + painter = painterResource(R.drawable.arrow_left), + contentDescription = null + ) + } + }, + actions = { + IconButton( + onClick = onCustom + ) { + Icon( + painter = painterResource(R.drawable.plus), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpViewModel.kt new file mode 100644 index 0000000..00f51ae --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/NtpViewModel.kt @@ -0,0 +1,141 @@ +package dev.sanmer.authenticator.ui.screen.ntp + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.sanmer.auth.ntp.NtpClock +import dev.sanmer.auth.ntp.NtpMessage +import dev.sanmer.auth.ntp.NtpServer +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.datastore.model.Ntp +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.model.LoadData.Default.loadData +import dev.sanmer.authenticator.repository.PreferenceRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.launch +import kotlin.time.Duration + +class NtpViewModel( + private val preferenceRepository: PreferenceRepository +) : ViewModel() { + private val _list = mutableStateListOf( + Ntp.Alibaba to NtpServer.Alibaba, + Ntp.Apple to NtpServer.Apple, + Ntp.Amazon to NtpServer.Amazon, + Ntp.Cloudflare to NtpServer.Cloudflare, + Ntp.Google to NtpServer.Google, + Ntp.Meta to NtpServer.Meta, + Ntp.Microsoft to NtpServer.Microsoft, + Ntp.Tencent to NtpServer.Tencent + ) + + private val clocks = mutableStateMapOf>() + + val list by derivedStateOf { + _list.sortedBy { (_, server) -> + clock(server.address).getOrElse({ it.rtt }, Duration::INFINITE) + } + } + val listState = LazyListState() + + val ntpAddress = TextFieldState() + var bottomSheet by mutableStateOf(BottomSheet.None) + + private val logger = Logger.Android("NtpViewModel") + + init { + logger.d("init") + loadData() + syncAll() + } + + private fun loadData() { + viewModelScope.launch { + preferenceRepository.data + .distinctUntilChangedBy { it.ntpAddress } + .collect { + ntpAddress.setTextAndPlaceCursorAtEnd(it.ntpAddress) + val index = _list.indexOfFirst { (ntp, _) -> ntp == Ntp.Custom } + if (it.ntpAddress.isNotEmpty()) { + val server = NtpServer.Custom(it.ntpAddress).also(::sync) + if (index == -1) { + _list.add(Ntp.Custom to server) + } else { + _list[index] = Ntp.Custom to server + } + } else if (index != -1) { + _list.removeAt(index) + } + } + } + } + + private fun syncAll() { + viewModelScope.launch { + _list.map { (_, server) -> + async { + clocks[server.address] = LoadData.Loading + clocks[server.address] = loadData { + server.sync() + }.onFailure { + logger.w(it) + } + } + }.awaitAll() + listState.requestScrollToItem(0) + } + } + + fun clock(address: String) = clocks.getOrDefault(address, LoadData.Pending) + + private fun sync(server: NtpServer) { + viewModelScope.launch { + when (clock(server.address)) { + LoadData.Pending, is LoadData.Failure -> { + clocks[server.address] = LoadData.Loading + clocks[server.address] = loadData { + server.sync() + }.onSuccess { + listState.requestScrollToItem(0) + }.onFailure { + logger.w(it) + } + } + + else -> {} + } + } + } + + fun pick(ntp: Ntp, server: NtpServer) { + if (clock(server.address).isSuccess) { + viewModelScope.launch { + preferenceRepository.setNtp(ntp) + } + } else { + sync(server) + } + } + + fun setNtpAddress() { + viewModelScope.launch { + preferenceRepository.setNtpAddress(ntpAddress.text.trim().toString()) + } + } + + sealed interface BottomSheet { + data object None : BottomSheet + data object Custom : BottomSheet + data class NtpMsg(val ntp: Ntp, val msg: NtpMessage) : BottomSheet + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/CustomBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/CustomBottomSheet.kt new file mode 100644 index 0000000..db95f48 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/CustomBottomSheet.kt @@ -0,0 +1,87 @@ +package dev.sanmer.authenticator.ui.screen.ntp.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.datastore.compose.LocalPreference +import dev.sanmer.authenticator.ui.component.DragHandle +import dev.sanmer.authenticator.ui.ktx.bottom + +@Composable +fun CustomBottomSheet( + onClose: () -> Unit, + ntpAddress: TextFieldState, + onSave: () -> Unit +) = ModalBottomSheet( + onDismissRequest = onClose, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + shape = MaterialTheme.shapes.large.bottom(0.dp), + dragHandle = null +) { + DragHandle() + + Text( + text = stringResource(R.string.ntp_custom), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + val preference = LocalPreference.current + val isChanged by remember(ntpAddress) { + derivedStateOf { + ntpAddress.text.trim() != preference.ntpAddress + } + } + + OutlinedTextField( + state = ntpAddress, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + shape = MaterialTheme.shapes.medium, + lineLimits = TextFieldLineLimits.SingleLine, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + onSave() + onClose() + }, + enabled = isChanged, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.token_save)) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpItem.kt new file mode 100644 index 0000000..33aa6cb --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpItem.kt @@ -0,0 +1,201 @@ +package dev.sanmer.authenticator.ui.screen.ntp.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.auth.ntp.NtpClock +import dev.sanmer.auth.ntp.NtpServer +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.datastore.model.Ntp +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.ui.component.LabelText +import dev.sanmer.authenticator.ui.ktx.surface +import java.net.SocketTimeoutException +import kotlin.time.Duration +import kotlin.time.DurationUnit + +private fun Ntp.brand() = when (this) { + Ntp.Custom -> R.drawable.clock_record_thin + Ntp.Alibaba -> dev.sanmer.brand.R.drawable.brand_aliyun + Ntp.Apple -> dev.sanmer.brand.R.drawable.brand_apple + Ntp.Amazon -> dev.sanmer.brand.R.drawable.brand_aws + Ntp.Cloudflare -> dev.sanmer.brand.R.drawable.brand_cloudflare + Ntp.Google -> dev.sanmer.brand.R.drawable.brand_googlecloud + Ntp.Meta -> dev.sanmer.brand.R.drawable.brand_meta + Ntp.Microsoft -> dev.sanmer.brand.R.drawable.brand_azure + Ntp.Tencent -> dev.sanmer.brand.R.drawable.brand_tencentcloud +} + +@Composable +fun Ntp.name() = when (this) { + Ntp.Custom -> stringResource(R.string.ntp_custom) + else -> name +} + +@Composable +fun NtpItem( + ntp: Ntp, + server: NtpServer, + clock: LoadData, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) = Row( + modifier = modifier + .fillMaxWidth() + .surface( + shape = MaterialTheme.shapes.large, + backgroundColor = MaterialTheme.colorScheme.surface, + border = CardDefaults.outlinedCardBorder(false) + ) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + .padding(15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp) +) { + Image( + painter = painterResource(ntp.brand()), + contentDescription = null, + modifier = Modifier.size(45.dp), + colorFilter = if (ntp != Ntp.Custom) null else + ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = ntp.name(), + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = server.address, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + + if (!clock.isPending) AnimatedContent( + modifier = Modifier.padding(top = 5.dp), + targetState = clock, + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { clock -> + clock.onLoading { + LinearProgressIndicator( + modifier = Modifier + .padding(vertical = 8.dp) + .size(width = 120.dp, height = (5.5).dp) + ) + }.onSuccess { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + RTTLabel(it.rtt) + OffsetLabel(it.offset) + } + }.onFailure { + ErrorLabel(it) + } + } + } + + if (isSelected) Icon( + painter = painterResource(R.drawable.circle_check_filled), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(30.dp) + .align(Alignment.Top) + ) +} + +@Composable +private fun RTTLabel( + rtt: Duration +) { + val value by remember(rtt) { + derivedStateOf { rtt.toLong(DurationUnit.MILLISECONDS) } + } + val display by remember(rtt) { + derivedStateOf { rtt.toString(DurationUnit.MILLISECONDS) } + } + + LabelText( + text = display, + containerColor = if (isSystemInDarkTheme()) { + when (value) { + in 0..<100 -> Color(0xFF1B5E20) + in 100..500 -> Color(0xFFF57F17) + else -> Color(0xFFB71C1C) + } + } else { + when (value) { + in 0..<100 -> Color(0xFF81C784) + in 100..500 -> Color(0xFFFFF176) + else -> Color(0xFFE57373) + } + } + ) +} + +@Composable +private fun OffsetLabel( + offset: Duration +) { + val display by remember(offset) { + derivedStateOf { + offset.toString(DurationUnit.MILLISECONDS) + .let { + if (offset.isPositive()) "+$it" else it + } + } + } + + LabelText(text = display) +} + +@Composable +private fun ErrorLabel( + error: Throwable +) = LabelText( + text = if (error is SocketTimeoutException) { + stringResource(R.string.ntp_timeout) + } else { + error.message ?: error.javaClass.name + }, + containerColor = if (isSystemInDarkTheme()) { + Color(0xFFB71C1C) + } else { + Color(0xFFE57373) + } +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpList.kt new file mode 100644 index 0000000..0b33dbb --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpList.kt @@ -0,0 +1,52 @@ +package dev.sanmer.authenticator.ui.screen.ntp.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sanmer.auth.ntp.NtpClock +import dev.sanmer.auth.ntp.NtpMessage +import dev.sanmer.auth.ntp.NtpServer +import dev.sanmer.authenticator.datastore.model.Ntp +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.ui.ktx.plus + +@Composable +fun NtpList( + list: List>, + clock: (NtpServer) -> LoadData, + isSelected: (Ntp) -> Boolean, + onPick: (Ntp, NtpServer) -> Unit, + onView: (Ntp, NtpMessage) -> Unit, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp) +) = LazyColumn( + modifier = modifier, + state = state, + contentPadding = PaddingValues(15.dp) + contentPadding, + verticalArrangement = Arrangement.spacedBy(15.dp) +) { + items( + items = list, + key = { (ntp, _) -> ntp } + ) { (ntp, server) -> + NtpItem( + ntp = ntp, + server = server, + clock = clock(server), + isSelected = isSelected(ntp), + onClick = { onPick(ntp, server) }, + onLongClick = { + clock(server).onSuccess { + onView(ntp, it.message) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpMessageBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpMessageBottomSheet.kt new file mode 100644 index 0000000..64a5de1 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/NtpMessageBottomSheet.kt @@ -0,0 +1,98 @@ +package dev.sanmer.authenticator.ui.screen.ntp.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sanmer.auth.ntp.NtpMessage +import dev.sanmer.authenticator.Const.DATETIME_DISPLAY +import dev.sanmer.authenticator.datastore.model.Ntp +import dev.sanmer.authenticator.ui.component.DragHandle +import dev.sanmer.authenticator.ui.ktx.bottom +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime + +@Composable +fun NtpMessageBottomSheet( + onClose: () -> Unit, + ntp: Ntp, + message: NtpMessage +) = ModalBottomSheet( + onDismissRequest = onClose, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + shape = MaterialTheme.shapes.large.bottom(0.dp), + dragHandle = null +) { + DragHandle() + + Text( + text = ntp.name(), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + ValueItem( + name = "Version", + value = message.version + ) + + ValueItem( + name = "Stratum", + value = message.stratum + ) + + ValueItem( + name = "Precision", + value = message.precision + ) + + ValueItem( + name = "Root Delay", + value = message.rootDelay + ) + + ValueItem( + name = "Root Dispersion", + value = message.rootDispersion + ) + + if (message.stratum == NtpMessage.Stratum.Primary) { + ValueItem( + name = "Reference ID", + value = message.referenceIdentifier.kissCode() + ) + } + + val referenceTimestamp by remember { + derivedStateOf { + message.referenceTimestamp + .toLocalDateTime(TimeZone.currentSystemDefault()) + .format(DATETIME_DISPLAY) + } + } + + ValueItem( + name = "Reference Timestamp", + value = referenceTimestamp + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/ValueItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/ValueItem.kt new file mode 100644 index 0000000..d564ee8 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/ntp/component/ValueItem.kt @@ -0,0 +1,38 @@ +package dev.sanmer.authenticator.ui.screen.ntp.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.ui.ktx.surface + +@Composable +fun ValueItem( + name: String, + value: Any +) = Column( + modifier = Modifier + .fillMaxWidth() + .surface( + shape = MaterialTheme.shapes.medium, + backgroundColor = MaterialTheme.colorScheme.surface, + border = CardDefaults.outlinedCardBorder(false) + ) + .padding(horizontal = 15.dp, vertical = 10.dp) +) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanScreen.kt new file mode 100644 index 0000000..82caba9 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanScreen.kt @@ -0,0 +1,214 @@ +package dev.sanmer.authenticator.ui.screen.scan + +import android.content.Context +import android.net.Uri +import android.util.Size +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.SurfaceRequest +import androidx.camera.viewfinder.compose.MutableCoordinateTransformer +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import dev.sanmer.authenticator.R + +@Composable +fun ScanScreen( + viewModel: ScanViewModel, + goBack: () -> Unit +) { + val context = LocalContext.current + + BackHandler( + onBack = goBack + ) + + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Crossfade( + targetState = viewModel.isAllowed(context) + ) { isAllowed -> + if (isAllowed) CameraPreview( + surfaceRequest = viewModel.surfaceRequest, + onBind = viewModel::bindCamera, + onFocusChange = viewModel::setFocus, + onZoomChange = viewModel::setZoom + ) else CameraOff( + onClick = { viewModel.requestPermission(context) } + ) + } + + BottomBar( + onBack = goBack, + onImage = viewModel::fromImage, + torchEnabled = viewModel.torchEnabled, + onTorchEnabledChange = viewModel::setTorch, + modifier = Modifier + .align(Alignment.BottomEnd) + .alpha(0.8f) + ) + } +} + +@Composable +private fun CameraOff( + onClick: () -> Unit +) = Icon( + painter = painterResource(R.drawable.camera_off), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(45.dp) + .clickable( + onClick = onClick, + indication = ripple(bounded = false, radius = 45.dp), + interactionSource = remember { MutableInteractionSource() } + ) +) + +@Composable +private fun CameraPreview( + surfaceRequest: SurfaceRequest?, + onBind: suspend (Context, LifecycleOwner) -> Unit, + onFocusChange: (Size, Offset) -> Unit, + onZoomChange: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val coordinateTransformer = remember { MutableCoordinateTransformer() } + + LaunchedEffect(lifecycleOwner) { + onBind(context, lifecycleOwner) + } + + surfaceRequest?.let { + CameraXViewfinder( + surfaceRequest = it, + coordinateTransformer = coordinateTransformer, + contentScale = ContentScale.Crop, + modifier = modifier + .pointerInput(Unit) { + detectTapGestures { offset -> + with(coordinateTransformer) { + onFocusChange(it.resolution, offset.transform()) + } + } + } + .pointerInput(Unit) { + detectTransformGestures { _, _, zoom, _ -> + onZoomChange(zoom) + } + } + ) + } +} + +@Composable +private fun BottomBar( + onBack: () -> Unit, + onImage: (Context, Uri) -> Unit, + torchEnabled: Boolean, + onTorchEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) = Row( + modifier = modifier + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.Bottom +) { + val context = LocalContext.current + val isPhotoPickerAvailable = remember { PickVisualMedia.isPhotoPickerAvailable(context) } + val picker = rememberLauncherForActivityResult(PickVisualMedia()) { + if (it != null) onImage(context, it) + } + + if (isPhotoPickerAvailable) ActionButton( + onClick = { picker.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) } + ) { + Icon( + painter = painterResource(R.drawable.photo), + contentDescription = null + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Column( + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + ActionButton( + onClick = { onTorchEnabledChange(!torchEnabled) } + ) { + Icon( + painter = painterResource( + if (torchEnabled) R.drawable.bolt + else R.drawable.bolt_off + ), + contentDescription = null + ) + } + + ActionButton( + onClick = onBack + ) { + Icon( + painter = painterResource(R.drawable.x), + contentDescription = null + ) + } + } +} + +@Composable +private fun ActionButton( + onClick: () -> Unit, + content: @Composable () -> Unit +) = FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + shape = CircleShape, + content = content +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanViewModel.kt new file mode 100644 index 0000000..b31feb3 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/scan/ScanViewModel.kt @@ -0,0 +1,170 @@ +package dev.sanmer.authenticator.ui.screen.scan + +import android.Manifest +import android.content.Context +import android.net.Uri +import android.util.Size +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceOrientedMeteringPointFactory +import androidx.camera.core.SurfaceRequest +import androidx.camera.core.TorchState +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.lifecycle.awaitInstance +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import dev.sanmer.auth.OtpUri.Default.isOtpUri +import dev.sanmer.auth.QRCode +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.compat.PermissionCompat +import kotlinx.coroutines.awaitCancellation +import java.nio.ByteBuffer +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class ScanViewModel( + private val onUri: (Uri) -> Unit +) : ViewModel() { + var isAllowed by mutableStateOf(false) + private set + + var surfaceRequest by mutableStateOf(null) + private set + + private val preview = Preview.Builder() + .setResolutionSelector( + ResolutionSelector.Builder() + .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY) + .build() + ) + .build() + .apply { + setSurfaceProvider { surfaceRequest = it } + } + + private val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .build() + .apply { + setAnalyzer(Executors.newSingleThreadExecutor()) { image -> + try { + val plane = image.planes.first() + val data = plane.buffer.asByteArray() + val content = QRCode.decodeFromYuv( + yuvData = data, + dataWidth = plane.rowStride, + dataHeight = image.height, + width = image.width, + height = image.height, + ) + val uri = Uri.parse(content) + if (uri.isOtpUri()) onUri(uri) + } catch (_: Throwable) { + + } finally { + image.close() + } + } + } + + private var camera: Camera? = null + var torchEnabled by mutableStateOf(false) + private set + + private val logger = Logger.Android("ScanViewModel") + + init { + logger.d("init") + } + + fun isAllowed(context: Context) = if (isAllowed) { + true + } else { + isAllowed = PermissionCompat.checkPermission(context, Manifest.permission.CAMERA) + isAllowed + } + + fun requestPermission(context: Context) = + PermissionCompat.requestPermission(context, Manifest.permission.CAMERA) { + isAllowed = it + } + + suspend fun bindCamera(context: Context, lifecycleOwner: LifecycleOwner) { + val processCameraProvider = ProcessCameraProvider.awaitInstance(context) + val camera = processCameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, imageAnalysis + ).also { camera = it } + + camera.cameraInfo.torchState.observe(lifecycleOwner) { + torchEnabled = it == TorchState.ON + } + + try { + awaitCancellation() + } finally { + camera.cameraInfo.torchState.removeObservers(lifecycleOwner) + processCameraProvider.unbindAll() + } + } + + fun setTorch(enabled: Boolean) { + val camera = camera ?: return + camera.cameraControl.enableTorch(enabled) + } + + fun setFocus(resolution: Size, offset: Offset) { + val camera = camera ?: return + val point = SurfaceOrientedMeteringPointFactory( + resolution.width.toFloat(), + resolution.height.toFloat() + ).createPoint(offset.x, offset.y) + camera.cameraControl.startFocusAndMetering( + FocusMeteringAction.Builder( + point, FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE + ).setAutoCancelDuration(3, TimeUnit.SECONDS).build() + ) + } + + fun setZoom(zoom: Float) { + val camera = camera ?: return + val zoomState = camera.cameraInfo.zoomState.value ?: return + val newRatio = (zoomState.zoomRatio * zoom).coerceIn( + zoomState.minZoomRatio, + zoomState.maxZoomRatio + ) + camera.cameraControl.setZoomRatio(newRatio) + } + + fun fromImage(context: Context, uri: Uri) { + runCatching { + val cr = context.contentResolver + val stream = cr.openInputStream(uri) ?: return + val content = stream.use(QRCode::decodeFromStream) ?: return + val uri = Uri.parse(content) + if (uri.isOtpUri()) onUri(uri) + }.onFailure { + logger.e(it) + } + } + + private fun ByteBuffer.asByteArray(): ByteArray { + rewind() + val dst = ByteArray(remaining()) + get(dst) + return dst + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingScreen.kt new file mode 100644 index 0000000..756dc11 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingScreen.kt @@ -0,0 +1,263 @@ +package dev.sanmer.authenticator.ui.screen.setting + +import android.content.Intent +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.Const +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.crypto.BiometricKey +import dev.sanmer.authenticator.datastore.compose.LocalPreference +import dev.sanmer.authenticator.ui.ktx.bottomWith +import dev.sanmer.authenticator.ui.ktx.plus +import dev.sanmer.authenticator.ui.ktx.topWith +import dev.sanmer.authenticator.ui.screen.Screen +import dev.sanmer.authenticator.ui.screen.ntp.component.name +import dev.sanmer.authenticator.ui.screen.setting.SettingViewModel.BottomSheet +import dev.sanmer.authenticator.ui.screen.setting.component.PasswordBottomSheet +import dev.sanmer.authenticator.ui.screen.setting.component.SettingColumn +import dev.sanmer.authenticator.ui.screen.setting.component.SettingItem +import dev.sanmer.brand.Brand + +@Composable +fun SettingScreen( + viewModel: SettingViewModel, + goTo: (Screen) -> Unit, + goBack: () -> Unit +) { + val preference = LocalPreference.current + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + when (viewModel.bottomSheet) { + BottomSheet.None -> {} + BottomSheet.Password -> PasswordBottomSheet( + onClose = { viewModel.bottomSheet = BottomSheet.None }, + password = viewModel.password, + hidden = viewModel.hidden, + onSet = viewModel::setupPassword, + onRemove = viewModel::removePassword, + onChange = viewModel::changePassword + ) + } + + Scaffold( + topBar = { + TopBar( + onBack = goBack, + scrollBehavior = scrollBehavior + ) + } + ) { contentPadding -> + Column( + modifier = Modifier + .animateContentSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .verticalScroll(rememberScrollState()) + .padding(PaddingValues(15.dp) + contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + SettingColumn( + title = stringResource(R.string.setting_security) + ) { + SettingItem( + onClick = { viewModel.bottomSheet = BottomSheet.Password }, + title = stringResource(R.string.setting_password), + value = stringResource( + if (preference.isEncrypted) R.string.setting_password_set + else R.string.setting_password_not_set + ), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.shield), + contentDescription = null + ) + }, + shape = with(MaterialTheme.shapes) { large bottomWith extraSmall } + ) + + if (preference.isEncrypted && BiometricKey.isInitialized) SettingItem( + onClick = if (preference.isBiometric) { + viewModel::removeBiometric + } else { + viewModel::setupBiometric + }, + title = stringResource(R.string.setting_biometric), + value = stringResource(R.string.setting_biometric_desc), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.fingerprint), + contentDescription = null + ) + }, + trailingIcon = { + Switch( + checked = preference.isBiometric, + onCheckedChange = null + ) + } + ) + + SettingItem( + onClick = { viewModel.setSecureWindow(!preference.secureWindow) }, + title = stringResource(R.string.setting_prevent_screenshot), + value = stringResource(R.string.setting_prevent_screenshot_desc), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.spy), + contentDescription = null + ) + }, + trailingIcon = { + Switch( + checked = preference.secureWindow, + onCheckedChange = null + ) + }, + shape = with(MaterialTheme.shapes) { large topWith extraSmall } + ) + } + + SettingColumn( + title = stringResource(R.string.setting_database) + ) { + SettingItem( + onClick = { goTo(Screen.Trash) }, + title = stringResource(R.string.trash_title), + value = if (viewModel.isTrashNotEmpty) viewModel.trashed.toString() + else stringResource(R.string.trash_empty), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.trash), + contentDescription = null + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.chevron_right), + contentDescription = null + ) + }, + enabled = viewModel.isTrashNotEmpty, + shape = with(MaterialTheme.shapes) { large bottomWith extraSmall } + ) + + SettingItem( + onClick = { goTo(Screen.Export) }, + title = stringResource(R.string.setting_import_export), + value = stringResource(R.string.setting_import_export_desc), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.database), + contentDescription = null + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.chevron_right), + contentDescription = null + ) + }, + shape = with(MaterialTheme.shapes) { large topWith extraSmall } + ) + } + + SettingColumn( + title = stringResource(R.string.setting_other) + ) { + SettingItem( + onClick = { goTo(Screen.Ntp) }, + title = stringResource(R.string.ntp_title), + value = preference.ntp.name(), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.timezone), + contentDescription = null + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.chevron_right), + contentDescription = null + ) + }, + shape = with(MaterialTheme.shapes) { large bottomWith extraSmall } + ) + + SettingItem( + onClick = { goTo(Screen.Brand) }, + title = stringResource(R.string.setting_brand), + value = Brand.entries.size.toString(), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.copyright), + contentDescription = null + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.chevron_right), + contentDescription = null + ) + }, + shape = with(MaterialTheme.shapes) { large topWith extraSmall } + ) + } + } + } +} + +@Composable +private fun TopBar( + onBack: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) = TopAppBar( + title = { Text(text = stringResource(R.string.setting_title)) }, + navigationIcon = { + IconButton( + onClick = onBack + ) { + Icon( + painter = painterResource(R.drawable.arrow_left), + contentDescription = null + ) + } + }, + actions = { + val context = LocalContext.current + IconButton( + onClick = { + context.startActivity( + Intent.parseUri(Const.GITHUB_URL, Intent.URI_INTENT_SCHEME) + ) + } + ) { + Icon( + painter = painterResource(R.drawable.brand_github_2), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingViewModel.kt new file mode 100644 index 0000000..08dbc64 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/SettingViewModel.kt @@ -0,0 +1,101 @@ +package dev.sanmer.authenticator.ui.screen.setting + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.sanmer.auth.crypto.SessionKey +import dev.sanmer.auth.encodeBase64 +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.crypto.BiometricKey.Default.getKeyEncryptedByBiometric +import dev.sanmer.authenticator.repository.DbRepository +import dev.sanmer.authenticator.repository.PreferenceRepository +import kotlinx.coroutines.launch + +class SettingViewModel( + private val dbRepository: DbRepository, + private val preferenceRepository: PreferenceRepository +) : ViewModel() { + var trashed by mutableIntStateOf(0) + private set + val isTrashNotEmpty inline get() = trashed > 0 + + val password = TextFieldState() + val hidden = mutableStateOf(true) + + var bottomSheet by mutableStateOf(BottomSheet.None) + + private val logger = Logger.Android("SettingViewModel") + + init { + logger.d("init") + loadData() + } + + private fun loadData() { + viewModelScope.launch { + dbRepository.getTrashedCountAsFlow() + .collect { trashed = it } + } + } + + fun setupPassword() { + viewModelScope.launch { + val key = SessionKey.new() + val keyEncrypted = key.getKeyEncryptedByPassword(password.text.trim()) + preferenceRepository.setKeyEncryptedByPassword(keyEncrypted.encodeBase64()) + dbRepository.encrypt(key) + password.clearText() + } + } + + fun changePassword() { + viewModelScope.launch { + val key = dbRepository.getSessionKey() + if (key is SessionKey) { + val keyEncrypted = key.getKeyEncryptedByPassword(password.text.trim()) + preferenceRepository.setKeyEncryptedByPassword(keyEncrypted.encodeBase64()) + password.clearText() + } + } + } + + fun removePassword() { + viewModelScope.launch { + preferenceRepository.setKeyEncryptedByPassword("") + preferenceRepository.setKeyEncryptedByBiometric("") + dbRepository.decrypt() + } + } + + fun setupBiometric() { + viewModelScope.launch { + val key = dbRepository.getSessionKey() + if (key is SessionKey) { + val keyEncrypted = key.getKeyEncryptedByBiometric() + preferenceRepository.setKeyEncryptedByBiometric(keyEncrypted.encodeBase64()) + } + } + } + + fun removeBiometric() { + viewModelScope.launch { + preferenceRepository.setKeyEncryptedByBiometric("") + } + } + + fun setSecureWindow(value: Boolean) { + viewModelScope.launch { + preferenceRepository.setSecureWindow(value) + } + } + + sealed interface BottomSheet { + data object None : BottomSheet + data object Password : BottomSheet + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/PasswordBottomSheet.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/PasswordBottomSheet.kt new file mode 100644 index 0000000..043343b --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/PasswordBottomSheet.kt @@ -0,0 +1,129 @@ +package dev.sanmer.authenticator.ui.screen.setting.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.datastore.compose.LocalPreference +import dev.sanmer.authenticator.ui.component.DragHandle +import dev.sanmer.authenticator.ui.ktx.bottom + +@Composable +fun PasswordBottomSheet( + onClose: () -> Unit, + password: TextFieldState, + hidden: MutableState, + onSet: () -> Unit, + onRemove: () -> Unit, + onChange: () -> Unit, +) = ModalBottomSheet( + onDismissRequest = onClose, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + shape = MaterialTheme.shapes.large.bottom(0.dp), + dragHandle = null +) { + DragHandle() + + Text( + text = stringResource(R.string.setting_password), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp) + ) { + val preference = LocalPreference.current + val isEmpty by remember(password) { + derivedStateOf { + password.text.trim().isEmpty() + } + } + + OutlinedSecureTextField( + state = password, + trailingIcon = { + IconButton( + onClick = { hidden.value = !hidden.value } + ) { + Icon( + painter = painterResource( + if (hidden.value) R.drawable.sparkles_2 + else R.drawable.sparkles_2_off + ), + contentDescription = null + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + shape = MaterialTheme.shapes.medium, + textObfuscationMode = with(TextObfuscationMode) { if (hidden.value) Hidden else Visible }, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(15.dp) + ) { + if (preference.isEncrypted) { + OutlinedButton( + onClick = onRemove, + enabled = isEmpty, + modifier = Modifier.weight(1f) + ) { + Text(text = stringResource(R.string.setting_password_remove)) + } + + Button( + onClick = onChange, + enabled = !isEmpty, + modifier = Modifier.weight(1f) + ) { + Text(text = stringResource(R.string.setting_password_change)) + } + } else { + Button( + onClick = onSet, + enabled = !isEmpty, + modifier = Modifier.weight(1f) + ) { + Text(text = stringResource(R.string.token_save)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingColumn.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingColumn.kt new file mode 100644 index 0000000..8aaf9f4 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingColumn.kt @@ -0,0 +1,29 @@ +package dev.sanmer.authenticator.ui.screen.setting.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SettingColumn( + modifier: Modifier = Modifier, + title: String = "", + content: @Composable (ColumnScope.() -> Unit) +) = Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(2.dp) +) { + if (title.isNotEmpty()) Text( + text = title, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 20.dp, bottom = 8.dp) + ) + + content() +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingItem.kt new file mode 100644 index 0000000..c9ff0a8 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/setting/component/SettingItem.kt @@ -0,0 +1,62 @@ +package dev.sanmer.authenticator.ui.screen.setting.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.ui.ktx.surface + +@Composable +fun SettingItem( + onClick: () -> Unit, + title: String, + value: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: @Composable RowScope.() -> Unit = {}, + trailingIcon: @Composable RowScope. () -> Unit = {}, + shape: Shape = MaterialTheme.shapes.extraSmall +) = Row( + modifier = modifier + .surface( + shape = shape, + backgroundColor = MaterialTheme.colorScheme.surfaceContainer + ) + .clickable( + enabled = enabled, + onClick = onClick + ) + .padding(horizontal = 20.dp, vertical = 15.dp), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalAlignment = Alignment.CenterVertically +) { + leadingIcon() + + Column( + modifier = Modifier + .padding(start = 5.dp) + .weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + + trailingIcon() +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashScreen.kt new file mode 100644 index 0000000..d61ec4b --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashScreen.kt @@ -0,0 +1,141 @@ +package dev.sanmer.authenticator.ui.screen.trash + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.ui.screen.Screen +import dev.sanmer.authenticator.ui.screen.trash.component.AuthList + +@Composable +fun TrashScreen( + viewModel: TrashViewModel, + goTo: (Screen) -> Unit, + goBack: () -> Unit +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + BackHandler( + enabled = viewModel.isPick, + onBack = viewModel::clearSelected + ) + + Scaffold( + topBar = { + TopBar( + onBack = { + if (viewModel.isPick) { + viewModel.clearSelected() + } else { + goBack() + } + }, + selected = viewModel.selected, + isPick = viewModel.isPick, + onDelete = viewModel::delete, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + ActionButton( + onClick = viewModel::restore, + visible = viewModel.isPick + ) + } + ) { contentPadding -> + viewModel.data.onSuccess { list -> + AuthList( + list = list, + onEdit = { + if (viewModel.isPick) { + viewModel.pick(it) + } else { + goTo(Screen.Edit(authId = it.id)) + } + }, + isSelected = viewModel::isSelected, + onPick = viewModel::pick, + contentPadding = contentPadding, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) + } + } +} + +@Composable +private fun TopBar( + onBack: () -> Unit, + selected: Int, + isPick: Boolean, + onDelete: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) = TopAppBar( + title = { + Text( + text = if (isPick) selected.toString() + else stringResource(R.string.trash_title) + ) + }, + navigationIcon = { + IconButton( + onClick = onBack + ) { + Icon( + painter = painterResource(R.drawable.arrow_left), + contentDescription = null + ) + } + }, + actions = { + AnimatedVisibility( + visible = isPick, + enter = fadeIn() + scaleIn(), + exit = scaleOut() + fadeOut() + ) { + IconButton( + onClick = onDelete + ) { + Icon( + painter = painterResource(R.drawable.trash_x), + contentDescription = null + ) + } + } + }, + scrollBehavior = scrollBehavior +) + +@Composable +private fun ActionButton( + onClick: () -> Unit, + visible: Boolean = true +) = AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = scaleOut() + fadeOut() +) { + FloatingActionButton( + onClick = onClick + ) { + Icon( + painter = painterResource(R.drawable.restore), + contentDescription = null + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashViewModel.kt new file mode 100644 index 0000000..ce62a38 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/TrashViewModel.kt @@ -0,0 +1,81 @@ +package dev.sanmer.authenticator.ui.screen.trash + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.sanmer.authenticator.Const.INSTANT_ZERO +import dev.sanmer.authenticator.Logger +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.model.LoadData +import dev.sanmer.authenticator.repository.DbRepository +import kotlinx.coroutines.launch + +class TrashViewModel( + private val dbRepository: DbRepository +) : ViewModel() { + var data by mutableStateOf>>(LoadData.Loading) + private set + + private val _selected = mutableStateListOf() + val selected get() = _selected.size + val isPick get() = _selected.isNotEmpty() + + private val logger = Logger.Android("TrashViewModel") + + init { + logger.d("init") + loadData() + } + + private fun loadData() { + viewModelScope.launch { + dbRepository.getTrashedAuthAsFlow() + .collect { list -> + data = LoadData.Success( + list.sortedByDescending { it.trashedAt } + ) + } + } + } + + fun isSelected(auth: Auth) = _selected.contains(auth.id) + + fun pick(auth: Auth) = if (isSelected(auth)) { + _selected.remove(auth.id) + } else { + _selected.add(auth.id) + } + + fun clearSelected() { + _selected.clear() + } + + fun restore() { + viewModelScope.launch { + _selected.forEach { authId -> + runCatching { + dbRepository.trash(authId, INSTANT_ZERO) + }.onFailure { + logger.e(it) + } + } + clearSelected() + } + } + + fun delete() { + viewModelScope.launch { + _selected.forEach { authId -> + runCatching { + dbRepository.delete(authId) + }.onFailure { + logger.e(it) + } + } + clearSelected() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthItem.kt new file mode 100644 index 0000000..7338e3a --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthItem.kt @@ -0,0 +1,107 @@ +package dev.sanmer.authenticator.ui.screen.trash.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.Const.DATETIME_DISPLAY +import dev.sanmer.authenticator.R +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.ui.component.LabelText +import dev.sanmer.authenticator.ui.ktx.surface +import dev.sanmer.brand.Brand +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime + +@Composable +fun AuthItem( + auth: Auth, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) = Row( + modifier = modifier + .fillMaxWidth() + .surface( + shape = MaterialTheme.shapes.large, + backgroundColor = MaterialTheme.colorScheme.surface, + border = CardDefaults.outlinedCardBorder(false) + ) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + .padding(15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp) +) { + val brand by remember(auth.id) { + derivedStateOf { Brand.valueOfOrNull(auth.issuer) } + } + val trashedAt by remember(auth.id) { + derivedStateOf { + auth.trashedAt + .toLocalDateTime(TimeZone.currentSystemDefault()) + .format(DATETIME_DISPLAY) + } + } + + Image( + painter = painterResource(brand?.id ?: R.drawable.fingerprint_thin), + contentDescription = null, + modifier = Modifier.size(45.dp), + colorFilter = if (brand != null) null else + ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (auth.name.isNotEmpty()) Text( + text = auth.name, + style = MaterialTheme.typography.titleMedium + ) + + if (brand == null && auth.issuer.isNotEmpty()) LabelText( + text = auth.issuer + ) + } + + Text( + text = stringResource(R.string.trashed_at, trashedAt), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + + if (isSelected) Icon( + painter = painterResource(R.drawable.circle_check_filled), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(30.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthList.kt new file mode 100644 index 0000000..83138ca --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screen/trash/component/AuthList.kt @@ -0,0 +1,41 @@ +package dev.sanmer.authenticator.ui.screen.trash.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.sanmer.authenticator.database.model.Auth +import dev.sanmer.authenticator.ui.ktx.plus + +@Composable +fun AuthList( + list: List, + onEdit: (Auth) -> Unit, + isSelected: (Auth) -> Boolean, + onPick: (Auth) -> Unit, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp) +) = LazyColumn( + modifier = modifier, + state = state, + contentPadding = PaddingValues(15.dp) + contentPadding, + verticalArrangement = Arrangement.spacedBy(15.dp) +) { + items( + items = list, + key = { it.id } + ) { + AuthItem( + auth = it, + isSelected = isSelected(it), + onClick = { onEdit(it) }, + onLongClick = { onPick(it) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeScreen.kt deleted file mode 100644 index cc30005..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeScreen.kt +++ /dev/null @@ -1,73 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.authorize - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import dev.sanmer.authenticator.ktx.finishActivity -import dev.sanmer.authenticator.ui.AuthorizeActivity.Action -import dev.sanmer.authenticator.ui.screens.authorize.component.ChangePasswordItem -import dev.sanmer.authenticator.ui.screens.authorize.component.EnterPasswordItem -import org.koin.androidx.compose.koinViewModel - -@Composable -fun AuthorizeScreen( - viewModel: AuthorizeViewModel = koinViewModel() -) { - if (!viewModel.loadState.isReady) return - val context = LocalContext.current - - DisposableEffect(viewModel.type) { - if (viewModel.type.isBiometricPending) { - viewModel.loadSessionKeyByBiometric(context) - } - if (viewModel.type.isSucceed) { - context.finishActivity() - } - onDispose {} - } - - when (viewModel.action) { - Action.ChangePassword -> ChangePasswordItem( - onDismiss = context::finishActivity, - isPasswordError = viewModel.type.isPasswordFailed, - onChange = viewModel::changePassword - ) - - Action.Auth -> if (viewModel.type.isPassword) EnterPasswordItem( - onDismiss = context::finishActivity, - action = viewModel.action, - isPasswordError = viewModel.type.isPasswordFailed, - onEnter = viewModel::loadSessionKeyByPassword, - enableBiometric = viewModel.isSupportedBiometric, - onBiometric = viewModel::retryBiometric - ) - - Action.SetupPassword -> EnterPasswordItem( - onDismiss = context::finishActivity, - action = viewModel.action, - isPasswordError = viewModel.type.isPasswordFailed, - onEnter = viewModel::setupPassword - ) - - Action.RemovePassword -> EnterPasswordItem( - onDismiss = context::finishActivity, - action = viewModel.action, - isPasswordError = viewModel.type.isPasswordFailed, - onEnter = viewModel::removePassword - ) - - Action.SetupBiometric -> EnterPasswordItem( - onDismiss = context::finishActivity, - action = viewModel.action, - isPasswordError = viewModel.type.isPasswordFailed, - onEnter = { viewModel.setupBiometric(it, context) } - ) - - Action.RemoveBiometric -> EnterPasswordItem( - onDismiss = context::finishActivity, - action = viewModel.action, - isPasswordError = viewModel.type.isPasswordFailed, - onEnter = viewModel::removeBiometric - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeViewModel.kt deleted file mode 100644 index f4ad836..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/AuthorizeViewModel.kt +++ /dev/null @@ -1,212 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.authorize - -import android.content.Context -import android.content.Intent -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.repository.DbRepository -import dev.sanmer.authenticator.repository.PreferenceRepository -import dev.sanmer.authenticator.ui.AuthorizeActivity -import dev.sanmer.authenticator.ui.screens.main.MainViewModel.LoadState -import dev.sanmer.crypto.BiometricKey -import dev.sanmer.crypto.BiometricKey.Default.decryptKeyByBiometric -import dev.sanmer.crypto.BiometricKey.Default.getKeyEncryptedByBiometric -import dev.sanmer.crypto.Crypto -import dev.sanmer.crypto.SessionKey -import dev.sanmer.encoding.decodeBase64 -import dev.sanmer.encoding.encodeBase64 -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -class AuthorizeViewModel( - private val preferenceRepository: PreferenceRepository, - private val dbRepository: DbRepository -) : ViewModel() { - var action by mutableStateOf(AuthorizeActivity.Action.Auth) - private set - - var type by mutableStateOf(Type.PasswordPending) - private set - - var loadState by mutableStateOf(LoadState.Pending) - private set - private val preference inline get() = loadState.preference - - var isSupportedBiometric by mutableStateOf(false) - private set - - private val logger = Logger.Android("AuthorizeViewModel") - - init { - logger.d("init") - preferenceObserver() - } - - private fun preferenceObserver() { - viewModelScope.launch { - preferenceRepository.data.collect { - loadState = LoadState.Ready(it) - if (it.isBiometric) { - type = Type.BiometricPending - isSupportedBiometric = true - } - } - } - } - - fun updateFromIntent(getIntent: () -> Intent) { - val intent = getIntent() - action = AuthorizeActivity.Action.Default(intent.action) - } - - fun setupPassword(new: String) { - viewModelScope.launch { - runCatching { - val sessionKey = SessionKey.new() - val newKey = sessionKey.getKeyEncryptedByPassword(new).encodeBase64() - preferenceRepository.setKeyEncryptedByPassword(newKey) - dbRepository.encrypt(sessionKey) - dbRepository.setSessionKey(sessionKey) - type = Type.PasswordSucceed - }.onFailure { - logger.w(it) - } - } - } - - private fun checkPassword( - current: String, - callback: suspend CoroutineScope.(SessionKey) -> Unit - ) { - if (type.isPasswordFailed) type = Type.PasswordPending - viewModelScope.launch { - val key = preference.keyEncryptedByPassword - runCatching { - callback( - SessionKey.decryptKeyByPassword(key.decodeBase64(), current) - ) - }.onFailure { - type = Type.PasswordFailed - logger.w(it) - } - } - } - - fun changePassword(current: String, new: String) = checkPassword(current) { sessionKey -> - val newSessionKey = SessionKey.new() - val newKey = newSessionKey.getKeyEncryptedByPassword(new).encodeBase64() - preferenceRepository.setKeyEncryptedByPassword(newKey) - preferenceRepository.setKeyEncryptedByBiometric("") - dbRepository.reEncrypt(sessionKey, newSessionKey) - dbRepository.setSessionKey(newSessionKey) - type = Type.PasswordSucceed - } - - fun removePassword(current: String) = checkPassword(current) { sessionKey -> - preferenceRepository.setKeyEncryptedByPassword("") - preferenceRepository.setKeyEncryptedByBiometric("") - dbRepository.decrypt(sessionKey) - dbRepository.setSessionKey(Crypto.None) - type = Type.PasswordSucceed - } - - fun setupBiometric( - current: String, - context: Context - ) = checkPassword(current) { sessionKey -> - runCatching { - BiometricKey.new() - val key = sessionKey.getKeyEncryptedByBiometric(context).encodeBase64() - preferenceRepository.setKeyEncryptedByBiometric(key) - type = Type.BiometricSucceed - }.onFailure { - type = Type.BiometricFailed - logger.w(it) - } - } - - fun removeBiometric( - current: String - ) = checkPassword(current) { - preferenceRepository.setKeyEncryptedByBiometric("") - type = Type.PasswordSucceed - } - - fun loadSessionKeyByPassword(current: String) { - viewModelScope.launch { - val key = preference.keyEncryptedByPassword - runCatching { - dbRepository.setSessionKey( - SessionKey.decryptKeyByPassword(key.decodeBase64(), current) - ) - type = Type.PasswordSucceed - }.onFailure { - type = Type.PasswordFailed - logger.w(it) - } - } - } - - fun retryBiometric() { - type = Type.BiometricPending - } - - fun loadSessionKeyByBiometric(context: Context) { - if (action != AuthorizeActivity.Action.Auth) return - viewModelScope.launch { - if (!BiometricKey.canAuthenticate(context)) { - isSupportedBiometric = false - return@launch - } - - runCatching { - val key = preference.keyEncryptedByBiometric.decodeBase64() - dbRepository.setSessionKey( - SessionKey.decryptKeyByBiometric(key, context) - ) - type = Type.BiometricSucceed - }.onFailure { - logger.w(it) - type = Type.PasswordPending - } - } - } - - sealed class Type { - abstract val state: State - - data class Biometric( - override val state: State - ) : Type() - - data class Password( - override val state: State - ) : Type() - - val isPending inline get() = state == State.Pending - val isFailed inline get() = state == State.Failed - val isSucceed inline get() = state == State.Succeed - val isPassword inline get() = this is Password - val isBiometricPending inline get() = this is Biometric && isPending - val isPasswordFailed inline get() = isPassword && isFailed - - companion object Default { - val BiometricPending = Biometric(State.Pending) - val BiometricSucceed = Biometric(State.Succeed) - val BiometricFailed = Biometric(State.Failed) - val PasswordPending = Password(State.Pending) - val PasswordSucceed = Password(State.Succeed) - val PasswordFailed = Password(State.Failed) - } - } - - enum class State { - Pending, - Succeed, - Failed, - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt deleted file mode 100644 index d10fc20..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/ChangePasswordItem.kt +++ /dev/null @@ -1,45 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.authorize.component - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet - -@Composable -fun ChangePasswordItem( - onDismiss: () -> Unit, - isPasswordError: Boolean, - onChange: (String, String) -> Unit -) { - var currentPassword by remember { mutableStateOf("") } - var newPassword by remember { mutableStateOf("") } - - SettingBottomSheet( - onDismiss = onDismiss, - title = stringResource(id = R.string.security_change_password), - ) { - PasswordTextField( - password = currentPassword, - onPasswordChange = { currentPassword = it }, - imeAction = ImeAction.Next, - isError = isPasswordError, - title = stringResource(id = R.string.security_current_password), - modifier = Modifier.fillMaxWidth() - ) - - PasswordTextField( - password = newPassword, - onPasswordChange = { newPassword = it }, - onImeDone = { onChange(currentPassword, newPassword) }, - title = stringResource(id = R.string.security_new_password), - modifier = Modifier.fillMaxWidth() - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt deleted file mode 100644 index 7e610c0..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/EnterPasswordItem.kt +++ /dev/null @@ -1,52 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.authorize.component - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.AuthorizeActivity.Action -import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet - -@Composable -fun EnterPasswordItem( - onDismiss: () -> Unit, - action: Action, - isPasswordError: Boolean, - onEnter: (String) -> Unit, - enableBiometric: Boolean = false, - onBiometric: () -> Unit = {} -) { - var currentPassword by remember { mutableStateOf("") } - - SettingBottomSheet( - onDismiss = onDismiss, - title = stringResource( - id = when (action) { - Action.SetupPassword -> R.string.security_set_password - Action.RemovePassword -> R.string.security_remove_password - else -> R.string.security_enter_password - } - ), - ) { - PasswordTextField( - password = currentPassword, - onPasswordChange = { currentPassword = it }, - onImeDone = { onEnter(currentPassword) }, - isError = isPasswordError, - title = stringResource( - id = when (action) { - Action.SetupPassword -> R.string.security_new_password - else -> R.string.security_current_password - } - ), - modifier = Modifier.fillMaxWidth(), - actionIcon = if (enableBiometric) R.drawable.fingerprint else null, - onActionClick = if (enableBiometric) onBiometric else null - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/PasswordTextField.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/PasswordTextField.kt deleted file mode 100644 index 58b22c5..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/authorize/component/PasswordTextField.kt +++ /dev/null @@ -1,96 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.authorize.component - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R - -@Composable -fun PasswordTextField( - modifier: Modifier = Modifier, - password: String, - onPasswordChange: (String) -> Unit, - imeAction: ImeAction = ImeAction.Done, - onImeDone: () -> Unit = {}, - title: String? = null, - isError: Boolean = false, - placeholder: (@Composable () -> Unit)? = null, - @DrawableRes actionIcon: Int? = null, - onActionClick: (() -> Unit)? = null -) { - var hidden by remember { mutableStateOf(true) } - val passwordVisualTransformation = remember { PasswordVisualTransformation() } - - Column( - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - if (title != null) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium - ) - } - - OutlinedTextField( - value = password, - onValueChange = onPasswordChange, - shape = MaterialTheme.shapes.medium, - singleLine = true, - isError = isError, - modifier = modifier, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = imeAction - ), - keyboardActions = KeyboardActions( - onDone = { - onImeDone() - defaultKeyboardAction(ImeAction.Done) - } - ), - visualTransformation = if (hidden) { - passwordVisualTransformation - } else { - VisualTransformation.None - }, - placeholder = placeholder, - trailingIcon = { - IconButton( - onClick = onActionClick ?: { - hidden = !hidden - } - ) { - Icon( - painter = painterResource( - id = actionIcon ?: if (hidden) { - R.drawable.eye_closed - } else { - R.drawable.eye - } - ), - contentDescription = null - ) - } - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt deleted file mode 100644 index a8dced3..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoScreen.kt +++ /dev/null @@ -1,47 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.crypto - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ktx.finishActivity -import dev.sanmer.authenticator.ui.screens.authorize.component.PasswordTextField -import dev.sanmer.authenticator.ui.screens.settings.component.SettingBottomSheet -import org.koin.androidx.compose.koinViewModel - -@Composable -fun CryptoScreen( - viewModel: CryptoViewModel = koinViewModel() -) { - val context = LocalContext.current - - DisposableEffect(viewModel.state) { - if (viewModel.state.isSucceed) context.finishActivity() - onDispose {} - } - - SettingBottomSheet( - onDismiss = context::finishActivity, - title = stringResource( - id = if (viewModel.isEncrypt) { - R.string.crypto_encrypt - } else { - R.string.crypto_decrypt - } - ) - ) { - PasswordTextField( - password = viewModel.password, - onPasswordChange = viewModel::updatePassword, - onImeDone = viewModel::crypto, - isError = viewModel.state.isFailed, - title = stringResource(id = R.string.security_password), - modifier = Modifier.fillMaxWidth(), - actionIcon = if (viewModel.isSkip) R.drawable.lock_off else null, - onActionClick = if (viewModel.isSkip) viewModel::crypto else null - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoViewModel.kt deleted file mode 100644 index 087c872..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/crypto/CryptoViewModel.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.crypto - -import android.content.Intent -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.ui.CryptoActivity -import dev.sanmer.authenticator.ui.CryptoActivity.Default.bypass -import dev.sanmer.authenticator.ui.CryptoActivity.Default.input -import dev.sanmer.crypto.PasswordKey -import kotlinx.coroutines.launch - -class CryptoViewModel : ViewModel() { - private var data = emptyList() - private var action by mutableStateOf(CryptoActivity.Action.Encrypt) - private var bypass = true - - var state by mutableStateOf(State.Pending) - private set - - var password by mutableStateOf("") - private set - - val isSkip by derivedStateOf { password.isEmpty() && bypass } - val isEncrypt by lazy { action == CryptoActivity.Action.Encrypt } - val isDecrypt by lazy { action == CryptoActivity.Action.Decrypt } - - val output get() = if (state.isSucceed) data else emptyList() - - private val logger = Logger.Android("CryptoViewModel") - - init { - logger.d("init") - } - - fun crypto() { - if (state.isRunning) return - if (isSkip) { - state = State.Succeed - return - } - - viewModelScope.launch { - runCatching { - val key = PasswordKey.new(password) - state = State.Running - data = when (action) { - CryptoActivity.Action.Encrypt -> data.map { key.encrypt(it) } - CryptoActivity.Action.Decrypt -> data.map { key.decrypt(it) } - } - state = State.Succeed - }.onFailure { - state = State.Failed - } - } - } - - fun updateFromIntent(getIntent: () -> Intent) { - val intent = getIntent() - action = CryptoActivity.Action.Default(intent.action) - bypass = intent.bypass - data = intent.input.toList() - } - - fun updatePassword(value: String) { - password = value - } - - enum class State { - Pending, - Running, - Succeed, - Failed; - - val isRunning inline get() = this == Running - val isFailed inline get() = this == Failed - val isSucceed inline get() = this == Succeed - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditScreen.kt deleted file mode 100644 index 4a70b91..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditScreen.kt +++ /dev/null @@ -1,173 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.edit - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.ktx.setSensitiveText -import dev.sanmer.authenticator.ui.screens.edit.EditViewModel.Value -import dev.sanmer.authenticator.ui.screens.edit.component.DigitsAndPeriodItem -import dev.sanmer.authenticator.ui.screens.edit.component.SecretItem -import dev.sanmer.authenticator.ui.screens.edit.component.TextFieldItem -import dev.sanmer.authenticator.ui.screens.edit.component.TypeAndHashItem -import kotlinx.coroutines.launch - -@Composable -fun EditScreen( - viewModel: EditViewModel, - goBack: () -> Unit, -) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - - Scaffold( - topBar = { - TopBar( - isEdit = viewModel.isEdit, - uri = viewModel.uri, - onClose = goBack, - onSave = { viewModel.save { if (!viewModel.isEdit) goBack() } }, - scrollBehavior = scrollBehavior - ) - } - ) { contentPadding -> - Column( - modifier = Modifier - .imePadding() - .nestedScroll(scrollBehavior.nestedScrollConnection) - .verticalScroll(rememberScrollState()) - .padding(contentPadding) - .padding(vertical = 15.dp, horizontal = 5.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - TextFieldItem( - value = viewModel.input.name, - onValueChange = { name -> - viewModel.update { it.copy(name = name) } - }, - leadingIcon = R.drawable.user, - label = stringResource(id = R.string.edit_name), - isError = viewModel.isError(Value.Name) - ) - - TextFieldItem( - value = viewModel.input.issuer, - onValueChange = { issuer -> - viewModel.update { it.copy(issuer = issuer) } - }, - label = stringResource(id = R.string.edit_issuer), - isError = viewModel.isError(Value.Issuer) - ) - - SecretItem( - secret = viewModel.input.secret, - uri = viewModel.uri, - onSecretChange = { secret -> - viewModel.update { it.copy(secret = secret) } - }, - isError = viewModel.isError(Value.Secret), - readOnly = viewModel.isEdit - ) - - TypeAndHashItem( - type = viewModel.input.type, - onTypeChange = { type -> - viewModel.update { it.copy(type = type) } - }, - hash = viewModel.input.hash, - onHashChange = { hash -> - viewModel.update { it.copy(hash = hash) } - }, - readOnly = viewModel.isEdit - ) - - DigitsAndPeriodItem( - type = viewModel.input.type, - digits = viewModel.input.digits, - onDigitsChange = { digits -> - viewModel.update { it.copy(digits = digits) } - }, - period = viewModel.input.period, - onPeriodChange = { period -> - viewModel.update { it.copy(period = period) } - }, - readOnly = viewModel.isEdit - ) - } - } -} - -@Composable -private fun TopBar( - isEdit: Boolean, - uri: String, - onClose: () -> Unit, - onSave: () -> Unit, - scrollBehavior: TopAppBarScrollBehavior -) = TopAppBar( - title = { - Text( - text = stringResource( - id = if (isEdit) R.string.edit_edit_title else R.string.edit_add_title - ) - ) - }, - navigationIcon = { - IconButton( - onClick = onClose - ) { - Icon( - painter = painterResource(id = R.drawable.x), - contentDescription = null - ) - } - }, - actions = { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - - if (uri.isNotBlank()) { - IconButton( - onClick = { - scope.launch { - clipboard.setSensitiveText(uri) - } - }, - ) { - Icon( - painter = painterResource(id = R.drawable.clipboard_text), - contentDescription = null - ) - } - } - - IconButton( - onClick = onSave - ) { - Icon( - painter = painterResource(id = R.drawable.device_floppy), - contentDescription = null - ) - } - }, - scrollBehavior = scrollBehavior -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditViewModel.kt deleted file mode 100644 index 642deb1..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/EditViewModel.kt +++ /dev/null @@ -1,128 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.edit - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.authenticator.model.auth.AuthType -import dev.sanmer.authenticator.model.serializer.AuthUri.Default.toTotpEntity -import dev.sanmer.authenticator.model.serializer.AuthUri.Default.uri -import dev.sanmer.authenticator.repository.DbRepository -import dev.sanmer.encoding.isBase32 -import dev.sanmer.otp.Otp -import dev.sanmer.otp.OtpUri.Default.isOtpUri -import dev.sanmer.otp.OtpUri.Default.toOtpUri -import kotlinx.coroutines.launch - -class EditViewModel( - private val dbRepository: DbRepository, - private val id: Long, - uri: String -) : ViewModel() { - val isEdit = id != Long.MAX_VALUE - - var input by mutableStateOf(Input()) - private set - - var uri by mutableStateOf(uri) - private set - - private val result = mutableStateMapOf() - - private val logger = Logger.Android("EditViewModel") - - init { - logger.d("init") - updateFromUri(uri) - dataObserver() - } - - private fun dataObserver() { - viewModelScope.launch { - dbRepository.getTotpDecryptedByIdAsFlow(id) - .collect { entity -> - update { Input(entity) } - uri = entity.uri().toString() - } - } - } - - private fun isAllOk(): Boolean { - Value.Name.ok(input.name, result::put) - Value.Issuer.ok(input.issuer, result::put) - Value.Secret.ok(input.secret, result::put) - return result.all { it.value } - } - - fun isError(value: Value) = !(result[value] ?: true) - - private fun updateFromUri(uriString: String) { - if (!uriString.isOtpUri()) return - runCatching { - uriString.toOtpUri().toTotpEntity() - }.onSuccess { entity -> - update { Input(entity) } - }.onFailure { - logger.e(it) - } - } - - fun update(block: (Input) -> Input) { - input = block(input) - } - - fun save(block: () -> Unit = {}) { - if (isAllOk()) { - viewModelScope.launch { - when { - isEdit -> dbRepository.updateTotp(input.entity(id)) - else -> dbRepository.insertTotp(input.entity()) - } - block() - } - } - } - - data class Input( - val type: AuthType = AuthType.TOTP, - val name: String = "", - val issuer: String = "", - val secret: String = "", - val hash: Otp.Hash = Otp.Hash.SHA1, - val digits: String = "6", - val period: String = "30" - ) { - constructor(entity: TotpEntity) : this( - name = entity.name, - issuer = entity.issuer, - secret = entity.secret, - hash = entity.hash, - digits = entity.digits.toString(), - period = entity.period.toString() - ) - - fun entity(id: Long = 0) = TotpEntity( - id = id, - name = name.trim(), - issuer = issuer.trim(), - secret = secret.replace("\\s+".toRegex(), ""), - hash = hash, - digits = digits.toInt(), - period = period.toLong() - ) - } - - enum class Value(val ok: (String) -> Boolean) { - Issuer(String::isNotBlank), - Name(String::isNotBlank), - Secret(String::isBase32) - } - - private inline fun Value.ok(value: String, block: (Value, Boolean) -> Unit) { - block(this, ok(value)) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt deleted file mode 100644 index 693c30c..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/DigitsAndPeriodItem.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.edit.component - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.model.auth.AuthType - -@Composable -fun DigitsAndPeriodItem( - type: AuthType, - digits: String, - onDigitsChange: (String) -> Unit, - period: String, - onPeriodChange: (String) -> Unit, - readOnly: Boolean -) = TextFieldContent { - TextField( - value = digits, - onValueChange = onDigitsChange, - label = stringResource(id = R.string.edit_digits), - modifier = Modifier.weight(1f), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next - ), - readOnly = readOnly - ) - - Spacer(modifier = Modifier.width(15.dp)) - - TextField( - value = when (type) { - AuthType.TOTP -> period - }, - onValueChange = when (type) { - AuthType.TOTP -> onPeriodChange - }, - label = when (type) { - AuthType.TOTP -> stringResource(id = R.string.edit_period) - }, - modifier = Modifier.weight(1f), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done - ), - readOnly = readOnly - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/SecretItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/SecretItem.kt deleted file mode 100644 index fbf4acc..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/SecretItem.kt +++ /dev/null @@ -1,135 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.edit.component - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.component.DragHandle -import dev.sanmer.authenticator.ui.ktx.bottom -import dev.sanmer.qrcode.QRCode - -@Composable -fun SecretItem( - secret: String, - uri: String, - onSecretChange: (String) -> Unit, - isError: Boolean, - readOnly: Boolean -) { - var show by remember { mutableStateOf(false) } - if (show) QRCodeBottomSheet( - onDismiss = { show = false }, - uri = uri - ) - - TextFieldContent( - leading = { TextFieldContentIcon(icon = R.drawable.key) }, - trailing = { - if (readOnly && uri.isNotEmpty()) { - IconButton( - onClick = { show = true } - ) { - Icon( - painter = painterResource(R.drawable.qrcode), - contentDescription = null - ) - } - } else { - TextFieldContentSpacer() - } - } - ) { - val passwordVisualTransformation = remember { PasswordVisualTransformation() } - - TextField( - value = secret, - onValueChange = onSecretChange, - label = stringResource(id = R.string.edit_secret), - modifier = Modifier.weight(1f), - readOnly = readOnly, - isError = isError, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - visualTransformation = if (readOnly) { - passwordVisualTransformation - } else { - VisualTransformation.None - }, - ) - } -} - -@Composable -private fun QRCodeBottomSheet( - onDismiss: () -> Unit, - uri: String, -) = ModalBottomSheet( - onDismissRequest = onDismiss, - shape = MaterialTheme.shapes.large.bottom(0.dp), - dragHandle = null -) { - DragHandle() - - Text( - text = stringResource(id = R.string.edit_qrcode), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - - Box( - modifier = Modifier - .padding(vertical = 15.dp, horizontal = 40.dp) - .align(Alignment.CenterHorizontally), - contentAlignment = Alignment.Center - ) { - val sizePx = with(LocalDensity.current) { - BottomSheetDefaults.SheetMaxWidth.roundToPx() - } - - val foregroundColor = MaterialTheme.colorScheme.onSurface.toArgb() - val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow.toArgb() - - val bitmap by remember { - derivedStateOf { - QRCode.encodeToBitmap( - contents = uri, - size = sizePx, - foregroundColor = foregroundColor, - backgroundColor = backgroundColor - ) - } - } - - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldContent.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldContent.kt deleted file mode 100644 index 4ae4f7b..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldContent.kt +++ /dev/null @@ -1,169 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.edit.component - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.component.DropdownMenu - -@Composable -fun TextFieldContentIcon( - @DrawableRes icon: Int -) = Box( - modifier = Modifier.size(48.dp), - contentAlignment = Alignment.Center -) { - Icon( - painter = painterResource(id = icon), - contentDescription = null - ) -} - -@Composable -fun TextFieldContentSpacer() = Spacer(modifier = Modifier.width(48.dp)) - -@Composable -fun TextFieldContent( - modifier: Modifier = Modifier, - leading: @Composable (() -> Unit)? = null, - trailing: @Composable (() -> Unit)? = null, - content: @Composable RowScope.() -> Unit -) = Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically -) { - if (leading != null) { - leading() - } else { - TextFieldContentSpacer() - } - content() - if (trailing != null) { - trailing() - } else { - TextFieldContentSpacer() - } -} - -@Composable -fun TextField( - value: String, - onValueChange: (String) -> Unit, - label: String, - modifier: Modifier = Modifier, - readOnly: Boolean = false, - trailingIcon: @Composable (() -> Unit)? = null, - isError: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) = OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = modifier, - readOnly = readOnly, - label = { Text(text = label) }, - trailingIcon = trailingIcon, - shape = MaterialTheme.shapes.medium, - isError = isError, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - singleLine = true, - interactionSource = interactionSource -) - -@Composable -fun TextFieldDropdownMenu( - value: T, - values: Collection, - onValueChange: (T) -> Unit, - label: String, - modifier: Modifier = Modifier, - readOnly: Boolean = false -) { - var expanded by remember { mutableStateOf(false) } - val interactionSource = remember { MutableInteractionSource() } - - LaunchedEffect(interactionSource) { - interactionSource.interactions.collect { interaction -> - if (interaction is PressInteraction.Release) { - expanded = !expanded - } - } - } - - DropdownMenu( - modifier = modifier, - expanded = expanded, - onDismissRequest = { expanded = false }, - contentAlignment = Alignment.BottomStart, - offset = DpOffset(0.dp, 3.dp), - surface = { - TextField( - value = value.toString(), - onValueChange = {}, - label = label, - modifier = Modifier.fillMaxWidth(), - readOnly = true, - trailingIcon = { - Icon( - painter = painterResource( - id = if (expanded) { - R.drawable.caret_up - } else { - R.drawable.caret_down - } - ), - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - interactionSource = if (readOnly) { - remember { MutableInteractionSource() } - } else { - interactionSource - } - ) - } - ) { - values.forEach { value -> - DropdownMenuItem( - text = { - Text( - text = value.toString(), - style = MaterialTheme.typography.bodyLarge - ) - }, - onClick = { - onValueChange(value) - expanded = false - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt deleted file mode 100644 index 34c70c6..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TextFieldItem.kt +++ /dev/null @@ -1,48 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.edit.component - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation - -@Composable -fun TextFieldItem( - value: String, - onValueChange: (String) -> Unit, - label: String, - modifier: Modifier = Modifier, - @DrawableRes leadingIcon: Int? = null, - trailingIcon: @Composable (() -> Unit)? = null, - hidden: Boolean = false, - isError: Boolean = false, - keyboardOptions: KeyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next - ) -) = TextFieldContent( - modifier = modifier, - leading = leadingIcon?.let { { TextFieldContentIcon(icon = it) } }, - trailing = trailingIcon -) { - val passwordVisualTransformation = remember { PasswordVisualTransformation() } - - TextField( - value = value, - onValueChange = onValueChange, - label = label, - modifier = Modifier.weight(1f), - readOnly = hidden, - isError = isError, - keyboardOptions = keyboardOptions, - visualTransformation = if (hidden) { - passwordVisualTransformation - } else { - VisualTransformation.None - }, - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt deleted file mode 100644 index 02ee925..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/edit/component/TypeAndHashItem.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.edit.component - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.model.auth.AuthType -import dev.sanmer.otp.Otp - -@Composable -fun TypeAndHashItem( - type: AuthType, - onTypeChange: (AuthType) -> Unit, - hash: Otp.Hash, - onHashChange: (Otp.Hash) -> Unit, - readOnly: Boolean -) = TextFieldContent( - leading = { TextFieldContentIcon(icon = R.drawable.math_function) } -) { - TextFieldDropdownMenu( - value = type, - values = AuthType.entries, - onValueChange = onTypeChange, - label = stringResource(id = R.string.edit_type), - readOnly = readOnly, - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = Modifier.width(15.dp)) - - TextFieldDropdownMenu( - value = hash, - values = Otp.Hash.entries, - onValueChange = onHashChange, - label = stringResource(id = R.string.edit_hash), - readOnly = readOnly, - modifier = Modifier.weight(1f) - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeScreen.kt deleted file mode 100644 index dd654ea..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeScreen.kt +++ /dev/null @@ -1,172 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.encode - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R - -@Composable -fun EncodeScreen( - viewModel: EncodeViewModel, - goBack: () -> Unit -) { - val configuration = LocalConfiguration.current - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - - val maxWidth by remember { - derivedStateOf { configuration.smallestScreenWidthDp * 0.8f } - } - - Scaffold( - topBar = { - TopBar( - onBack = goBack, - scrollBehavior = scrollBehavior - ) - } - ) { contentPadding -> - Column( - modifier = Modifier - .imePadding() - .nestedScroll(scrollBehavior.nestedScrollConnection) - .verticalScroll(rememberScrollState()) - .padding(contentPadding) - .padding(vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(15.dp) - ) { - SingleChoiceSegmentedButtonRow { - EncodeViewModel.Type.entries.forEachIndexed { index, type -> - val selected by remember { derivedStateOf { viewModel.type == type } } - SegmentedButton( - selected = selected, - onClick = { viewModel.updateType(type) }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = EncodeViewModel.Type.entries.size - ), - icon = { - SegmentedIcon(active = selected) - } - ) { - Text(text = type.name) - } - } - } - - OutlinedTextField( - value = viewModel.decoded, - onValueChange = { viewModel.updateDecoded(it) }, - shape = MaterialTheme.shapes.large, - modifier = Modifier.size(width = maxWidth.dp, height = 180.dp), - isError = viewModel.isError(EncodeViewModel.Error.Encode), - placeholder = { Text(text = stringResource(id = R.string.encode_decoded)) } - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - ButtonItem( - onClick = viewModel::encode, - icon = R.drawable.arrow_bar_down - ) - - Spacer(modifier = Modifier.width(10.dp)) - - ButtonItem( - onClick = viewModel::decode, - icon = R.drawable.arrow_bar_up - ) - } - - OutlinedTextField( - value = viewModel.encoded, - onValueChange = { viewModel.updateEncoded(it) }, - shape = MaterialTheme.shapes.large, - modifier = Modifier.size(width = maxWidth.dp, height = 180.dp), - isError = viewModel.isError(EncodeViewModel.Error.Decode), - placeholder = { Text(text = stringResource(id = R.string.encode_encoded)) } - ) - } - } -} - -@Composable -private fun SegmentedIcon( - active: Boolean -) = SegmentedButtonDefaults.Icon( - active = active, - activeContent = { - Icon( - painter = painterResource(id = R.drawable.check), - contentDescription = null, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize) - ) - } -) - -@Composable -private fun ButtonItem( - onClick: () -> Unit, - @DrawableRes icon: Int -) = IconButton( - onClick = onClick -) { - Icon( - painter = painterResource(id = icon), - contentDescription = null - ) -} - -@Composable -private fun TopBar( - onBack: () -> Unit, - scrollBehavior: TopAppBarScrollBehavior -) = TopAppBar( - title = { Text(text = stringResource(id = R.string.settings_encode_decode)) }, - navigationIcon = { - IconButton( - onClick = onBack - ) { - Icon( - painter = painterResource(id = R.drawable.arrow_left), - contentDescription = null - ) - } - }, - scrollBehavior = scrollBehavior -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeViewModel.kt deleted file mode 100644 index a26fd6b..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/encode/EncodeViewModel.kt +++ /dev/null @@ -1,87 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.encode - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel -import dev.sanmer.authenticator.Logger -import dev.sanmer.encoding.decodeBase32 -import dev.sanmer.encoding.decodeBase64 -import dev.sanmer.encoding.encodeBase32 -import dev.sanmer.encoding.encodeBase64 - -class EncodeViewModel : ViewModel() { - var encoded by mutableStateOf(TextFieldValue()) - private set - - var decoded by mutableStateOf(TextFieldValue()) - private set - - var type by mutableStateOf(Type.Base64) - private set - - private val errors = mutableStateMapOf() - - private val logger = Logger.Android("EncodeViewModel") - - init { - logger.d("init") - } - - fun updateEncoded(value: TextFieldValue) { - encoded = value - } - - fun updateDecoded(value: TextFieldValue) { - decoded = value - } - - fun updateType(value: Type) { - type = value - } - - fun isError(value: Error) = - errors.getOrDefault(value, false) - - fun encode() { - errors[Error.Decode] = false - errors[Error.Encode] = runCatching { - encoded = type.encode(decoded.text).asTextFieldValue() - }.isFailure - } - - fun decode() { - errors[Error.Encode] = false - errors[Error.Decode] = runCatching { - decoded = type.decode(encoded.text).asTextFieldValue() - }.isFailure - } - - private fun String.asTextFieldValue() = - TextFieldValue( - text = this, - selection = TextRange(length) - ) - - enum class Type( - val encode: (String) -> String, - val decode: (String) -> String - ) { - Base32( - encode = String::encodeBase32, - decode = String::decodeBase32 - ), - Base64( - encode = String::encodeBase64, - decode = String::decodeBase64 - ) - } - - enum class Error { - Encode, - Decode - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeScreen.kt deleted file mode 100644 index 52d62a7..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeScreen.kt +++ /dev/null @@ -1,122 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.home - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.component.LabelText -import dev.sanmer.authenticator.ui.component.PageIndicator -import dev.sanmer.authenticator.ui.ktx.isScrollingUp -import dev.sanmer.authenticator.ui.screens.Screen -import dev.sanmer.authenticator.ui.screens.home.component.AuthList -import java.time.LocalTime - -@Composable -fun HomeScreen( - viewModel: HomeViewModel, - goTo: (Screen) -> Unit, -) { - val time by viewModel.time.collectAsStateWithLifecycle() - - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val listState = rememberLazyListState() - val isScrollingUp by listState.isScrollingUp() - - Scaffold( - topBar = { - TopBar( - time = time, - scrollBehavior = scrollBehavior - ) - }, - floatingActionButton = { - AnimatedVisibility( - visible = isScrollingUp, - enter = fadeIn() + scaleIn(), - exit = scaleOut() + fadeOut() - ) { - ActionButton( - onClick = { goTo(Screen.Settings) } - ) - } - } - ) { contentPadding -> - Box( - modifier = Modifier - .imePadding() - .nestedScroll(scrollBehavior.nestedScrollConnection) - .fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - if (viewModel.totp.isEmpty() && !viewModel.isPending) { - PageIndicator( - icon = R.drawable.key, - text = R.string.home_empty, - modifier = Modifier.padding(contentPadding) - ) - } - - AuthList( - state = listState, - totp = viewModel.totp, - onEdit = { goTo(Screen.Edit(id = it.id)) }, - onDelete = viewModel::moveToTrash, - contentPadding = contentPadding - ) - } - } -} - -@Composable -private fun ActionButton( - onClick: () -> Unit -) { - FloatingActionButton( - onClick = onClick - ) { - Icon( - painter = painterResource(id = R.drawable.settings_2), - contentDescription = null - ) - } -} - -@Composable -private fun TopBar( - time: LocalTime, - scrollBehavior: TopAppBarScrollBehavior -) = TopAppBar( - title = { Text(text = stringResource(id = R.string.app_name)) }, - actions = { - LabelText( - text = time.toString(), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(end = 15.dp) - ) - }, - scrollBehavior = scrollBehavior -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeViewModel.kt deleted file mode 100644 index 0e13bc9..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/HomeViewModel.kt +++ /dev/null @@ -1,91 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.home - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.authenticator.model.auth.TotpAuth -import dev.sanmer.authenticator.repository.DbRepository -import dev.sanmer.authenticator.repository.TimeRepository -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import java.time.Instant -import java.time.LocalTime -import java.time.ZoneId - -class HomeViewModel( - private val dbRepository: DbRepository, - private val timeRepository: TimeRepository -) : ViewModel() { - var loadState by mutableStateOf(LoadState.Pending) - private set - val totp inline get() = loadState.totp - val isPending inline get() = loadState.isPending - - val time = timeRepository.epochSeconds.map { epochSecond -> - Instant.ofEpochSecond(epochSecond) - .atZone(ZoneId.systemDefault()) - .toLocalTime() - }.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = LocalTime.now() - ) - - private val logger = Logger.Android("HomeViewModel") - - init { - logger.d("init") - dataObserver() - clearTrash() - } - - private fun dataObserver() { - viewModelScope.launch { - dbRepository.getTotpAllDecryptedAsFlow() - .collect { totp -> - loadState = LoadState.Ready( - totp = totp.map { - TotpAuth( - entity = it, - epochSeconds = timeRepository.epochSeconds - ) - }.sortedBy { it.entity.issuer.lowercase() }, - ) - } - } - } - - private fun clearTrash() { - viewModelScope.launch { - dbRepository.deleteTotp( - dbRepository.getTotpAllTrashed(dead = true) - ) - } - } - - fun moveToTrash(entity: TotpEntity) { - viewModelScope.launch { - dbRepository.updateTotp(entity.toTrash()) - } - } - - sealed class LoadState { - abstract val totp: List - - data object Pending : LoadState() { - override val totp = emptyList() - } - - data class Ready( - override val totp: List - ) : LoadState() - - val isPending inline get() = this is Pending - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthItem.kt deleted file mode 100644 index 4cf369f..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthItem.kt +++ /dev/null @@ -1,216 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.home.component - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideIn -import androidx.compose.animation.slideOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.model.auth.TotpAuth -import dev.sanmer.authenticator.ui.component.SwipeContent -import dev.sanmer.authenticator.ui.ktx.surface -import dev.sanmer.logo.Logo - -@Composable -fun AuthItem( - auth: TotpAuth, - onEdit: () -> Unit, - onDelete: () -> Unit, - enabled: Boolean = true, - onClick: () -> Unit = {} -) { - SwipeContent( - content = { release -> - AuthItemButtons( - onEdit = { - release() - onEdit() - }, - onDelete = { - release() - onDelete() - } - ) - }, - surface = { - AuthItemContent( - auth = auth, - enabled = enabled, - onClick = onClick - ) - } - ) -} - -@Composable -private fun AuthItemButtons( - onEdit: () -> Unit, - onDelete: () -> Unit -) = Row( - modifier = Modifier.padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp) -) { - FilledTonalIconButton( - onClick = onEdit - ) { - Icon( - painter = painterResource(id = R.drawable.edit), - contentDescription = null - ) - } - - FilledTonalIconButton( - onClick = onDelete, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Icon( - painter = painterResource(id = R.drawable.trash_x), - contentDescription = null - ) - } -} - -@Composable -private fun AuthItemContent( - auth: TotpAuth, - enabled: Boolean = true, - onClick: () -> Unit = {} -) = Row( - modifier = Modifier - .sizeIn(maxWidth = 450.dp) - .fillMaxWidth() - .surface( - shape = MaterialTheme.shapes.large, - backgroundColor = MaterialTheme.colorScheme.surface, - border = CardDefaults.outlinedCardBorder(false) - ) - .clickable( - enabled = enabled, - onClick = onClick - ) - .padding(all = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) -) { - val logo by remember(auth.entity.issuer) { - derivedStateOf { Logo.getOrDefault(auth.entity.issuer) } - } - - val otp by auth.otp.collectAsStateWithLifecycle(initialValue = auth.otp()) - - Image( - painter = painterResource(id = logo.res), - contentDescription = null, - modifier = Modifier.size(40.dp), - colorFilter = if (logo.refillable) { - ColorFilter.tint(MaterialTheme.colorScheme.primary) - } else null - ) - - Column( - horizontalAlignment = Alignment.Start - ) { - OtpItem( - otp = otp - ) - - Text( - text = auth.entity.displayName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - } -} - -@Composable -private fun OtpItem( - otp: String -) = Row( - verticalAlignment = Alignment.CenterVertically -) { - otp.forEachIndexed { index, char -> - AnimatedDigit( - digit = char, - position = index - ) - if ((index + 1) % 3 == 0 && index < otp.length - 1) { - Spacer(modifier = Modifier.width(12.dp)) - } - } -} - -@Composable -private fun AnimatedDigit( - digit: Char, - position: Int -) { - val isOddPosition = remember { (position % 2 == 0) } - val enterDirection = remember { if (isOddPosition) -1 else 1 } - val exitDirection = remember { if (isOddPosition) 1 else -1 } - - AnimatedContent( - targetState = digit, - transitionSpec = { - slideIn( - animationSpec = tween(500) - ) { - IntOffset(0, enterDirection * it.height) - } + scaleIn( - animationSpec = tween(durationMillis = 500) - ) + fadeIn( - animationSpec = tween(durationMillis = 500) - ) togetherWith slideOut( - animationSpec = tween(500) - ) { - IntOffset(0, exitDirection * it.height) - } + scaleOut( - animationSpec = tween(durationMillis = 500) - ) + fadeOut( - animationSpec = tween(durationMillis = 500) - ) - } - ) { targetDigit -> - Text( - text = targetDigit.toString(), - style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace - ) - ) - } -} diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt deleted file mode 100644 index 3252f2d..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/home/component/AuthList.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.home.component - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.authenticator.model.auth.TotpAuth -import dev.sanmer.authenticator.ui.ktx.plus - -@Composable -fun AuthList( - state: LazyListState, - totp: List, - onEdit: (TotpEntity) -> Unit, - onDelete: (TotpEntity) -> Unit, - contentPadding: PaddingValues = PaddingValues(0.dp) -) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - state = state, - contentPadding = contentPadding + PaddingValues(15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - items(totp) { - AuthItem( - auth = it, - enabled = false, - onEdit = { onEdit(it.entity) }, - onDelete = { onDelete(it.entity) } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/LockScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/LockScreen.kt deleted file mode 100644 index 862f5f7..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/LockScreen.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.main - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ktx.finishActivity -import dev.sanmer.authenticator.ui.AuthorizeActivity -import org.koin.androidx.compose.koinViewModel - -@Composable -fun LockScreen( - viewModel: MainViewModel = koinViewModel() -) { - val context = LocalContext.current - - LaunchedEffect(true) { - if (AuthorizeActivity.auth(context)) { - viewModel.setUnlocked() - } - } - - BackHandler(onBack = context::finishActivity) - - Box( - modifier = Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - AuthorizeActivity.auth(context) { - if (it) viewModel.setUnlocked() - } - } - ) - .background(color = MaterialTheme.colorScheme.background) - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = R.drawable.lock), - contentDescription = null, - modifier = Modifier.size(50.dp), - tint = MaterialTheme.colorScheme.outline - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainViewModel.kt deleted file mode 100644 index 0b7664b..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/main/MainViewModel.kt +++ /dev/null @@ -1,65 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.main - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.datastore.model.Preference -import dev.sanmer.authenticator.repository.PreferenceRepository -import kotlinx.coroutines.launch - -class MainViewModel( - private val preferenceRepository: PreferenceRepository -) : ViewModel() { - var loadState by mutableStateOf(LoadState.Pending) - private set - - val isPending inline get() = loadState.isPending - val isLocked inline get() = loadState.isLocked - val preference inline get() = loadState.preference - - private val logger = Logger.Android("MainViewModel") - - init { - logger.d("init") - preferenceObserver() - } - - private fun preferenceObserver() { - viewModelScope.launch { - preferenceRepository.data.collect { - loadState = if (loadState.isReady || !it.isEncrypted) { - LoadState.Ready(it) - } else { - LoadState.Locked(it) - } - } - } - } - - fun setUnlocked() { - loadState = LoadState.Ready(preference) - } - - sealed class LoadState { - abstract val preference: Preference - - data object Pending : LoadState() { - override val preference = Preference() - } - - data class Locked( - override val preference: Preference - ) : LoadState() - - data class Ready( - override val preference: Preference - ) : LoadState() - - val isPending inline get() = this is Pending - val isLocked inline get() = this is Locked - val isReady inline get() = this is Ready - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpScreen.kt deleted file mode 100644 index 91b62f9..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpScreen.kt +++ /dev/null @@ -1,122 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.ntp - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.screens.ntp.component.NtpList - -@Composable -fun NtpScreen( - viewModel: NtpViewModel, - goBack: () -> Unit -) { - val ntps by viewModel.ntps.collectAsStateWithLifecycle(initialValue = emptyList()) - val syncState by viewModel.syncState.collectAsStateWithLifecycle() - - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val listState = rememberLazyListState() - - Scaffold( - topBar = { - TopBar( - syncState = syncState, - onBack = goBack, - onSync = viewModel::syncAll, - scrollBehavior = scrollBehavior - ) - } - ) { contentPadding -> - Box( - modifier = Modifier - .imePadding() - .nestedScroll(scrollBehavior.nestedScrollConnection) - .fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - NtpList( - state = listState, - ntps = ntps, - setNtp = viewModel::setNtp, - contentPadding = contentPadding - ) - } - } -} - -@Composable -private fun TopBar( - syncState: NtpViewModel.SyncState, - onBack: () -> Unit, - onSync: () -> Unit, - scrollBehavior: TopAppBarScrollBehavior -) = TopAppBar( - title = { Text(text = stringResource(id = R.string.settings_ntp_server)) }, - navigationIcon = { - IconButton( - onClick = onBack - ) { - Icon( - painter = painterResource(id = R.drawable.arrow_left), - contentDescription = null - ) - } - }, - actions = { - IconButton( - onClick = onSync, - ) { - val animatedScale by animateFloatAsState( - targetValue = if (syncState.isRunning) 0.65f else 1f, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow) - ) - - AnimatedVisibility( - visible = syncState.isRunning, - enter = fadeIn() + scaleIn(), - exit = scaleOut() + fadeOut() - ) { - CircularProgressIndicator( - progress = { syncState.progress }, - strokeWidth = 2.dp, - modifier = Modifier.size(24.dp) - ) - } - - Icon( - painter = painterResource(id = R.drawable.refresh), - contentDescription = null, - modifier = Modifier.scale(animatedScale) - ) - } - }, - scrollBehavior = scrollBehavior -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpViewModel.kt deleted file mode 100644 index f18205b..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/NtpViewModel.kt +++ /dev/null @@ -1,186 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.ntp - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.datastore.model.Ntp -import dev.sanmer.authenticator.repository.PreferenceRepository -import dev.sanmer.authenticator.repository.TimeRepository -import dev.sanmer.logo.Brand -import dev.sanmer.ntp.NtpServer -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlin.time.Duration - -class NtpViewModel( - private val preferenceRepository: PreferenceRepository, - private val timeRepository: TimeRepository -) : ViewModel() { - private val _ntps = MutableStateFlow(defaultNtps) - val ntps = _ntps.map { list -> - list.sortedBy { it.ntpTime.rtt } - } - - private val _syncState = MutableStateFlow(SyncState.Pending) - val syncState = _syncState.asStateFlow() - - private val logger = Logger.Android("NtpViewModel") - - init { - logger.d("init") - timeObserver() - syncAll() - } - - private fun timeObserver() { - viewModelScope.launch { - timeRepository.ntpTime - .collect { ntpTime -> - val preference = preferenceRepository.data.first() - _ntps.update { ntps -> - val index = ntps.indexOfFirst { it.ntp == preference.ntp } - ntps.toMutableList().apply { - set(index, ntps[index].copy(ntpTime = ntpTime)) - } - } - } - } - } - - fun syncAll() { - viewModelScope.launch { - val preference = preferenceRepository.data.first() - _syncState.update { SyncState.Ready(defaultNtps.size) } - defaultNtps.mapIndexed { index, ntp -> - async { - val ntpTime = if (ntp.ntp == preference.ntp) { - timeRepository.sync(preference) - } else { - sync(ntp.server) - }.getOrDefault( - NtpServer.NtpTime(Duration.INFINITE, Duration.ZERO) - ) - - _syncState.update { SyncState.Updating(it.size, it.finished + 1) } - _ntps.update { - it.toMutableList().apply { set(index, ntp.copy(ntpTime = ntpTime)) } - } - } - }.awaitAll() - - _syncState.update { SyncState.Finished(it.size, it.finished) } - } - } - - fun setNtp(ntp: Ntp) { - viewModelScope.launch { - preferenceRepository.setNtp(ntp) - } - } - - private suspend fun sync(server: NtpServer) = - runCatching { - server.sync() - }.onSuccess { - logger.d("NTP(${server.address}): $it") - }.onFailure { - logger.w(it) - } - - data class NtpCompat( - val brand: Brand, - val ntp: Ntp, - val server: NtpServer, - val ntpTime: NtpServer.NtpTime - ) - - sealed class SyncState { - abstract val size: Int - abstract val finished: Int - - data object Pending : SyncState() { - override val size = 1 - override val finished = -1 - } - - data class Ready( - override val size: Int - ) : SyncState() { - override val finished = 0 - } - - data class Updating( - override val size: Int, - override val finished: Int - ) : SyncState() - - data class Finished( - override val size: Int, - override val finished: Int - ) : SyncState() - - val progress inline get() = finished / size.toFloat() - - val isRunning inline get() = this is Ready || this is Updating - - } - - companion object Default { - private val defaultNtps = listOf( - NtpCompat( - brand = Brand.Aliyun, - ntp = Ntp.Alibaba, - server = NtpServer.Alibaba, - ntpTime = NtpServer.NtpTime() - ), - NtpCompat( - brand = Brand.Apple, - ntp = Ntp.Apple, - server = NtpServer.Apple, - ntpTime = NtpServer.NtpTime() - ), - NtpCompat( - brand = Brand.AWS, - ntp = Ntp.Amazon, - server = NtpServer.Amazon, - ntpTime = NtpServer.NtpTime() - ), - NtpCompat( - brand = Brand.Cloudflare, - ntp = Ntp.Cloudflare, - server = NtpServer.Cloudflare, - ntpTime = NtpServer.NtpTime() - ), - NtpCompat( - brand = Brand.Google, - ntp = Ntp.Google, - server = NtpServer.Google, - ntpTime = NtpServer.NtpTime() - ), - NtpCompat( - brand = Brand.Meta, - ntp = Ntp.Meta, - server = NtpServer.Meta, - ntpTime = NtpServer.NtpTime() - ), - NtpCompat( - brand = Brand.Microsoft, - ntp = Ntp.Microsoft, - server = NtpServer.Microsoft, - ntpTime = NtpServer.NtpTime() - ), - NtpCompat( - brand = Brand.TencentCloud, - ntp = Ntp.Tencent, - server = NtpServer.Tencent, - ntpTime = NtpServer.NtpTime() - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpItem.kt deleted file mode 100644 index 3d822a8..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpItem.kt +++ /dev/null @@ -1,144 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.ntp.component - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.component.LabelText -import dev.sanmer.authenticator.ui.ktx.surface -import dev.sanmer.authenticator.ui.screens.ntp.NtpViewModel -import kotlin.time.Duration -import kotlin.time.DurationUnit - -@Composable -fun NtpItem( - ntp: NtpViewModel.NtpCompat, - selected: Boolean, - enabled: Boolean = true, - onClick: () -> Unit = {} -) = Row( - modifier = Modifier - .fillMaxWidth() - .surface( - shape = MaterialTheme.shapes.large, - backgroundColor = MaterialTheme.colorScheme.surface, - border = CardDefaults.outlinedCardBorder(false) - ) - .clickable( - enabled = enabled, - onClick = onClick - ) - .padding(all = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) -) { - Image( - painter = painterResource(id = ntp.brand.res), - contentDescription = null, - modifier = Modifier.size(40.dp) - ) - - Column( - horizontalAlignment = Alignment.Start - ) { - Text( - text = ntp.ntp.name, - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = ntp.server.address, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - - Row( - modifier = Modifier.padding(top = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - RTTLabel(rtt = ntp.ntpTime.rtt) - OffsetLabel(offset = ntp.ntpTime.offset) - } - } - - if (selected) { - Spacer(Modifier.weight(1f)) - - Icon( - painter = painterResource(id = R.drawable.circle_check_filled), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(30.dp) - .align(Alignment.Top) - ) - } -} - -@Composable -private fun OffsetLabel( - offset: Duration -) { - val valueStr by remember(offset) { - derivedStateOf { offset.toString(DurationUnit.MILLISECONDS) } - } - - LabelText( - text = stringResource(id = R.string.ntp_offset, valueStr) - ) -} - -@Composable -private fun RTTLabel( - rtt: Duration -) { - val value by remember(rtt) { - derivedStateOf { rtt.toLong(DurationUnit.MILLISECONDS) } - } - val valueStr by remember(rtt) { - derivedStateOf { rtt.toString(DurationUnit.MILLISECONDS) } - } - - LabelText( - text = if (rtt != Duration.INFINITE) { - stringResource(id = R.string.ntp_rtt, valueStr) - } else { - stringResource(id = R.string.ntp_timeout) - }, - containerColor = if (isSystemInDarkTheme()) { - when (value) { - in 0..<100 -> colorResource(R.color.material_green_900) - in 100..500 -> colorResource(R.color.material_yellow_900) - else -> colorResource(R.color.material_red_900) - } - } else { - when (value) { - in 0..<100 -> colorResource(R.color.material_green_300) - in 100..500 -> colorResource(R.color.material_yellow_300) - else -> colorResource(R.color.material_red_300) - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt deleted file mode 100644 index 04e9ce2..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/ntp/component/NtpList.kt +++ /dev/null @@ -1,45 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.ntp.component - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.datastore.compose.LocalPreference -import dev.sanmer.authenticator.datastore.model.Ntp -import dev.sanmer.authenticator.ui.ktx.plus -import dev.sanmer.authenticator.ui.screens.ntp.NtpViewModel - -@Composable -fun NtpList( - state: LazyListState, - ntps: List, - setNtp: (Ntp) -> Unit, - contentPadding: PaddingValues = PaddingValues(0.dp) -) { - val preference = LocalPreference.current - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - state = state, - contentPadding = contentPadding + PaddingValues(15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - items(ntps) { - NtpItem( - ntp = it, - selected = preference.ntp == it.ntp, - onClick = { setNtp(it.ntp) } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanScreen.kt deleted file mode 100644 index 19b1bfe..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanScreen.kt +++ /dev/null @@ -1,233 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.scan - -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.compose.CameraXViewfinder -import androidx.camera.core.SurfaceOrientedMeteringPointFactory -import androidx.camera.viewfinder.compose.MutableCoordinateTransformer -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.screens.Screen -import dev.sanmer.otp.OtpUri.Default.isOtpUri - -@Composable -fun ScanScreen( - viewModel: ScanViewModel, - goTo: (Screen) -> Unit, - goBack: () -> Unit -) { - val context = LocalContext.current - val text by viewModel.text.collectAsStateWithLifecycle() - - DisposableEffect(text) { - if (text.isOtpUri()) goTo(Screen.Edit(uri = text)) - onDispose { viewModel.rewind() } - } - - Box( - modifier = Modifier.fillMaxSize() - ) { - Crossfade( - targetState = viewModel.isAllowed, - animationSpec = tween(600) - ) { isAllowed -> - if (isAllowed) { - CameraXPreview( - viewModel = viewModel, - modifier = Modifier.fillMaxSize() - ) - } else { - CameraOff( - onClick = { viewModel.requestPermission(context) }, - modifier = Modifier.fillMaxSize() - ) - } - } - - ActionButtons( - modifier = Modifier.align(Alignment.BottomEnd), - scanImage = viewModel::scanImage, - onBack = goBack, - torchEnabled = viewModel.torchEnabled, - enableTorch = viewModel::enableTorch - ) - } -} - -@Composable -private fun CameraOff( - onClick: () -> Unit, - modifier: Modifier = Modifier -) = Box( - modifier = modifier - .background( - color = MaterialTheme.colorScheme.surfaceContainer - ) - .clickable( - enabled = true, - onClick = onClick - ), - contentAlignment = Alignment.Center -) { - Icon( - painter = painterResource(id = R.drawable.camera_off), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(45.dp), - ) -} - -@Composable -private fun CameraXPreview( - viewModel: ScanViewModel, - modifier: Modifier = Modifier, -) { - val lifecycleOwner = LocalLifecycleOwner.current - val coordinateTransformer = remember { MutableCoordinateTransformer() } - val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle() - - LaunchedEffect(lifecycleOwner) { - viewModel.bindToCamera(lifecycleOwner) - } - - val request = surfaceRequest ?: return - val meteringFactory by remember(request) { - derivedStateOf { - with(request.resolution) { - SurfaceOrientedMeteringPointFactory(width.toFloat(), height.toFloat()) - } - } - } - CameraXViewfinder( - surfaceRequest = request, - coordinateTransformer = coordinateTransformer, - modifier = modifier - .pointerInput(true) { - detectTapGestures { offset -> - val surfacePoint = with(coordinateTransformer) { - offset.transform() - } - val focusPoint = meteringFactory.createPoint( - surfacePoint.x, - surfacePoint.y - ) - viewModel.startFocusAndMetering(focusPoint) - } - } - .pointerInput(true) { - detectTransformGestures { _, _, zoom, _ -> - viewModel.setZoomRatio(zoom) - } - } - ) -} - -@Composable -private fun ActionButtons( - modifier: Modifier = Modifier, - onBack: () -> Unit, - scanImage: (Context, Uri) -> Unit, - torchEnabled: Boolean, - enableTorch: (Boolean) -> Unit -) { - val context = LocalContext.current - val pickImage = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickVisualMedia(), - onResult = { if (it != null) scanImage(context, it) } - ) - - Row( - modifier = modifier - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(all = 20.dp), - verticalAlignment = Alignment.Bottom - ) { - ActionButton( - onClick = { - pickImage.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - ) { - Icon( - painter = painterResource(id = R.drawable.photo), - contentDescription = null - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - Column( - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - ActionButton( - onClick = { enableTorch(!torchEnabled) } - ) { - Icon( - painter = painterResource(id = if (torchEnabled) R.drawable.bolt else R.drawable.bolt_off), - contentDescription = null - ) - } - - ActionButton( - onClick = onBack - ) { - Icon( - painter = painterResource(id = R.drawable.x), - contentDescription = null - ) - } - } - } -} - -@Composable -private fun ActionButton( - onClick: () -> Unit, - content: @Composable () -> Unit -) = FloatingActionButton( - onClick = onClick, - containerColor = FloatingActionButtonDefaults.containerColor.copy(alpha = 0.8f), - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), - shape = CircleShape, - content = content -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanViewModel.kt deleted file mode 100644 index 466adc6..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/scan/ScanViewModel.kt +++ /dev/null @@ -1,157 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.scan - -import android.Manifest -import android.app.Application -import android.content.Context -import android.net.Uri -import androidx.camera.core.Camera -import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA -import androidx.camera.core.FocusMeteringAction -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.MeteringPoint -import androidx.camera.core.Preview -import androidx.camera.core.SurfaceRequest -import androidx.camera.core.TorchState -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.lifecycle.awaitInstance -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ViewModel -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.compat.PermissionCompat -import dev.sanmer.qrcode.QRCode -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import java.nio.ByteBuffer -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit - -class ScanViewModel( - private val context: Application -) : ViewModel() { - private val _isAllowed: Boolean - get() = PermissionCompat.checkPermission( - context, Manifest.permission.CAMERA - ) - var isAllowed by mutableStateOf(_isAllowed) - private set - - private val _surfaceRequest = MutableStateFlow(null) - val surfaceRequest: StateFlow = _surfaceRequest - - private val preview = Preview.Builder().build().apply { - setSurfaceProvider { surfaceRequest -> - _surfaceRequest.update { surfaceRequest } - } - } - - private val _text = MutableStateFlow("") - val text = _text.asStateFlow() - - private val imageAnalysis = ImageAnalysis.Builder().apply { - setBackgroundExecutor(Executors.newSingleThreadExecutor()) - }.build().apply { - setAnalyzer(context.mainExecutor) { image -> - try { - val plane = image.planes.first() - val data = plane.buffer.asByteArray() - val content = QRCode.decodeFromYuv( - yuvData = data, - dataWidth = plane.rowStride, - dataHeight = image.height, - width = image.width, - height = image.height, - ) - _text.update { content } - } catch (_: Throwable) { - - } finally { - image.close() - } - } - } - - private var camera: Camera? = null - var torchEnabled by mutableStateOf(false) - private set - - private val logger = Logger.Android("ScanViewModel") - - init { - logger.d("init") - } - - fun requestPermission(context: Context) { - PermissionCompat.requestPermission(context, Manifest.permission.CAMERA) { - isAllowed = it - } - } - - suspend fun bindToCamera(lifecycleOwner: LifecycleOwner) { - val processCameraProvider = ProcessCameraProvider.awaitInstance(context) - val camera = processCameraProvider.bindToLifecycle( - lifecycleOwner, DEFAULT_BACK_CAMERA, preview, imageAnalysis - ).also { camera = it } - camera.cameraInfo.torchState.observe(lifecycleOwner) { - torchEnabled = it == TorchState.ON - } - - try { - awaitCancellation() - } finally { - camera.cameraInfo.torchState.removeObservers(lifecycleOwner) - processCameraProvider.unbindAll() - } - } - - fun enableTorch(enabled: Boolean) { - val camera = camera ?: return - camera.cameraControl.enableTorch(enabled) - } - - fun startFocusAndMetering(focusPoint: MeteringPoint) { - val camera = camera ?: return - val action = FocusMeteringAction.Builder( - focusPoint, - FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE - ).setAutoCancelDuration(3, TimeUnit.SECONDS).build() - camera.cameraControl.startFocusAndMetering(action) - } - - fun setZoomRatio(zoom: Float) { - val camera = camera ?: return - val zoomState = camera.cameraInfo.zoomState.value ?: return - val newRatio = (zoomState.zoomRatio * zoom).coerceIn( - zoomState.minZoomRatio, - zoomState.maxZoomRatio - ) - camera.cameraControl.setZoomRatio(newRatio) - } - - fun rewind() { - _text.update { "" } - } - - fun scanImage(context: Context, uri: Uri) { - runCatching { - val cr = context.contentResolver - checkNotNull(cr.openInputStream(uri)).use(QRCode::decodeFromStream) - }.onSuccess { content -> - content?.let { _text.update { content } } - }.onFailure { - logger.e(it) - } - } - - private fun ByteBuffer.asByteArray(): ByteArray { - rewind() - val dst = ByteArray(remaining()) - get(dst) - return dst - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/SecurityScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/SecurityScreen.kt deleted file mode 100644 index 42f2c27..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/SecurityScreen.kt +++ /dev/null @@ -1,122 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.security - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.datastore.compose.LocalPreference -import dev.sanmer.authenticator.ui.AuthorizeActivity -import dev.sanmer.authenticator.ui.screens.security.component.SecurityItem -import dev.sanmer.crypto.BiometricKey - -@Composable -fun SecurityScreen( - goBack: () -> Unit -) { - val context = LocalContext.current - val preference = LocalPreference.current - - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - - Scaffold( - topBar = { - TopBar( - onBack = goBack, - scrollBehavior = scrollBehavior - ) - } - ) { contentPadding -> - Column( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .verticalScroll(rememberScrollState()) - .padding(contentPadding) - ) { - SecurityItem( - icon = R.drawable.shield, - title = stringResource(id = R.string.security_password), - desc = stringResource( - id = if (preference.isEncrypted) { - R.string.security_password_mask - } else { - R.string.security_password_empty - } - ), - onClick = { - val action = if (preference.isEncrypted) { - AuthorizeActivity.Action.ChangePassword - } else { - AuthorizeActivity.Action.SetupPassword - } - AuthorizeActivity.start(context, action) {} - } - ) - - SecurityItem( - icon = R.drawable.fingerprint, - title = stringResource(id = R.string.security_biometric), - desc = stringResource(id = R.string.security_biometric_desc), - enabled = preference.isEncrypted && BiometricKey.canAuthenticate(context), - onClick = { - val action = if (preference.isBiometric) { - AuthorizeActivity.Action.RemoveBiometric - } else { - AuthorizeActivity.Action.SetupBiometric - } - AuthorizeActivity.start(context, action) {} - }, - trailing = { - Switch( - checked = preference.isBiometric, - onCheckedChange = null - ) - } - ) - - SecurityItem( - icon = R.drawable.shield_off, - title = stringResource(id = R.string.security_remove_password), - desc = stringResource(id = R.string.security_remove_password_desc), - enabled = preference.isEncrypted, - onClick = { - val action = AuthorizeActivity.Action.RemovePassword - AuthorizeActivity.start(context, action) {} - } - ) - } - } -} - -@Composable -private fun TopBar( - onBack: () -> Unit, - scrollBehavior: TopAppBarScrollBehavior -) = TopAppBar( - title = { Text(text = stringResource(id = R.string.settings_security)) }, - navigationIcon = { - IconButton( - onClick = onBack - ) { - Icon( - painter = painterResource(id = R.drawable.arrow_left), - contentDescription = null - ) - } - }, - scrollBehavior = scrollBehavior -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/component/SecurityItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/component/SecurityItem.kt deleted file mode 100644 index 72e15ca..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/security/component/SecurityItem.kt +++ /dev/null @@ -1,75 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.security.component - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp - -@Composable -fun SecurityItem( - @DrawableRes icon: Int, - title: String, - desc: String, - enabled: Boolean = true, - onClick: () -> Unit = {}, - trailing: @Composable (() -> Unit)? = null, -) = Row( - modifier = Modifier - .clickable( - enabled = enabled, - onClick = onClick - ) - .alpha(if (enabled) 1f else 0.5f) - .padding(vertical = 20.dp, horizontal = 25.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp) -) { - Box( - modifier = Modifier - .border( - width = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant, - shape = CircleShape - ) - .size(40.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = icon), - contentDescription = null - ) - } - - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.Start - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - } - - trailing?.invoke() -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt deleted file mode 100644 index 15dc7cd..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsScreen.kt +++ /dev/null @@ -1,219 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.settings - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.Const -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ktx.viewUrl -import dev.sanmer.authenticator.ui.screens.Screen -import dev.sanmer.authenticator.ui.screens.settings.SettingsViewModel.BottomSheet -import dev.sanmer.authenticator.ui.screens.settings.component.DatabaseItem -import dev.sanmer.authenticator.ui.screens.settings.component.PreferenceItem -import dev.sanmer.authenticator.ui.screens.settings.component.SettingIcon -import dev.sanmer.authenticator.ui.screens.settings.component.SettingItem -import dev.sanmer.authenticator.ui.screens.settings.component.TokenItem -import dev.sanmer.authenticator.ui.screens.settings.component.ToolItem - -@Composable -fun SettingsScreen( - viewModel: SettingsViewModel, - goTo: (Screen) -> Unit, - goBack: () -> Unit, -) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - - when (viewModel.bottomSheet) { - BottomSheet.Closed -> Unit - BottomSheet.Token -> TokenItem( - onDismiss = viewModel::closeBottomSheet, - onEdit = { goTo(Screen.Edit()) }, - onScan = { goTo(Screen.Scan) } - ) - - BottomSheet.Database -> DatabaseItem( - onDismiss = viewModel::closeBottomSheet, - isEmpty = viewModel.isEmpty, - importJson = viewModel::importJson, - importUri = viewModel::importUri, - encrypt = viewModel::encrypt, - exportJson = viewModel::exportJson, - exportUri = viewModel::exportUri, - ) - - BottomSheet.Tool -> ToolItem( - onDismiss = viewModel::closeBottomSheet, - onEncode = { goTo(Screen.Encode) }, - decryptFromJson = viewModel::decryptFromJson, - decryptedToJson = viewModel::decryptedToJson, - ) - - BottomSheet.Preference -> PreferenceItem( - onDismiss = viewModel::closeBottomSheet, - onSecurity = { goTo(Screen.Security) }, - onNtp = { goTo(Screen.Ntp) } - ) - } - - Scaffold( - topBar = { - TopBar( - onBack = goBack, - scrollBehavior = scrollBehavior - ) - }, - floatingActionButton = { - AnimatedVisibility( - visible = viewModel.bottomSheet.isClosed, - enter = fadeIn() + scaleIn(), - exit = scaleOut() + fadeOut() - ) { - ActionButton( - onClick = { goTo(Screen.Trash) } - ) - } - } - ) { contentPadding -> - Column( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .verticalScroll(rememberScrollState()) - .padding(contentPadding) - .padding(all = 15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp) - ) { - SettingItem( - icon = { - SettingIcon( - icon = R.drawable.key, - color = if (isSystemInDarkTheme()) { - colorResource(id = R.color.material_orange_900) - } else { - colorResource(id = R.color.material_orange_300) - } - ) - }, - title = stringResource(id = R.string.settings_token), - text = stringResource(id = R.string.settings_token_desc), - onClick = { viewModel.updateBottomSheet { BottomSheet.Token } } - ) - - SettingItem( - icon = { - SettingIcon( - icon = R.drawable.database, - color = if (isSystemInDarkTheme()) { - colorResource(id = R.color.material_green_900) - } else { - colorResource(id = R.color.material_green_300) - } - ) - }, - title = stringResource(id = R.string.settings_import_export), - text = stringResource(id = R.string.settings_import_export_desc), - onClick = { viewModel.updateBottomSheet { BottomSheet.Database } } - ) - - SettingItem( - icon = { - SettingIcon( - icon = R.drawable.tool, - color = if (isSystemInDarkTheme()) { - colorResource(id = R.color.material_blue_900) - } else { - colorResource(id = R.color.material_blue_300) - } - ) - }, - title = stringResource(id = R.string.settings_tools), - text = stringResource(id = R.string.settings_tools_desc), - onClick = { viewModel.updateBottomSheet { BottomSheet.Tool } } - ) - - SettingItem( - icon = { - SettingIcon( - icon = R.drawable.mood_heart, - color = if (isSystemInDarkTheme()) { - colorResource(id = R.color.material_deep_orange_900) - } else { - colorResource(id = R.color.material_deep_orange_300) - } - ) - }, - title = stringResource(id = R.string.settings_preference), - text = stringResource(id = R.string.settings_preference_desc), - onClick = { viewModel.updateBottomSheet { BottomSheet.Preference } } - ) - } - } -} - -@Composable -private fun ActionButton( - onClick: () -> Unit -) { - FloatingActionButton( - onClick = onClick - ) { - Icon( - painter = painterResource(id = R.drawable.trash), - contentDescription = null - ) - } -} - -@Composable -private fun TopBar( - onBack: () -> Unit, - scrollBehavior: TopAppBarScrollBehavior -) = TopAppBar( - title = { Text(text = stringResource(id = R.string.settings_title)) }, - navigationIcon = { - IconButton( - onClick = onBack - ) { - Icon( - painter = painterResource(id = R.drawable.arrow_left), - contentDescription = null - ) - } - }, - actions = { - val context = LocalContext.current - - IconButton( - onClick = { context.viewUrl(Const.GITHUB_URL) } - ) { - Icon( - painter = painterResource(id = R.drawable.brand_github_2), - contentDescription = null - ) - } - }, - scrollBehavior = scrollBehavior -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsViewModel.kt deleted file mode 100644 index f5f0894..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/SettingsViewModel.kt +++ /dev/null @@ -1,257 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.settings - -import android.content.Context -import android.net.Uri -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.authenticator.model.serializer.AuthJson -import dev.sanmer.authenticator.model.serializer.AuthUri -import dev.sanmer.authenticator.repository.DbRepository -import dev.sanmer.authenticator.ui.CryptoActivity -import dev.sanmer.encoding.isBase32 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.InputStream -import java.io.OutputStream - -class SettingsViewModel( - private val dbRepository: DbRepository -) : ViewModel() { - private var entities by mutableStateOf(emptyList()) - val isEmpty get() = entities.isEmpty() - - private var tmp = emptyList() - - var bottomSheet by mutableStateOf(BottomSheet.Closed) - private set - - private val logger = Logger.Android("SettingsViewModel") - - init { - logger.d("init") - dataObserver() - } - - private fun dataObserver() { - viewModelScope.launch { - dbRepository.getTotpAllDecryptedAsFlow() - .collect { list -> - entities = list - } - } - } - - fun updateBottomSheet(block: (BottomSheet) -> BottomSheet) { - bottomSheet = block(bottomSheet) - } - - fun closeBottomSheet() { - bottomSheet = BottomSheet.Closed - } - - private suspend fun import( - fileType: FileType, - context: Context, - uri: Uri, - bypass: Boolean, - ) = withContext(Dispatchers.IO) { - runCatching { - val cr = context.contentResolver - val entities = checkNotNull(cr.openInputStream(uri)).use(fileType::decodeFrom) - when (fileType) { - FileType.Json -> { - val secrets = CryptoActivity.decrypt( - context = context, - input = entities.map { it.secret }, - bypass = bypass - ) - entities.mapIndexed { index, it -> - it.copy(secret = secrets[index]) - } - } - - FileType.Uri -> entities - } - }.onFailure { - logger.e(it) - } - } - - private fun import( - fileType: FileType, - context: Context, - uri: Uri - ) { - viewModelScope.launch { - import( - fileType = fileType, - context = context, - uri = uri, - bypass = true - ).onSuccess { entities -> - dbRepository.insertTotp( - entities.filter { it.secret.isBase32() } - ) - } - } - } - - fun importJson( - context: Context, - uri: Uri - ) = import( - fileType = FileType.Json, - context = context, - uri - ) - - fun importUri( - context: Context, - uri: Uri - ) = import( - fileType = FileType.Uri, - context = context, - uri - ) - - private suspend fun export( - entities: List, - fileType: FileType, - context: Context, - uri: Uri, - ) = withContext(Dispatchers.IO) { - runCatching { - val cr = context.contentResolver - checkNotNull(cr.openOutputStream(uri)).use { fileType.decodeTo(entities, it) } - }.onFailure { - logger.e(it) - } - } - - fun encrypt( - context: Context, - onReady: () -> Unit - ) { - viewModelScope.launch { - runCatching { - val secrets = CryptoActivity.encrypt( - context = context, - input = entities.map { it.secret } - ) - tmp = entities.mapIndexed { index, it -> - it.copy(secret = secrets[index]) - } - }.onSuccess { - onReady() - } - } - } - - private fun export( - fileType: FileType, - context: Context, - uri: Uri - ) { - viewModelScope.launch { - export( - entities = tmp, - fileType = fileType, - context = context, - uri = uri, - ) - tmp = emptyList() - } - } - - fun exportJson( - context: Context, - uri: Uri - ) = export( - fileType = FileType.Json, - context = context, - uri = uri - ) - - fun exportUri( - context: Context, - uri: Uri - ) = export( - fileType = FileType.Uri, - context = context, - uri = uri - ) - - fun decryptFromJson( - context: Context, - uri: Uri, - onReady: () -> Unit - ) { - viewModelScope.launch { - import( - fileType = FileType.Json, - context = context, - uri = uri, - bypass = false, - ).onSuccess { entities -> - tmp = entities - onReady() - } - } - } - - fun decryptedToJson( - context: Context, - uri: Uri, - ) { - viewModelScope.launch { - export( - entities = tmp, - fileType = FileType.Json, - context = context, - uri = uri, - ) - tmp = emptyList() - } - } - - private sealed interface FileType { - fun decodeFrom(input: InputStream): List - fun decodeTo(auths: List, output: OutputStream) - - data object Uri : FileType { - override fun decodeFrom(input: InputStream): List { - return AuthUri.decodeFrom(input).totp - } - - override fun decodeTo(auths: List, output: OutputStream) { - AuthUri(auths).encodeTo(output) - } - } - - data object Json : FileType { - override fun decodeFrom(input: InputStream): List { - return AuthJson.decodeFrom(input).entities() - } - - override fun decodeTo(auths: List, output: OutputStream) { - AuthJson.entities(auths).encodeTo(output) - } - } - } - - enum class BottomSheet { - Closed, - Token, - Database, - Tool, - Preference; - - val isClosed inline get() = this == Closed - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/DatabaseItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/DatabaseItem.kt deleted file mode 100644 index 69197e8..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/DatabaseItem.kt +++ /dev/null @@ -1,70 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.settings.component - -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.model.serializer.AuthJson -import dev.sanmer.authenticator.model.serializer.AuthUri - -@Composable -fun DatabaseItem( - onDismiss: () -> Unit, - isEmpty: Boolean, - importJson: (Context, Uri) -> Unit, - importUri: (Context, Uri) -> Unit, - encrypt: (Context, () -> Unit) -> Unit, - exportJson: (Context, Uri) -> Unit, - exportUri: (Context, Uri) -> Unit, -) { - val context = LocalContext.current - val jsonImporter = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { if (it != null) importJson(context, it) } - ) - val jsonExporter = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(AuthJson.MIME_TYPE), - onResult = { if (it != null) exportJson(context, it) } - ) - val uriImporter = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { if (it != null) importUri(context, it) } - ) - val uriExporter = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(AuthUri.MIME_TYPE), - onResult = { if (it != null) exportUri(context, it) } - ) - - SettingBottomSheet( - onDismiss = onDismiss, - title = stringResource(id = R.string.settings_import_export) - ) { - SettingItem( - icon = R.drawable.database_import, - title = stringResource(id = R.string.settings_import_json), - onClick = { jsonImporter.launch(AuthJson.MIME_TYPE) } - ) - - if (!isEmpty) SettingItem( - icon = R.drawable.database_export, - title = stringResource(id = R.string.settings_export_json), - onClick = { encrypt(context) { jsonExporter.launch(AuthJson.FILE_NAME) } } - ) - - SettingItem( - icon = R.drawable.file_export, - title = stringResource(id = R.string.settings_import_txt), - onClick = { uriImporter.launch(AuthUri.MIME_TYPE) } - ) - - if (!isEmpty) SettingItem( - icon = R.drawable.file_import, - title = stringResource(id = R.string.settings_export_txt), - onClick = { uriExporter.launch(AuthUri.FILE_NAME) } - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt deleted file mode 100644 index baa33cf..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/PreferenceItem.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.settings.component - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R - -@Composable -fun PreferenceItem( - onDismiss: () -> Unit, - onSecurity: () -> Unit, - onNtp: () -> Unit -) { - SettingBottomSheet( - onDismiss = onDismiss, - title = stringResource(id = R.string.settings_preference) - ) { - SettingItem( - icon = R.drawable.shield, - title = stringResource(id = R.string.settings_security), - onClick = { - onSecurity() - onDismiss() - } - ) - - SettingItem( - icon = R.drawable.timezone, - title = stringResource(id = R.string.settings_ntp_server), - onClick = { - onNtp() - onDismiss() - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/SettingItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/SettingItem.kt deleted file mode 100644 index 33f4224..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/SettingItem.kt +++ /dev/null @@ -1,129 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.settings.component - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.ui.component.DragHandle -import dev.sanmer.authenticator.ui.component.Logo -import dev.sanmer.authenticator.ui.ktx.bottom -import dev.sanmer.authenticator.ui.ktx.surface - -@Composable -fun SettingItem( - icon: @Composable () -> Unit, - title: String, - text: String, - onClick: () -> Unit -) = Row( - modifier = Modifier - .surface( - shape = MaterialTheme.shapes.large, - backgroundColor = MaterialTheme.colorScheme.surfaceContainer, - border = CardDefaults.outlinedCardBorder(false) - ) - .clickable(onClick = onClick) - .padding(all = 20.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically -) { - icon() - - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - } -} - -@Composable -fun SettingIcon( - @DrawableRes icon: Int, - color: Color -) = Logo( - icon = icon, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - containerColor = color, - modifier = Modifier.size(40.dp) -) - -@Composable -fun SettingItem( - @DrawableRes icon: Int, - title: String, - onClick: () -> Unit, - enabled: Boolean = true -) = Row( - modifier = Modifier - .surface( - shape = MaterialTheme.shapes.medium, - backgroundColor = MaterialTheme.colorScheme.surfaceContainer, - border = CardDefaults.outlinedCardBorder(false) - ) - .clickable(enabled = enabled, onClick = onClick) - .padding(all = 15.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) -) { - Icon( - painter = painterResource(id = icon), - contentDescription = null - ) - - Text( - text = title, - style = MaterialTheme.typography.titleSmall - ) -} - -@Composable -fun SettingBottomSheet( - onDismiss: () -> Unit, - title: String, - content: @Composable ColumnScope.() -> Unit -) = ModalBottomSheet( - onDismissRequest = onDismiss, - shape = MaterialTheme.shapes.large.bottom(0.dp), - dragHandle = null -) { - DragHandle() - - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - - Column( - modifier = Modifier.padding(all = 20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt deleted file mode 100644 index d726ae7..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/TokenItem.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.settings.component - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R - -@Composable -fun TokenItem( - onDismiss: () -> Unit, - onEdit: () -> Unit, - onScan: () -> Unit -) { - SettingBottomSheet( - onDismiss = onDismiss, - title = stringResource(id = R.string.settings_token) - ) { - SettingItem( - icon = R.drawable.edit, - title = stringResource(id = R.string.settings_enter), - onClick = { - onEdit() - onDismiss() - } - ) - - SettingItem( - icon = R.drawable.scan, - title = stringResource(id = R.string.settings_scan), - onClick = { - onScan() - onDismiss() - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt deleted file mode 100644 index cb501e6..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/settings/component/ToolItem.kt +++ /dev/null @@ -1,53 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.settings.component - -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.model.serializer.AuthJson - -@Composable -fun ToolItem( - onDismiss: () -> Unit, - onEncode: () -> Unit, - decryptFromJson: (Context, Uri, () -> Unit) -> Unit, - decryptedToJson: (Context, Uri) -> Unit -) { - val context = LocalContext.current - val jsonExporter = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(AuthJson.MIME_TYPE), - onResult = { if (it != null) decryptedToJson(context, it) } - ) - val jsonImporter = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { - if (it != null) decryptFromJson(context, it) { - jsonExporter.launch(AuthJson.FILE_NAME) - } - } - ) - - SettingBottomSheet( - onDismiss = onDismiss, - title = stringResource(id = R.string.settings_tools), - ) { - SettingItem( - icon = R.drawable.lock_open, - title = stringResource(id = R.string.settings_decrypt), - onClick = { jsonImporter.launch(AuthJson.MIME_TYPE) } - ) - - SettingItem( - icon = R.drawable.a_b, - title = stringResource(id = R.string.settings_encode_decode), - onClick = { - onEncode() - onDismiss() - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashScreen.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashScreen.kt deleted file mode 100644 index 98fde2a..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashScreen.kt +++ /dev/null @@ -1,94 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.trash - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.ui.component.PageIndicator -import dev.sanmer.authenticator.ui.screens.trash.component.AuthList - -@Composable -fun TrashScreen( - viewModel: TrashViewModel, - goBack: () -> Unit -) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val listState = rememberLazyListState() - - Scaffold( - topBar = { - TopBar( - onBack = goBack, - onRestore = viewModel::restoreAll, - scrollBehavior = scrollBehavior - ) - } - ) { contentPadding -> - Box( - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - if (viewModel.totp.isEmpty() && !viewModel.isPending) { - PageIndicator( - icon = R.drawable.trash, - text = R.string.trash_empty, - modifier = Modifier.padding(contentPadding) - ) - } - - AuthList( - state = listState, - totp = viewModel.totp, - restore = viewModel::restore, - delete = viewModel::delete, - contentPadding = contentPadding - ) - } - } -} - -@Composable -private fun TopBar( - onBack: () -> Unit, - onRestore: () -> Unit, - scrollBehavior: TopAppBarScrollBehavior -) = TopAppBar( - title = { Text(text = stringResource(id = R.string.trash_title)) }, - navigationIcon = { - IconButton( - onClick = onBack - ) { - Icon( - painter = painterResource(id = R.drawable.arrow_left), - contentDescription = null - ) - } - }, - actions = { - IconButton( - onClick = onRestore - ) { - Icon( - painter = painterResource(id = R.drawable.restore), - contentDescription = null - ) - } - }, - scrollBehavior = scrollBehavior -) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashViewModel.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashViewModel.kt deleted file mode 100644 index 1f1e68f..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/TrashViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.trash - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.sanmer.authenticator.Logger -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.authenticator.repository.DbRepository -import kotlinx.coroutines.launch - -class TrashViewModel( - private val dbRepository: DbRepository -) : ViewModel() { - var loadState by mutableStateOf(LoadState.Pending) - private set - val totp inline get() = loadState.totp - val isPending inline get() = loadState.isPending - - private val logger = Logger.Android("TrashViewModel") - - init { - logger.d("init") - dataObserver() - } - - private fun dataObserver() { - viewModelScope.launch { - dbRepository.getTotpAllDecryptedTrashedAsFlow() - .collect { totp -> - loadState = LoadState.Ready(totp) - } - } - } - - fun restoreAll() { - viewModelScope.launch { - dbRepository.updateTotp(totp.map { it.copy(deletedAt = 0) }) - } - } - - fun restore(entity: TotpEntity) { - viewModelScope.launch { - dbRepository.updateTotp(entity.copy(deletedAt = 0)) - } - } - - fun delete(entity: TotpEntity) { - viewModelScope.launch { - dbRepository.deleteTotp(entity) - } - } - - sealed class LoadState { - abstract val totp: List - - data object Pending : LoadState() { - override val totp = emptyList() - } - - data class Ready( - override val totp: List - ) : LoadState() - - val isPending inline get() = this is Pending - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthItem.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthItem.kt deleted file mode 100644 index ad08efc..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthItem.kt +++ /dev/null @@ -1,150 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.trash.component - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.R -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.authenticator.ktx.hidden -import dev.sanmer.authenticator.ui.component.LabelText -import dev.sanmer.authenticator.ui.component.SwipeContent -import dev.sanmer.authenticator.ui.ktx.surface -import dev.sanmer.logo.Logo -import kotlin.time.DurationUnit - -@Composable -fun AuthItem( - entity: TotpEntity, - onRestore: () -> Unit, - onDelete: () -> Unit -) = SwipeContent( - content = { release -> - AuthItemButtons( - onRestore = { - release() - onRestore() - }, - onDelete = { - release() - onDelete() - } - ) - }, - surface = { - AuthItemContent( - entity = entity - ) - } -) - -@Composable -private fun AuthItemContent( - entity: TotpEntity -) = Row( - modifier = Modifier - .sizeIn(maxWidth = 450.dp) - .fillMaxWidth() - .surface( - shape = MaterialTheme.shapes.large, - backgroundColor = MaterialTheme.colorScheme.surface, - border = CardDefaults.outlinedCardBorder(false) - ) - .padding(all = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) -) { - val logo by remember(entity.issuer) { derivedStateOf { Logo.getOrDefault(entity.issuer) } } - val hiddenSecret by remember { derivedStateOf { entity.secret.hidden() } } - val lifetimeString by remember { - derivedStateOf { entity.lifetime.toString(unit = DurationUnit.HOURS, decimals = 2) } - } - - Image( - painter = painterResource(id = logo.res), - contentDescription = null, - modifier = Modifier.size(40.dp), - colorFilter = if (logo.refillable) { - ColorFilter.tint(MaterialTheme.colorScheme.primary) - } else null - ) - - Column( - modifier = Modifier.fillMaxWidth() - ) { - Row( - verticalAlignment = Alignment.Top - ) { - Text( - text = hiddenSecret, - style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace - ), - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = Modifier.width(10.dp)) - - LabelText(text = lifetimeString) - } - - Text( - text = entity.displayName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - } -} - -@Composable -private fun AuthItemButtons( - onRestore: () -> Unit, - onDelete: () -> Unit -) = Row( - modifier = Modifier.padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp) -) { - FilledTonalIconButton( - onClick = onRestore - ) { - Icon( - painter = painterResource(id = R.drawable.restore), - contentDescription = null - ) - } - - FilledTonalIconButton( - onClick = onDelete, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Icon( - painter = painterResource(id = R.drawable.trash_x), - contentDescription = null - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt deleted file mode 100644 index 41f5ddc..0000000 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/screens/trash/component/AuthList.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.sanmer.authenticator.ui.screens.trash.component - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import dev.sanmer.authenticator.database.entity.TotpEntity -import dev.sanmer.authenticator.ui.ktx.plus - -@Composable -fun AuthList( - state: LazyListState, - totp: List, - restore: (TotpEntity) -> Unit, - delete: (TotpEntity) -> Unit, - contentPadding: PaddingValues = PaddingValues(0.dp) -) = LazyColumn( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - state = state, - contentPadding = contentPadding + PaddingValues(15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - horizontalAlignment = Alignment.CenterHorizontally -) { - items(totp) { - AuthItem( - entity = it, - onRestore = { restore(it) }, - onDelete = { delete(it) } - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Color.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Color.kt new file mode 100644 index 0000000..02d9a04 --- /dev/null +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Color.kt @@ -0,0 +1,106 @@ +package dev.sanmer.authenticator.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color + +val LightColorScheme = ColorScheme( + primary = Color(0xFF000000), + onPrimary = Color(0xFFE2E2E2), + primaryContainer = Color(0xFF3B3B3B), + onPrimaryContainer = Color(0xFFFFFFFF), + inversePrimary = Color(0xFFFFFFFF), + secondary = Color(0xFF5E5E5E), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFD4D4D4), + onSecondaryContainer = Color(0xFF1B1B1B), + tertiary = Color(0xFF3B3B3B), + onTertiary = Color(0xFFE2E2E2), + tertiaryContainer = Color(0xFF747474), + onTertiaryContainer = Color(0xFFFFFFFF), + background = Color(0xFFF9F9F9), + onBackground = Color(0xFF1B1B1B), + surface = Color(0xFFF9F9F9), + onSurface = Color(0xFF1B1B1B), + surfaceVariant = Color(0xFFE2E2E2), + onSurfaceVariant = Color(0xFF474747), + surfaceTint = Color(0xFF000000), + inverseSurface = Color(0xFF131313), + inverseOnSurface = Color(0xFFE2E2E2), + error = Color(0xFFB3261E), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B), + outline = Color(0xFF777777), + outlineVariant = Color(0xFFC6C6C6), + scrim = Color(0xFF000000), + surfaceBright = Color(0xFFF9F9F9), + surfaceDim = Color(0xFFDADADA), + surfaceContainer = Color(0xFFEEEEEE), + surfaceContainerHigh = Color(0xFFE8E8E8), + surfaceContainerHighest = Color(0xFFE2E2E2), + surfaceContainerLow = Color(0xFFF3F3F3), + surfaceContainerLowest = Color(0xFFFFFFFF), + primaryFixed = Color(0xFF5E5E5E), + primaryFixedDim = Color(0xFF474747), + onPrimaryFixed = Color(0xFFFFFFFF), + onPrimaryFixedVariant = Color(0xFFE2E2E2), + secondaryFixed = Color(0xFFC6C6C6), + secondaryFixedDim = Color(0xFFABABAB), + onSecondaryFixed = Color(0xFF1B1B1B), + onSecondaryFixedVariant = Color(0xFF3B3B3B), + tertiaryFixed = Color(0xFF5E5E5E), + tertiaryFixedDim = Color(0xFF474747), + onTertiaryFixed = Color(0xFFFFFFFF), + onTertiaryFixedVariant = Color(0xFFE2E2E2) +) + +val DarkColorScheme = ColorScheme( + primary = Color(0xFFFFFFFF), + onPrimary = Color(0xFF1B1B1B), + primaryContainer = Color(0xFFD4D4D4), + onPrimaryContainer = Color(0xFF000000), + inversePrimary = Color(0xFF000000), + secondary = Color(0xFFC6C6C6), + onSecondary = Color(0xFF1B1B1B), + secondaryContainer = Color(0xFF474747), + onSecondaryContainer = Color(0xFFE2E2E2), + tertiary = Color(0xFFE2E2E2), + onTertiary = Color(0xFF1B1B1B), + tertiaryContainer = Color(0xFF919191), + onTertiaryContainer = Color(0xFF000000), + background = Color(0xFF131313), + onBackground = Color(0xFFE2E2E2), + surface = Color(0xFF131313), + onSurface = Color(0xFFE2E2E2), + surfaceVariant = Color(0xFF474747), + onSurfaceVariant = Color(0xFFC6C6C6), + surfaceTint = Color(0xFFFFFFFF), + inverseSurface = Color(0xFFF9F9F9), + inverseOnSurface = Color(0xFF1B1B1B), + error = Color(0xFFF2B8B5), + onError = Color(0xFF601410), + errorContainer = Color(0xFF8C1D18), + onErrorContainer = Color(0xFFF9DEDC), + outline = Color(0xFF919191), + outlineVariant = Color(0xFF474747), + scrim = Color(0xFF000000), + surfaceBright = Color(0xFF393939), + surfaceDim = Color(0xFF131313), + surfaceContainer = Color(0xFF1F1F1F), + surfaceContainerHigh = Color(0xFF2A2A2A), + surfaceContainerHighest = Color(0xFF353535), + surfaceContainerLow = Color(0xFF1B1B1B), + surfaceContainerLowest = Color(0xFF0E0E0E), + primaryFixed = Color(0xFF5E5E5E), + primaryFixedDim = Color(0xFF474747), + onPrimaryFixed = Color(0xFFFFFFFF), + onPrimaryFixedVariant = Color(0xFFE2E2E2), + secondaryFixed = Color(0xFFC6C6C6), + secondaryFixedDim = Color(0xFFABABAB), + onSecondaryFixed = Color(0xFF1B1B1B), + onSecondaryFixedVariant = Color(0xFF3B3B3B), + tertiaryFixed = Color(0xFF5E5E5E), + tertiaryFixedDim = Color(0xFF474747), + onTertiaryFixed = Color(0xFFFFFFFF), + onTertiaryFixedVariant = Color(0xFFE2E2E2) +) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt index 338c620..cdb8c7b 100644 --- a/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt +++ b/app/src/main/kotlin/dev/sanmer/authenticator/ui/theme/Theme.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import dev.sanmer.authenticator.compat.BuildCompat @Composable fun AppTheme( @@ -21,9 +22,16 @@ fun AppTheme( @Composable private fun colorScheme( - darkMode: Boolean, + darkMode: Boolean = isSystemInDarkTheme(), context: Context = LocalContext.current -) = when { - darkMode -> dynamicDarkColorScheme(context) - else -> dynamicLightColorScheme(context) +) = if (BuildCompat.atLeastS) { + when { + darkMode -> dynamicDarkColorScheme(context) + else -> dynamicLightColorScheme(context) + } +} else { + when { + darkMode -> DarkColorScheme + else -> LightColorScheme + } } diff --git a/app/src/main/res/drawable/arrow_bar_down.xml b/app/src/main/res/drawable/arrow_bar_down.xml deleted file mode 100644 index 8c61928..0000000 --- a/app/src/main/res/drawable/arrow_bar_down.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/arrow_bar_up.xml b/app/src/main/res/drawable/arrow_bar_up.xml deleted file mode 100644 index 97ad2a4..0000000 --- a/app/src/main/res/drawable/arrow_bar_up.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/a_b.xml b/app/src/main/res/drawable/arrow_right.xml similarity index 50% rename from app/src/main/res/drawable/a_b.xml rename to app/src/main/res/drawable/arrow_right.xml index bb5307a..f3b8907 100644 --- a/app/src/main/res/drawable/a_b.xml +++ b/app/src/main/res/drawable/arrow_right.xml @@ -4,21 +4,21 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> diff --git a/app/src/main/res/drawable/arrows_shuffle.xml b/app/src/main/res/drawable/arrows_shuffle.xml new file mode 100644 index 0000000..28704e8 --- /dev/null +++ b/app/src/main/res/drawable/arrows_shuffle.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/app/src/main/res/drawable/bug.xml b/app/src/main/res/drawable/bug.xml deleted file mode 100644 index e35ee57..0000000 --- a/app/src/main/res/drawable/bug.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/caret_down.xml b/app/src/main/res/drawable/caret_down.xml deleted file mode 100644 index ecc4b07..0000000 --- a/app/src/main/res/drawable/caret_down.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/caret_up.xml b/app/src/main/res/drawable/caret_up.xml deleted file mode 100644 index f02df89..0000000 --- a/app/src/main/res/drawable/caret_up.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/chevron_right.xml b/app/src/main/res/drawable/chevron_right.xml new file mode 100644 index 0000000..f84f3a4 --- /dev/null +++ b/app/src/main/res/drawable/chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/clear_all.xml b/app/src/main/res/drawable/clear_all.xml new file mode 100644 index 0000000..51a4ac6 --- /dev/null +++ b/app/src/main/res/drawable/clear_all.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable/clipboard_text.xml b/app/src/main/res/drawable/clipboard_text.xml deleted file mode 100644 index 91b677e..0000000 --- a/app/src/main/res/drawable/clipboard_text.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/clock_record_thin.xml b/app/src/main/res/drawable/clock_record_thin.xml new file mode 100644 index 0000000..3bb230b --- /dev/null +++ b/app/src/main/res/drawable/clock_record_thin.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable/eye.xml b/app/src/main/res/drawable/copyright.xml similarity index 50% rename from app/src/main/res/drawable/eye.xml rename to app/src/main/res/drawable/copyright.xml index 719a32b..ce772a4 100644 --- a/app/src/main/res/drawable/eye.xml +++ b/app/src/main/res/drawable/copyright.xml @@ -4,15 +4,15 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> diff --git a/app/src/main/res/drawable/database_export.xml b/app/src/main/res/drawable/database_export.xml deleted file mode 100644 index 7bf78ea..0000000 --- a/app/src/main/res/drawable/database_export.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml new file mode 100644 index 0000000..ef701dc --- /dev/null +++ b/app/src/main/res/drawable/download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/edit.xml b/app/src/main/res/drawable/edit.xml deleted file mode 100644 index 45a8528..0000000 --- a/app/src/main/res/drawable/edit.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/eye_closed.xml b/app/src/main/res/drawable/eye_closed.xml deleted file mode 100644 index 89db104..0000000 --- a/app/src/main/res/drawable/eye_closed.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/file_export.xml b/app/src/main/res/drawable/file_export.xml deleted file mode 100644 index a1138d0..0000000 --- a/app/src/main/res/drawable/file_export.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/file_import.xml b/app/src/main/res/drawable/file_import.xml deleted file mode 100644 index 1daefc4..0000000 --- a/app/src/main/res/drawable/file_import.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - diff --git a/logo/src/main/res/drawable/normal_default.xml b/app/src/main/res/drawable/fingerprint_thin.xml similarity index 100% rename from logo/src/main/res/drawable/normal_default.xml rename to app/src/main/res/drawable/fingerprint_thin.xml diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml deleted file mode 100644 index 40607ed..0000000 --- a/app/src/main/res/drawable/lock.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/lock_off.xml b/app/src/main/res/drawable/lock_off.xml deleted file mode 100644 index 5a60804..0000000 --- a/app/src/main/res/drawable/lock_off.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/lock_open.xml b/app/src/main/res/drawable/lock_open.xml deleted file mode 100644 index f0dc6ab..0000000 --- a/app/src/main/res/drawable/lock_open.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/mood_heart.xml b/app/src/main/res/drawable/mood_heart.xml deleted file mode 100644 index f80c983..0000000 --- a/app/src/main/res/drawable/mood_heart.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/plus.xml b/app/src/main/res/drawable/plus.xml new file mode 100644 index 0000000..959ea0e --- /dev/null +++ b/app/src/main/res/drawable/plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/scan.xml b/app/src/main/res/drawable/scan.xml deleted file mode 100644 index f3a7162..0000000 --- a/app/src/main/res/drawable/scan.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/refresh.xml b/app/src/main/res/drawable/search.xml similarity index 54% rename from app/src/main/res/drawable/refresh.xml rename to app/src/main/res/drawable/search.xml index 2edf266..27a49cf 100644 --- a/app/src/main/res/drawable/refresh.xml +++ b/app/src/main/res/drawable/search.xml @@ -4,15 +4,15 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + android:strokeLineCap="round" + android:strokeLineJoin="round" /> diff --git a/app/src/main/res/drawable/shield_off.xml b/app/src/main/res/drawable/shield_off.xml deleted file mode 100644 index bc8465b..0000000 --- a/app/src/main/res/drawable/shield_off.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/sparkles_2.xml b/app/src/main/res/drawable/sparkles_2.xml new file mode 100644 index 0000000..aa3458a --- /dev/null +++ b/app/src/main/res/drawable/sparkles_2.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/sparkles_2_off.xml b/app/src/main/res/drawable/sparkles_2_off.xml new file mode 100644 index 0000000..e8222be --- /dev/null +++ b/app/src/main/res/drawable/sparkles_2_off.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/spy.xml b/app/src/main/res/drawable/spy.xml new file mode 100644 index 0000000..581474b --- /dev/null +++ b/app/src/main/res/drawable/spy.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/tool.xml b/app/src/main/res/drawable/tool.xml deleted file mode 100644 index a844205..0000000 --- a/app/src/main/res/drawable/tool.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/upload.xml b/app/src/main/res/drawable/upload.xml new file mode 100644 index 0000000..60a8747 --- /dev/null +++ b/app/src/main/res/drawable/upload.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 63d859c..d271dc9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,70 +1,47 @@ - - 加密 - 解密 - - - 没有令牌 - - 添加令牌 - 编辑令牌 - 名称 - 发行方 - 密钥 - 类型 - 哈希算法 - 位数 - 周期 - 二维码 - - - 设置 - 令牌 - 添加新令牌 - 手动输入 - 扫描二维码 - 导入与导出 - 导入与导出数据库 - 从 JSON 导入 - 导出为 JSON - 从 URI 导入 - 导出为 URI - 工具 - 解密、编码与解码 - 解密 JSON - 编码与解码 - 偏好 - 安全性、时间服务器 - 安全性 - 时间服务器 + 添加令牌 + 编辑令牌 + 名称 + 发行方 + 密钥 + 类型 + 哈希算法 + 位数 + 周期 + 预览 + 保存 + 复制 + 关闭 + + + 设置 + 安全 + 密码 + 未设置 + 已设置 + 移除 + 修改 + 生物特征 + 使用生物认证解锁应用 + 验证身份 + 禁止截图 + 防止应用出现在屏幕截图 + 数据库 + 导入与导出 + 导入与导出令牌 + 其他 + 商标 回收站 - 没有项目 - - - 已编码 - 已解码 - - - 验证身份 - 密码 - 未设置密码 - 生物识别 - 使用生物认证解锁应用 - 删除密码 - 删除当前的密码,同时生物认证也将禁用 - 设置密码 - 修改密码 - 输入密码 - 新密码 - 当前密码 + 空的 + 丢弃于 %1$s - 延迟: %1$s - 偏移: %1$s + 时间服务器 + 自定义 超时 \ No newline at end of file diff --git a/app/src/main/res/values/colors_material.xml b/app/src/main/res/values/colors_material.xml deleted file mode 100644 index 34d2c90..0000000 --- a/app/src/main/res/values/colors_material.xml +++ /dev/null @@ -1,300 +0,0 @@ - - - - - - #FFEBEE - #FFCDD2 - #EF9A9A - #E57373 - #EF5350 - #F44336 - #E53935 - #D32F2F - #C62828 - #B71C1C - #FF8A80 - #FF5252 - #FF1744 - #D50000 - - - #FCE4EC - #F8BBD0 - #F48FB1 - #F06292 - #EC407A - #E91E63 - #D81B60 - #C2185B - #AD1457 - #880E4F - #FF80AB - #FF4081 - #F50057 - #C51162 - - - #F3E5F5 - #E1BEE7 - #CE93D8 - #BA68C8 - #AB47BC - #9C27B0 - #8E24AA - #7B1FA2 - #6A1B9A - #4A148C - #EA80FC - #E040FB - #D500F9 - #AA00FF - - - #EDE7F6 - #D1C4E9 - #B39DDB - #9575CD - #7E57C2 - #673AB7 - #5E35B1 - #512DA8 - #4527A0 - #311B92 - #B388FF - #7C4DFF - #651FFF - #6200EA - - - #E8EAF6 - #C5CAE9 - #9FA8DA - #7986CB - #5C6BC0 - #3F51B5 - #3949AB - #303F9F - #283593 - #1A237E - #8C9EFF - #536DFE - #3D5AFE - #304FFE - - - #E3F2FD - #BBDEFB - #90CAF9 - #64B5F6 - #42A5F5 - #2196F3 - #1E88E5 - #1976D2 - #1565C0 - #0D47A1 - #82B1FF - #448AFF - #2979FF - #2962FF - - - #E1F5FE - #B3E5FC - #81D4fA - #4fC3F7 - #29B6FC - #03A9F4 - #039BE5 - #0288D1 - #0277BD - #01579B - #80D8FF - #40C4FF - #00B0FF - #0091EA - - - #E0F7FA - #B2EBF2 - #80DEEA - #4DD0E1 - #26C6DA - #00BCD4 - #00ACC1 - #0097A7 - #00838F - #006064 - #84FFFF - #18FFFF - #00E5FF - #00B8D4 - - - #E0F2F1 - #B2DFDB - #80CBC4 - #4DB6AC - #26A69A - #009688 - #00897B - #00796B - #00695C - #004D40 - #A7FFEB - #64FFDA - #1DE9B6 - #00BFA5 - - - #E8F5E9 - #C8E6C9 - #A5D6A7 - #81C784 - #66BB6A - #4CAF50 - #43A047 - #388E3C - #2E7D32 - #1B5E20 - #B9F6CA - #69F0AE - #00E676 - #00C853 - - - #F1F8E9 - #DCEDC8 - #C5E1A5 - #AED581 - #9CCC65 - #8BC34A - #7CB342 - #689F38 - #558B2F - #33691E - #CCFF90 - #B2FF59 - #76FF03 - #64DD17 - - - #F9FBE7 - #F0F4C3 - #E6EE9C - #DCE775 - #D4E157 - #CDDC39 - #C0CA33 - #A4B42B - #9E9D24 - #827717 - #F4FF81 - #EEFF41 - #C6FF00 - #AEEA00 - - - #FFFDE7 - #FFF9C4 - #FFF590 - #FFF176 - #FFEE58 - #FFEB3B - #FDD835 - #FBC02D - #F9A825 - #F57F17 - #FFFF82 - #FFFF00 - #FFEA00 - #FFD600 - - - #FFF8E1 - #FFECB3 - #FFE082 - #FFD54F - #FFCA28 - #FFC107 - #FFB300 - #FFA000 - #FF8F00 - #FF6F00 - #FFE57F - #FFD740 - #FFC400 - #FFAB00 - - - #FFF3E0 - #FFE0B2 - #FFCC80 - #FFB74D - #FFA726 - #FF9800 - #FB8C00 - #F57C00 - #EF6C00 - #E65100 - #FFD180 - #FFAB40 - #FF9100 - #FF6D00 - - - #FBE9A7 - #FFCCBC - #FFAB91 - #FF8A65 - #FF7043 - #FF5722 - #F4511E - #E64A19 - #D84315 - #BF360C - #FF9E80 - #FF6E40 - #FF3D00 - #DD2600 - - - #EFEBE9 - #D7CCC8 - #BCAAA4 - #A1887F - #8D6E63 - #795548 - #6D4C41 - #5D4037 - #4E342E - #3E2723 - - - #FAFAFA - #F5F5F5 - #EEEEEE - #E0E0E0 - #BDBDBD - #9E9E9E - #757575 - #616161 - #424242 - #303030 - #212121 - #000000 - #ffffff - - - #ECEFF1 - #CFD8DC - #B0BBC5 - #90A4AE - #78909C - #607D8B - #546E7A - #455A64 - #37474F - #263238 - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b1f38b..e3601d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,70 +1,47 @@ - - Encrypt - Decrypt - - - No tokens - - Add token - Edit token - Name - Issuer - Secret - Type - Hash - Digits - Period - QR Code - - - Settings - Token - Add new token - Enter manually - Scan QR Code - Import & Export - Import and export database - Import from JSON - Export to JSON - Import from URI - Export to URI - Tools - Decrypt, encode and decode - Decrypt a JSON - Encode & Decode - Preference - Security, time server - Security - Time server + Add token + Edit token + Name + Issuer + Secret + Type + Hash + Digits + Period + Preview + Save + Copy + Close + + + Settings + Security + Password + Not set + Set + Remove + Change + Biometric + Use biometric authentication to unlock app + Verify it\'s you + Prevent screenshots + Prevent app from appearing in screenshots + Database + Import and export + Import and export tokens + Other + Brands Trash - No items - - - Encoded - Decoded - - - Verify it\'s you - Password - No password set - Biometric - Use biometric authentication to unlock app - Remove password - Delete the current password and biometric authentication will be disabled - Set password - Change password - Enter password - New password - Current password + Empty + Trashed at %1$s - RTT: %1$s - Offset: %1$s + Time server + Custom Timeout \ No newline at end of file diff --git a/app/src/main/res/values/strings_untranslatable.xml b/app/src/main/res/values/strings_untranslatable.xml index 186e16b..0b662b5 100644 --- a/app/src/main/res/values/strings_untranslatable.xml +++ b/app/src/main/res/values/strings_untranslatable.xml @@ -1,5 +1,4 @@ - + Authenticator - •••••••• \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2d49003..a1898f6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -22,12 +22,4 @@ true - - \ No newline at end of file diff --git a/logo/build.gradle.kts b/brand/build.gradle.kts similarity index 78% rename from logo/build.gradle.kts rename to brand/build.gradle.kts index 67e7c3e..c119604 100644 --- a/logo/build.gradle.kts +++ b/brand/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "dev.sanmer.logo" + namespace = "dev.sanmer.brand" } dependencies { diff --git a/brand/src/main/kotlin/dev/sanmer/brand/Brand.kt b/brand/src/main/kotlin/dev/sanmer/brand/Brand.kt new file mode 100644 index 0000000..1d459f9 --- /dev/null +++ b/brand/src/main/kotlin/dev/sanmer/brand/Brand.kt @@ -0,0 +1,174 @@ +package dev.sanmer.brand + +import androidx.annotation.DrawableRes + +@Suppress("SpellCheckingInspection") +enum class Brand( + @DrawableRes val id: Int, + private val regex: Lazy +) { + Aliyun( + id = R.drawable.brand_aliyun, + regex = lazy { "(?i)Aliyun(.com)?|Alibaba\\s*Cloud".toRegex() } + ), + Apple( + id = R.drawable.brand_apple, + regex = lazy { "(?i)Apple(.com)?".toRegex() } + ), + AWS( + id = R.drawable.brand_aws, + regex = lazy { "(?i)AWS(.com|.Amazon.com)?|Amazon\\s*Web\\s*Service".toRegex() } + ), + Azure( + id = R.drawable.brand_azure, + regex = lazy { "(?i)Azure(.com|.Microsoft.com)?|Microsoft\\s*Azure".toRegex() } + ), + Binance( + id = R.drawable.brand_binance, + regex = lazy { "(?i)Binance(.com)?".toRegex() } + ), + Bybit( + id = R.drawable.brand_bybit, + regex = lazy { "(?i)Bybit(.com)?".toRegex() } + ), + Cloudflare( + id = R.drawable.brand_cloudflare, + regex = lazy { "(?i)Cloudflare(.com)?".toRegex() } + ), + Coinbase( + id = R.drawable.brand_coinbase, + regex = lazy { "(?i)Coinbase(.com)?".toRegex() } + ), + Crowdin( + id = R.drawable.brand_crowdin, + regex = lazy { "(?i)Crowdin(.com)?".toRegex() } + ), + DigitalOcean( + id = R.drawable.brand_digitalocean, + regex = lazy { "(?i)DigitalOcean(.com)?".toRegex() } + ), + Discord( + id = R.drawable.brand_discord, + regex = lazy { "(?i)Discord(.com)?".toRegex() } + ), + Docker( + id = R.drawable.brand_docker, + regex = lazy { "(?i).*Docker.*".toRegex() } + ), + Facebook( + id = R.drawable.brand_facebook, + regex = lazy { "(?i)Facebook(.com)?".toRegex() } + ), + Fansly( + id = R.drawable.brand_fansly, + regex = lazy { "(?i)Fansly(.com)?".toRegex() } + ), + Gitea( + id = R.drawable.brand_gitea, + regex = lazy { "(?i).*Gitea.*".toRegex() } + ), + GitHub( + id = R.drawable.brand_github, + regex = lazy { "(?i).*GitHub.*".toRegex() } + ), + GitLab( + id = R.drawable.brand_gitlab, + regex = lazy { "(?i).*GitLab.*".toRegex() } + ), + Google( + id = R.drawable.brand_google, + regex = lazy { "(?i)Google(.com)?".toRegex() } + ), + GoogleCloud( + id = R.drawable.brand_googlecloud, + regex = lazy { "(?i)cloud.Google.com|Google\\s*Cloud".toRegex() } + ), + Instagram( + id = R.drawable.brand_instagram, + regex = lazy { "(?i)Instagram(.com)?".toRegex() } + ), + JetBrains( + id = R.drawable.brand_jetbrains, + regex = lazy { "(?i)JetBrains(.com)?".toRegex() } + ), + Lark( + id = R.drawable.brand_lark, + regex = lazy { "(?i)Lark(suite.com)?|Feishu(.cn)?".toRegex() } + ), + Mega( + id = R.drawable.brand_mega, + regex = lazy { "(?i)Mega(.io)?".toRegex() } + ), + Meta( + id = R.drawable.brand_meta, + regex = lazy { "(?i)Meta(.com)?".toRegex() } + ), + Microsoft( + id = R.drawable.brand_microsoft, + regex = lazy { "(?i)Microsoft(.com)?".toRegex() } + ), + OKX( + id = R.drawable.brand_okx, + regex = lazy { "(?i)OKX(.com)?".toRegex() } + ), + OnlyFans( + id = R.drawable.brand_onlyfans, + regex = lazy { "(?i)OnlyFans(.com)?".toRegex() } + ), + OpenAI( + id = R.drawable.brand_openai, + regex = lazy { "(?i)(ChatGPT|OpenAI)(.com)?".toRegex() } + ), + ORCID( + id = R.drawable.brand_orcid, + regex = lazy { "(?i)ORCID(.org)?".toRegex() } + ), + Patreon( + id = R.drawable.brand_patreon, + regex = lazy { "(?i)Patreon(.com)?".toRegex() } + ), + PayPal( + id = R.drawable.brand_paypal, + regex = lazy { "(?i)PayPal(.com)?".toRegex() } + ), + Pixiv( + id = R.drawable.brand_pixiv, + regex = lazy { "(?i)Pixiv(.net|.com)?".toRegex() } + ), + Tencent( + id = R.drawable.brand_tencent, + regex = lazy { "(?i)Tencent(.com)?".toRegex() } + ), + TencentCloud( + id = R.drawable.brand_tencentcloud, + regex = lazy { "(?i)cloud.Tencent.com|Tencent\\s*Cloud".toRegex() } + ), + Threads( + id = R.drawable.brand_threads, + regex = lazy { "(?i)Threads(.com)?".toRegex() } + ), + WhatsApp( + id = R.drawable.brand_whatsapp, + regex = lazy { "(?i)WhatsApp(.com)?".toRegex() } + ), + Wise( + id = R.drawable.brand_wise, + regex = lazy { "(?i)Wise(.com)?".toRegex() } + ), + X( + id = R.drawable.brand_x, + regex = lazy { "(?i)X(.com)?".toRegex() } + ); + + companion object Default { + fun matches(name: String) = entries.firstOrNull { + it.regex.value.matches(name) + } + + fun valueOfOrNull(name: String) = try { + valueOf(name) + } catch (_: IllegalArgumentException) { + null + } + } +} \ No newline at end of file diff --git a/logo/src/main/res/drawable-night/brand_apple.xml b/brand/src/main/res/drawable-night/brand_apple.xml similarity index 88% rename from logo/src/main/res/drawable-night/brand_apple.xml rename to brand/src/main/res/drawable-night/brand_apple.xml index bea619a..5ae256b 100644 --- a/logo/src/main/res/drawable-night/brand_apple.xml +++ b/brand/src/main/res/drawable-night/brand_apple.xml @@ -4,7 +4,7 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M522,282.4C559.4,282.4 606.2,256.3 634.1,221.6C659.3,190.1 677.7,146.2 677.7,102.3C677.7,96.4 677.2,90.4 676.1,85.5C634.6,87.1 584.6,114.2 554.6,150.6C530.9,178.2 509.4,221.6 509.4,266.1C509.4,272.6 510.4,279.1 511,281.2C513.6,281.8 517.8,282.4 522,282.4ZM390.5,938.5C441.5,938.5 464.1,903.3 527.8,903.3C592.5,903.3 606.7,937.4 663.5,937.4C719.3,937.4 756.6,884.3 791.9,832.2C831.3,772.6 847.7,714 848.7,711.3C845,710.2 738.2,665.2 738.2,538.8C738.2,429.3 822.4,380 827.2,376.1C771.4,293.7 686.7,291.6 663.5,291.6C600.9,291.6 549.9,330.6 517.8,330.6C483,330.6 437.3,293.7 383.1,293.7C280,293.7 175.3,381.6 175.3,547.5C175.3,650.5 214.2,759.5 262.1,830C303.1,889.7 338.9,938.5 390.5,938.5Z" /> diff --git a/logo/src/main/res/drawable-night/brand_aws.xml b/brand/src/main/res/drawable-night/brand_aws.xml similarity index 95% rename from logo/src/main/res/drawable-night/brand_aws.xml rename to brand/src/main/res/drawable-night/brand_aws.xml index eb48515..cd43f7d 100644 --- a/logo/src/main/res/drawable-night/brand_aws.xml +++ b/brand/src/main/res/drawable-night/brand_aws.xml @@ -1,12 +1,10 @@ + android:pathData="M244.2,256.4C205.6,256.4 166.3,271.5 159.8,279.7C158.1,283.1 154.1,310.7 163.5,310.7C166.6,310.7 168,311.3 177.1,307C211.3,293.7 232.9,293.9 236,293.9C274.4,290.3 296.6,316.4 293.2,350.3L293.2,370.2C260.7,362.5 242.3,362.2 233.2,362.2C186,359.4 142.3,384.3 142.3,439C142.3,499 195.9,511.8 216.6,510.9C247.6,511.2 277.2,497.3 296.8,473.1C312.5,508.1 322.4,505.8 322.7,505.8C325.5,505.8 327.8,504.7 330.1,503.2L346.3,491.9C349.1,490.2 351.4,487.3 351.7,483.9C351.4,475.6 336.6,462.9 337.8,434.1L337.8,345.4C339.6,321 331.4,296.9 315,278.6C295.3,262.1 269.8,254.2 244.2,256.4ZM795.1,256.4C738.2,256.4 705.5,292 706.4,328.1C706.4,377.6 756.4,393.2 762.1,394.9C810.2,410 816.7,410.5 830.1,421.9C841.5,433.6 840,456.3 823.3,466.3C818.4,469.1 797.7,481.6 750.8,472C735.1,468.8 726.9,465.1 714.1,459.7C710.7,458.6 702.7,456.6 702.7,467.1L702.7,481.1C702.7,487.6 706.7,493.6 712.7,496.4C742.5,511.5 778.3,512 786,512C787.2,512 852.6,512.1 874.4,468C878.9,458.9 890.7,425.6 868.8,397.2C850.6,375.8 834.9,373.6 788.3,359.4C784.3,358.2 749.9,349.4 750.2,325.2C748.5,294.2 790.6,292.5 799.4,293.1C834.9,292.5 852.6,305.9 862.2,306.7C866.5,306.7 868.5,304.2 868.5,298.5L868.5,285.4C868.6,282.3 867.7,279.2 865.9,276.6C854.5,261.8 811,256.4 795.1,256.4ZM363.5,263.5C360.3,264.1 358.1,267.2 358.6,270.4C359.2,274 359.8,277.7 361.2,281.4L424.9,491.6C426.3,498.4 430.9,505.8 440.8,504.7L464.1,504.7C478.3,506.1 480.3,492.4 480.6,491L522.4,315.8L564.8,491.3C565.1,492.7 567.1,506.4 581,504.9L604.6,504.9C614.8,506.1 619.7,498.7 621.1,491.9C692.8,261.3 687.9,276.9 688.5,274.6C689.6,262.7 682.8,263.5 681.7,263.8L656.4,263.8C643.6,262.4 641,274 640.4,276.9L593.2,459.2L550.6,276.9C548.6,263 537.2,263.5 534.4,263.8L512.5,263.8C500,262.7 496.8,272.6 496,276.9L453.6,456.6L408.1,276.9C407,271.2 403.3,262.4 392.2,263.5L363.5,263.5ZM242.5,395.2C263,395.5 280.7,398.6 292.9,401.4C292.9,415.6 293.4,423.6 290.3,436.4C286.3,450.1 268.7,474.8 227.2,475.4C203.3,476.5 187.7,457.7 189.1,436.4C187.7,402.3 222.9,393.8 242.5,395.2L242.5,395.2ZM882.8,569C856.3,569.3 824.9,575.3 801.2,592C793.8,597.1 795.1,604.1 803.3,603.2C830,600 889.5,592.7 900.1,606.3C910.8,619.9 888.4,676 878.4,701.1C875.4,708.6 881.8,711.7 888.7,706C933.2,668.7 944.7,590.6 935.6,579.4C931.1,573.8 909.3,568.6 882.8,569ZM91.6,597.4C85.4,598.3 82.8,606.1 89.2,612.3C200.6,716.8 347.8,768 511.2,768C627.8,768 763.1,729.9 856.5,658.3C871.9,646.3 858.7,628.5 842.9,635.5C738.2,681.8 624.4,704 520.9,704C367.5,704 218.9,672 98.8,599.2C96.7,597.8 94.2,597.2 91.6,597.4Z" /> diff --git a/logo/src/main/res/drawable-night/brand_bybit.xml b/brand/src/main/res/drawable-night/brand_bybit.xml similarity index 81% rename from logo/src/main/res/drawable-night/brand_bybit.xml rename to brand/src/main/res/drawable-night/brand_bybit.xml index 56d38b5..c0ae66e 100644 --- a/logo/src/main/res/drawable-night/brand_bybit.xml +++ b/brand/src/main/res/drawable-night/brand_bybit.xml @@ -4,23 +4,23 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M693.5,369.8l44.1,0l0,219.2l-44.1,0z" /> + android:fillType="nonZero" + android:pathData="M180,654.1L85.5,654.1L85.5,434.9L176.2,434.9C220.2,434.9 245.9,459 245.9,496.5C245.9,520.8 229.4,536.6 218,541.8C231.6,547.9 249.1,561.8 249.1,591C249.1,632 220.2,654.1 180,654.1ZM172.7,473.1L129.6,473.1L129.6,523.6L172.7,523.6C191.4,523.6 201.8,513.5 201.8,498.4C201.8,483.3 191.4,473.1 172.7,473.1ZM175.5,562.1L129.6,562.1L129.6,616L175.5,616C195.5,616 205,603.7 205,588.9C205,574.1 195.5,562.1 175.5,562.1Z" /> + android:fillType="nonZero" + android:pathData="M383.4,564.2L383.4,654.1L339.7,654.1L339.7,564.2L271.9,434.9L319.7,434.9L361.9,523.3L403.4,434.9L451.3,434.9L383.4,564.2Z" /> + android:fillType="nonZero" + android:pathData="M576.2,654.1L481.7,654.1L481.7,434.9L572.4,434.9C616.4,434.9 642.1,459 642.1,496.5C642.1,520.8 625.6,536.6 614.2,541.8C627.8,547.9 645.3,561.8 645.3,591C645.3,632 616.4,654.1 576.2,654.1ZM568.9,473.1L525.8,473.1L525.8,523.6L568.9,523.6C587.6,523.6 598.1,513.5 598.1,498.4C598.1,483.3 587.6,473.1 568.9,473.1ZM571.7,562.1L525.8,562.1L525.8,616L571.7,616C591.7,616 601.2,603.7 601.2,588.9C601.2,574.1 591.7,562.1 571.7,562.1Z" /> + android:fillType="nonZero" + android:pathData="M879.5,473.1L879.5,654.2L835.5,654.2L835.5,473.1L776.5,473.1L776.5,434.9L938.5,434.9L938.5,473.1L879.5,473.1Z" /> diff --git a/brand/src/main/res/drawable-night/brand_crowdin.xml b/brand/src/main/res/drawable-night/brand_crowdin.xml new file mode 100644 index 0000000..da9dd7a --- /dev/null +++ b/brand/src/main/res/drawable-night/brand_crowdin.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/logo/src/main/res/drawable/brand_github.xml b/brand/src/main/res/drawable-night/brand_github.xml similarity index 84% rename from logo/src/main/res/drawable/brand_github.xml rename to brand/src/main/res/drawable-night/brand_github.xml index 7102668..081806b 100644 --- a/logo/src/main/res/drawable/brand_github.xml +++ b/brand/src/main/res/drawable-night/brand_github.xml @@ -1,12 +1,10 @@ + android:pathData="M512,96C276.4,96 85.5,286.9 85.5,522.5C85.5,711.2 207.6,870.7 377.1,927.2C398.4,930.9 406.4,918.1 406.4,906.9C406.4,896.8 405.9,863.2 405.9,827.5C298.8,847.2 271,801.3 262.5,777.3C257.7,765.1 236.9,727.2 218.8,717.1C203.8,709.1 182.5,689.4 218.2,688.9C251.8,688.3 275.8,719.8 283.8,732.6C322.2,797.1 383.5,778.9 408,767.8C411.8,740 423,721.4 435.2,710.7C340.3,700.1 241.2,663.3 241.2,500.1C241.2,453.7 257.7,415.4 284.9,385.5C280.6,374.9 265.7,331.1 289.2,272.5C289.2,272.5 324.9,261.3 406.4,316.2C440.6,306.6 476.8,301.8 513.1,301.8C549.3,301.8 585.6,306.6 619.7,316.2C701.2,260.7 737,272.5 737,272.5C760.4,331.1 745.5,374.9 741.2,385.5C768.4,415.4 785,453.2 785,500.1C785,663.8 685.3,700.1 590.4,710.7C605.8,724 619.2,749.6 619.2,789.6C619.2,846.7 618.6,892.5 618.6,906.9C618.6,918.1 626.6,931.4 647.9,927.2C816.4,870.7 938.5,710.7 938.5,522.5C938.5,286.9 747.6,96 512,96Z" /> diff --git a/logo/src/main/res/drawable/brand_okx.xml b/brand/src/main/res/drawable-night/brand_okx.xml similarity index 57% rename from logo/src/main/res/drawable/brand_okx.xml rename to brand/src/main/res/drawable-night/brand_okx.xml index 6c7cd5c..bbf3b3b 100644 --- a/logo/src/main/res/drawable/brand_okx.xml +++ b/brand/src/main/res/drawable-night/brand_okx.xml @@ -4,23 +4,23 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M635.1,379L388.7,379C378.2,379 369.7,386.9 369.7,396.7L369.7,627.3C369.7,637.1 378.2,645 388.7,645L635.1,645C645.6,645 654,637.1 654,627.3L654,396.7C654,386.9 645.6,379 635.1,379Z" /> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M350.8,113L104.5,113C94,113 85.5,120.9 85.5,130.7L85.5,361.2C85.5,371 94,379 104.5,379L350.8,379C361.3,379 369.8,371 369.8,361.2L369.8,130.7C369.8,120.9 361.3,113 350.8,113Z" /> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M919.5,113L673.1,113C662.7,113 654.2,120.9 654.2,130.7L654.2,361.2C654.2,371 662.7,379 673.1,379L919.5,379C930,379 938.5,371 938.5,361.2L938.5,130.7C938.5,120.9 930,113 919.5,113Z" /> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M350.8,645L104.5,645C94,645 85.5,653 85.5,662.8L85.5,893.3C85.5,903.1 94,911 104.5,911L350.8,911C361.3,911 369.8,903.1 369.8,893.3L369.8,662.8C369.8,653 361.3,645 350.8,645Z" /> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M919.5,645L673.1,645C662.7,645 654.2,653 654.2,662.8L654.2,893.3C654.2,903.1 662.7,911 673.1,911L919.5,911C930,911 938.5,903.1 938.5,893.3L938.5,662.8C938.5,653 930,645 919.5,645Z" /> diff --git a/logo/src/main/res/drawable-night/brand_openai.xml b/brand/src/main/res/drawable-night/brand_openai.xml similarity index 92% rename from logo/src/main/res/drawable-night/brand_openai.xml rename to brand/src/main/res/drawable-night/brand_openai.xml index ab78069..8896c0b 100644 --- a/logo/src/main/res/drawable-night/brand_openai.xml +++ b/brand/src/main/res/drawable-night/brand_openai.xml @@ -1,12 +1,10 @@ + android:pathData="M412.7,397L412.7,316.7C412.7,310 415.2,304.9 421.1,301.5L582.6,208.5C604.6,195.8 630.8,189.9 657.8,189.9C759.3,189.9 823.5,268.5 823.5,352.2C823.5,358.1 823.5,364.9 822.7,371.7L655.3,273.6C645.1,267.7 635,267.7 624.8,273.6L412.7,397ZM789.7,709.8L789.7,517.9C789.7,506.1 784.6,497.6 774.5,491.7L562.3,368.3L631.6,328.5C637.5,325.2 642.6,325.2 648.5,328.5L810,421.5C856.5,448.6 887.8,506.1 887.8,561.9C887.8,626.1 849.7,685.3 789.7,709.8L789.7,709.8ZM362.8,540.8L293.5,500.2C287.5,496.8 285,491.7 285,484.9L285,299C285,208.5 354.3,140 448.2,140C483.7,140 516.6,151.9 544.6,173L378,269.4C367.9,275.3 362.8,283.8 362.8,295.6L362.8,540.8L362.8,540.8ZM512,627L412.7,571.2L412.7,452.8L512,397L611.3,452.8L611.3,571.2L512,627ZM575.8,884C540.3,884 507.3,872.1 479.5,851L646,754.6C656.1,748.7 661.2,740.3 661.2,728.4L661.2,483.3L731.4,523.8C737.3,527.2 739.8,532.3 739.8,539.1L739.8,725C739.8,815.5 669.6,884 575.8,884L575.8,884ZM375.5,695.4L214,602.5C167.5,575.4 136.2,517.9 136.2,462.1C136.2,397 175.1,338.7 235.1,314.2L235.1,506.9C235.1,518.8 240.2,527.2 250.3,533.1L461.7,655.7L392.4,695.4C386.5,698.8 381.4,698.8 375.5,695.4ZM366.2,834.1C270.6,834.1 200.5,762.2 200.5,673.5C200.5,666.7 201.3,659.9 202.2,653.2L368.7,749.6C378.8,755.5 389,755.5 399.1,749.6L611.3,627L611.3,707.3C611.3,714.1 608.8,719.1 602.9,722.5L441.4,815.5C419.4,828.2 393.2,834.1 366.2,834.1L366.2,834.1ZM575.8,934.7C678.1,934.7 763.5,862 782.9,765.6C877.6,741.1 938.5,652.3 938.5,561.9C938.5,502.7 913.1,445.2 867.5,403.8C871.7,386 874.2,368.3 874.2,350.5C874.2,229.6 776.2,139.2 662.9,139.2C640.1,139.2 618.1,142.5 596.1,150.2C558.1,113 505.7,89.3 448.2,89.3C345.9,89.3 260.5,162 241,258.4C146.4,282.9 85.5,371.7 85.5,462.1C85.5,521.3 110.9,578.8 156.5,620.2C152.3,638 149.7,655.7 149.7,673.5C149.7,794.3 247.8,884.8 361.1,884.8C383.9,884.8 405.9,881.4 427.9,873.8C465.9,911 518.3,934.7 575.8,934.7Z" /> diff --git a/brand/src/main/res/drawable-night/brand_patreon.xml b/brand/src/main/res/drawable-night/brand_patreon.xml new file mode 100644 index 0000000..d4cc925 --- /dev/null +++ b/brand/src/main/res/drawable-night/brand_patreon.xml @@ -0,0 +1,10 @@ + + + diff --git a/brand/src/main/res/drawable-night/brand_threads.xml b/brand/src/main/res/drawable-night/brand_threads.xml new file mode 100644 index 0000000..1ea55cb --- /dev/null +++ b/brand/src/main/res/drawable-night/brand_threads.xml @@ -0,0 +1,10 @@ + + + diff --git a/brand/src/main/res/drawable-night/brand_wise.xml b/brand/src/main/res/drawable-night/brand_wise.xml new file mode 100644 index 0000000..6e91983 --- /dev/null +++ b/brand/src/main/res/drawable-night/brand_wise.xml @@ -0,0 +1,10 @@ + + + diff --git a/logo/src/main/res/drawable-night/brand_x.xml b/brand/src/main/res/drawable-night/brand_x.xml similarity index 76% rename from logo/src/main/res/drawable-night/brand_x.xml rename to brand/src/main/res/drawable-night/brand_x.xml index bce8579..32c2ad6 100644 --- a/logo/src/main/res/drawable-night/brand_x.xml +++ b/brand/src/main/res/drawable-night/brand_x.xml @@ -4,7 +4,7 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M757.3,126.5L888.1,126.5L602.3,453.1L938.5,897.5L675.3,897.5L469.1,628L233.2,897.5L102.3,897.5L408,548.2L85.5,126.5L355.4,126.5L541.8,372.9L757.3,126.5ZM711.4,819.2L783.9,819.2L316,200.7L238.2,200.7L711.4,819.2Z" /> diff --git a/brand/src/main/res/drawable/brand_aliyun.xml b/brand/src/main/res/drawable/brand_aliyun.xml new file mode 100644 index 0000000..963f1fe --- /dev/null +++ b/brand/src/main/res/drawable/brand_aliyun.xml @@ -0,0 +1,14 @@ + + + + diff --git a/logo/src/main/res/drawable/brand_apple.xml b/brand/src/main/res/drawable/brand_apple.xml similarity index 84% rename from logo/src/main/res/drawable/brand_apple.xml rename to brand/src/main/res/drawable/brand_apple.xml index abc3c38..c381842 100644 --- a/logo/src/main/res/drawable/brand_apple.xml +++ b/brand/src/main/res/drawable/brand_apple.xml @@ -4,7 +4,7 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FF000000" + android:fillType="nonZero" + android:pathData="M522,282.4C559.4,282.4 606.2,256.3 634.1,221.6C659.3,190.1 677.7,146.2 677.7,102.3C677.7,96.4 677.2,90.4 676.1,85.5C634.6,87.1 584.6,114.2 554.6,150.6C530.9,178.2 509.4,221.6 509.4,266.1C509.4,272.6 510.4,279.1 511,281.2C513.6,281.8 517.8,282.4 522,282.4ZM390.5,938.5C441.5,938.5 464.1,903.3 527.8,903.3C592.5,903.3 606.7,937.4 663.5,937.4C719.3,937.4 756.6,884.3 791.9,832.2C831.3,772.6 847.7,714 848.7,711.3C845,710.2 738.2,665.2 738.2,538.8C738.2,429.3 822.4,380 827.2,376.1C771.4,293.7 686.7,291.6 663.5,291.6C600.9,291.6 549.9,330.6 517.8,330.6C483,330.6 437.3,293.7 383.1,293.7C280,293.7 175.3,381.6 175.3,547.5C175.3,650.5 214.2,759.5 262.1,830C303.1,889.7 338.9,938.5 390.5,938.5Z" /> diff --git a/logo/src/main/res/drawable/brand_aws.xml b/brand/src/main/res/drawable/brand_aws.xml similarity index 94% rename from logo/src/main/res/drawable/brand_aws.xml rename to brand/src/main/res/drawable/brand_aws.xml index 8129975..794bce0 100644 --- a/logo/src/main/res/drawable/brand_aws.xml +++ b/brand/src/main/res/drawable/brand_aws.xml @@ -1,12 +1,10 @@ + android:pathData="M244.2,256.4C205.6,256.4 166.3,271.5 159.8,279.7C158.1,283.1 154.1,310.7 163.5,310.7C166.6,310.7 168,311.3 177.1,307C211.3,293.7 232.9,293.9 236,293.9C274.4,290.3 296.6,316.4 293.2,350.3L293.2,370.2C260.7,362.5 242.3,362.2 233.2,362.2C186,359.4 142.3,384.3 142.3,439C142.3,499 195.9,511.8 216.6,510.9C247.6,511.2 277.2,497.3 296.8,473.1C312.5,508.1 322.4,505.8 322.7,505.8C325.5,505.8 327.8,504.7 330.1,503.2L346.3,491.9C349.1,490.2 351.4,487.3 351.7,483.9C351.4,475.6 336.6,462.9 337.8,434.1L337.8,345.4C339.6,321 331.4,296.9 315,278.6C295.3,262.1 269.8,254.2 244.2,256.4ZM795.1,256.4C738.2,256.4 705.5,292 706.4,328.1C706.4,377.6 756.4,393.2 762.1,394.9C810.2,410 816.7,410.5 830.1,421.9C841.5,433.6 840,456.3 823.3,466.3C818.4,469.1 797.7,481.6 750.8,472C735.1,468.8 726.9,465.1 714.1,459.7C710.7,458.6 702.7,456.6 702.7,467.1L702.7,481.1C702.7,487.6 706.7,493.6 712.7,496.4C742.5,511.5 778.3,512 786,512C787.2,512 852.6,512.1 874.4,468C878.9,458.9 890.7,425.6 868.8,397.2C850.6,375.8 834.9,373.6 788.3,359.4C784.3,358.2 749.9,349.4 750.2,325.2C748.5,294.2 790.6,292.5 799.4,293.1C834.9,292.5 852.6,305.9 862.2,306.7C866.5,306.7 868.5,304.2 868.5,298.5L868.5,285.4C868.6,282.3 867.7,279.2 865.9,276.6C854.5,261.8 811,256.4 795.1,256.4ZM363.5,263.5C360.3,264.1 358.1,267.2 358.6,270.4C359.2,274 359.8,277.7 361.2,281.4L424.9,491.6C426.3,498.4 430.9,505.8 440.8,504.7L464.1,504.7C478.3,506.1 480.3,492.4 480.6,491L522.4,315.8L564.8,491.3C565.1,492.7 567.1,506.4 581,504.9L604.6,504.9C614.8,506.1 619.7,498.7 621.1,491.9C692.8,261.3 687.9,276.9 688.5,274.6C689.6,262.7 682.8,263.5 681.7,263.8L656.4,263.8C643.6,262.4 641,274 640.4,276.9L593.2,459.2L550.6,276.9C548.6,263 537.2,263.5 534.4,263.8L512.5,263.8C500,262.7 496.8,272.6 496,276.9L453.6,456.6L408.1,276.9C407,271.2 403.3,262.4 392.2,263.5L363.5,263.5ZM242.5,395.2C263,395.5 280.7,398.6 292.9,401.4C292.9,415.6 293.4,423.6 290.3,436.4C286.3,450.1 268.7,474.8 227.2,475.4C203.3,476.5 187.7,457.7 189.1,436.4C187.7,402.3 222.9,393.8 242.5,395.2L242.5,395.2ZM882.8,569C856.3,569.3 824.9,575.3 801.2,592C793.8,597.1 795.1,604.1 803.3,603.2C830,600 889.5,592.7 900.1,606.3C910.8,619.9 888.4,676 878.4,701.1C875.4,708.6 881.8,711.7 888.7,706C933.2,668.7 944.7,590.6 935.6,579.4C931.1,573.8 909.3,568.6 882.8,569ZM91.6,597.4C85.4,598.3 82.8,606.1 89.2,612.3C200.6,716.8 347.8,768 511.2,768C627.8,768 763.1,729.9 856.5,658.3C871.9,646.3 858.7,628.5 842.9,635.5C738.2,681.8 624.4,704 520.9,704C367.5,704 218.9,672 98.8,599.2C96.7,597.8 94.2,597.2 91.6,597.4Z" /> diff --git a/logo/src/main/res/drawable/brand_azure.xml b/brand/src/main/res/drawable/brand_azure.xml similarity index 54% rename from logo/src/main/res/drawable/brand_azure.xml rename to brand/src/main/res/drawable/brand_azure.xml index e9c3239..38b83f1 100644 --- a/logo/src/main/res/drawable/brand_azure.xml +++ b/brand/src/main/res/drawable/brand_azure.xml @@ -5,72 +5,72 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M369.9,110.1L622.3,110.1L360.3,886.5C354.8,902.8 339.3,913.9 322.1,913.9C322.1,913.9 125.7,913.9 125.7,913.9C103.6,913.9 85.5,895.7 85.5,873.7C85.5,869.3 86.2,865 87.6,860.8L331.7,137.5C337.2,121.2 352.6,110.1 369.9,110.1Z"> + android:color="#FF114A8B" + android:offset="0" /> + android:color="#FF0669BC" + android:offset="1" /> + android:fillType="nonZero" + android:pathData="M736.7,630.9L336.4,630.9C336.4,630.9 336.4,630.9 336.4,630.9C326.2,630.9 317.9,639.2 317.9,649.4C317.9,654.5 320,659.4 323.7,662.9L581,903C588.4,910 598.3,913.9 608.5,913.9L835.2,913.9L736.7,630.9Z" /> + android:fillType="nonZero" + android:pathData="M369.9,110.1C352.4,110.1 336.9,121.4 331.6,138L87.9,860.2C86.4,864.5 85.6,869.1 85.6,873.7C85.6,895.7 103.7,913.9 125.7,913.9C125.7,913.9 125.8,913.9 125.8,913.9L327.3,913.9C342.6,911.1 355.2,900.4 360.4,885.7L409,742.5L582.6,904.4C589.9,910.5 599,913.8 608.4,913.9L834.2,913.9L735.2,630.9L446.5,630.9L623.2,110.1L369.9,110.1Z"> + android:color="#4C000000" + android:offset="0" /> + android:color="#33000000" + android:offset="0.1" /> + android:color="#19000000" + android:offset="0.3" /> + android:color="#0C000000" + android:offset="0.6" /> + android:color="#00000000" + android:offset="1" /> + android:fillType="nonZero" + android:pathData="M692.3,137.5C686.8,121.2 671.4,110.1 654.2,110.1L372.9,110.1C390.1,110.1 405.5,121.2 411,137.5L655.1,860.8C656.5,865 657.2,869.3 657.2,873.7C657.2,895.7 639.1,913.9 617,913.9L898.3,913.9C920.4,913.9 938.5,895.7 938.5,873.7C938.5,869.3 937.8,865 936.4,860.8L692.3,137.5Z"> + android:color="#FF3CCBF4" + android:offset="0" /> + android:color="#FF2892DF" + android:offset="1" /> diff --git a/logo/src/main/res/drawable/brand_binance.xml b/brand/src/main/res/drawable/brand_binance.xml similarity index 50% rename from logo/src/main/res/drawable/brand_binance.xml rename to brand/src/main/res/drawable/brand_binance.xml index 909018b..0823b89 100644 --- a/logo/src/main/res/drawable/brand_binance.xml +++ b/brand/src/main/res/drawable/brand_binance.xml @@ -4,23 +4,23 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#F0B90A" + android:fillType="nonZero" + android:pathData="M346.4,443.9L512.1,278.3L677.8,444.1L774.2,347.7L512.1,85.5L250,347.6L346.4,443.9Z" /> + android:fillColor="#F0B90A" + android:fillType="nonZero" + android:pathData="M85.5,512.1L181.8,415.7L278.3,512.1L181.8,608.4L85.5,512.1Z" /> + android:fillColor="#F0B90A" + android:fillType="nonZero" + android:pathData="M346.4,580.1L512.1,745.8L677.8,580.1L774.2,676.3L512.2,938.5L250,676.5L346.4,580.1Z" /> + android:fillColor="#F0B90A" + android:fillType="nonZero" + android:pathData="M745.7,512.1L842.1,415.7L938.5,512L842.1,608.5L745.7,512.1Z" /> + android:fillColor="#F0B90A" + android:fillType="nonZero" + android:pathData="M609.9,512L512.1,414.2L439.8,486.5L431.5,494.8L414.3,511.9L512.1,609.6L609.9,512.1L609.9,512Z" /> diff --git a/logo/src/main/res/drawable/brand_bybit.xml b/brand/src/main/res/drawable/brand_bybit.xml similarity index 81% rename from logo/src/main/res/drawable/brand_bybit.xml rename to brand/src/main/res/drawable/brand_bybit.xml index ab324c7..66c5365 100644 --- a/logo/src/main/res/drawable/brand_bybit.xml +++ b/brand/src/main/res/drawable/brand_bybit.xml @@ -4,23 +4,23 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M693.5,369.8l44.1,0l0,219.2l-44.1,0z" /> + android:fillType="nonZero" + android:pathData="M180,654.1L85.5,654.1L85.5,434.9L176.2,434.9C220.2,434.9 245.9,459 245.9,496.5C245.9,520.8 229.4,536.6 218,541.8C231.6,547.9 249.1,561.8 249.1,591C249.1,632 220.2,654.1 180,654.1ZM172.7,473.1L129.6,473.1L129.6,523.6L172.7,523.6C191.4,523.6 201.8,513.5 201.8,498.4C201.8,483.3 191.4,473.1 172.7,473.1ZM175.5,562.1L129.6,562.1L129.6,616L175.5,616C195.5,616 205,603.7 205,588.9C205,574.1 195.5,562.1 175.5,562.1Z" /> + android:fillType="nonZero" + android:pathData="M383.4,564.2L383.4,654.1L339.7,654.1L339.7,564.2L271.9,434.9L319.7,434.9L361.9,523.3L403.4,434.9L451.3,434.9L383.4,564.2Z" /> + android:fillType="nonZero" + android:pathData="M576.2,654.1L481.7,654.1L481.7,434.9L572.4,434.9C616.4,434.9 642.1,459 642.1,496.5C642.1,520.8 625.6,536.6 614.2,541.8C627.8,547.9 645.3,561.8 645.3,591C645.3,632 616.4,654.1 576.2,654.1ZM568.9,473.1L525.8,473.1L525.8,523.6L568.9,523.6C587.6,523.6 598.1,513.5 598.1,498.4C598.1,483.3 587.6,473.1 568.9,473.1ZM571.7,562.1L525.8,562.1L525.8,616L571.7,616C591.7,616 601.2,603.7 601.2,588.9C601.2,574.1 591.7,562.1 571.7,562.1Z" /> + android:fillType="nonZero" + android:pathData="M879.5,473.1L879.5,654.2L835.5,654.2L835.5,473.1L776.5,473.1L776.5,434.9L938.5,434.9L938.5,473.1L879.5,473.1Z" /> diff --git a/brand/src/main/res/drawable/brand_cloudflare.xml b/brand/src/main/res/drawable/brand_cloudflare.xml new file mode 100644 index 0000000..0976bf7 --- /dev/null +++ b/brand/src/main/res/drawable/brand_cloudflare.xml @@ -0,0 +1,14 @@ + + + + diff --git a/brand/src/main/res/drawable/brand_coinbase.xml b/brand/src/main/res/drawable/brand_coinbase.xml new file mode 100644 index 0000000..0c86b6b --- /dev/null +++ b/brand/src/main/res/drawable/brand_coinbase.xml @@ -0,0 +1,10 @@ + + + diff --git a/brand/src/main/res/drawable/brand_crowdin.xml b/brand/src/main/res/drawable/brand_crowdin.xml new file mode 100644 index 0000000..a081d40 --- /dev/null +++ b/brand/src/main/res/drawable/brand_crowdin.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/logo/src/main/res/drawable/brand_digitalocean.xml b/brand/src/main/res/drawable/brand_digitalocean.xml similarity index 88% rename from logo/src/main/res/drawable/brand_digitalocean.xml rename to brand/src/main/res/drawable/brand_digitalocean.xml index 4678713..c065374 100644 --- a/logo/src/main/res/drawable/brand_digitalocean.xml +++ b/brand/src/main/res/drawable/brand_digitalocean.xml @@ -4,9 +4,9 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#0069FF" + android:pathData="M914.7,376C875.3,248.4 773.9,146.8 647.1,106.7C356.4,15.3 88.3,232.9 88.3,511.9L252.1,511.9C252.1,336.9 424.6,201.5 607.6,268.2C675.4,292.9 729.7,347.5 754.2,415.2C820.9,599.3 686.2,772.8 512.6,773.1L512.6,609.2L348.7,609.2L348.7,774.1L512.1,774.1L512.1,938.5C789.3,938.5 1005.5,668.6 914.7,376Z" /> + android:fillColor="#0069FF" + android:pathData="M223.3,905.1L348.7,905.1L348.7,778.9L223.4,778.9L223.4,673L118.1,673L118.1,778.9L223.3,778.9L223.3,905.1Z" /> diff --git a/logo/src/main/res/drawable/brand_discord.xml b/brand/src/main/res/drawable/brand_discord.xml similarity index 85% rename from logo/src/main/res/drawable/brand_discord.xml rename to brand/src/main/res/drawable/brand_discord.xml index 578abdf..7a91cbf 100644 --- a/logo/src/main/res/drawable/brand_discord.xml +++ b/brand/src/main/res/drawable/brand_discord.xml @@ -1,16 +1,14 @@ + android:fillType="nonZero" + android:pathData="M938.5,512C938.5,747.6 747.6,938.5 512,938.5C276.4,938.5 85.5,747.6 85.5,512C85.5,276.4 276.4,85.5 512,85.5C747.6,85.5 938.5,276.4 938.5,512Z" /> + android:pathData="M597.7,282.6C591.8,293.1 586.5,304 581.6,315.1C535.8,308.2 489.1,308.2 443.1,315.1C438.4,304 433,293.1 427,282.6C383.9,290 341.9,302.9 302.1,321.1C223.2,438.1 201.8,552 212.4,664.4C258.7,698.5 310.4,724.6 365.6,741.3C378,724.6 389,706.9 398.4,688.4C380.6,681.8 363.3,673.4 346.8,663.8C351.1,660.6 355.3,657.4 359.4,654.2C456.4,699.9 568.6,699.9 665.6,654.2C669.8,657.6 674,660.9 678.3,663.8C661.8,673.5 644.5,681.8 626.5,688.5C636,707 646.9,724.7 659.4,741.4C714.5,724.7 766.3,698.8 812.5,664.6C825.1,534.3 791,421.3 722.6,321.2C682.9,303 640.9,290.1 597.8,282.9L597.7,282.6ZM411.9,595.2C382.1,595.2 357.4,568.2 357.4,534.8C357.4,501.3 381.2,474.2 411.8,474.2C442.5,474.2 466.9,501.4 466.4,534.8C465.9,568.1 442.4,595.2 411.9,595.2ZM613,595.2C583.1,595.2 558.6,568.2 558.6,534.8C558.6,501.3 582.4,474.2 613,474.2C643.7,474.2 667.9,501.4 667.5,534.8C667,568.1 643.4,595.2 613,595.2Z" /> diff --git a/brand/src/main/res/drawable/brand_docker.xml b/brand/src/main/res/drawable/brand_docker.xml new file mode 100644 index 0000000..b0c5489 --- /dev/null +++ b/brand/src/main/res/drawable/brand_docker.xml @@ -0,0 +1,14 @@ + + + + diff --git a/brand/src/main/res/drawable/brand_facebook.xml b/brand/src/main/res/drawable/brand_facebook.xml new file mode 100644 index 0000000..53ae0f1 --- /dev/null +++ b/brand/src/main/res/drawable/brand_facebook.xml @@ -0,0 +1,14 @@ + + + + diff --git a/brand/src/main/res/drawable/brand_fansly.xml b/brand/src/main/res/drawable/brand_fansly.xml new file mode 100644 index 0000000..81b22a8 --- /dev/null +++ b/brand/src/main/res/drawable/brand_fansly.xml @@ -0,0 +1,10 @@ + + + diff --git a/logo/src/main/res/drawable/brand_gitea.xml b/brand/src/main/res/drawable/brand_gitea.xml similarity index 79% rename from logo/src/main/res/drawable/brand_gitea.xml rename to brand/src/main/res/drawable/brand_gitea.xml index 1ee92c3..c64afb3 100644 --- a/logo/src/main/res/drawable/brand_gitea.xml +++ b/brand/src/main/res/drawable/brand_gitea.xml @@ -1,21 +1,18 @@ + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M615,712L442.8,629.2C425.9,621 418.5,600.4 426.8,583.3L509.6,411.1C517.7,394.2 538.3,386.8 555.4,395.1C578.8,406.4 592.2,412.8 592.2,412.8L592.1,264.6L614.7,264.4L614.9,423.3C614.9,423.3 692.8,456.2 727.6,477.8C732.7,480.9 741.5,487 745.1,497.3C748,505.6 747.9,515.1 743.8,523.5L661,695.7C652.6,712.9 632,720.2 615,712Z" /> + android:pathData="M922.8,258.2C917.2,252.6 909.7,252.8 909.7,252.8C909.7,252.8 750.7,261.7 668.3,263.6C650.3,264 632.4,264.4 614.6,264.6L614.6,423.6C607.1,420.1 599.5,416.4 592.1,412.9C592.1,363.5 591.9,264.7 591.9,264.7C552.6,265.3 470.9,261.7 470.9,261.7C470.9,261.7 279,252.1 258.1,250.2C244.8,249.4 227.6,247.4 205.2,252.2C193.4,254.7 159.8,262.3 132.2,288.7C71.2,343.1 86.8,429.7 88.7,442.7C91,458.6 98,502.7 131.7,541.1C193.8,617.2 327.6,615.5 327.6,615.5C327.6,615.5 344,654.7 369.1,690.8C403.1,735.7 437.9,770.7 471.9,774.9C557.3,774.9 728.2,774.8 728.2,774.8C728.2,774.8 744.5,774.9 766.6,760.8C785.6,749.3 802.5,729.1 802.5,729.1C802.5,729.1 820,710.3 844.5,667.6C851.9,654.4 858.2,641.7 863.6,629.6C863.6,629.6 938.5,470.7 938.5,316C937,269.2 925.5,260.9 922.8,258.2ZM248.2,535.2C213.1,523.6 198.2,509.8 198.2,509.8C198.2,509.8 172.2,491.6 159.2,455.8C136.8,395.8 157.3,359.2 157.3,359.2C157.3,359.2 168.7,328.6 209.6,318.5C228.3,313.4 251.6,314.2 251.6,314.2C251.6,314.2 261.3,394.8 272.9,442.1C282.7,481.7 306.6,547.5 306.6,547.5C306.6,547.5 271.2,543.3 248.2,535.2ZM655.7,681.2C655.7,681.2 647.4,700.8 629.1,702.1C621.3,702.6 615.1,700.4 615.1,700.4C615.1,700.4 614.7,700.3 608,697.6L454.8,622.9C454.8,622.9 440,615.2 437.4,601.8C434.4,590.8 441.1,577.2 441.1,577.2L514.7,425.4C514.7,425.4 521.2,412.2 531.3,407.7C532.1,407.3 534.4,406.4 537.4,405.7C548.4,402.9 561.8,409.5 561.8,409.5L712,482.4C712,482.4 729.1,490.1 732.8,504.4C735.4,514.4 732.1,523.3 730.3,527.7C721.8,548.6 655.7,681.2 655.7,681.2Z" /> + android:pathData="M521.2,570.7C510.1,570.8 500.4,578.6 497.8,589.4C495.2,600.3 500.5,611.5 510.1,616.6C520.6,622 533.9,619 540.9,609.2C547.8,599.6 546.8,586.3 538.5,577.9L571,511.3C573.1,511.4 576.1,511.5 579.5,510.6C585,509.4 589.1,505.7 589.1,505.7C594.8,508.1 600.8,510.9 607,514C613.5,517.2 619.6,520.6 625.2,523.9C626.4,524.6 627.6,525.4 629,526.5C631.2,528.2 633.6,530.7 635.4,533.9C637.9,541.4 632.8,554.1 632.8,554.1C629.7,564.5 607.8,609.2 607.8,609.2C596.8,609 587.1,616 583.8,626.2C580.3,637.2 585.3,649.7 595.9,655.1C606.5,660.5 619.5,657.4 626.4,647.9C633.2,638.7 632.7,625.8 624.9,617.2C627.5,612.2 629.9,607.2 632.5,601.9C639.3,587.8 650.8,560.7 650.8,560.7C652.1,558.4 658.6,546.7 654.5,531.8C651.1,516.3 637.4,509.1 637.4,509.1C620.8,498.4 597.8,488.5 597.8,488.5C597.8,488.5 597.8,482.9 596.3,478.8C594.8,474.6 592.5,471.9 591,470.3C597.4,457.1 603.7,444.1 610.1,430.9C604.6,428.2 599.1,425.5 593.6,422.7C587.1,436 580.4,449.4 573.9,462.7C564.8,462.6 556.4,467.4 552.1,475.4C547.4,484 548.4,494.6 554.6,502.3C543.5,525.1 532.4,547.9 521.2,570.7Z" /> diff --git a/logo/src/main/res/drawable-night/brand_github.xml b/brand/src/main/res/drawable/brand_github.xml similarity index 84% rename from logo/src/main/res/drawable-night/brand_github.xml rename to brand/src/main/res/drawable/brand_github.xml index f515d91..d6b4a1d 100644 --- a/logo/src/main/res/drawable-night/brand_github.xml +++ b/brand/src/main/res/drawable/brand_github.xml @@ -1,12 +1,10 @@ + android:pathData="M512,96C276.4,96 85.5,286.9 85.5,522.5C85.5,711.2 207.6,870.7 377.1,927.2C398.4,930.9 406.4,918.1 406.4,906.9C406.4,896.8 405.9,863.2 405.9,827.5C298.8,847.2 271,801.3 262.5,777.3C257.7,765.1 236.9,727.2 218.8,717.1C203.8,709.1 182.5,689.4 218.2,688.9C251.8,688.3 275.8,719.8 283.8,732.6C322.2,797.1 383.5,778.9 408,767.8C411.8,740 423,721.4 435.2,710.7C340.3,700.1 241.2,663.3 241.2,500.1C241.2,453.7 257.7,415.4 284.9,385.5C280.6,374.9 265.7,331.1 289.2,272.5C289.2,272.5 324.9,261.3 406.4,316.2C440.6,306.6 476.8,301.8 513.1,301.8C549.3,301.8 585.6,306.6 619.7,316.2C701.2,260.7 737,272.5 737,272.5C760.4,331.1 745.5,374.9 741.2,385.5C768.4,415.4 785,453.2 785,500.1C785,663.8 685.3,700.1 590.4,710.7C605.8,724 619.2,749.6 619.2,789.6C619.2,846.7 618.6,892.5 618.6,906.9C618.6,918.1 626.6,931.4 647.9,927.2C816.4,870.7 938.5,710.7 938.5,522.5C938.5,286.9 747.6,96 512,96Z" /> diff --git a/logo/src/main/res/drawable/brand_gitlab.xml b/brand/src/main/res/drawable/brand_gitlab.xml similarity index 70% rename from logo/src/main/res/drawable/brand_gitlab.xml rename to brand/src/main/res/drawable/brand_gitlab.xml index a1714e4..53eef08 100644 --- a/logo/src/main/res/drawable/brand_gitlab.xml +++ b/brand/src/main/res/drawable/brand_gitlab.xml @@ -4,19 +4,19 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M924.2,426.4L923,423.3L806.9,120.4C804.6,114.4 800.4,109.4 795,106C789.6,102.5 783.2,100.9 776.8,101.3C770.4,101.6 764.2,103.9 759.2,108C754.2,112 750.6,117.5 749,123.7L670.7,363.5L353.5,363.5L275,123.7C273.3,117.6 269.7,112.1 264.7,108.1C259.7,104.1 253.5,101.7 247.1,101.4C240.7,101.1 234.3,102.7 228.9,106.1C223.5,109.6 219.3,114.6 217,120.6L100.9,423.3L99.8,426.4C83,470.1 81,518 93.9,562.9C106.8,607.9 133.9,647.4 171.3,675.6L171.6,675.8L172.7,676.6L349.6,809.1L437.1,875.3L490.3,915.5C496.5,920.2 504.1,922.8 512,922.8C519.8,922.8 527.4,920.2 533.7,915.5L586.9,875.3L674.4,809.1L852.2,676L852.7,675.7C890.1,647.5 917.2,607.9 930.1,563C943,518 941,470.1 924.2,426.4Z" /> + android:fillType="nonZero" + android:pathData="M924.2,426.4L923,423.3C866.5,435 813.2,458.9 766.9,493.6L512,686.3L674.4,809.1L852.3,676L852.8,675.7C890.1,647.5 917.2,607.9 930.1,563C943.1,518 941,470.1 924.2,426.4Z" /> + android:fillType="nonZero" + android:pathData="M349.6,809.1L437.1,875.3L490.3,915.5C496.5,920.2 504.1,922.8 512,922.8C519.8,922.8 527.4,920.2 533.7,915.5L586.9,875.3L674.4,809.1L512,686.3L349.6,809.1Z" /> + android:fillType="nonZero" + android:pathData="M257,493.6C210.7,458.9 157.5,435 100.9,423.3L99.8,426.4C83,470.1 81,518 93.9,562.9C106.8,607.9 133.9,647.4 171.3,675.6L171.6,675.8L172.7,676.6L349.6,809.1L512,686.3L257,493.6Z" /> diff --git a/logo/src/main/res/drawable/brand_google.xml b/brand/src/main/res/drawable/brand_google.xml similarity index 70% rename from logo/src/main/res/drawable/brand_google.xml rename to brand/src/main/res/drawable/brand_google.xml index 879db67..229bc35 100644 --- a/logo/src/main/res/drawable/brand_google.xml +++ b/brand/src/main/res/drawable/brand_google.xml @@ -4,19 +4,19 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M275.5,595.3C344.2,783.1 545.8,812.9 667.2,732.7L809.2,732.7L809.2,842.7C729.6,910.8 628.2,948.3 523.4,948.3C358,948.3 206.1,855 131.2,707.5" /> + android:fillType="nonZero" + android:pathData="M809.2,842.7C910.8,748.2 958.5,587 930.6,432.7L520.6,432.7L520.6,602.2L754.3,602.2C743.6,658.7 714.5,702.2 667.2,732.7" /> + android:fillType="nonZero" + android:pathData="M131.2,707.5C70.3,583.6 70.3,439.8 131.2,315.9L275.5,428.1C257.2,484.6 257.2,540.3 275.5,595.3" /> + android:fillType="nonZero" + android:pathData="M275.5,428.1C325.9,270 541.2,178.4 685.5,313.6L811.5,189.9C632.8,18.1 284.7,24.9 131.2,315.9" /> diff --git a/brand/src/main/res/drawable/brand_googlecloud.xml b/brand/src/main/res/drawable/brand_googlecloud.xml new file mode 100644 index 0000000..20c8fc5 --- /dev/null +++ b/brand/src/main/res/drawable/brand_googlecloud.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/brand/src/main/res/drawable/brand_instagram.xml b/brand/src/main/res/drawable/brand_instagram.xml new file mode 100644 index 0000000..e4e4930 --- /dev/null +++ b/brand/src/main/res/drawable/brand_instagram.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo/src/main/res/drawable/brand_jetbrains.xml b/brand/src/main/res/drawable/brand_jetbrains.xml similarity index 65% rename from logo/src/main/res/drawable/brand_jetbrains.xml rename to brand/src/main/res/drawable/brand_jetbrains.xml index b0d5574..9a90ce7 100644 --- a/logo/src/main/res/drawable/brand_jetbrains.xml +++ b/brand/src/main/res/drawable/brand_jetbrains.xml @@ -5,36 +5,36 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M356.6,134.3L134.3,356.6C103.1,387.8 85.5,430.2 85.5,474.4L85.5,871.9C85.5,908.6 115.4,938.5 152.1,938.5L549.6,938.5C593.8,938.5 636.1,920.9 667.4,889.7L889.7,667.4C920.9,636.2 938.5,593.8 938.5,549.6L938.5,152.1C938.5,115.4 908.6,85.5 871.9,85.5L474.4,85.5C430.2,85.5 387.9,103.1 356.6,134.3Z"> + android:color="#FFFF9419" + android:offset="0" /> + android:color="#FFFF021D" + android:offset="0.4" /> + android:color="#FFE600FF" + android:offset="1" /> + android:color="#FFE600FF" + android:offset="1" /> + android:fillType="nonZero" + android:pathData="M192.1,298.8l533.1,0l0,533.1l-533.1,0z" /> + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M258.8,711.9l226.6,0l0,53.3l-226.6,0z" /> diff --git a/brand/src/main/res/drawable/brand_lark.xml b/brand/src/main/res/drawable/brand_lark.xml new file mode 100644 index 0000000..737a42c --- /dev/null +++ b/brand/src/main/res/drawable/brand_lark.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/brand/src/main/res/drawable/brand_mega.xml b/brand/src/main/res/drawable/brand_mega.xml new file mode 100644 index 0000000..8fc0dfd --- /dev/null +++ b/brand/src/main/res/drawable/brand_mega.xml @@ -0,0 +1,12 @@ + + + + diff --git a/brand/src/main/res/drawable/brand_meta.xml b/brand/src/main/res/drawable/brand_meta.xml new file mode 100644 index 0000000..948770d --- /dev/null +++ b/brand/src/main/res/drawable/brand_meta.xml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo/src/main/res/drawable/brand_microsoft.xml b/brand/src/main/res/drawable/brand_microsoft.xml similarity index 54% rename from logo/src/main/res/drawable/brand_microsoft.xml rename to brand/src/main/res/drawable/brand_microsoft.xml index 82f9379..51dec15 100644 --- a/logo/src/main/res/drawable/brand_microsoft.xml +++ b/brand/src/main/res/drawable/brand_microsoft.xml @@ -4,19 +4,19 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#F25022" + android:fillType="nonZero" + android:pathData="M85.5,85.5l406.2,0l0,406.2l-406.2,0z" /> + android:fillColor="#7FBA00" + android:fillType="nonZero" + android:pathData="M532.3,85.5l406.2,0l0,406.2l-406.2,0z" /> + android:fillColor="#00A4EF" + android:fillType="nonZero" + android:pathData="M85.5,532.3l406.2,0l0,406.2l-406.2,0z" /> + android:fillColor="#FFB900" + android:fillType="nonZero" + android:pathData="M532.3,532.3l406.2,0l0,406.2l-406.2,0z" /> diff --git a/logo/src/main/res/drawable-night/brand_okx.xml b/brand/src/main/res/drawable/brand_okx.xml similarity index 57% rename from logo/src/main/res/drawable-night/brand_okx.xml rename to brand/src/main/res/drawable/brand_okx.xml index 78eb10c..812379f 100644 --- a/logo/src/main/res/drawable-night/brand_okx.xml +++ b/brand/src/main/res/drawable/brand_okx.xml @@ -4,23 +4,23 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FF000000" + android:fillType="nonZero" + android:pathData="M635.1,379L388.7,379C378.2,379 369.7,386.9 369.7,396.7L369.7,627.3C369.7,637.1 378.2,645 388.7,645L635.1,645C645.6,645 654,637.1 654,627.3L654,396.7C654,386.9 645.6,379 635.1,379Z" /> + android:fillColor="#FF000000" + android:fillType="nonZero" + android:pathData="M350.8,113L104.5,113C94,113 85.5,120.9 85.5,130.7L85.5,361.2C85.5,371 94,379 104.5,379L350.8,379C361.3,379 369.8,371 369.8,361.2L369.8,130.7C369.8,120.9 361.3,113 350.8,113Z" /> + android:fillColor="#FF000000" + android:fillType="nonZero" + android:pathData="M919.5,113L673.1,113C662.7,113 654.2,120.9 654.2,130.7L654.2,361.2C654.2,371 662.7,379 673.1,379L919.5,379C930,379 938.5,371 938.5,361.2L938.5,130.7C938.5,120.9 930,113 919.5,113Z" /> + android:fillColor="#FF000000" + android:fillType="nonZero" + android:pathData="M350.8,645L104.5,645C94,645 85.5,653 85.5,662.8L85.5,893.3C85.5,903.1 94,911 104.5,911L350.8,911C361.3,911 369.8,903.1 369.8,893.3L369.8,662.8C369.8,653 361.3,645 350.8,645Z" /> + android:fillColor="#FF000000" + android:fillType="nonZero" + android:pathData="M919.5,645L673.1,645C662.7,645 654.2,653 654.2,662.8L654.2,893.3C654.2,903.1 662.7,911 673.1,911L919.5,911C930,911 938.5,903.1 938.5,893.3L938.5,662.8C938.5,653 930,645 919.5,645Z" /> diff --git a/logo/src/main/res/drawable/brand_onlyfans.xml b/brand/src/main/res/drawable/brand_onlyfans.xml similarity index 68% rename from logo/src/main/res/drawable/brand_onlyfans.xml rename to brand/src/main/res/drawable/brand_onlyfans.xml index 7261279..564ba8f 100644 --- a/logo/src/main/res/drawable/brand_onlyfans.xml +++ b/brand/src/main/res/drawable/brand_onlyfans.xml @@ -4,11 +4,11 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M369.8,226.9C407.2,226.9 444.1,234.2 478.6,248.6C513.1,262.9 544.5,283.9 570.9,310.4C597.3,336.8 618.2,368.3 632.5,402.9C646.8,437.5 654.2,474.6 654.2,512C654.2,627.3 584.9,731.3 478.6,775.4C372.4,819.6 250.1,795.2 168.8,713.6C87.5,632.1 63.1,509.4 107.1,402.9C151.2,296.3 254.8,226.9 369.8,226.9ZM369.8,597.5C381,597.5 392.1,595.3 402.5,591.1C412.9,586.8 422.3,580.5 430.2,572.5C438.1,564.6 444.4,555.1 448.7,544.7C452.9,534.4 455.1,523.2 455.1,512C455.1,477.4 434.4,446.2 402.5,433C370.6,419.7 333.9,427 309.5,451.5C285.1,476 277.8,512.8 291,544.7C304.2,576.7 335.3,597.5 369.8,597.5L369.8,597.5Z" /> + android:fillType="nonZero" + android:pathData="M689.4,440.7C761.7,461.6 846.9,440.7 846.9,440.7C822.2,549.1 743.7,616.9 630.5,625.2C619.6,650.6 605.1,674.3 587.4,695.5C569.6,716.6 548.9,735.1 525.9,750.3C502.8,765.5 477.7,777.3 451.3,785.2C424.8,793.1 397.4,797.1 369.8,797.1L455.1,525.3C542.8,245.8 587.8,226.9 795.7,226.9L938.5,226.9C914.6,332.4 832.3,413 689.4,440.7L689.4,440.7Z" /> diff --git a/logo/src/main/res/drawable/brand_openai.xml b/brand/src/main/res/drawable/brand_openai.xml similarity index 94% rename from logo/src/main/res/drawable/brand_openai.xml rename to brand/src/main/res/drawable/brand_openai.xml index 42114bc..8b760e3 100644 --- a/logo/src/main/res/drawable/brand_openai.xml +++ b/brand/src/main/res/drawable/brand_openai.xml @@ -1,12 +1,10 @@ + android:pathData="M412.7,397L412.7,316.7C412.7,310 415.2,304.9 421.1,301.5L582.6,208.5C604.6,195.8 630.8,189.9 657.8,189.9C759.3,189.9 823.5,268.5 823.5,352.2C823.5,358.1 823.5,364.9 822.7,371.7L655.3,273.6C645.1,267.7 635,267.7 624.8,273.6L412.7,397ZM789.7,709.8L789.7,517.9C789.7,506.1 784.6,497.6 774.5,491.7L562.3,368.3L631.6,328.5C637.5,325.2 642.6,325.2 648.5,328.5L810,421.5C856.5,448.6 887.8,506.1 887.8,561.9C887.8,626.1 849.7,685.3 789.7,709.8L789.7,709.8ZM362.8,540.8L293.5,500.2C287.5,496.8 285,491.7 285,484.9L285,299C285,208.5 354.3,140 448.2,140C483.7,140 516.6,151.9 544.6,173L378,269.4C367.9,275.3 362.8,283.8 362.8,295.6L362.8,540.8L362.8,540.8ZM512,627L412.7,571.2L412.7,452.8L512,397L611.3,452.8L611.3,571.2L512,627ZM575.8,884C540.3,884 507.3,872.1 479.5,851L646,754.6C656.1,748.7 661.2,740.3 661.2,728.4L661.2,483.3L731.4,523.8C737.3,527.2 739.8,532.3 739.8,539.1L739.8,725C739.8,815.5 669.6,884 575.8,884L575.8,884ZM375.5,695.4L214,602.5C167.5,575.4 136.2,517.9 136.2,462.1C136.2,397 175.1,338.7 235.1,314.2L235.1,506.9C235.1,518.8 240.2,527.2 250.3,533.1L461.7,655.7L392.4,695.4C386.5,698.8 381.4,698.8 375.5,695.4ZM366.2,834.1C270.6,834.1 200.5,762.2 200.5,673.5C200.5,666.7 201.3,659.9 202.2,653.2L368.7,749.6C378.8,755.5 389,755.5 399.1,749.6L611.3,627L611.3,707.3C611.3,714.1 608.8,719.1 602.9,722.5L441.4,815.5C419.4,828.2 393.2,834.1 366.2,834.1L366.2,834.1ZM575.8,934.7C678.1,934.7 763.5,862 782.9,765.6C877.6,741.1 938.5,652.3 938.5,561.9C938.5,502.7 913.1,445.2 867.5,403.8C871.7,386 874.2,368.3 874.2,350.5C874.2,229.6 776.2,139.2 662.9,139.2C640.1,139.2 618.1,142.5 596.1,150.2C558.1,113 505.7,89.3 448.2,89.3C345.9,89.3 260.5,162 241,258.4C146.4,282.9 85.5,371.7 85.5,462.1C85.5,521.3 110.9,578.8 156.5,620.2C152.3,638 149.7,655.7 149.7,673.5C149.7,794.3 247.8,884.8 361.1,884.8C383.9,884.8 405.9,881.4 427.9,873.8C465.9,911 518.3,934.7 575.8,934.7Z" /> diff --git a/logo/src/main/res/drawable/brand_orcid.xml b/brand/src/main/res/drawable/brand_orcid.xml similarity index 71% rename from logo/src/main/res/drawable/brand_orcid.xml rename to brand/src/main/res/drawable/brand_orcid.xml index 3b880bd..09807ba 100644 --- a/logo/src/main/res/drawable/brand_orcid.xml +++ b/brand/src/main/res/drawable/brand_orcid.xml @@ -4,19 +4,19 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M938.5,512C938.5,747.6 747.6,938.5 512,938.5C276.4,938.5 85.5,747.6 85.5,512C85.5,276.4 276.4,85.5 512,85.5C747.6,85.5 938.5,276.4 938.5,512Z" /> + android:fillType="nonZero" + android:pathData="M321.7,349.1l51.3,0l0,356.9l-51.3,0z" /> + android:fillType="nonZero" + android:pathData="M448.3,349.1L587,349.1C718.9,349.1 776.9,443.3 776.9,527.7C776.9,619.3 705.3,706.3 587.7,706.3L448.3,706.3L448.3,349.1ZM499.7,659.9L581.3,659.9C697.6,659.9 724.2,571.7 724.2,527.7C724.2,456 678.6,395.4 578.6,395.4L499.7,395.4L499.7,659.9Z" /> + android:fillType="nonZero" + android:pathData="M381.1,274.8C381.1,293.1 366.1,308.4 347.4,308.4C328.8,308.4 313.7,293.1 313.7,274.8C313.7,256.1 328.8,241.1 347.4,241.1C366.1,241.1 381.1,256.4 381.1,274.8Z" /> diff --git a/brand/src/main/res/drawable/brand_patreon.xml b/brand/src/main/res/drawable/brand_patreon.xml new file mode 100644 index 0000000..6d02d82 --- /dev/null +++ b/brand/src/main/res/drawable/brand_patreon.xml @@ -0,0 +1,10 @@ + + + diff --git a/brand/src/main/res/drawable/brand_paypal.xml b/brand/src/main/res/drawable/brand_paypal.xml new file mode 100644 index 0000000..1ebb6a7 --- /dev/null +++ b/brand/src/main/res/drawable/brand_paypal.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/logo/src/main/res/drawable/brand_pixiv.xml b/brand/src/main/res/drawable/brand_pixiv.xml similarity index 82% rename from logo/src/main/res/drawable/brand_pixiv.xml rename to brand/src/main/res/drawable/brand_pixiv.xml index 4bb724f..309bc15 100644 --- a/logo/src/main/res/drawable/brand_pixiv.xml +++ b/brand/src/main/res/drawable/brand_pixiv.xml @@ -4,31 +4,31 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M325.2,435.5L361.2,435.5C364.6,435.5 367.2,438.2 367.2,441.5L367.2,630.6C367.2,633.9 364.5,636.6 361.2,636.6L325.2,636.6C321.9,636.6 319.2,633.9 319.2,630.6L319.2,441.5C319.2,438.2 321.9,435.5 325.2,435.5Z" /> + android:fillType="nonZero" + android:pathData="M548.3,537.8C547.6,536.7 547.6,535.3 548.3,534.3L614.2,440.2C615.6,438.2 614.2,435.5 611.7,435.5L568.2,435.5C562.3,435.5 556.8,438.4 553.4,443.2L506.1,510.8C504.9,512.5 502.4,512.5 501.2,510.8L453.8,443.2C450.4,438.4 444.9,435.5 439,435.5L395.5,435.5C393.1,435.5 391.6,438.2 393,440.2L458.9,534.3C459.6,535.3 459.6,536.7 458.9,537.8L393,631.9C391.6,633.9 393,636.6 395.5,636.6L439,636.6C444.9,636.6 450.4,633.7 453.8,628.9L501.2,561.2C502.4,559.5 504.9,559.5 506.1,561.2L553.5,628.9C556.8,633.7 562.3,636.6 568.2,636.6L611.8,636.6C614.2,636.6 615.6,633.9 614.2,631.9L548.3,537.8Z" /> + android:fillType="nonZero" + android:pathData="M85.5,654.6C85.5,657.9 88.2,660.6 91.5,660.6L127.6,660.6C130.9,660.6 133.6,657.9 133.6,654.6L133.6,621.8C148.8,631.2 166.8,636.6 186,636.6C241.6,636.6 286.6,591.6 286.6,536C286.6,480.5 241.6,435.5 186,435.5C162.4,435.5 140.7,443.7 123.6,457.3L111.3,438.3C110.2,436.5 108.3,435.5 106.3,435.5L91.5,435.5C88.2,435.5 85.5,438.2 85.5,441.5L85.5,654.6L85.5,654.6ZM186,588.5C157.1,588.5 133.6,565 133.6,536C133.6,507.1 157.1,483.6 186,483.6C215,483.6 238.5,507.1 238.5,536C238.5,565 215,588.5 186,588.5Z" /> + android:fillType="nonZero" + android:pathData="M343.2,411.5C356.5,411.5 367.2,400.7 367.2,387.4C367.2,374.2 356.5,363.4 343.2,363.4C329.9,363.4 319.2,374.2 319.2,387.4C319.2,400.7 329.9,411.5 343.2,411.5Z" /> + android:fillType="nonZero" + android:pathData="M645.9,435.5L682,435.5C685.3,435.5 688,438.2 688,441.5L688,630.6C688,633.9 685.3,636.6 682,636.6L645.9,636.6C642.6,636.6 639.9,633.9 639.9,630.6L639.9,441.5C639.9,438.2 642.6,435.5 645.9,435.5Z" /> + android:fillType="nonZero" + android:pathData="M663.9,411.5C677.2,411.5 688,400.7 688,387.4C688,374.2 677.2,363.4 663.9,363.4C650.7,363.4 639.9,374.2 639.9,387.4C639.9,400.7 650.7,411.5 663.9,411.5Z" /> + android:fillType="nonZero" + android:pathData="M883.9,447.1L827.4,594.4C826.4,597 822.8,597 821.8,594.4L765.2,447.1C762.5,440.1 755.8,435.5 748.4,435.5L713.6,435.5C711.5,435.5 710.1,437.6 710.9,439.6L785,632.7C785.9,635.1 788.1,636.6 790.6,636.6L858.5,636.6C861,636.6 863.3,635.1 864.1,632.7L938.3,439.6C939.1,437.6 937.6,435.5 935.5,435.5L900.8,435.5C893.3,435.5 886.6,440.1 883.9,447.1L883.9,447.1Z" /> diff --git a/brand/src/main/res/drawable/brand_tencent.xml b/brand/src/main/res/drawable/brand_tencent.xml new file mode 100644 index 0000000..b5681b2 --- /dev/null +++ b/brand/src/main/res/drawable/brand_tencent.xml @@ -0,0 +1,9 @@ + + + diff --git a/logo/src/main/res/drawable/brand_tencent_cloud.xml b/brand/src/main/res/drawable/brand_tencentcloud.xml similarity index 85% rename from logo/src/main/res/drawable/brand_tencent_cloud.xml rename to brand/src/main/res/drawable/brand_tencentcloud.xml index ad9b984..ef0448e 100644 --- a/logo/src/main/res/drawable/brand_tencent_cloud.xml +++ b/brand/src/main/res/drawable/brand_tencentcloud.xml @@ -4,15 +4,15 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M824,705.3C810.3,718.9 782.9,739.2 734.9,739.2L440,739.2C529.1,654.4 604.6,583.2 611.4,576.4C618.3,569.6 635.4,552.7 652.6,539.1C686.9,508.6 714.3,505.2 738.3,505.2C772.6,505.2 800,518.8 824,539.1C872,583.2 872,661.2 824,705.3ZM882.4,484.9C848.1,447.6 796.6,423.8 741.8,423.8C693.8,423.8 652.6,440.8 614.9,467.9C601.2,481.5 580.6,495 563.4,515.4C549.7,529 254.8,813.8 254.8,813.8C272,817.2 292.5,817.2 309.7,817.2L683.5,817.2C710.9,817.2 731.5,817.2 752,813.8C796.6,810.4 841.2,793.4 878.9,759.5C957.8,684.9 957.8,559.5 882.4,484.9Z" /> + android:fillType="nonZero" + android:pathData="M402.3,464.5C364.6,437.4 326.8,423.8 282.2,423.8C227.4,423.8 176,447.6 141.7,484.9C66.2,562.9 66.2,684.9 145.1,762.9C179.4,793.4 213.7,810.4 254.8,813.8L333.7,739.2L289.1,739.2C244.5,735.8 217.1,722.2 199.9,705.3C151.9,657.8 151.9,583.2 196.5,535.7C220.5,512 247.9,501.8 282.2,501.8C302.8,501.8 333.7,505.2 364.6,535.7C378.3,549.3 416,576.4 429.7,590L433.1,590L484.6,539.1L484.6,535.7C460.6,512 422.8,481.5 402.3,464.5" /> + android:fillType="nonZero" + android:pathData="M762.3,379.8C724.6,278 625.1,206.8 512,206.8C378.3,206.8 272,305.1 251.4,427.2C261.7,427.2 272,423.8 285.7,423.8C299.4,423.8 316.6,427.2 330.3,427.2C347.4,342.4 422.8,281.4 512,281.4C587.4,281.4 652.6,325.5 683.5,389.9C683.5,389.9 686.9,393.3 686.9,389.9C710.9,386.5 738.3,379.8 762.3,379.8C762.3,383.2 762.3,383.2 762.3,379.8" /> diff --git a/brand/src/main/res/drawable/brand_threads.xml b/brand/src/main/res/drawable/brand_threads.xml new file mode 100644 index 0000000..9ffe2d8 --- /dev/null +++ b/brand/src/main/res/drawable/brand_threads.xml @@ -0,0 +1,10 @@ + + + diff --git a/brand/src/main/res/drawable/brand_whatsapp.xml b/brand/src/main/res/drawable/brand_whatsapp.xml new file mode 100644 index 0000000..c95d3ca --- /dev/null +++ b/brand/src/main/res/drawable/brand_whatsapp.xml @@ -0,0 +1,14 @@ + + + + diff --git a/logo/src/main/res/drawable/brand_wise.xml b/brand/src/main/res/drawable/brand_wise.xml similarity index 91% rename from logo/src/main/res/drawable/brand_wise.xml rename to brand/src/main/res/drawable/brand_wise.xml index d8e79c9..cc82698 100644 --- a/logo/src/main/res/drawable/brand_wise.xml +++ b/brand/src/main/res/drawable/brand_wise.xml @@ -4,7 +4,7 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillType="nonZero" + android:pathData="M316.1,350.9L85.5,620.4L497.2,620.4L543.5,493.4L367.1,493.4L474.9,368.7L475.2,365.4L405.1,244.8L720.5,244.8L476,917.4L643.3,917.4L938.5,106.6L175.9,106.6L316.1,350.9Z" /> diff --git a/logo/src/main/res/drawable/brand_x.xml b/brand/src/main/res/drawable/brand_x.xml similarity index 76% rename from logo/src/main/res/drawable/brand_x.xml rename to brand/src/main/res/drawable/brand_x.xml index b4ed7ab..e2314b8 100644 --- a/logo/src/main/res/drawable/brand_x.xml +++ b/brand/src/main/res/drawable/brand_x.xml @@ -4,7 +4,7 @@ android:viewportWidth="1024" android:viewportHeight="1024"> + android:fillColor="#FF000000" + android:fillType="nonZero" + android:pathData="M757.3,126.5L888.1,126.5L602.3,453.1L938.5,897.5L675.3,897.5L469.1,628L233.2,897.5L102.3,897.5L408,548.2L85.5,126.5L355.4,126.5L541.8,372.9L757.3,126.5ZM711.4,819.2L783.9,819.2L316,200.7L238.2,200.7L711.4,819.2Z" /> diff --git a/build-logic/plugins/build.gradle.kts b/build-logic/plugins/build.gradle.kts index 17f3f72..53f4202 100644 --- a/build-logic/plugins/build.gradle.kts +++ b/build-logic/plugins/build.gradle.kts @@ -25,10 +25,5 @@ gradlePlugin { id = "self.compose" implementationClass = "ComposeConventionPlugin" } - - register("self.room") { - id = "self.room" - implementationClass = "RoomConventionPlugin" - } } } \ No newline at end of file diff --git a/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt index a0b402d..0c047fd 100644 --- a/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt +++ b/build-logic/plugins/src/main/kotlin/ApplicationConventionPlugin.kt @@ -16,7 +16,7 @@ class ApplicationConventionPlugin : Plugin { buildToolsVersion = "37.0.0" defaultConfig { - minSdk = 33 + minSdk = 30 targetSdk = compileSdk } @@ -33,7 +33,10 @@ class ApplicationConventionPlugin : Plugin { extensions.configure { compilerOptions { languageVersion.set(KotlinVersion.KOTLIN_2_4) - optIn.add("kotlinx.serialization.ExperimentalSerializationApi") + optIn.addAll( + "kotlinx.coroutines.FlowPreview", + "kotlinx.serialization.ExperimentalSerializationApi", + ) } } } diff --git a/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt index 114815c..106ac5d 100644 --- a/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt +++ b/build-logic/plugins/src/main/kotlin/ComposeConventionPlugin.kt @@ -21,7 +21,10 @@ class ComposeConventionPlugin : Plugin { extensions.configure { compilerOptions { - optIn.add("androidx.compose.material3.ExperimentalMaterial3Api") + optIn.addAll( + "androidx.compose.material3.ExperimentalMaterial3Api", + "androidx.compose.foundation.layout.ExperimentalLayoutApi" + ) } } diff --git a/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt index e98cb6e..08bbd7d 100644 --- a/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt +++ b/build-logic/plugins/src/main/kotlin/LibraryConventionPlugin.kt @@ -16,7 +16,7 @@ class LibraryConventionPlugin : Plugin { buildToolsVersion = "37.0.0" defaultConfig { - minSdk = 33 + minSdk = 30 } compileOptions { diff --git a/build-logic/plugins/src/main/kotlin/RoomConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/RoomConventionPlugin.kt deleted file mode 100644 index d8a1741..0000000 --- a/build-logic/plugins/src/main/kotlin/RoomConventionPlugin.kt +++ /dev/null @@ -1,27 +0,0 @@ -import com.google.devtools.ksp.gradle.KspExtension -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.kotlin.dsl.apply -import org.gradle.kotlin.dsl.configure -import org.gradle.kotlin.dsl.dependencies -import org.gradle.kotlin.dsl.getByType - -class RoomConventionPlugin : Plugin { - override fun apply(target: Project) = with(target) { - apply(plugin = "com.google.devtools.ksp") - - extensions.configure { - arg("room.incremental", "true") - arg("room.expandProjection", "true") - arg("room.schemaLocation", "$projectDir/schemas") - } - - val libs = extensions.getByType().named("libs") - dependencies { - "implementation"(libs.findLibrary("androidx.room.ktx").get()) - "implementation"(libs.findLibrary("androidx.room.runtime").get()) - "ksp"(libs.findLibrary("androidx.room.compiler").get()) - } - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c9698b6..43879c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false + alias(libs.plugins.androidx.room) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 2f78c3d..d6bea6b 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "dev.sanmer.core" + namespace = "dev.sanmer.auth" } dependencies { diff --git a/core/src/main/kotlin/dev/sanmer/encoding/Base32.kt b/core/src/main/kotlin/dev/sanmer/auth/Base32.kt similarity index 96% rename from core/src/main/kotlin/dev/sanmer/encoding/Base32.kt rename to core/src/main/kotlin/dev/sanmer/auth/Base32.kt index 1e116ea..d4c5fe5 100644 --- a/core/src/main/kotlin/dev/sanmer/encoding/Base32.kt +++ b/core/src/main/kotlin/dev/sanmer/auth/Base32.kt @@ -1,6 +1,6 @@ @file:Suppress("NOTHING_TO_INLINE") -package dev.sanmer.encoding +package dev.sanmer.auth import io.matthewnelson.encoding.base32.Base32 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray diff --git a/core/src/main/kotlin/dev/sanmer/encoding/Base64.kt b/core/src/main/kotlin/dev/sanmer/auth/Base64.kt similarity index 96% rename from core/src/main/kotlin/dev/sanmer/encoding/Base64.kt rename to core/src/main/kotlin/dev/sanmer/auth/Base64.kt index fa388a1..0dedcfe 100644 --- a/core/src/main/kotlin/dev/sanmer/encoding/Base64.kt +++ b/core/src/main/kotlin/dev/sanmer/auth/Base64.kt @@ -1,6 +1,6 @@ @file:Suppress("NOTHING_TO_INLINE") -package dev.sanmer.encoding +package dev.sanmer.auth import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray diff --git a/core/src/main/kotlin/dev/sanmer/otp/Otp.kt b/core/src/main/kotlin/dev/sanmer/auth/Otp.kt similarity index 98% rename from core/src/main/kotlin/dev/sanmer/otp/Otp.kt rename to core/src/main/kotlin/dev/sanmer/auth/Otp.kt index 3a1b84e..5288713 100644 --- a/core/src/main/kotlin/dev/sanmer/otp/Otp.kt +++ b/core/src/main/kotlin/dev/sanmer/auth/Otp.kt @@ -1,4 +1,4 @@ -package dev.sanmer.otp +package dev.sanmer.auth import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/core/src/main/kotlin/dev/sanmer/auth/OtpUri.kt b/core/src/main/kotlin/dev/sanmer/auth/OtpUri.kt new file mode 100644 index 0000000..c885ab7 --- /dev/null +++ b/core/src/main/kotlin/dev/sanmer/auth/OtpUri.kt @@ -0,0 +1,64 @@ +package dev.sanmer.auth + +import android.net.Uri + +data class OtpUri( + val type: String, + val name: String, + val issuer: String, + val secret: String, + val algorithm: String?, + val digits: Int?, + val period: Long? +) { + fun toUri(): Uri { + val builder = Uri.Builder() + builder.scheme(SCHEME) + builder.authority(type) + builder.appendQueryParameter(Query.SECRET, secret) + algorithm?.let { builder.appendQueryParameter(Query.ALGORITHM, it) } + digits?.let { builder.appendQueryParameter(Query.DIGITS, it.toString()) } + period?.let { builder.appendQueryParameter(Query.PERIOD, it.toString()) } + if (issuer.isNotEmpty()) { + builder.appendQueryParameter(Query.ISSUER, issuer) + builder.path(if (name.isNotEmpty()) "${issuer}:${name}" else issuer) + } else if (name.isNotEmpty()) { + builder.path(name) + } + return builder.build() + } + + private object Query { + const val SECRET = "secret" + const val ALGORITHM = "algorithm" + const val DIGITS = "digits" + const val PERIOD = "period" + const val ISSUER = "issuer" + } + + companion object Default { + const val SCHEME = "otpauth" + + fun Uri.isOtpUri() = scheme == SCHEME + + fun Uri.toOtpUri(): OtpUri { + require(isOtpUri()) { "Expect scheme = $SCHEME" } + val label = path?.substring(1).orEmpty() + val (issuer, name) = if (label.contains(":")) { + val values = label.split(":".toRegex(), limit = 2) + values[0] to values[1] + } else { + getQueryParameter(Query.ISSUER).orEmpty() to label + } + return OtpUri( + type = requireNotNull(host) { "Expect type" }, + name = name, + issuer = issuer, + secret = requireNotNull(getQueryParameter(Query.SECRET)) { "Expect secret" }, + algorithm = getQueryParameter(Query.ALGORITHM)?.uppercase(), + digits = getQueryParameter(Query.DIGITS)?.let(String::toInt), + period = getQueryParameter(Query.PERIOD)?.let(String::toLong) + ) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt b/core/src/main/kotlin/dev/sanmer/auth/QRCode.kt similarity index 77% rename from core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt rename to core/src/main/kotlin/dev/sanmer/auth/QRCode.kt index d5f2cec..7094e25 100644 --- a/core/src/main/kotlin/dev/sanmer/qrcode/QRCode.kt +++ b/core/src/main/kotlin/dev/sanmer/auth/QRCode.kt @@ -1,4 +1,4 @@ -package dev.sanmer.qrcode +package dev.sanmer.auth import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -11,7 +11,6 @@ import com.google.zxing.LuminanceSource import com.google.zxing.NotFoundException import com.google.zxing.PlanarYUVLuminanceSource import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.BitMatrix import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeReader import com.google.zxing.qrcode.QRCodeWriter @@ -30,13 +29,11 @@ object QRCode { private fun decode(source: LuminanceSource): String { val reader = QRCodeReader() - val result = try { + return try { reader.decode(BinaryBitmap(HybridBinarizer(source))) } catch (_: NotFoundException) { - val inverted = source.invert() - reader.decode(BinaryBitmap(HybridBinarizer(inverted))) - } - return result.text.trim() + reader.decode(BinaryBitmap(HybridBinarizer(source.invert()))) + }.text.trim() } fun decodeFromYuv( @@ -46,7 +43,7 @@ object QRCode { width: Int, height: Int ) = decode( - source = PlanarYUVLuminanceSource( + PlanarYUVLuminanceSource( yuvData, dataWidth, dataHeight, @@ -62,10 +59,8 @@ object QRCode { if (maxHeight <= 0 || maxWidth <= 0) { return this } - val maxRatio = maxWidth.toFloat() / maxHeight val ratio = width.toFloat() / height - var width = maxWidth var height = maxHeight if (maxRatio > 1) { @@ -73,15 +68,15 @@ object QRCode { } else { height = (maxWidth.toFloat() / ratio).toInt() } - return Bitmap.createScaledBitmap(this, width, height, true) } fun decodeFromStream(stream: InputStream): String? { - var bitmap = requireNotNull( - BitmapFactory.decodeStream(stream, null, BitmapFactory.Options()) - ) { "Unable to decode stream to bitmap" } - + var bitmap = BitmapFactory.decodeStream( + stream, + null, + BitmapFactory.Options() + ) ?: return null for (i in 0..2) { if (i != 0) { bitmap = bitmap.resize(bitmap.width / (i * 2), bitmap.height / (i * 2)) @@ -89,18 +84,20 @@ object QRCode { try { val pixels = IntArray(bitmap.width * bitmap.height) bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - return decode(source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels)) + return decode(RGBLuminanceSource(bitmap.width, bitmap.height, pixels)) } catch (_: NotFoundException) { } } return null } - private fun createBitmap( - bitMatrix: BitMatrix, - @ColorInt foregroundColor: Int, - @ColorInt backgroundColor: Int + fun encodeToBitmap( + content: String, + size: Int, + @ColorInt foregroundColor: Int = Color.WHITE, + @ColorInt backgroundColor: Int = Color.BLACK, ): Bitmap { + val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) val width = bitMatrix.width val height = bitMatrix.height val pixels = IntArray(width * height) @@ -114,15 +111,4 @@ object QRCode { bitmap.setPixels(pixels, 0, width, 0, 0, width, height) return bitmap } - - fun encodeToBitmap( - contents: String, - size: Int, - @ColorInt foregroundColor: Int = Color.WHITE, - @ColorInt backgroundColor: Int = Color.BLACK, - ) = createBitmap( - bitMatrix = QRCodeWriter().encode(contents, BarcodeFormat.QR_CODE, size, size, hints), - foregroundColor = foregroundColor, - backgroundColor = backgroundColor - ) } \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/crypto/Crypto.kt b/core/src/main/kotlin/dev/sanmer/auth/crypto/Crypto.kt similarity index 72% rename from core/src/main/kotlin/dev/sanmer/crypto/Crypto.kt rename to core/src/main/kotlin/dev/sanmer/auth/crypto/Crypto.kt index 828b1cb..47dd514 100644 --- a/core/src/main/kotlin/dev/sanmer/crypto/Crypto.kt +++ b/core/src/main/kotlin/dev/sanmer/auth/crypto/Crypto.kt @@ -1,7 +1,7 @@ -package dev.sanmer.crypto +package dev.sanmer.auth.crypto -import dev.sanmer.encoding.decodeBase64 -import dev.sanmer.encoding.encodeBase64 +import dev.sanmer.auth.decodeBase64 +import dev.sanmer.auth.encodeBase64 import java.security.SecureRandom import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory @@ -11,30 +11,15 @@ import javax.crypto.spec.SecretKeySpec interface Crypto { suspend fun encrypt(input: ByteArray): ByteArray suspend fun encrypt(input: String) = encrypt(input.toByteArray(Charsets.UTF_8)).encodeBase64() - suspend fun decrypt(input: ByteArray): ByteArray suspend fun decrypt(input: String) = decrypt(input.decodeBase64()).toString(Charsets.UTF_8) - object None : Crypto { - override suspend fun encrypt(input: ByteArray): ByteArray { - return input - } - - override suspend fun encrypt(input: String): String { - return input - } - - override suspend fun decrypt(input: ByteArray): ByteArray { - return input - } - - override suspend fun decrypt(input: String): String { - return input - } - - } + companion object Default : Crypto { + override suspend fun encrypt(input: ByteArray) = input + override suspend fun encrypt(input: String) = input + override suspend fun decrypt(input: ByteArray) = input + override suspend fun decrypt(input: String) = input - companion object Default { const val FACTORY_ALGORITHM = "PBKDF2WithHmacSHA256" const val ALGORITHM = "AES/GCM/NoPadding" const val KEY_ALGORITHM = "AES" diff --git a/core/src/main/kotlin/dev/sanmer/crypto/PasswordKey.kt b/core/src/main/kotlin/dev/sanmer/auth/crypto/PasswordKey.kt similarity index 80% rename from core/src/main/kotlin/dev/sanmer/crypto/PasswordKey.kt rename to core/src/main/kotlin/dev/sanmer/auth/crypto/PasswordKey.kt index fb0d550..c2bafa6 100644 --- a/core/src/main/kotlin/dev/sanmer/crypto/PasswordKey.kt +++ b/core/src/main/kotlin/dev/sanmer/auth/crypto/PasswordKey.kt @@ -1,8 +1,8 @@ -package dev.sanmer.crypto +package dev.sanmer.auth.crypto -import dev.sanmer.crypto.Crypto.Default.generateSecretKey -import dev.sanmer.crypto.Crypto.Default.randomIv -import dev.sanmer.crypto.Crypto.Default.randomSalt +import dev.sanmer.auth.crypto.Crypto.Default.generateSecretKey +import dev.sanmer.auth.crypto.Crypto.Default.randomIv +import dev.sanmer.auth.crypto.Crypto.Default.randomSalt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.crypto.Cipher @@ -41,6 +41,7 @@ class PasswordKey private constructor( } companion object Default { - fun new(password: String) = PasswordKey(password.toCharArray()) + private fun CharSequence.toCharArray() = CharArray(length) { get(it) } + fun new(password: CharSequence) = PasswordKey(password.toCharArray()) } } diff --git a/core/src/main/kotlin/dev/sanmer/crypto/SessionKey.kt b/core/src/main/kotlin/dev/sanmer/auth/crypto/SessionKey.kt similarity index 89% rename from core/src/main/kotlin/dev/sanmer/crypto/SessionKey.kt rename to core/src/main/kotlin/dev/sanmer/auth/crypto/SessionKey.kt index 2d2d122..7e9ad53 100644 --- a/core/src/main/kotlin/dev/sanmer/crypto/SessionKey.kt +++ b/core/src/main/kotlin/dev/sanmer/auth/crypto/SessionKey.kt @@ -1,6 +1,6 @@ -package dev.sanmer.crypto +package dev.sanmer.auth.crypto -import dev.sanmer.crypto.Crypto.Default.randomIv +import dev.sanmer.auth.crypto.Crypto.Default.randomIv import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.crypto.Cipher @@ -28,7 +28,7 @@ class SessionKey( cipher.doFinal(data) } - suspend fun getKeyEncryptedByPassword(password: String) = + suspend fun getKeyEncryptedByPassword(password: CharSequence) = PasswordKey.new(password).encrypt(key.encoded) companion object Default { @@ -41,7 +41,7 @@ class SessionKey( }.generateKey() ) - suspend fun decryptKeyByPassword(key: ByteArray, password: String) = SessionKey( + suspend fun decryptKeyByPassword(key: ByteArray, password: CharSequence) = SessionKey( key = PasswordKey.new(password).decrypt(key).toSecretKey() ) } diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/BaseClock.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/BaseClock.kt new file mode 100644 index 0000000..4f1cffc --- /dev/null +++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/BaseClock.kt @@ -0,0 +1,19 @@ +package dev.sanmer.auth.ntp + +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Instant +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +internal class BaseClock( + val instant: Instant = Clock.System.now(), + val mark: TimeMark = TimeSource.Monotonic.markNow(), +) : Clock { + override fun now() = instant + mark.elapsedNow() + + fun sync(offset: Duration) = BaseClock( + instant = now() + offset, + mark = TimeSource.Monotonic.markNow() + ) +} \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpClock.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpClock.kt new file mode 100644 index 0000000..4766ad2 --- /dev/null +++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpClock.kt @@ -0,0 +1,11 @@ +package dev.sanmer.auth.ntp + +import kotlin.time.Clock +import kotlin.time.Duration + +class NtpClock( + private val clock: Clock, + val message: NtpMessage, + val offset: Duration, + val rtt: Duration +) : Clock by clock \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpMessage.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpMessage.kt new file mode 100644 index 0000000..b06c444 --- /dev/null +++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpMessage.kt @@ -0,0 +1,65 @@ +package dev.sanmer.auth.ntp + +import java.net.Inet4Address +import java.net.InetAddress +import kotlin.time.Duration +import kotlin.time.Instant + +data class NtpMessage( + val leapIndicator: LeapIndicator, + val version: Int, + val stratum: Stratum, + val poll: Duration, + val precision: Duration, + val rootDelay: Duration, + val rootDispersion: Duration, + val referenceIdentifier: ReferenceIdentifier, + val referenceTimestamp: Instant +) { + internal constructor(packet: NtpPacket) : this( + leapIndicator = packet.readLeapIndicator().also { + check(it != LeapIndicator.Unknown) { "Not synchronized" } + }, + version = packet.readVersion().also { + check(it == 3 || it == 4) { "Not supported" } + }, + stratum = packet.readStratum().also { + check(it != Stratum.Unknown) { "Not synchronized" } + }, + poll = packet.readPoll(), + precision = packet.readPrecision(), + rootDelay = packet.readRootDelay(), + rootDispersion = packet.readRootDispersion(), + referenceIdentifier = packet.readReferenceIdentifier(), + referenceTimestamp = packet.readReferenceTimestamp() + ) + + enum class LeapIndicator { + None, + PlusOneSecond, + MinusOneSecond, + Unknown + } + + sealed interface Stratum { + data object Unspecified : Stratum + data object Primary : Stratum + + @JvmInline + value class Secondary(val stratum: Int) : Stratum { + override fun toString() = stratum.toString() + } + + data object Unknown : Stratum + } + + class ReferenceIdentifier internal constructor( + private val bytes: ByteArray + ) { + fun kissCode() = bytes.toString(Charsets.US_ASCII) + + fun ipv4Address(): InetAddress = Inet4Address.getByAddress(bytes) + + fun ipv6Hash() = bytes.toHexString() + } +} diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpPacket.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpPacket.kt new file mode 100644 index 0000000..4a7a976 --- /dev/null +++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpPacket.kt @@ -0,0 +1,129 @@ +package dev.sanmer.auth.ntp + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.log2 +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit +import kotlin.time.Instant + +internal class NtpPacket( + val bytes: ByteArray +) { + constructor(size: Int = PACKET_SIZE) : this( + bytes = ByteArray(size) + ) + + private val buffer = ByteBuffer.wrap(bytes) + .order(ByteOrder.BIG_ENDIAN) + + fun readLeapIndicator(): NtpMessage.LeapIndicator { + val leap = (buffer.get(0).toInt() and 0xff) ushr 6 + return when (leap) { + 0 -> NtpMessage.LeapIndicator.None + 1 -> NtpMessage.LeapIndicator.PlusOneSecond + 2 -> NtpMessage.LeapIndicator.MinusOneSecond + else -> NtpMessage.LeapIndicator.Unknown + } + } + + fun writeVersion(value: Int = VERSION) { + val current = buffer.get(0).toInt() and 0xFF + val updated = (current and 0xC7) or (value shl 3) + buffer.put(0, updated.toByte()) + } + + fun readVersion(): Int { + return ((buffer.get(0).toInt() and 0xFF) ushr 3) and 0x07 + } + + fun writeMode(value: Int = MODE_CLIENT) { + val current = buffer.get(0).toInt() and 0xFF + val updated = (current and 0xF8) or value + buffer.put(0, updated.toByte()) + } + + fun readMode(): Int { + return buffer.get(0).toInt() and 0x07 + } + + fun readStratum(): NtpMessage.Stratum { + val stratum = buffer.get(1).toInt() and 0xFF + return when { + stratum == 0 -> NtpMessage.Stratum.Unspecified + stratum == 1 -> NtpMessage.Stratum.Primary + stratum <= 15 -> NtpMessage.Stratum.Secondary(stratum) + else -> NtpMessage.Stratum.Unknown + } + } + + fun writePoll(value: Duration) { + val value = log2(value.toDouble(DurationUnit.SECONDS)) + buffer.put(2, value.roundToInt().toByte()) + } + + fun readPoll(): Duration { + val value = buffer.get(2).toInt() + return Math.scalb(1.0, value).seconds + } + + fun writePrecision(value: Duration) { + val value = log2(value.toDouble(DurationUnit.SECONDS)) + buffer.put(3, value.roundToInt().toByte()) + } + + fun readPrecision(): Duration { + val value = buffer.get(3).toInt() + return Math.scalb(1.0, value).seconds + } + + fun readRootDelay(): Duration { + val value = buffer.getInt(4) + return (value / FIXED_POINT_SCALE).seconds + } + + fun readRootDispersion(): Duration { + val value = buffer.getInt(8).toUInt().toLong() + return (value / FIXED_POINT_SCALE).seconds + } + + fun readReferenceIdentifier(): NtpMessage.ReferenceIdentifier { + return NtpMessage.ReferenceIdentifier(bytes.copyOfRange(12, 16)) + } + + fun writeTimestamp(index: Int, value: Instant) { + val seconds = value.epochSeconds + NTP_OFFSET + val fraction = (value.nanosecondsOfSecond.toLong() shl 32) / 1_000_000_000L + buffer.putInt(index, seconds.toInt()) + buffer.putInt(index + 4, fraction.toInt()) + } + + fun writeTransmitTimestamp(value: Instant) = writeTimestamp(40, value) + + fun readTimestamp(index: Int): Instant { + val seconds = buffer.getInt(index).toUInt() + val epochSeconds = seconds.toLong() - NTP_OFFSET + val fraction = buffer.getInt(index + 4).toUInt() + val nanosecondsOfSecond = ((fraction.toLong() * 1_000_000_000L) ushr 32).toInt() + return Instant.fromEpochSeconds(epochSeconds, nanosecondsOfSecond) + } + + fun readReferenceTimestamp() = readTimestamp(16) + + fun readOriginTimestamp() = readTimestamp(24) + + fun readReceiveTimestamp() = readTimestamp(32) + + fun readTransmitTimestamp() = readTimestamp(40) + + companion object Default { + const val VERSION = 4 + const val MODE_CLIENT = 3 + const val MODE_SERVER = 4 + const val PACKET_SIZE = 48 + const val NTP_OFFSET = 2208988800L + const val FIXED_POINT_SCALE = 65536.0 + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpServer.kt b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpServer.kt new file mode 100644 index 0000000..1890a5f --- /dev/null +++ b/core/src/main/kotlin/dev/sanmer/auth/ntp/NtpServer.kt @@ -0,0 +1,81 @@ +package dev.sanmer.auth.ntp + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import kotlin.time.Duration +import kotlin.time.Duration.Companion.microseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +interface NtpServer { + val address: String + val port: Int get() = 123 + val timeout: Duration get() = 1.seconds + + suspend fun sync() = withContext(Dispatchers.IO) { + val address = InetAddress.getByName(address) + val socket = DatagramSocket() + socket.soTimeout = timeout.toInt(DurationUnit.MILLISECONDS) + + val clock = BaseClock() + val packet = NtpPacket() + packet.writeVersion() + packet.writeMode() + packet.writePoll(64.seconds) + packet.writePrecision(1.microseconds) + + val p = DatagramPacket(packet.bytes, NtpPacket.PACKET_SIZE, address, port) + val t1 = clock.now() + packet.writeTransmitTimestamp(t1) + socket.send(p) + socket.receive(p) + val t4 = clock.now() + socket.close() + + val message = NtpMessage(packet) + val t2 = packet.readReceiveTimestamp() + val t3 = packet.readTransmitTimestamp() + val offset = (((t2 - t1) + (t3 - t4)) / 2) + val rtt = ((t4 - t1) - (t3 - t2)) + NtpClock(clock.sync(offset), message, offset, rtt) + } + + data class Custom( + override val address: String + ) : NtpServer + + data object Alibaba : NtpServer { + override val address = "ntp.aliyun.com" + } + + data object Apple : NtpServer { + override val address = "time.apple.com" + } + + data object Amazon : NtpServer { + override val address = "time.aws.com" + } + + data object Cloudflare : NtpServer { + override val address = "time.cloudflare.com" + } + + data object Google : NtpServer { + override val address = "time.google.com" + } + + data object Meta : NtpServer { + override val address = "time.facebook.com" + } + + data object Microsoft : NtpServer { + override val address = "time.windows.com" + } + + data object Tencent : NtpServer { + override val address = "ntp.tencent.com" + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/ntp/NtpServer.kt b/core/src/main/kotlin/dev/sanmer/ntp/NtpServer.kt deleted file mode 100644 index f9526e6..0000000 --- a/core/src/main/kotlin/dev/sanmer/ntp/NtpServer.kt +++ /dev/null @@ -1,138 +0,0 @@ -package dev.sanmer.ntp - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetAddress -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.DurationUnit - -interface NtpServer { - val address: String - val port: Int get() = NTP_PORT - val timeout: Duration get() = 1.seconds - - suspend fun address() = address.toInetAddress() - suspend fun sync() = sync(this) - - data class Custom( - override val address: String - ) : NtpServer - - data object Alibaba : NtpServer { - override val address = "ntp.aliyun.com" - } - - data object Apple : NtpServer { - override val address = "time.apple.com" - } - - data object Amazon : NtpServer { - override val address = "time.aws.com" - } - - data object Cloudflare : NtpServer { - override val address = "time.cloudflare.com" - } - - data object Google : NtpServer { - override val address = "time.google.com" - } - - data object Meta : NtpServer { - override val address = "time.facebook.com" - } - - data object Microsoft : NtpServer { - override val address = "time.windows.com" - } - - data object Tencent : NtpServer { - override val address = "ntp.tencent.com" - } - - data class NtpTime( - val rtt: Duration = Duration.ZERO, - val offset: Duration = Duration.ZERO - ) { - val offsetValue by lazy { offset.toLong(DurationUnit.MILLISECONDS) } - val currentTimeMillis inline get() = System.currentTimeMillis() + offsetValue - } - - private companion object Impl { - const val NTP_PORT = 123 - const val NTP_MODE = 3 - const val NTP_VERSION = 3 - const val NTP_PACKET_SIZE = 48 - const val NTP_OFFSET = 2208988800L - - const val INDEX_VERSION = 0 - const val INDEX_RECEIVE_TIME = 32 - const val INDEX_TRANSMIT_TIME = 40 - - suspend fun sync(server: NtpServer) = withContext(Dispatchers.IO) { - val data = NtpData(NTP_PACKET_SIZE) - data.writeNtpVersion(NTP_VERSION) - - DatagramSocket().use { socket -> - socket.soTimeout = server.timeout.toInt(DurationUnit.MILLISECONDS) - - val request = DatagramPacket(data.bytes, data.size, server.address(), server.port) - val t1 = System.currentTimeMillis() - data.writeTransmitTimestamp(t1) - socket.send(request) - - val response = DatagramPacket(data.bytes, data.size) - socket.receive(response) - val t4 = System.currentTimeMillis() - - val t2 = data.readTimestamp(INDEX_RECEIVE_TIME) - val t3 = data.readTimestamp(INDEX_TRANSMIT_TIME) - - NtpTime( - rtt = ((t4 - t1) - (t3 - t2)).milliseconds, - offset = (((t2 - t1) + (t3 - t4)) / 2.0).milliseconds - ) - } - } - - suspend fun String.toInetAddress(): InetAddress = withContext(Dispatchers.IO) { - InetAddress.getByName(this@toInetAddress) - } - - class NtpData( - val bytes: ByteArray - ) { - constructor(size: Int) : this( - bytes = ByteArray(size) - ) - - private val buffer = ByteBuffer.wrap(bytes) - .order(ByteOrder.BIG_ENDIAN) - - val size inline get() = bytes.size - - fun writeNtpVersion(value: Int) { - buffer.put(INDEX_VERSION, (NTP_MODE or (value shl 3)).toByte()) - } - - fun writeTransmitTimestamp(value: Long) { - val seconds = (value / 1000L) + NTP_OFFSET - val fraction = ((value % 1000L) * 0x100000000L / 1000L) - buffer.putInt(INDEX_TRANSMIT_TIME, seconds.toInt()) - buffer.putInt(INDEX_TRANSMIT_TIME + 4, fraction.toInt()) - } - - fun readTimestamp(index: Int): Long { - val seconds = buffer.getInt(index).toLong() and 0xFFFFFFFFL - val fraction = buffer.getInt(index + 4).toLong() and 0xFFFFFFFFL - return ((seconds - NTP_OFFSET) * 1000) + (fraction * 1000) / 0x100000000L - } - } - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt b/core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt deleted file mode 100644 index e2db886..0000000 --- a/core/src/main/kotlin/dev/sanmer/otp/OtpUri.kt +++ /dev/null @@ -1,116 +0,0 @@ -package dev.sanmer.otp - -import android.net.Uri - -class OtpUri private constructor() { - constructor( - issuer: String, - name: String, - type: String, - secret: String, - algorithm: String?, - digits: Int?, - counter: Long? = null, - period: Long? = null - ) : this() { - this.issuer = issuer - this.name = name - this.type = type - this.secret = secret - this.algorithm = algorithm - this.digits = digits - this.counter = counter - this.period = period - } - - var issuer: String = "" - private set - - var name: String = "" - private set - - var type: String = "" - private set - - var secret: String = "" - private set - - var algorithm: String? = null - private set - - var digits: Int? = null - private set - - var counter: Long? = null - private set - - var period: Long? = null - private set - - private fun fromUri(uri: Uri): OtpUri { - require(uri.isOtpUri()) { "Expected scheme = $SCHEME" } - type = requireNotNull(uri.host) { "Expected type" } - secret = requireNotNull(uri.getQueryParameter(Query.SECRET)) { "Expected secret" } - algorithm = uri.getQueryParameter(Query.ALGORITHM) - digits = uri.getQueryParameter(Query.DIGITS)?.let(String::toInt) - counter = uri.getQueryParameter(Query.COUNTER)?.let(String::toLong) - period = uri.getQueryParameter(Query.PERIOD)?.let(String::toLong) - - val label = uri.path?.substring(1).orEmpty() - if (label.contains(":")) { - val values = label.split(":".toRegex(), limit = 2) - issuer = values[0] - name = values[1] - } else { - issuer = uri.getQueryParameter(Query.ISSUER).orEmpty() - name = label - } - - return this - } - - private fun toUri(): Uri { - val builder = Uri.Builder() - builder.scheme(SCHEME) - builder.authority(type) - builder.appendQueryParameter(Query.SECRET, secret) - algorithm?.let { builder.appendQueryParameter(Query.ALGORITHM, it) } - digits?.let { builder.appendQueryParameter(Query.DIGITS, it.toString()) } - period?.let { builder.appendQueryParameter(Query.PERIOD, it.toString()) } - counter?.let { builder.appendQueryParameter(Query.COUNTER, it.toString()) } - - if (issuer.isNotBlank()) { - builder.appendQueryParameter(Query.ISSUER, issuer) - builder.path(if (name.isNotBlank()) "${issuer}:${name}" else issuer) - } else if (name.isNotBlank()) { - builder.path(name) - } - - return builder.build() - } - - override fun toString(): String { - return toUri().toString() - } - - internal object Query { - const val SECRET = "secret" - const val ALGORITHM = "algorithm" - const val DIGITS = "digits" - const val PERIOD = "period" - const val COUNTER = "counter" - const val ISSUER = "issuer" - } - - companion object Default { - const val SCHEME = "otpauth" - - fun parse(uriString: String) = Uri.parse(uriString).let(OtpUri()::fromUri) - - fun String.toOtpUri() = parse(this) - - fun String.isOtpUri() = startsWith(SCHEME) - - fun Uri.isOtpUri() = scheme == SCHEME - } -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6f2619..edcd5f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,12 +12,13 @@ androidxCoreSplashscreen = "1.2.0" androidxDataStore = "1.2.1" androidxLifecycle = "2.11.0" androidxNavigation3 = "1.1.5" -androidxRoom = "2.8.4" +androidxRoom = "3.0.1" encoding = "2.6.0" googleZxing = "3.5.4" koin = "4.2.2" kotlin = "2.4.10" kotlinxCoroutines = "1.11.0" +kotlinxDatetime = "0.8.0" kotlinxSerialization = "1.11.0" ksp = "2.3.7" @@ -47,9 +48,8 @@ androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmode androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycle" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" } -androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidxRoom" } -androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidxRoom" } -androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidxRoom" } +androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "androidxRoom" } +androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "androidxRoom" } encoding-base32 = { module = "io.matthewnelson.encoding:base32", version.ref = "encoding" } encoding-base64 = { module = "io.matthewnelson.encoding:base64", version.ref = "encoding" } google-zxing = { module = "com.google.zxing:core", version.ref = "googleZxing" } @@ -57,6 +57,7 @@ koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" } @@ -64,10 +65,10 @@ kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-seria self-application = { id = "self.application", version = "unspecified" } self-library = { id = "self.library", version = "unspecified" } self-compose = { id = "self.compose", version = "unspecified" } -self-room = { id = "self.room", version = "unspecified" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +androidx-room = { id = "androidx.room3", version.ref = "androidxRoom" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/logo/afphoto/aliyun.afphoto b/logo/afphoto/aliyun.afphoto deleted file mode 100644 index 23c9c369af137f9c7c28ba128bf5fdb47344d234..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13449 zcmd^lbyQT*+wM>z4bojhcM2m70z*lcAl)EHqm)t#NX*a;(jh6O(lvAlQUb~V(hZKp z+=IXGTkEd7?p^oqyY60`Ip^%N&(3$h@AEtd5L#1_0CXSp+{as&9s1mHmJ0MQDO3OF z9Q9xK|G5K!dbf|9?yj+c0~0uOy*+*J0cUnq7kBPYLN_4wjYbfix1XAY!Z)%Pq@XuX zt9*3mZi+9G*9;jO4jH?RuuUP2FP-{drV#8BY&&do;Z$#55txGw7O8}4 z#+OB78boCHube{i{#)0?V4A{T_#g(*+bKe7RhDFBZl^E_YHD#^92`^Tc@rueas}&d z1+p>FqPR285C$%0Boo9QO@HAx$vrjn;sbJfs89*3U-dFs*VosIh=TIaK(S4+==J)* zdxjNWGi_<3-&UUYNBP9T`}WtLI~PB|iq-J^-BoDJ*5HZ+T%g@sS1>W=vH}i>Xz%dt zKrGmt#VeBj149LOhV$z<{0lv36eU#M28<=jN~#^t!6NeawF;4hwVt?*2g74~ntDvk z4wEaze`O-y-pq2oAT4;uFD1BFdodJ4##>4Ah8x6A5G?Ba6NxKu!I&8u$bw65$kn9A zFX~)~#AKHdfh1ew(|jRitV;~lwZXynFRx18j=?4L>b*~WSRU2)B7%6Dx9JOLfQU$~ zV2v?Zn3~0drlLtr^wGc+v6pMSmPlYh1aCCUqME1$&1)56Rt{CJ2UH9 zV!@NK2`8(Jy)TsLqC4bfOXanEEr*j?DG`~475U$p1bWqs zPcRmRG^!;rO~08G>0-=>>2ZClI8B5EaUaaP#)jb6^6t|uk*x1f46?dDD6^P( z;5V`dgI6~~uuIsxzUwy*5qEe&u^py}AjJR{ujGualAFDViWHxL+9GrGxh?1LyN`i| zl@+C76CL-r&=GaEn`Jj2wHUXV2VRPhZ#2iiIqb<9k8On)oTjDiLRU643h7P+gl5~ttcLKe<5n9NPERAC$HhqsQ zf8+QzNr`1mO|4JCOu|y>isEd0nzPE0CsV7n5+Tcr|!X283tZS zuOul37L5wS56V0*is`r|)cF|jO_Xt69^qoMy9>)=ow7Fg zP3ZED!>`2WDMuC0_XlpNSA4^hbDhoTm*S3^G10lXjNU`LVAQ2DhObd$e}ObFV< ztKk&C2mYc|Q_nfq$#ATi)hGx9{xIuJ)BeQ~E?U{1x=k~!x>oCVG{3#EtxiM#5E?Ll zfi@q}eCJ(NKBinXqkDPNK@RB{MEd)KpotQJSK3~ z$J8mooecZMkcrKo%gNfFI^LKZ9up__QV6|aQ0XevFjYg$Y|xe+OE#4n$+6c(!{^7A z#Z`xe277S`{#3SqlXd2tyD_MSpO{E;^fV;6_(=U{a?iTMf!C)=3MQxbVzTs$D7j`p{MG2Afg+$f;VTt(_C z3+a7qQITyuH(OC8u?YcAZoNtBS6yTCP!`hP>%A&5XDXS==S@&O3jvD9k2xMksm2mB zLRwR3#U<@H-qI>!lc{J76PwkDbIJlGu8kjA2&CZN$Nu|AxKN~bfRMi6!TX>BVM@Dc zW%bl<$48DVaEgYsI>zm`4=L|Hsl%BX8Mj?}99bIDglXNI?FQD@mF=geyB~P>(mj3N z_u#8d&{Rqu|6_Istbt;+T^v!2H{_NXXHr&TC4}W^N~Xo`oWG8j9&7Pl>olz<`X@Tv$@h6eu_NtEq@|7Q<|aA=#TFx*zH5jjpQFPC##E?Uicz&gq6Tct zSrR||@y3St3wc(Y<>MzAJe1@Ke-cJU9gH@7y7{bO(=f@?VEkOO&hSSw_M@mJt_cEZ zfg#?ZFev3cR7)}b1R4%RLs6Y8fiao+FlRK5jl}F!sGZFBQFOCNo?G-x=wmqL?P*;R z0>AAMxwtfKqdQ`L72_9g&DF>}WoD(9r;D*>wn;=$o{2(1;_tWkxRLknpP^D|C`o^_ zLmu?LR>&{Izzn%KFN}7-p-*$xKmummWW!5P5pVn?ypnoy%c@^X%zr#sRY;1vp+hb% zA=sWH?ho`!M&`>Ob0kf#|&)s=hR!@39ZDt30it-{kaq5?aL&pGTaE4diF zLO+{W{;V6s!WxL9=HSQ~FI9c;K&K2W;YwH5cr~d0euU^aA0kj@0IG*AlcLM!;%88w zUg(w0Y@jsth0J)HtXyEtLN0#7v~gRS1H_yW$JfFN6voPnhfXuyxAb*Cs(MZpCF|a?Jp`hg%O;&dHY7FvEQE7 ziygPT$gV${NN6yYh(c&kgE)nb*OG{W0Zh*no4~-beeazru6-9P1`{R4H9tx`nhTiP<$k`^d=25fYHA>SOaH5o; zom}xH=SUnorynFTRl{NS9Mb#g{S-=nAMVG!tUF$d7)IU*>y}i7hIhpmcY^UuoE0${ z*wxl(6j8E8?B&x&@+29_5q4la26kdoj07^uH5w6=n{cLN13bh?bee!{--Lrb`L_GU z=zYN-p|o>w(M=_9#cjGVt~T!e68ZxS#X8&8uUy!MvP!vr_?9w6MC6xEw2(2oLErfJ zcC`y`(|4_6bM)66tzwpCG)r|U1Z_2C{?aU|58!BeF(3){O63xxzZbd&Zcwu#aiV|2%XB6AM2kNau)>MWY?`$ zxcl_oiBI(?9F}5>0vq2|7*ngK;@CabvPEq+wLA`qxbc(G~5bjQ4CGivCLj945>LvN4EEP8J?QjVBxR5 zCNUzldBNUbiz#D|5lg17@GbHQ2`ypP5gCW#{YQV!lDZ4ePUW05Kqzs}~) z$f5}+wV>e9FIG=xD-!VjmsHGJEd(7rR`hhabI=uh>}TK#rrI?OqHN#r!C){wI_i?({XI@~NqY{N+PC?V9StlAyo9ldU z*>9VA^@%EdMy=>3dcE@Y`#g!b_CcBARRB&_X@WBrdDOdcFRqz--Zza5)#m$ToO*?hcxU7^*~x~W zY>c3HuJ5~FdNjfk%du^LpVnFN?yi@#zd;6L?2kmfPk&AZXmIEWo4=nQCa{xK)@Xvd z4#_^hgkZc5)dsf>z%|9pMw>a!S{E;TWBHfd{ZHWqLq40u{RB!rr=6sB&axh)QweWSEgs6N@ zp%5@gPwB*9HFMFil~85(NfsNY%T9;h ziiJBehguIZwkyG}NG#X2e#;Z8Wly4+j?0V)Dv_%*yk|;b{K*clj7t4Z|7|iIC%4Uo zD#gbKRx;4c@$w9ffe-?`(og#(zuNYZXZnh!?GCrU3HWSFs zUmR{%J2@*;oHZ+dJ8eFfA#L`kc&y}sUTwit7P1(J94G{$dS(VW2%j+Tf8XeTgdXt= zI@URmpOUL9{`E}f)@TLlcM=;r()N`Jg1jP`BIP)VW~O;wi|{U7>J&_gioSj9z&&!B z*kCI-QCnEgqCZhvZ_7C!!PjBZj$!q>IN{LG0#Llm6J`Eb~G?Uro*u}SrXGmA*vgDN82#HUC|?Jjis zjg7A}-|R8QIEC$GBo$PuzMuI+RAegG;vx6?Yqa3i;E|RX08Z3s>uwVt4TlRq?@v2w z`dUUR@NkNZ$|dynk*I(ynr(zZmLS@>Z6Aa@aPJ2lUu%j^&`j_(CmHiKNAjf-DUvMT zQnRW@Q+2inx=PSkwzJuM%y8mK1D&1dL~V`k#~taXMgz4Gl0`@aV_MnJ4O`A(eUyOj z>&I8;`U)?PY2GVzr_IFK#r9rO5U#`ONCkX(7jAe3wGaNpxs_T}+3QI&a`W$|q;oyAS!AV}dtt#FavO`mqS2 z2(o~^LCz^g{TUB$ggI?=Q*tWd^--3lpu_9%v`h-OuwAw#29-bG#$Q>INr;GYVNF?! z)lAAtUgAFxj~t1`ETv>*IKB7XXh}aqU+^=*8|VX0#WAr@ZYOmWIz=oC;VU?5lFx0S z1LCNtfnd%*R(Rvp!f? z?WL!zGg8KOzU1=Y>cEA_uqiP}6%`4TqPkZ5gdK{9FrNxEW*})Ivx~7Jo!+cU9xnbR z;gre$U8^8Ta8f{)Qg$WbwnB_Yx_D`737X+Iq}D-Bh8-oWm@h^%d84-#b|&AzG$^@b zs9(`fXHOCE_Tm%U6@||<4_6X4zJBwr%ir*((gpu8vbkV41Yf4s-ObI&K^(N#_8P}m zZ8iO!qMrpWEu%>ho!?O*-GtMa-=V;!i~q`pu;5 zmZ$`;A2@!+kH}k5T9n0mCSG?)7@(jC?TqJ@cwPfe&S9!KD$nzlAkVB?J8Nh3TGQ*J zC27jA*4t7m&kMLWh`E5JQ7%vAQz&q3;harR&(;XlfAiD-E3PkVXZoaJQv~NEO&wHU zlU~+4({}u^m4bw>kj|SIXIfY%z4sH53m0I9s7c9H4MR=lmjtin&Vo|1^0-v64n;oa{iA{!IQtA>Ie0lyd zb;3m7tBK_6S3$y=apT|p%uJd3=Cqfl!!jqn9Q-&Msnu-0b2mk-=7N2`DZ=Ze84~7I z!rof$CR#A>vmgqG6n{%S6J$IkiaD!IX7f-@!4@UCf#X`i(CwahWSEk1r`| z54IJ${*rc)eU2BZz3r&LAVLF)UDZ$=(4_yR{}!zOPl7haf0YyacgFm8tNvekx{Cj~ z_-_%sWsukt6A#Gq`{a0y3$pjL6STMSy=&3X*~9kkRY^t=2>AP#cz_Fxce*-iMEG?0 zKn@X9T}A&tsyQa;{+(!k7WnNiaKiIaH}e63i2Lpij8r~idJu?V396!K7?`vFBc#dG z&ywrtaucdR+DE}`pxIAK$$=de%*MyX=c;d_y(3;?_o|ZCk1iYPX0$U}J&=P~lpU=! zhPXiAXT8rPS!*0>ROGc**C6G6`$op+^7?p*dR(#fGRJ2^I{$0f)w#Kw5XEiKs?Xs~ z$ZF@|<>$`TJ8}R2w`A=iF^e(Z+Mx|cc7bs@7Fwi;=`Sa=)1Ou;yO(v0-N5!tawCX7 zb09uJpF6jo&DzXogj8Z3js2Cp0tcDG*kLlRflSHBN&b6R5iwf@;8(N7_VDGD{=?mB zdwqO+Q~n`vpPrA^Z8+#(nG3rzhnP|$#4Kn;U?u4RD_u@0AEN4$e}up?UfTRyb? zv7gx(*dfJ{qrkDI-AhF#{WROYIyghu9xhp=Ag}pD51)9n2Zz$1)Dz%$Nz#|@Cl1?o zg{i{IINAbSxOqd$AqvTDx9wXyU0^km2?AKLr;iKF0G^Zfn6>o_{|~JNtUB$#l26o6 z$f2S92&~w3VWtV!_Ai}~K7V@0j*c8;-41{JfHZU^1zDq=E@TvwiG^cLaPGkbLs~hA z9LbkT5wH7b!j!WGznkDzOtu86!I&$@yMHfdAwA(=q!CsUme0Az8=EM?wIzcarKJ%Q zjv`+@;Y)Pq$mCAvfda~UymJjsAgwZ*Zrr<(%)e+sHosBF4hr#*y!lDH-@nQS=LjDICB_m{5qV2O^$a(wP7=Z84*xEFnL&}Vtb#*pkSpcEaBHPK(m zS46wl)SM`UDa2+1wfWi0AGTYmEF46lb>b9~eR=MYP);mHiQo17%!4Tj`4t+X5%{cL6~vXkV;-P}(F*_hXJrO< zz4ZrGCjmUp&6GMw6*wLOmyx_`Z6(KA#_x@ib+UgUAmkn;hfu3jj)%@{Y=ZO4$GfN7 z)nKdJOV369d=zidcD=vyuD5o{`s448$f74OFv&g14@7~d=eDZv89kN3mPD8oC(@SrPc{Y3h8KdRA( zN;4aO`9Cg1?_ZpOJu{c2F&cS5y+nG48s64^RF2!fN=G(kSvkA-@r**H5S}J6^E!>( zppa}Vc+P_sl-^)hv!o-m{A}7Lah`v)2n0sefD>zoyQ^vE=Qi!2&+jh%LbBNbpA=If z1iVKLMpf)EjONjXOE|Y%WFvEcn?+f8&U8zglV4Iz^S8UfF+xLIfWcj&N=K+Gm9^eB zsC;4SAAA?T_*KsVc?o-%xJQh3Td!V%w10L;53qb;9+2Zs6yA2gwo7(=9>=DQkv zubdbIx&qjmy5HNHhLpQ{Gr&Ss;}}?CpxsH|Hcmv)tTYQk(afFAN<-5GSU+vR>vhbU zu^_0k1=9^$ok!`5XJIBG<@*QZ2m1e_^YlP8+05HZ z8$n-#dG3}O1A43NM+}(yd>+bc56jvj3tU@CTyK_kw+@NWi&|o!(p2QP9a4iiUzC0d zXke?YSL77_-4J))ASBxp>g~dS(#(Ad3+6>^L*=Ap^4%a<-yMH1Eps7W1iT~`AnISf zahciRs#M;qaNq{jLQgbjr882)Ms1T;rPuOjeB0_Jr-KP&LKu@DlSzgC*|LN08Dz$p zwFw}S4OWqlwlODL7Pt`d{Z(P^xx28O%tsvy`76Gttwr_a1uxWhk+u({4C%=3l0s8| zPv`b8Um$&X880ycbzlKTTh=an>L;1idBfeB<`X`J%des?ESr{aq9J{rWw{^0++bz1rykhB5Qy zh}-VNC59|y?EKBd;&KXd`L3B@4{#+>KKqm5oy<2^DbcnXD+|8TZIC`y?KaRCFyhc; z%eqMru~TtkB&Wtzk(>`r1!M?8&V=HEi8fIX?)5J>3;LoKkkIJ|(4 z?i-+>a!bAodReh%+e^#xRRrDjP-0%9$EiM9f`h;i-a?5BLbAhhk%?74bpnDDj1f0n z@SIuSwvUnzssX3<2$5{|)z`~DZL7)X;8{NM*1+Ok#!ffwL^*L+S}Q9;+DtYT9kr-l z_o}z5Xp3Y5tph}{uMHWrra^Ch{xqUp5Y>jkVP8H$hF)s>-CK)$F*Nw?94a`z^ZsSn z!;Jt}n}ENPBojL&5nSLlH5YElUgn2C&IF6IEiisB$?zhgW~EJAs^B@vkLLC#fQ@cR zkLP;saiQt$Ys~C+y?1QtB~Il`9LccbMh&LRQp^Y@lE zPh%l^86E^HU>|Yp*@F2%aVCRhtfSE#P%vD`@!UGhe`X_kQC(QOm#;cZR%Uw%03K6! z<2HnStrGQ4&r8*+0%^Utw>BM!+cNzSmXkV5e=b=29{cpp-%th6S6e^=tzEcR=6r)f z{obBu7-G;ozit;KACa|r)G+T^?^7o)8#nUexqvoKF#Kn5N@^`D6C4>nt$XUNII~ed z%Q|PLkL9wuc?=z|8n5r`fXy19oWw0VjOp>_-vY1z%9K8|;n3LAoBaNymx$U3mm*r-zll$97M%VS&5~YzG4oC9S`-CpT&h3MH-@ZneJ{DY6b} zhYXqXfKHE5)pBx|7U}C9STEIf8M0J*v^+3(>n05YJj>qZ2 zTP;_7ai<`5u`gM#rjEYwK>teO+SiAzwess&%JO`)w5l2eqvCc*-AX|G>2Zxx>_7Jp z+Hoaq1EVfK^q&6h80&_12q6eyGF%s=d&72>Sk;5y@Vk&Y=8K%^oTziKXM-~Hyq0#l z8$1VwOz9XShh1yHnB&>JOqh%rtz}D%LBt=4g_S(DP6*~sU)Rj}N?M^8|+$rW@C%!w#(nOJtqr!485&{j0`P*hIGBuDg}Y1xl42W z|GL6}>0Nooh@DYZI${FLVF+lf_8mHM><4RAfHH2a{=9@8dO&d5Fg``M++XCzc(i{P zvsvdfFf!8Q5I)(0PR6(x>c*)cF&F`S0k;68HRAcua^)!_rgcdn_7|xaBBq>8B-R=@ z)(7+;`D|qBEGy_hV`ifgEHrFe>OIbR>fi@F6s6~D9;%AQf`C*jwoh)shaboF+(qqX zQ}DZTLOAEXhr0w?`hn(DF*2gjbHFwRst?E#WPWn;bBAFs4^uewAt$O6sCv#ul=|0b zCXi7kY5B2=7^99j>?vdT4+GG^q2(+FXzs(dw;ok`iijYE zaiJ{y?uQe#Hb6k1OF{8Vlym6U@BZrhf&8^GXj+7faY6G1RaIDZZuniTL6^)Ky!lq_ zWgt)&ArA;7tPBvm(Ins>qQr{-pU!G};s-J{2H+~EbOB_r@5iLCbO{8TpB)F(JWda1 zc*bb^>!TJ0XdXWe1hS5%0D&@CaX=tPbqr8AEx>L6*DP;RQuo|WZEt74x9>ieLO}d{ zKt+IAKAxoo9VBr4-(LUU)cF5Emi2Ph4hTfw6!2fB(_4uJa-USL{Ez7{-dSA)lNsY3 zDj(PfqVO$XxRMGOG}1WM*8tuE)iH%+UvU87mH5u`Iu9#=ES-sW#|~%L_ul7Yg0#j+ z3~{Y5DZ-CmX{>H4-kE)o=c&MPzrzC{Z|Na|uTmLQJr$-7K+gOAo*Fo7%{!o|XQEB6 z;Zr+H3^1e<5aT2bctPKdaJhXaz@5*4?es8I8o@ft8vDbL(YmJ85cIKzSa+`^#LbRK zSSKXA%t_=%J^)TzMG@|T>g^cAssdX0)*>yA$hSAQ8#c}yHI*n?t*$k}rFnhD8DzBz zVWXjmxpR0cxyYwgjq#C~bqhe4w_l;h2!MzpDl|a4HJ(1!umFxAZ@)A$z{?KVht@sa z1L($OO{cHI`;M^|4wN;lj8csjo}DGwVi;puRY4i>O3jy$I z7BUAsXzWCr2@Qefnq&Nl*#WUPK7mVvXR9ASvva-4$DH$hGefkIY}rTDfyHf?jY=bazzb&mV#X9e=%_IYX`I|(vR8o zHvIMJ}S;iZH#N02=p0x z;}s`s69@x`WRJjeL}Z*erK`60D#E3Am&Bn=smRu!(sBs9#@2|)^XzXl5}z=w?^&-i zWgwA1p|B25AH4%^Y~YpJZErh0IqSG{58@F3oxFhNr6B93bw?cZ?F@mj$VNB6^q_KD zZ$v%U@eV!U#&H6pv}PRCTzIX2062*=$l~CSW0(a@&Cxdhirgpg9}}8$^$sKbE9u0E z8sA|M^Y(x>HWpC;;Z~O(ueA|a0mpO0TXHyqE}iSMjz!;@EbYw22MVxYDf}*fh*aEr zg(s^hp}&$Qn+ddUa!b~BV!edbLb4+o{xDcb=N{%InTJ_1T7NAy1<55L0oLPqa0H&9 z&9kr1UxQVz*atobs8UgIoj5>=9gd?X#$B%0VGOBKmv;7YfDwKzZN>J|g;m+5@67+m zLcRgs_;U#WR852Kp>vOeYycSy4ir8z@Y0{z03i9UNE>DnFvE$Q(uEM*UH=fO!I+O0 zkk(?!2j2i3lit7E&WC&L$w$<$x}1SrF?bc{8mkF{db?7D^FV|V?|R_?QV8a#x+Kq$ zF0pL7+k-Jqcfg>2BSTJYR1ea8p#`ot9%h7SIOmJWn ztI6AztpfuxCEF+OfN@A5{*6Ok{p8mULrR+nqRmX!!0W|KiI8?bKR}K0?*`5(T`o>x z)B!_c7%Z>JY+!mR!4d(0Y}k>7tiGGt#~$F)kDiTTR53f%3`fl7bg?2qb^saxcw=7* zfdU*5OA^b+d&(`-0666&VcGGF{#`wtoC#AhP3xJ^0EkS-T ztE%zt;=Q3?10Pd0ar0f7g?c>$776 z50;I)!ETwjGelGRcjo{AmZ~Z{NkshO$Db#B1e0mn_BQiNdT$<$d87GT=~ z0&PDJugUJv`@GhrwOHx)f_C~PKnTd;LjT773?^SYjsblR^$w+qTI+1(44MF}4?erP zm!E>1oYo!vv7r6%#%(*jLgCbfuQ87EDb#FXBzS^uX zgE?rV4ZEdp3>}vMVuJK&r(rIZv6QQ6r=uMO_rPWm|5U@uG(|YrrTsbxc4@p7t_Vkd z28ezJX4m|+?aW5=4r#VS1%B6>6Zv4fDBZn!A{s}Xgi3%TUw4*{ECG#{mhgeJ)8pXV zOk+dSdLYTo&$c)D#{jRHFuEh%CZKz02}x{G;>5$Jht;0pzxp!1G;g1vfO7wz62Tmb1aHrwg6@73=*g;_k!jWT;>ySlly zLrPvGEOO5Y9OMAV5KZ53SlVuC!l?l+Id=i|R`Pp`S8uQMsg^V7wn69Su<7jXc{s}z zMmh57J{_SL$CK*u?&@=BXv4O*3!slk#$gHvn!#qOl!c#m%zhlhGs}Cbky-js{D-&2 zMdPD8?ItvQ%l?IjuzQOgAoUr?WPrpt3?TvtA%JNL0?(n-zHPjSOU|}x2ti|`C#(z* zI_`?xLG%C6>2yFN0+uG2HSU3zAD{sZt~C?%5+VYC{hB71js)xt2%8H!jeK`?p?qP? z>fRwhvHr7*kFwH;&{@`uTs~N^=gh|N4$G298V(EeAM*WRW^n=tU9JD{gX$g+coYIk1BistnUBJ_5rzhHr zNW2OHTI^;CppMQY+jzV09RvgLvz#$w@$?>n0u0bXCw|?m$5l4gol%5;_P3xC1J}(0 zOPuo4Cv1))yxVJM<{fmLH=)P-mG|o6Z+~hE_U2xIKhOK326tHO3P9D9z9?@XGzR&+ zph)`;M9Las5hZ-k(|u1^&Np9_hF?;>F6f|m`{aG;2m$=s_X}&+obY^&@aS8aHVCe! zn;PspC#t)2&WBB146b3Y?5Ap}t0APqX&`=qKyvddg4?_wv z1DK{?kt&ii9#8-gU5e*U>{;r8=E}EE=D>+MCrf767!k5RpL)Ve5OQzCyFB|~7H z8s5P(8)o;=V>b7!ZuJ>6GVFgA3 z-qT$U$C{pS1dGc_?_OT&{B{3+!t?gvN{C8S$Jis-wXPqr>Q*y9@o|t3tmRQhz%Dmv zsoL{296X`ptq*hJYzy;Xi@mwB*>*MtkO2o^H@%gO!gTq=wv%C+GaIox!afBc3c57B zgwdk=GGcMr_ep9-`_$A5}c}&;Qd`q`2LOlQg diff --git a/logo/afphoto/apple.afphoto b/logo/afphoto/apple.afphoto deleted file mode 100644 index 49601dd1c7b18ccd69b3ec501f7e6921054b62d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14913 zcmc(_cRbbKA2|NH_j1i^gzS}TWM`92xb`Np%FZka71t&uu92O%6-xFh)h zA-YEfRqlPA^Ln1=dG2vu7b2PH&_UEt(A6uJLYN?Li<8j5tK+|K z65IdA|L61g_sW7&ZG$_Nt{-K%&*$u zD!G)+@tpc~Cvghzv_tGz)Fzj03k3IWB~c_%cu>IJ>(ZSj67L43kGDX1Zrq?drj)_oc%WT4m$olow)$m9%OjHeJ(A-9=tMM$IkWzCUwP zlAoilhucn2*F!ML(wu3Inu5I|6HBQ&ryi`|LxI&6?B<4jiTtwcAuRbNQuF=CA5S;0FrAM3c_sWQgCXG+E&tkuKFg<}C1$J!;cE%BzU}O6 zy-Zn_@Io1NKCOyLl@ae>pP7b;NO)r@C)_Aev|u6T<@K+LY4V<#m*SNF^f8Jv#li!B zq+TH-8EXEZioKacbt0mSr{Y;{s~%nlQBu)AfTE{f2$S#mIvP)E>Btf8&4}zyx!>QD z>~wcCU`0kXLQ+8|E&@gsMQmFnZ~24w7(Pw7LNcgF4LZ+Fr@LpokMqy4^J1d3_|32X zI`1dHe)q&?uz)|s^15fKIX{zGhvux5Z6e-(jla`Wn$>c$%%j_et!>^jp&6SH&5U)> zz$jHqX5L^9FWIi9KI?Hz<^K9T>)A6GoVVw7)ct0&{3sF*v=Tj+_E-zb2cBdEgg$sR z^6l6rc*^`S^3BmD%&JQR)Z61cs$*mr_URLoQ-H89v?3PfBpU)jA+?ww_Mr%fh z@ZXFWN#*eW_595AT>8A}mfJ-?U+>F4G2Dn;1BKnyU=4n`Xu8}BPf=l?-k;gm=RNz1 zSCzm;G)hkAQnPn-^e0lE@8nu|B9u&%nZE<6R>}rgsc?d==*?p=_eS zihgBL=;5czzt4H^yz`oA@vM4rj9CfR5qOgJSg+b%BA#H0i&>*^PW05|yqiNGspm;| z4I^0fD`$auhW2I8MsD%BOlg!4=DA7FQJU;nn#fq>nVjZ}DPfv`IgVUPXOB-$>yazE zR|6m3;lGR7Djbb?bX)Rwj(EEKvBNSo>gen1>;yu>5f@-FoVUS)FOq!KDC08<+VvLjF|Vd1VD> z8ZX$`PhPlWeT=;Goigs!OF4n1r;Q#Zeon_QCW}0d0SjFutmfzX6i+6c`;EYtjxYSS zJvZV9=;O*<`5998L(D~9B#FqKJ2f;`LuD==cY2NL@}d5DxA)wU0Hpq*RND6+S*#%2H{tx>&i_kt-O@%6Q-F?=^Ggv zn?%WEWZl1S!lq{j=y`$FE@ixz#jO-FO@PVSaZ+D(@-EHF-YBTcWeYsx z=lOi&o7Yr*86Z@=hSTIyLvB9cZLznh+$(F`sry^T#RZpT*8jA9j(F z=~c)^+$(=muJ?+8PF`v(ZS9mj^#1f#%`F~uY{v)>JtnsB2I%Q*CK=0X`f7ZU^x1Rfg5qTh#|O6K@N@{p8a%C=Je zd32biDpB-)o7>Nz`8cK)&-rmVAo4gjp!jko|H#WL{daz@u6)J(0G7Ib;Y-3XcV9jS z-BI!5w1aB@yN@1PeeWn1er&DS~`S> z2-76kO7QDx7r)tA&NaKuVCXEO)BnY)RAh5s48J7!{QZY@B>jhii^4IEL<&s36LYYm zT{U%?1b4J+s|s@-TfwQU8Q5vmV72*2nRABwQA~@Q65;~y4a2_qsDb-;VB%7X(WmU^ z^XZRYocx&nn5+J4L6WWdB-_Q?l>3L`A0r3e7u@R~Q_W%DJ|Bjb490vDg?as4-ox0+ z2|o2n6aVvWpu*GPJBNbZOOjPiwQd(&d&2+g9*)2BTQXy~nZ{!Kk-NiW4<92vhlq%< zFz-;AJsQnhP0=*dobJ8Kk7%kdK0y-8TJ=Q=1b;le9(-j;ZN~p>{`>nsWpui&ZQmsQ zP+f?M0LWx=2O^?5%D7%WTn$Xwe0@ygt~Gu4nzS@YMs0&swN*tzH$K{zGH!CUZkmhX zdscPf?J%zL#e%wMknQSEo#QkEeU2-vC6OmSZA3;S;Z>lc0W}qi+ zDFmat5^zJcfV#r;yK-i18#Y4e^TxM1HNi_$7w)Ax_Jvs5S2QG4me0!%Or_Pcc`Y_Q zOmWR&9CQw!o#lHnv>20oE0a-0FvBQ^z~A@w=I48i`ICz>9}r$= ziEdug(wm+hJ#z)lE2M+h?x&^=yHj*1XDn)d`(jIFSg&|}huNd@(JqDqrKS^aW5(Kj zGgkwb(|>C*Z-)lJr_CQmE}N?&u|4(Eq^vT`#Yf#(MeMgj5ek7C+N@VBr;;th_4M%e z@(-_161JWF!v2tMqfE;AKa(!5Ja3Fyr!LBA?m(S8vsH6Z&x+pTrtqW}Y54Yh<%G*q zmos8#?RdNFiXBdFSo$|PRoe@V*Kf5gAMw~&yL{T-mZ*GA;#N8|e54vp>2k7==`2cvFvwj`@R50GcI3uwQL_HcsWi!Yrce(b@@0rm{P1flszd+0<9YJM?4mTPCIQc4dgct;(bok^ntPVcZ&ZZ=M&zr57)U~ruOxknArbu5$tus}l8k=p@no}o)l%%OTR6>9CZ~!M z3?0lv*E{#Mql2#eeb~t&Ot&seU|{uh3hG+6366eheQR0k_Ev%bO(;;8=g#1CFAHaQ zp%_YZV`18j`UdTg&2`#WNuqKqAk6!A9{WwpvW_skSt=`jCm^xf&)+bWcbepW=d|a? zbP0|RF^FfzA}>F8b@%4!;#OB%tv{1El~_j_4z5i{KRq#$Ww6le>#Ow^SrRf4v~ifR zIG0f6{>h@g*qEoO5P$SQ>ReXuYI6&IsqdK6J?8R@I$(doL*wdo0)8c*E$TBbRLvgo z_q{RU{_A|@Z^1)2m0NEqjZ8F@fmhYF(nY$_`@KrzM`8*@Xy5d8WzJtldQqquM4#Ar zDW1)+$iZ25$unhMFZx3Q9Dc;D8dYs6GBn0HoI7Uq`aFyAHqpDG=)pJJGDR_2BBOw6 z7tLuKnSmH%mq(2xyBR0HS!b5z0x3d)<%een!@tE}i#jzJ*LAQt$XCT=fKR&?FB;CC zn5l>lr$}l3D@n&519_<%&Kz7;TZdU6zMc9wmsEdpuLe4p%}{V|?0T(__D~fjDH^`x zsbG@~@6n3X%c8hVrBX{NY(P9WaQ>C;W^P5cZFu06R?@-;jn2M?1Ae!s1@B2J&qH)c zQ>n_8?&iW>ij@YkVbiB>ggkw>bw!}s{^_J?#pNHI?N)DpKhPh{-p1q_zf9(Q+uGWl zqpx~d_eN{$>guwP4T-5%!rJWG!uG7gN_5#W2^KEgXUqN*AJ|p>#^m@c;rW}i@j#Pr zcBHoxwl=-TnteITGckSX{V1A#JL>~j}6HGI&1RDgI-;A z&}m3N$R2YoG_r&rg7WwqYBil+zlw~A2^5^G!SSeOaWqRVq1Gw4g zYzNzmmO*q?I-Dq|d-a{EW%XV5yIs zn-iZt9RM|mefagjtH*J7x!FUkgnjR7fJ$>#L`kt?^_R3wThfEavhNMARcjogwPIUVHXBV;0rqwfAZ#fKgZj&_>HgA`qPo&XC$l>d-LMzg2l!~ zUJW-mkKQahvIrk#TWQ{5Hkf9K3^lb+DE4XTa2dV15W{%Tvupdom@Y1zT_$fKjRlIO zi=oex^DYYrc2FqYb*qn|UsHFO*$zJ^G#4}a+_%@!2a`A28)#PUf2B;QdgJgg=DUB? z@!P4k4aZPhv!JAVS`&(2dp2iBQ->p((eI4BFnLywE*dhkfCH*j{;@cTP}@k#Tm!V#%5kol zVtRB~q4=lh;Dd9sBbN?bnge-0Uyi|{`ZK4GB%B?&Qa{&tR*%N`&I9KAj-G0M+&U~Y zw!5mKmFh2SqCtAB*72O`nhTd!df=m6-yPpF&z-ceV_}q^rQW?&sg#Y-UM*j8>vd`c zy-NC+c{IUYe{eNGl*2fjRB@$geLB#$xi2jsSg%3&U}q)kOXSO#6OZpa{8SHR-p$&3 zAzqPBuX4h0$ITNHl~?;KKVhus)3%ar_umJ}NAM;mGq2~OBNk1S?fy4zC$74wSvCuARBgXh z#pEbg8yAc$?YVuyd;8wF5m#Lv!+wz4UjO$&dwt`Z?QpxX4AQx2euLX0sX6w#u52QS zZ|EPLjsUrVQLOzQ^l&99>CJZGifr?A2+z^t@oTxH_>Gzqqfa-4XPX0^Y3O;HNqX^D z&4imTr4Fn(B4f8I)@8kzP&raOuUNnBtO|pMT#suHx?LP7}Z1x=(U`4%5O;1m3JjHuzv+SD*i6FVT7T z?tKfkbmzC!p4wj1RkaS56IL%{wo4?2gu`$`rZ^Unx7Qp+EDG-4=D+uf(Z0A6dKb$S z{JxYXRjlwfgYDu%U9bANyV6NLn=RE>cJs3uAF>R;6sRVi3;L#8?)@4;i4LigEJ)L_mAfeqPF-uwtNkl23pY<<;{QMqGW;*rE@%rkqHL#M`I1x2p+f((ey z<@wC#%)R*Tutfd{H7*)R&An_omAta~CR9Ssa^^5duwGXlUyQ2G4$hrEw|YuOfcWb1MA_Iq4xG)`p+|Ks*LPK=CqJ(kwm(z8rewBh8fZz^TzJjW&#N$pja^T@RZ|7A!iDYNF?-r3xfYZz=erx$Y5FIy zfF;a2Ka&N6y;QX^wtrc~LRzYZQDRjA(a@SM_UobweUM2~6ixijhcnNv?BrcT#Qsrw zw?<xtSLB5G|7y-v}y^2v5x9+3#b|kHM4~L5okOU@CNo2!}Igu zK^W{>i_dFwS|dv1*KuP4;|tXNQUe<>C`x z7P!$JORFNbjQy4NZgo{J$)qRkH{`Y5X$WR#lWUg`m19fXt^U@NLR!xDbx2cFafp&+ z*>%zvy^#CpBm>pGhi3!s`8yQ$tMbKT`rGu~t-f59quY=dna(wWn~LpbpYyRA+0e_~m&?677-i|yD?_6}JRQnlK5*e0qWp%-&83Hfa;QO-%A1z&-Td_* zdcM_Wjj!}Q>-Ujl@LXd54sDPJM1ePW0*&)7%9y-89}xBM^QWhXv}ChKX_M}CTd6a} zAKY-SxmAPC`Ow^0l6-Ti74}0V>Yd)7;?MBNosN%-oZGa_sWItWMV&!y213lpn{`}Q zE&F`AJKr~7P7kM8H!b#l`Xexw!>wf1%jUgERk7Hu119g&>_xAPH4(6@cA7~OY#nA^ z1C8FTwpv$MN#GJPuvGvtPj*?;j!O8#ohW{HH{WsU8_`jR~bNt+n?iGZS z&zUJI--l8`p23%-Jw3w6JKFjNUL-FK6M`V#|5p2edswQaxgm;{ix$kGFh=^;|2waS zL)7G>>%S2b`{0Wv#OVB02x90Z|G{!37`P!w0K(|&*hUnsevWK(v%9skzB8mQi-dD{ zQ*W{+$XaAl#Zo&~u(IJ9N?j~pN}7vRYuZap`Dlm*@>g1GpUgM%iWT+~S@E<(x%PahJa8P`;^Pp%mx_ZmyLsQk#&l0yQ+dC~gng>`k2KWE$|7jV$ za)gn!Sv4Yn)|A4PGPkcU6Zu3`4U6^)P^o3FRrV{L=bL7jhI7=tiU~fk_HtkxSG|rv}h)R7}GCV6bllM?{mYvhMDh`FkD-}=| zjXBJoT>Ba5KzJZ%H-OZ(b`F4w^tt}-$n1{rIjlVp#o%7NNAS=cvN}>rN>oPMP09ed z>?^bG3amljQTq~8jsDS67+j;c5OzFPw8ER{=E`WdK8V5LwG@!Mtg>1DRL9@9jP|<9 z&={v6^jaj*>mu^_`?c#IyDHEa<8=6Ama}UzHQXl0F2Wf9j%w@!CauL9M=hyM_S^xY zDui3U$hyW!q{Azcir%F%2%vHS;c9ubMHXt^GKPRbUcjKLGykr1$i=V>#afu<>NI3D zFbN}g5pqX$kOZW5lRO&8uG*72gds$Ji{lXHZAg`Yt{fK07@oX0H>oupb)A!44~JJ$ zK}IC*cDHBERP7E5Vyqibh)&ACC$uuITW^M{+TrjzoOBmV4h81SG%R=RGR28D5UwnM z;*dU%hZk=$4s=#r3(H{jI7|FVP#V>tbw`vsUHNQmJ-LneUCJ4+1?a*x8uY`0S-OJU zJXakS*>LDe4^0M1!DxbX{STN>TDuVrt@yTbV&_cPNe_xPWc=F?E#fMmCN*8$y z&VSXWm+_t5r0{+&Wi3tjnm`?1Tfun!hEywm=PbwsHC zQ0c7$UNi>oZsRV!9v`Y->_XTAi*uv354Cx$He*qAtL6{u_{%a4*z>>0JmV)Vlte7yKnxMd6+GF51!ko_5|*1m`r^I zwss+m8iT7+V$Cx@R9;UhKm<%231@G7r^n)tc`R-y6N(wzd3bekc0|yVEbb=v zt_yjyQ_s#tQAqF4Moeze%QhbjicTiWLpb{|cqNT*p?qvhxtYy>VmG15af47@BL0pS_o)0)sO5a{N zCh6;ZFva0#&H@3p-QG90Vh1CU){Ncmwv`vPwNHU{>Z$7;LN4Ae2zrU-ix}Jl#aSjT z?fd7iLu6aEj^E`dod`0UV+K`|KI)S}n|)oR)DFT66BF_*wg zugZ?_R0d0|D^ZeoxF}%#8Mxu9-42o{Ok^b{EeZhhcuz-t2=<=5kH#$3}1v5f3`fyS&hX{{rU1{A4w z>1Eq{=bdqQ`@tm+;%mYq{B;&19R38a*&(5eX_v3o>}}UmG-jEljY5`j*YL1U24np_ zylg*~Bt_(3i;TwLUIpkX#z)J9oDD(NMhkZV%3&F)QjEuF7tx9uDTjNv?3hm58|bN4L^b#nnnED+MGX+Y^PV( zD>xIu)7nx~(g$;^sxAT1qF$EEv85&o+j8K~uvE(Tf0O0jRYolOsO}4aV{9(vjKtwj zFG5+T)2E%QFj;O#E}ZRh1`0bQa{oMXDQ>i@?5qxhe)&*mDD%dk%i^EUQy9(8g59jn`12X>MZ2Eb5sNcP~Z7;&<494 zy>d-ZmOY~6@0;0%TU!cikZf^SxkE<)jE8!KX2pPOx7<}<;obzjI{Yv^v4A(*P|6!o z`r;!lOy~i{%IMBjld8R!hf0;qt@p#mJM;fmo9`_OqBf|qkVo-G^J`a?5v_%PuP=Wf z^r+aB;yyxRJ{kmReBqwpEk!|vQT`hxVLiu+Yjni6H?mTS@H{vV6_1Gq@=!4{f1cFw zB7H)@$hy3mu88^&8^-c%Kd0$)=Ux!knyUN7QFenEcb*X15XvRRp3w)lK5oqu<}ctyvyXM@@(0%Ceb8fTzWvCC^^=$@VYmq(@K$d7g>AB!NCR>_l&a0v|uiv zXXwz1_y_ZAoU1jIz=_Y#Ssan=e}USiUYgHBdj(n$b;p7Z77KlB%@CRl&&Izu1yp$u zZWxUp(4~;tmo0-ck6p}u6QfM5{S0tZm;a!T4f1VXt}+~I5zQ8A9#N9csWF_#z1WBzJHI!o+|)e5 zJMp!yrU^5@{dqUvM+znlDJ}m&WaE!lpif4Jcb`=&QnjXpP2G}y*>S@tQo7Hp^u@$4 zLgmwaSz$k~^4OY8rKjiS+n?Ev$I4{5UgGv(8C&na_gthr=ei4`{nB?vdtB~W)(G5g zVNTPsldjtNb%lXoq(}yX{EOW1qHohlQFp`Co}iEJjS?aW)(gSbQ-ojF32pcmF@}S3 zi)$fKMukjW`cd9p%JK6f;!lp_6`qzS4SKCw?iJ8qxGlq1`BFLdSFE$qy?71%sM(7# z2Jo}M0^j1xBU{8&%Qx0(LryH&>L_NTu>0eBP9KiAKF8d!EpZjnc(G^Th7#?#>BHlv z5fO1g!ONXyiEpiXQ9wKyg|+wMSwj%>K&*Rp#O>GB-wvih8D+1O|Mz5bb=*~>f&N>B zb%K1is%@#@C@eeYUgn%kr(FmGCNf`N?bY5IfKNwZ1&%a!Mb>HvDMU%!Mk8#A<4aaq zd<91*ukoeWLCpNIu-~;LTrRvsv?It-(HrqThy}}yJq>clWFHgJaV$${Jg%XFP6=9V zLM?I57njEyRTc9Q#m2t4w@~<4B?mw=em-NwKi$%}7wghvato+Y9fb%0-k`iB70qo( zl{rC81^hM;vHLx+k<(>S;)x=^;am%t z!ua)AtuzL~%B^q;*M6b+22Yn3Mya#}H0-yp9v|s<~Hjrc`|4X5S|0`ftCZ|ORu?0)@4rnn8aRIgECL3TC55}>2WqL+q;_8qorMCgq zXh9o=rA%Q4U->#)A@0hi#Mq^`!C;!uN>l4P+z*yVM^p3MjgP6Gh9>%%cW=94_V$n= z;(Tj1XQg2iJ(fhxvGX&y?D$6n(O5cQZSq0zXUXHCYxlG7MdFK(KLGXoEP}n6e?j<4 z*afPfct@EI4+1Lx-AW}8^OFcTWDG|9>F(}g24J%%31RM@zyzw5`Bjqswm#q@aAMnU zF;m50)=$FwzY^!ghM{klO~zVC%s|>rGuA|l*r(<36^>x~7Z1;&@au!iJcrzGIkD=2 z=k~X?)K!x4;Rh!}?8B&YdcfpARCULQT~jd8&xt(@pioaMB%xpDBTWTY4;}PW5g8BU zL)PxI{`4AiJ_4wQ0ReYT-8$GI@?)5D;f8Q!11hUMKfb=uoIBx;oL+4(k0RNM7R2dV-gV?NC2SB!yH0+xP&zia) zODWoJ=`10CRM1ufEDMoiIL1{eBUFI4FHS?>zFZoGp_|5*2t|ubw>=O{!NI_;nQ%@& zFB12`%q0ZVpKOrzoqSHNErGEYg6T5#TIWBAaRT?KAnPDfUM$vRwNn2@mv>E%}OIDOUx^q%=@G*!w^m04=mDt6}l> zZA=jgY6%-(PG}b;uYK3TRjn)4O08LkF?|Q3;Ba0b%2cY|ClYINrlDRHaj;@)k+PGQ zS9h-tmJ%8k24Sj071un&sK57XK$G0CcH}cQ5GEve=s%qVK%vOer~_d8m0Z`q5^JKT zA=^J0WbVGUFG2(NQQBJ!w;2Q~*}Nf#bIF#S0Z^49>$H|y0^YtgJ8b_!qp=b+Im7EH zN}k!Z3WL56AxoX8KubA6yMtgfOtzc@OTO=S;l+1X-7bGqg(jnDr_}&--)wGJSLXhA zdhKgdi00cMNUT5pLac$+!YYQ*MWLCtnomfI0swnHD=5ysN+F)#VR#rtep&+5qd2EW z*aZ4x1OTC+QU3^hu5=$f?W1;o0#v~@g49NMBP{P$$9@5NP@EeYh1v20;Au@DpuR$e zb`D

~TpN9CNM(664P^(zU41a$qtw-~%l)yHH(CB6<%UoNDDAxf2%R&8)Q$(_ zXoB|Ks{!`D^dpoFD74OxiWM!_tB4MO27C|#m8K&wy0ZTbchZ3pxQ3QuuJs~IZ+<1? zYkX3Ms3-xTEkQu3celO6pvf$Ne<}?qX!M5xm8Awi`;_XRG7K7EmWBXU@gxXvy4K}G zKt56RFtPjNq<>7QT|`()D?`TOg(xif{&^i)swMvozWf{9fF_u^u~$AqM&gC?&~ge& zyN%3CVD$es{x=vpxbts0^FO7$S%j)Y$xHH0{tselGRs#VTk+Px#6FCXnHu4Q$V^>G z`zItu9vd>9UV!}o?*DigBx|$}h?dOIzd_OexcLXA^nXbi68;YrIBxK7m-rzfVdO@ogIn&Fca~4C14y{O3-&FWIMbegD>{=;z=9-wqhz_qeG*}*yJR@nY$jb!_!c|?- z2fG_ugkeu|7)MDEYsr~B_)IM`pU!hw2!Jt(Wd{wff8rOy1Tal1ZmhpLJFwx)Jm7Q7 z9bt*uff-c|SP@bdnFb!9v=xB@?YvwrAm2m4(|Z7=Ihw$~_CU7kvmr}n;0&P^fEEH! zKC)fe89<`I@#oa3O1Xrvk$8C+x{`K>$PI7e8is5yF|UExj;XF!u5|+eW)NAbCXFzv znh`BWh`Et{mk-eLtDSKg4FVWY2Kji(jvH3Q$*UV#535KA9(L-GtyYH|oWNSZ@Tq5r zo0