From a412040d7c6fcf9ea76b95f56f4fbb9c962be1f0 Mon Sep 17 00:00:00 2001 From: Artem Chepurnoy Date: Fri, 5 Jan 2024 17:54:59 +0200 Subject: [PATCH] feat: URL override and Placeholders --- README.md | 1 + .../keyguard/copy/LinkInfoExtractorExecute.kt | 21 - .../session/FingerprintRepositoryModule.kt | 22 +- .../common/model/DGlobalUrlOverride.kt | 21 + .../common/service/execute/ExecuteCommand.kt | 7 + .../execute/impl/ExecuteCommandImpl.kt | 79 ++ .../extract/impl}/LinkInfoExtractorExecute.kt | 2 +- .../common/service/placeholder/Placeholder.kt | 14 + .../placeholder/PlaceholderFormatter.kt | 20 + .../service/placeholder/PlaceholderScope.kt | 9 + .../placeholder/impl/CipherPlaceholder.kt | 45 ++ .../placeholder/impl/CommentPlaceholder.kt | 18 + .../placeholder/impl/CustomPlaceholder.kt | 29 + .../placeholder/impl/DateTimePlaceholder.kt | 108 +++ .../impl/TextTransformPlaceholder.kt | 88 +++ .../placeholder/impl/UrlPlaceholder.kt | 82 ++ .../common/service/placeholder/util/Parser.kt | 38 + .../urloverride/UrlOverrideRepository.kt | 13 + .../urloverride/UrlOverrideRepositoryImpl.kt | 93 +++ .../keyguard/common/usecase/AddUrlOverride.kt | 6 + .../common/usecase/CipherPlaceholder.kt | 6 + .../common/usecase/GetUrlOverrides.kt | 10 + .../common/usecase/RemoveUrlOverrideById.kt | 7 + .../common/usecase/impl/AddUrlOverrideImpl.kt | 21 + .../keyguard/common/util/StringFormat.kt | 109 +++ .../keyguard/core/session/usecase/SubDI.kt | 20 + .../keyguard/core/store/DatabaseManager.kt | 2 + .../feature/confirmation/ConfirmationRoute.kt | 3 + .../confirmation/ConfirmationScreen.kt | 155 ++-- .../feature/confirmation/ConfirmationState.kt | 1 + .../confirmation/ConfirmationStateProducer.kt | 7 +- .../emailrelay/EmailRelayListScreen.kt | 24 +- .../emailrelay/EmailRelayListStateProducer.kt | 17 + .../home/settings/SettingPaneContent.kt | 3 + .../settings/component/SettingUrlOverride.kt | 67 ++ .../settings/other/OtherSettingsScreen.kt | 6 + .../home/vault/component/VaultViewUriItem.kt | 191 ++++- .../feature/home/vault/model/VaultViewItem.kt | 13 +- .../vault/screen/VaultViewStateProducer.kt | 703 +++++++++++------- .../urloverride/UrlOverrideListRoute.kt | 11 + .../urloverride/UrlOverrideListScreen.kt | 338 +++++++++ .../urloverride/UrlOverrideListState.kt | 42 ++ .../UrlOverrideListStateProducer.kt | 366 +++++++++ .../bitwarden/usecase/GetUrlOverride.kt | 32 + .../usecase/RemoveUrlOverrideById.kt | 30 + .../commonMain/resources/MR/base/strings.xml | 18 + .../artemchep/keyguard/data/UrlOverride.sq | 38 + .../commonMain/sqldelight/migrations/6.sqm | 7 + .../keyguard/copy/LinkInfoExtractorLaunch.kt | 26 - .../artemchep/keyguard/di/GlobalModuleJvm.kt | 11 + wiki/PLACEHOLDERS.md | 121 +++ wiki/URL_OVERRIDE.md | 23 + 52 files changed, 2690 insertions(+), 454 deletions(-) delete mode 100644 common/src/androidMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorExecute.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DGlobalUrlOverride.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/ExecuteCommand.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/impl/ExecuteCommandImpl.kt rename common/src/{desktopMain/kotlin/com/artemchep/keyguard/copy => commonMain/kotlin/com/artemchep/keyguard/common/service/extract/impl}/LinkInfoExtractorExecute.kt (94%) create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/Placeholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderFormatter.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderScope.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CipherPlaceholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CommentPlaceholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CustomPlaceholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/DateTimePlaceholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/TextTransformPlaceholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/UrlPlaceholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/util/Parser.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepository.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepositoryImpl.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddUrlOverride.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/CipherPlaceholder.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/GetUrlOverrides.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveUrlOverrideById.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddUrlOverrideImpl.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/util/StringFormat.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingUrlOverride.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListRoute.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListScreen.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListState.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/GetUrlOverride.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveUrlOverrideById.kt create mode 100644 common/src/commonMain/sqldelight/com/artemchep/keyguard/data/UrlOverride.sq create mode 100644 common/src/commonMain/sqldelight/migrations/6.sqm delete mode 100644 common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorLaunch.kt create mode 100644 wiki/PLACEHOLDERS.md create mode 100644 wiki/URL_OVERRIDE.md diff --git a/README.md b/README.md index e787232b..492640e8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ _Can be used with any Bitwarden® installation. This product is not associated w - **multi-account support** with secure login and two-factor authentication support; - add items, modify, and view your vault **offline**. - beautiful **Light**/**Dark theme**; +- a support for [placeholders](wiki/PLACEHOLDERS.md) and [URL overrides](wiki/URL_OVERRIDE.md); - and much more! #### Looks: diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorExecute.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorExecute.kt deleted file mode 100644 index e5c287e0..00000000 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorExecute.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.artemchep.keyguard.copy - -import com.artemchep.keyguard.common.io.IO -import com.artemchep.keyguard.common.io.io -import com.artemchep.keyguard.common.model.DSecret -import com.artemchep.keyguard.common.model.LinkInfoExecute -import com.artemchep.keyguard.common.service.extract.LinkInfoExtractor -import kotlin.reflect.KClass - -class LinkInfoExtractorExecute( -) : LinkInfoExtractor { - override val from: KClass get() = DSecret.Uri::class - - override val to: KClass get() = LinkInfoExecute::class - - override fun extractInfo( - uri: DSecret.Uri, - ): IO = io(LinkInfoExecute.Deny) - - override fun handles(uri: DSecret.Uri): Boolean = true -} diff --git a/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt b/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt index e113767e..a45a02d4 100644 --- a/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt +++ b/common/src/androidMain/kotlin/com/artemchep/keyguard/core/session/FingerprintRepositoryModule.kt @@ -3,7 +3,6 @@ package com.artemchep.keyguard.core.session import android.app.Application import android.content.Context import android.content.pm.PackageManager -import android.util.Log import com.artemchep.keyguard.android.downloader.DownloadClientAndroid import com.artemchep.keyguard.android.downloader.DownloadManagerImpl import com.artemchep.keyguard.android.downloader.journal.DownloadRepository @@ -44,7 +43,7 @@ import com.artemchep.keyguard.copy.ClipboardServiceAndroid import com.artemchep.keyguard.copy.ConnectivityServiceAndroid import com.artemchep.keyguard.copy.GetBarcodeImageJvm import com.artemchep.keyguard.copy.LinkInfoExtractorAndroid -import com.artemchep.keyguard.copy.LinkInfoExtractorExecute +import com.artemchep.keyguard.common.service.extract.impl.LinkInfoExtractorExecute import com.artemchep.keyguard.copy.LinkInfoExtractorLaunch import com.artemchep.keyguard.copy.LogRepositoryAndroid import com.artemchep.keyguard.copy.PermissionServiceAndroid @@ -59,24 +58,8 @@ import com.artemchep.keyguard.core.session.usecase.GetLocaleAndroid import com.artemchep.keyguard.core.session.usecase.PutLocaleAndroid import com.artemchep.keyguard.di.globalModuleJvm import com.artemchep.keyguard.platform.LeContext -import com.artemchep.keyguard.platform.util.isRelease import db_key_value.crypto_prefs.SecurePrefKeyValueStore import db_key_value.shared_prefs.SharedPrefsKeyValueStore -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.plugins.UserAgent -import io.ktor.client.plugins.cache.HttpCache -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.websocket.WebSockets -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.serialization.kotlinx.KotlinxSerializationConverter -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor import org.kodein.di.DI import org.kodein.di.bind import org.kodein.di.bindProvider @@ -179,9 +162,6 @@ fun diFingerprintRepositoryModule() = DI.Module( packageManager = instance(), ) } - bindSingleton { - LinkInfoExtractorExecute() - } bindSingleton { TextServiceAndroid( directDI = this, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DGlobalUrlOverride.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DGlobalUrlOverride.kt new file mode 100644 index 00000000..6bd89749 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DGlobalUrlOverride.kt @@ -0,0 +1,21 @@ +package com.artemchep.keyguard.common.model + +import com.artemchep.keyguard.ui.icons.generateAccentColors +import kotlinx.datetime.Instant + +data class DGlobalUrlOverride( + val id: String? = null, + val name: String, + val regex: Regex, + val command: String, + val createdDate: Instant, +) : Comparable { + val accentColor = run { + val colors = generateAccentColors(name) + colors + } + + override fun compareTo(other: DGlobalUrlOverride): Int { + return name.compareTo(other.name, ignoreCase = true) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/ExecuteCommand.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/ExecuteCommand.kt new file mode 100644 index 00000000..d3702015 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/ExecuteCommand.kt @@ -0,0 +1,7 @@ +package com.artemchep.keyguard.common.service.execute + +import com.artemchep.keyguard.common.io.IO + +interface ExecuteCommand : (String) -> IO { + val interpreter: String? +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/impl/ExecuteCommandImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/impl/ExecuteCommandImpl.kt new file mode 100644 index 00000000..2234758c --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/execute/impl/ExecuteCommandImpl.kt @@ -0,0 +1,79 @@ +package com.artemchep.keyguard.common.service.execute.impl + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.bind +import com.artemchep.keyguard.common.io.ioEffect +import com.artemchep.keyguard.common.service.execute.ExecuteCommand +import com.artemchep.keyguard.platform.CurrentPlatform +import com.artemchep.keyguard.platform.Platform +import org.kodein.di.DirectDI + +class ExecuteCommandImpl( +) : ExecuteCommand { + private val executor: ExecuteCommand? = when (CurrentPlatform) { + is Platform.Desktop.Windows -> ExecuteCommandCmd() + + is Platform.Desktop.MacOS, + is Platform.Desktop.Linux, + -> ExecuteCommandBash() + + is Platform.Mobile.Android -> ExecuteCommandSh() + + // Not supported. + else -> null + } + + override val interpreter: String? get() = executor?.interpreter + + constructor( + directDI: DirectDI, + ) : this( + ) + + override fun invoke(command: String): IO = ioEffect { + requireNotNull(executor) { + "Unsupported platform." + } + .invoke(command) + .bind() + } +} + +private class ExecuteCommandCmd : ExecuteCommand { + override val interpreter: String get() = "cmd" + + override fun invoke(command: String): IO = ioEffect { + val arr = arrayOf( + "cmd", + "/c", + command, + ) + Runtime.getRuntime().exec(arr) + } +} + +private class ExecuteCommandBash : ExecuteCommand { + override val interpreter: String get() = "bash" + + override fun invoke(command: String): IO = ioEffect { + val arr = arrayOf( + "bash", + "-c", + command, + ) + Runtime.getRuntime().exec(arr) + } +} + +private class ExecuteCommandSh : ExecuteCommand { + override val interpreter: String get() = "sh" + + override fun invoke(command: String): IO = ioEffect { + val arr = arrayOf( + "sh", + "-c", + command, + ) + Runtime.getRuntime().exec(arr) + } +} diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorExecute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/extract/impl/LinkInfoExtractorExecute.kt similarity index 94% rename from common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorExecute.kt rename to common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/extract/impl/LinkInfoExtractorExecute.kt index 4d8f29f7..f8ddeb74 100644 --- a/common/src/desktopMain/kotlin/com/artemchep/keyguard/copy/LinkInfoExtractorExecute.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/extract/impl/LinkInfoExtractorExecute.kt @@ -1,4 +1,4 @@ -package com.artemchep.keyguard.copy +package com.artemchep.keyguard.common.service.extract.impl import com.artemchep.keyguard.common.io.IO import com.artemchep.keyguard.common.io.ioEffect diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/Placeholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/Placeholder.kt new file mode 100644 index 00000000..d29ccab3 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/Placeholder.kt @@ -0,0 +1,14 @@ +package com.artemchep.keyguard.common.service.placeholder + +import arrow.core.Option +import com.artemchep.keyguard.common.io.IO + +// See: +// https://keepass.info/help/base/placeholders.html +interface Placeholder { + operator fun get(key: String): IO? + + interface Factory { + + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderFormatter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderFormatter.kt new file mode 100644 index 00000000..b458c920 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderFormatter.kt @@ -0,0 +1,20 @@ +package com.artemchep.keyguard.common.service.placeholder + +import com.artemchep.keyguard.common.io.bind +import com.artemchep.keyguard.common.util.simpleFormat2 + +suspend fun String.placeholderFormat( + placeholders: List, +) = this + .simpleFormat2( + getter = { key -> + val p = placeholders + .firstNotNullOfOrNull { p -> p[key] } + when (p) { + // If no substitute value was found then we keep the + // placeholder intact. + null -> null + else -> p.bind().orEmpty() + } + }, + ) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderScope.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderScope.kt new file mode 100644 index 00000000..8def516d --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/PlaceholderScope.kt @@ -0,0 +1,9 @@ +package com.artemchep.keyguard.common.service.placeholder + +import com.artemchep.keyguard.common.model.DSecret +import kotlinx.datetime.Instant + +data class PlaceholderScope( + val cipher: DSecret, + val now: Instant, +) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CipherPlaceholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CipherPlaceholder.kt new file mode 100644 index 00000000..b34b376a --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CipherPlaceholder.kt @@ -0,0 +1,45 @@ +package com.artemchep.keyguard.common.service.placeholder.impl + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.io +import com.artemchep.keyguard.common.io.map +import com.artemchep.keyguard.common.io.toIO +import com.artemchep.keyguard.common.model.DSecret +import com.artemchep.keyguard.common.service.placeholder.Placeholder +import com.artemchep.keyguard.common.usecase.GetTotpCode + +class CipherPlaceholder( +// private val getTotpCode: GetTotpCode, + private val cipher: DSecret, +) : Placeholder { + override fun get( + key: String, + ): IO? = when { + key.equals("uuid", ignoreCase = true) -> + cipher.service.remote?.id.let(::io) + key.equals("title", ignoreCase = true) -> + cipher.name.let(::io) + key.equals("username", ignoreCase = true) -> + cipher.login?.username.let(::io) + key.equals("password", ignoreCase = true) -> + cipher.login?.password.let(::io) +// key.equals("otp", ignoreCase = true) -> run { +// val token = cipher.login?.totp?.token +// ?: return@run null.let(::io) +// getTotpCode(token) +// .toIO() +// .map { code -> code.code } +// } + key.equals("notes", ignoreCase = true) -> + cipher.notes.let(::io) + // extras + key.equals("favorite", ignoreCase = true) -> + cipher.favorite.toString().let(::io) + // unknown + else -> null + } + + class Factory { + + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CommentPlaceholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CommentPlaceholder.kt new file mode 100644 index 00000000..486a8104 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CommentPlaceholder.kt @@ -0,0 +1,18 @@ +package com.artemchep.keyguard.common.service.placeholder.impl + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.io +import com.artemchep.keyguard.common.service.placeholder.Placeholder + +class CommentPlaceholder( +) : Placeholder { + override fun get( + key: String, + ): IO? = when { + // Comment; is removed. + key.startsWith("c:") -> + null.let(::io) + // unknown + else -> null + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CustomPlaceholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CustomPlaceholder.kt new file mode 100644 index 00000000..c4a3cfaf --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/CustomPlaceholder.kt @@ -0,0 +1,29 @@ +package com.artemchep.keyguard.common.service.placeholder.impl + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.ioEffect +import com.artemchep.keyguard.common.model.DSecret +import com.artemchep.keyguard.common.service.placeholder.Placeholder + +class CustomPlaceholder( + private val cipher: DSecret, +) : Placeholder { + override fun get( + key: String, + ): IO? = when { + // Custom strings can be referenced using {S:Name}. + // For example, if you have a custom string named "eMail", + // you can use the placeholder {S:email}. + key.startsWith("s:", ignoreCase = true) -> ioEffect { + val name = key.substringAfter(':') + val field = cipher.fields + .firstOrNull { field -> + field.name + .equals(name, ignoreCase = true) + } + field?.value + } + // unknown + else -> null + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/DateTimePlaceholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/DateTimePlaceholder.kt new file mode 100644 index 00000000..fed93a97 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/DateTimePlaceholder.kt @@ -0,0 +1,108 @@ +package com.artemchep.keyguard.common.service.placeholder.impl + +import arrow.core.None +import arrow.core.Option +import arrow.core.some +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.io +import com.artemchep.keyguard.common.service.placeholder.Placeholder +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import java.time.format.DateTimeFormatter + +class DateTimePlaceholder( +) : Placeholder { + private val now = Clock.System.now() + + private val localDateTime by lazy { + val tz = TimeZone.currentSystemDefault() + now.toLocalDateTime(tz) + } + + private val utcDateTime by lazy { + val tz = TimeZone.UTC + now.toLocalDateTime(tz) + } + + // ...for 2012-07-25 17:05:34 the value is 20120725170534 + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") + + override fun get( + key: String, + ): IO? = when { + // + // Local Date Time + // + + // Current local date/time as a simple, sortable string. + key.equals("dt_simple", ignoreCase = true) -> { + localDateTime.toJavaLocalDateTime() + .format(dateTimeFormatter) + .let(::io) + } + // Year component of the current local date/time. + key.equals("dt_year", ignoreCase = true) -> { + localDateTime.year.toString().let(::io) + } + // Month component of the current local date/time. + key.equals("dt_month", ignoreCase = true) -> { + localDateTime.month.value.toString().let(::io) + } + // Day component of the current local date/time. + key.equals("dt_day", ignoreCase = true) -> { + localDateTime.dayOfMonth.toString().let(::io) + } + // Hour component of the current local date/time. + key.equals("dt_hour", ignoreCase = true) -> { + localDateTime.hour.toString().let(::io) + } + // Minute component of the current local date/time. + key.equals("dt_minute", ignoreCase = true) -> { + localDateTime.minute.toString().let(::io) + } + // Second component of the current local date/time. + key.equals("dt_second", ignoreCase = true) -> { + localDateTime.second.toString().let(::io) + } + + // + // UTC Date Time + // + + // Current UTC date/time as a simple, sortable string. + key.equals("dt_utc_simple", ignoreCase = true) -> { + utcDateTime.toJavaLocalDateTime() + .format(dateTimeFormatter) + .let(::io) + } + // Year component of the current UTC date/time. + key.equals("dt_utc_year", ignoreCase = true) -> { + utcDateTime.year.toString().let(::io) + } + // Month component of the current UTC date/time. + key.equals("dt_utc_month", ignoreCase = true) -> { + utcDateTime.month.value.toString().let(::io) + } + // Day component of the current UTC date/time. + key.equals("dt_utc_day", ignoreCase = true) -> { + utcDateTime.dayOfMonth.toString().let(::io) + } + // Hour component of the current UTC date/time. + key.equals("dt_utc_hour", ignoreCase = true) -> { + utcDateTime.hour.toString().let(::io) + } + // Minute component of the current UTC date/time. + key.equals("dt_utc_minute", ignoreCase = true) -> { + utcDateTime.minute.toString().let(::io) + } + // Second component of the current UTC date/time. + key.equals("dt_utc_second", ignoreCase = true) -> { + utcDateTime.second.toString().let(::io) + } + + // unknown + else -> null + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/TextTransformPlaceholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/TextTransformPlaceholder.kt new file mode 100644 index 00000000..06720a5b --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/TextTransformPlaceholder.kt @@ -0,0 +1,88 @@ +package com.artemchep.keyguard.common.service.placeholder.impl + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.io +import com.artemchep.keyguard.common.service.placeholder.Placeholder +import com.artemchep.keyguard.common.service.placeholder.util.Parser +import io.ktor.util.* +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.Locale + +class TextTransformPlaceholder( +) : Placeholder { + private val parser = Parser( + name = "t-conv", + count = 2, + ) + + override fun get( + key: String, + ): IO? { + val params = parser.parse(key) + ?: return null + val command = params.params.firstOrNull() + val value = when { + // Lower-case. + command.equals("l", ignoreCase = true) || + command.equals("lower", ignoreCase = true) + -> transformLowercase(params.value) + + // Upper-case. + command.equals("u", ignoreCase = true) || + command.equals("upper", ignoreCase = true) + -> transformUppercase(params.value) + + // The Base64 encoding of the UTF-8 representation of the text. + command.equals("base64", ignoreCase = true) -> transformBase64(params.value) + + // The Hex encoding of the UTF-8 representation of the text. + command.equals("hex", ignoreCase = true) -> transformHex(params.value) + + // The URI-escaped representation of the text. + command.equals("uri", ignoreCase = true) -> transformUriEncode(params.value) + + // The URI-unescaped representation of the text. + command.equals("uri-dec", ignoreCase = true) -> transformUriDecode(params.value) + + else -> null + } + return value.let(::io) + } + + private fun transformLowercase( + value: String, + ): String = value.lowercase(Locale.ENGLISH) + + private fun transformUppercase( + value: String, + ): String = value.uppercase(Locale.ENGLISH) + + private fun transformBase64( + value: String, + ): String { + val bytes = value.toByteArray() + return java.util.Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(bytes) + } + + private fun transformHex( + value: String, + ): String { + val bytes = value.toByteArray() + return hex(bytes) + } + + private fun transformUriEncode( + value: String, + ): String { + return URLEncoder.encode(value, "UTF-8") + } + + private fun transformUriDecode( + value: String, + ): String { + return URLDecoder.decode(value, "UTF-8") + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/UrlPlaceholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/UrlPlaceholder.kt new file mode 100644 index 00000000..5aac542f --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/impl/UrlPlaceholder.kt @@ -0,0 +1,82 @@ +package com.artemchep.keyguard.common.service.placeholder.impl + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.io +import com.artemchep.keyguard.common.service.placeholder.Placeholder +import io.ktor.http.* + +class UrlPlaceholder( + private val url: String, +) : Placeholder { + private val uuu = Url(url) + + override fun get( + key: String, + ): IO? = when { + key.equals("url", ignoreCase = true) || + key.equals("base", ignoreCase = true) -> + url.let(::io) + + key.equals("url:rmvscm", ignoreCase = true) || + key.equals("base:rmvscm", ignoreCase = true) -> { + // Cut out the scheme from the provided URL. + val regex = "^.*://".toRegex() + url.replace(regex, "").let(::io) + } + + key.equals("url:scm", ignoreCase = true) || + key.equals("base:scm", ignoreCase = true)-> { + uuu.protocol.name + .let(::io) + } + + key.equals("url:host", ignoreCase = true) || + key.equals("base:host", ignoreCase = true) -> { + uuu.host + .let(::io) + } + + key.equals("url:port", ignoreCase = true) || + key.equals("base:port", ignoreCase = true) -> { + uuu.port + .toString() + .let(::io) + } + + key.equals("url:path", ignoreCase = true) || + key.equals("base:path", ignoreCase = true) -> { + uuu.encodedPath + .let(::io) + } + + key.equals("url:query", ignoreCase = true) || + key.equals("base:query", ignoreCase = true) -> { + uuu.encodedQuery + .let(::io) + } + + key.equals("url:userinfo", ignoreCase = true) || + key.equals("base:userinfo", ignoreCase = true) -> { + "todo" + .let(::io) + } + + key.equals("url:username", ignoreCase = true) || + key.equals("base:username", ignoreCase = true) -> { + uuu.user + .let(::io) + } + + key.equals("url:password", ignoreCase = true) || + key.equals("base:password", ignoreCase = true) -> { + uuu.password + .let(::io) + } + // unknown + else -> null + } + + class Factory { + + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/util/Parser.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/util/Parser.kt new file mode 100644 index 00000000..2876386c --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/placeholder/util/Parser.kt @@ -0,0 +1,38 @@ +package com.artemchep.keyguard.common.service.placeholder.util + +class Parser( + private val name: String, + private val count: Int, +) { + private val prefix = "$name:" + + fun parse(key: String): ParserResult? { + val handles = key.startsWith(prefix, ignoreCase = true) + if (!handles) { + return null + } + + val separator = key.getOrNull(prefix.length) + ?: return null // a separator must be defined! + val suffix = key.substring(prefix.length + 1) + val suffixA = suffix + .split(separator) + if (suffixA.size < count) { + return null + } + val value = suffixA + .dropLast(count) + .joinToString(separator = "") + return ParserResult( + value = value, + params = suffixA + .takeLast(count) + .dropLast(1), + ) + } +} + +data class ParserResult( + val value: String, + val params: List, +) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepository.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepository.kt new file mode 100644 index 00000000..5ae4ae41 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepository.kt @@ -0,0 +1,13 @@ +package com.artemchep.keyguard.common.service.urloverride + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.model.DGlobalUrlOverride +import com.artemchep.keyguard.provider.bitwarden.repository.BaseRepository + +interface UrlOverrideRepository : BaseRepository { + fun removeAll(): IO + + fun removeByIds( + ids: Set, + ): IO +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepositoryImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepositoryImpl.kt new file mode 100644 index 00000000..344df3d9 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/urloverride/UrlOverrideRepositoryImpl.kt @@ -0,0 +1,93 @@ +package com.artemchep.keyguard.common.service.urloverride + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.effectMap +import com.artemchep.keyguard.common.model.DGlobalUrlOverride +import com.artemchep.keyguard.common.util.sqldelight.flatMapQueryToList +import com.artemchep.keyguard.core.store.DatabaseDispatcher +import com.artemchep.keyguard.core.store.DatabaseManager +import com.artemchep.keyguard.data.UrlOverrideQueries +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.kodein.di.DirectDI +import org.kodein.di.instance + +class UrlOverrideRepositoryImpl( + private val databaseManager: DatabaseManager, + private val dispatcher: CoroutineDispatcher, +) : UrlOverrideRepository { + constructor( + directDI: DirectDI, + ) : this( + databaseManager = directDI.instance(), + dispatcher = directDI.instance(tag = DatabaseDispatcher), + ) + + override fun get(): Flow> = + daoEffect { dao -> + dao.get(1000) + } + .flatMapQueryToList(dispatcher) + .map { entities -> + entities + .map { entity -> + val regex = entity.regex.toRegex() + DGlobalUrlOverride( + id = entity.id.toString(), + name = entity.name, + regex = regex, + command = entity.command, + createdDate = entity.createdAt, + ) + } + } + + override fun put(model: DGlobalUrlOverride): IO = + daoEffect { dao -> + val id = model.id + ?.toLongOrNull() + val regex = model.regex.toString() + if (id != null) { + dao.update( + id = id, + name = model.name, + regex = regex, + command = model.command, + createdAt = model.createdDate, + ) + } else { + dao.insert( + name = model.name, + regex = regex, + command = model.command, + createdAt = model.createdDate, + ) + } + } + + override fun removeAll(): IO = + daoEffect { dao -> + dao.deleteAll() + } + + override fun removeByIds(ids: Set): IO = + daoEffect { dao -> + dao.transaction { + ids.forEach { + val id = it.toLongOrNull() + ?: return@forEach + dao.deleteByIds(id) + } + } + } + + private inline fun daoEffect( + crossinline block: suspend (UrlOverrideQueries) -> T, + ): IO = databaseManager + .get() + .effectMap(dispatcher) { db -> + val dao = db.urlOverrideQueries + block(dao) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddUrlOverride.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddUrlOverride.kt new file mode 100644 index 00000000..43597ffd --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddUrlOverride.kt @@ -0,0 +1,6 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.model.DGlobalUrlOverride + +interface AddUrlOverride : (DGlobalUrlOverride) -> IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/CipherPlaceholder.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/CipherPlaceholder.kt new file mode 100644 index 00000000..dda506e6 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/CipherPlaceholder.kt @@ -0,0 +1,6 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.model.DSecret + +interface CipherPlaceholder : (DSecret.Uri, String) -> IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/GetUrlOverrides.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/GetUrlOverrides.kt new file mode 100644 index 00000000..14c88bc7 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/GetUrlOverrides.kt @@ -0,0 +1,10 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.model.DGlobalUrlOverride +import kotlinx.coroutines.flow.Flow + +/** + * Provides a list of all available + * global url overrides. + */ +interface GetUrlOverrides : () -> Flow> diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveUrlOverrideById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveUrlOverrideById.kt new file mode 100644 index 00000000..a5a62436 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveUrlOverrideById.kt @@ -0,0 +1,7 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO + +interface RemoveUrlOverrideById : ( + Set, +) -> IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddUrlOverrideImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddUrlOverrideImpl.kt new file mode 100644 index 00000000..d538bec1 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddUrlOverrideImpl.kt @@ -0,0 +1,21 @@ +package com.artemchep.keyguard.common.usecase.impl + +import com.artemchep.keyguard.common.model.DGeneratorEmailRelay +import com.artemchep.keyguard.common.model.DGlobalUrlOverride +import com.artemchep.keyguard.common.service.relays.repo.GeneratorEmailRelayRepository +import com.artemchep.keyguard.common.service.urloverride.UrlOverrideRepository +import com.artemchep.keyguard.common.usecase.AddEmailRelay +import com.artemchep.keyguard.common.usecase.AddUrlOverride +import org.kodein.di.DirectDI +import org.kodein.di.instance + +class AddUrlOverrideImpl( + private val urlOverrideRepository: UrlOverrideRepository, +) : AddUrlOverride { + constructor(directDI: DirectDI) : this( + urlOverrideRepository = directDI.instance(), + ) + + override fun invoke(model: DGlobalUrlOverride) = urlOverrideRepository + .put(model) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/util/StringFormat.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/util/StringFormat.kt new file mode 100644 index 00000000..d97a92af --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/util/StringFormat.kt @@ -0,0 +1,109 @@ +package com.artemchep.keyguard.common.util + +private const val DIVIDER_START = '{' +private const val DIVIDER_END = '}' + +private sealed interface Node { + suspend fun eval(): String + + data class Placeholder( + val parent: Placeholder?, + var start: Int, + val dividerStart: Char, + val dividerEnd: Char, + val getter: suspend (key: String) -> String?, + val nodes: MutableList = mutableListOf(), + ) : Node { + override suspend fun eval(): String { + val key = run { + // If the number of nodes is 1, then + // we can skip joining them. + if (nodes.size == 1) { + return@run nodes + .first() + .eval() + } + + buildString { + nodes.forEach { node -> + val s = node.eval() + append(s) + } + } + } + val value = getter(key) + return value + ?: "$dividerStart$key$dividerEnd" + } + } + + data class Text( + val value: String, + ) : Node { + override suspend fun eval(): String = value + } +} + +suspend fun String.simpleFormat2( + dividerStart: Char = DIVIDER_START, + dividerEnd: Char = DIVIDER_END, + getter: suspend (key: String) -> String?, +): String { + val root = Node.Placeholder( + parent = null, + start = 0, + dividerStart = dividerStart, + dividerEnd = dividerEnd, + getter = { it }, + ) + var node: Node.Placeholder = root + + fun appendPrefixPlainTextToSelectedNode(i: Int) { + // Copy the text before the command start + // symbol. This text is the plain text node. + if (i > node.start) { + val plainText = substring(node.start, i) + node.nodes += Node.Text( + value = plainText, + ) + } + } + + var i = 0 + while (true) { + val c = getOrNull(i) + when (c) { + null -> { + appendPrefixPlainTextToSelectedNode(i) + break + } + + dividerStart -> { + appendPrefixPlainTextToSelectedNode(i) + val placeholderNode = Node.Placeholder( + parent = node, + start = i + 1, // Skip the command start symbol + dividerStart = dividerStart, + dividerEnd = dividerEnd, + getter = getter, + ) + node.nodes += placeholderNode + node = placeholderNode + } + + dividerEnd -> { + val parent = node.parent + if (parent != null) { + appendPrefixPlainTextToSelectedNode(i) + node = parent + node.start = i + 1 + } + } + } + i += 1 + } + if (root !== node) { + return this + } + return root.eval() +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt index 3c88d323..e661a294 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt @@ -19,6 +19,8 @@ import com.artemchep.keyguard.common.service.hibp.passwords.impl.PasswordPwnageD import com.artemchep.keyguard.common.service.hibp.passwords.impl.PasswordPwnageRepositoryImpl import com.artemchep.keyguard.common.service.relays.repo.GeneratorEmailRelayRepository import com.artemchep.keyguard.common.service.relays.repo.GeneratorEmailRelayRepositoryImpl +import com.artemchep.keyguard.common.service.urloverride.UrlOverrideRepository +import com.artemchep.keyguard.common.service.urloverride.UrlOverrideRepositoryImpl import com.artemchep.keyguard.common.usecase.AddCipher import com.artemchep.keyguard.common.usecase.AddCipherOpenedHistory import com.artemchep.keyguard.common.usecase.AddCipherUsedAutofillHistory @@ -28,6 +30,7 @@ import com.artemchep.keyguard.common.usecase.AddFolder import com.artemchep.keyguard.common.usecase.AddGeneratorHistory import com.artemchep.keyguard.common.usecase.AddPasskeyCipher import com.artemchep.keyguard.common.usecase.AddUriCipher +import com.artemchep.keyguard.common.usecase.AddUrlOverride import com.artemchep.keyguard.common.usecase.ChangeCipherNameById import com.artemchep.keyguard.common.usecase.ChangeCipherPasswordById import com.artemchep.keyguard.common.usecase.CheckPasswordLeak @@ -69,6 +72,7 @@ import com.artemchep.keyguard.common.usecase.GetOrganizations import com.artemchep.keyguard.common.usecase.GetProfiles import com.artemchep.keyguard.common.usecase.GetSends import com.artemchep.keyguard.common.usecase.GetShouldRequestAppReview +import com.artemchep.keyguard.common.usecase.GetUrlOverrides import com.artemchep.keyguard.common.usecase.MergeFolderById import com.artemchep.keyguard.common.usecase.MoveCipherToFolderById import com.artemchep.keyguard.common.usecase.PutAccountColorById @@ -82,6 +86,7 @@ import com.artemchep.keyguard.common.usecase.RemoveEmailRelayById import com.artemchep.keyguard.common.usecase.RemoveFolderById import com.artemchep.keyguard.common.usecase.RemoveGeneratorHistory import com.artemchep.keyguard.common.usecase.RemoveGeneratorHistoryById +import com.artemchep.keyguard.common.usecase.RemoveUrlOverrideById import com.artemchep.keyguard.common.usecase.RenameFolderById import com.artemchep.keyguard.common.usecase.RestoreCipherById import com.artemchep.keyguard.common.usecase.RetryCipher @@ -95,6 +100,7 @@ import com.artemchep.keyguard.common.usecase.Watchdog import com.artemchep.keyguard.common.usecase.WatchdogImpl import com.artemchep.keyguard.common.usecase.impl.AddEmailRelayImpl import com.artemchep.keyguard.common.usecase.impl.AddGeneratorHistoryImpl +import com.artemchep.keyguard.common.usecase.impl.AddUrlOverrideImpl import com.artemchep.keyguard.common.usecase.impl.DownloadAttachmentImpl2 import com.artemchep.keyguard.common.usecase.impl.GetAccountStatusImpl import com.artemchep.keyguard.common.usecase.impl.GetCanAddAccountImpl @@ -160,6 +166,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.GetMetasImpl import com.artemchep.keyguard.provider.bitwarden.usecase.GetOrganizationsImpl import com.artemchep.keyguard.provider.bitwarden.usecase.GetProfilesImpl import com.artemchep.keyguard.provider.bitwarden.usecase.GetSendsImpl +import com.artemchep.keyguard.provider.bitwarden.usecase.GetUrlOverridesImpl import com.artemchep.keyguard.provider.bitwarden.usecase.MergeFolderByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.MoveCipherToFolderByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountColorByIdImpl @@ -171,6 +178,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveAccountsImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveCipherByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveEmailRelayByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveFolderByIdImpl +import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveUrlOverrideByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RenameFolderByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RestoreCipherByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RetryCipherImpl @@ -219,6 +227,15 @@ fun DI.Builder.createSubDi2( bindSingleton { RemoveEmailRelayByIdImpl(this) } + bindSingleton { + AddUrlOverrideImpl(this) + } + bindSingleton { + GetUrlOverridesImpl(this) + } + bindSingleton { + RemoveUrlOverrideByIdImpl(this) + } bindSingleton { GetAccountsImpl(this) } @@ -419,6 +436,9 @@ fun DI.Builder.createSubDi2( bindSingleton { GeneratorEmailRelayRepositoryImpl(this) } + bindSingleton { + UrlOverrideRepositoryImpl(this) + } bindSingleton { PasswordPwnageDataSourceLocalImpl(this) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt index f87fecf9..412a8f70 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt @@ -23,6 +23,7 @@ import com.artemchep.keyguard.data.CipherUsageHistory import com.artemchep.keyguard.data.Database import com.artemchep.keyguard.data.GeneratorEmailRelay import com.artemchep.keyguard.data.GeneratorHistory +import com.artemchep.keyguard.data.UrlOverride import com.artemchep.keyguard.data.bitwarden.Account import com.artemchep.keyguard.data.bitwarden.Cipher import com.artemchep.keyguard.data.bitwarden.Collection @@ -106,6 +107,7 @@ class DatabaseManagerImpl( cipherUsageHistoryAdapter = CipherUsageHistory.Adapter(InstantToLongAdapter), generatorHistoryAdapter = GeneratorHistory.Adapter(InstantToLongAdapter), generatorEmailRelayAdapter = GeneratorEmailRelay.Adapter(InstantToLongAdapter), + urlOverrideAdapter = UrlOverride.Adapter(InstantToLongAdapter), cipherAdapter = Cipher.Adapter(bitwardenCipherToStringAdapter), sendAdapter = Send.Adapter(bitwardenSendToStringAdapter), collectionAdapter = Collection.Adapter(bitwardenCollectionToStringAdapter), diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationRoute.kt index 466357b8..e100c506 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationRoute.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationRoute.kt @@ -34,6 +34,7 @@ class ConfirmationRoute( override val value: String = "", val title: String, val hint: String? = null, + val description: String? = null, val type: Type = Type.Text, /** * `true` if the empty value is a valid @@ -44,6 +45,8 @@ class ConfirmationRoute( enum class Type { Text, Token, + Regex, + Command, Password, Username, } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt index eb667025..cb390be2 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt @@ -222,78 +222,95 @@ private fun ConfirmationStringItem( isVisible = !item.sensitive, ) } - FlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - label = item.title, - value = item.state, - textStyle = when { - item.monospace -> - TextStyle( - fontFamily = monoFontFamily, - ) + Column( + modifier = modifier, + ) { + FlatTextField( + modifier = Modifier + .padding(horizontal = Dimens.horizontalPadding), + label = item.title, + value = item.state, + textStyle = when { + item.monospace -> + TextStyle( + fontFamily = monoFontFamily, + ) - else -> LocalTextStyle.current - }, - visualTransformation = if (visibilityState.isVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - keyboardOptions = when { - item.password -> - KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - ) + else -> LocalTextStyle.current + }, + visualTransformation = if (visibilityState.isVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = when { + item.password -> + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + ) - else -> KeyboardOptions.Default - }, - singleLine = true, - maxLines = 1, - trailing = { - ExpandedIfNotEmptyForRow( - Unit.takeIf { item.sensitive }, - ) { - VisibilityToggle( - visibilityState = visibilityState, - ) - } - ExpandedIfNotEmptyForRow( - item.generator, - ) { generator -> - val key = when (generator) { - ConfirmationState.Item.StringItem.Generator.Username -> "username" - ConfirmationState.Item.StringItem.Generator.Password -> "password" + else -> KeyboardOptions.Default + }, + singleLine = true, + maxLines = 1, + trailing = { + ExpandedIfNotEmptyForRow( + Unit.takeIf { item.sensitive }, + ) { + VisibilityToggle( + visibilityState = visibilityState, + ) } - AutofillButton( - key = key, - username = generator == ConfirmationState.Item.StringItem.Generator.Username, - password = generator == ConfirmationState.Item.StringItem.Generator.Password, - onValueChange = item.state.onChange, - ) - } - }, - content = { - ExpandedIfNotEmpty( - valueOrNull = Unit - .takeIf { - item.value.isNotEmpty() && - item.password && - item.state.error == null - }, - ) { - PasswordStrengthBadge( - modifier = Modifier - .padding( - top = 8.dp, - bottom = 8.dp, - ), - password = item.value, - ) - } - }, - ) + ExpandedIfNotEmptyForRow( + item.generator, + ) { generator -> + val key = when (generator) { + ConfirmationState.Item.StringItem.Generator.Username -> "username" + ConfirmationState.Item.StringItem.Generator.Password -> "password" + } + AutofillButton( + key = key, + username = generator == ConfirmationState.Item.StringItem.Generator.Username, + password = generator == ConfirmationState.Item.StringItem.Generator.Password, + onValueChange = item.state.onChange, + ) + } + }, + content = { + ExpandedIfNotEmpty( + valueOrNull = Unit + .takeIf { + item.value.isNotEmpty() && + item.password && + item.state.error == null + }, + ) { + PasswordStrengthBadge( + modifier = Modifier + .padding( + top = 8.dp, + bottom = 8.dp, + ), + password = item.value, + ) + } + }, + ) + if (item.description != null) { + Text( + modifier = Modifier + .padding( + horizontal = Dimens.horizontalPadding, + vertical = 8.dp, + ), + text = item.description, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current + .combineAlpha(MediumEmphasisAlpha), + ) + } + } } @OptIn(ExperimentalLayoutApi::class) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationState.kt index 70fe2a76..e9138d03 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationState.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationState.kt @@ -29,6 +29,7 @@ data class ConfirmationState( override val value: String, val state: TextFieldModel2, val title: String, + val description: String?, val sensitive: Boolean, val monospace: Boolean, val password: Boolean, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationStateProducer.kt index 6a6058d7..19c7b3b1 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationStateProducer.kt @@ -78,7 +78,9 @@ fun confirmationState( item.type == ConfirmationRoute.Args.Item.StringItem.Type.Token val monospace = item.type == ConfirmationRoute.Args.Item.StringItem.Type.Password || - item.type == ConfirmationRoute.Args.Item.StringItem.Type.Token + item.type == ConfirmationRoute.Args.Item.StringItem.Type.Token || + item.type == ConfirmationRoute.Args.Item.StringItem.Type.Regex || + item.type == ConfirmationRoute.Args.Item.StringItem.Type.Command val password = item.type == ConfirmationRoute.Args.Item.StringItem.Type.Password val generator = when (item.type) { @@ -86,6 +88,8 @@ fun confirmationState( ConfirmationRoute.Args.Item.StringItem.Type.Password -> ConfirmationState.Item.StringItem.Generator.Password ConfirmationRoute.Args.Item.StringItem.Type.Token, ConfirmationRoute.Args.Item.StringItem.Type.Text, + ConfirmationRoute.Args.Item.StringItem.Type.Regex, + ConfirmationRoute.Args.Item.StringItem.Type.Command, -> null } requireNotNull(state) @@ -99,6 +103,7 @@ fun confirmationState( ConfirmationState.Item.StringItem( key = item.key, title = item.title, + description = item.description, sensitive = sensitive, monospace = monospace, password = password, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListScreen.kt index 74561d4c..c14a9493 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListScreen.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.artemchep.keyguard.common.model.Loadable import com.artemchep.keyguard.common.model.flatMap +import com.artemchep.keyguard.common.model.fold import com.artemchep.keyguard.common.model.getOrNull import com.artemchep.keyguard.feature.EmptyView import com.artemchep.keyguard.feature.ErrorView @@ -47,10 +48,12 @@ import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow import com.artemchep.keyguard.ui.FabState import com.artemchep.keyguard.ui.FlatDropdown import com.artemchep.keyguard.ui.FlatItemTextContent +import com.artemchep.keyguard.ui.OptionsButton import com.artemchep.keyguard.ui.ScaffoldLazyColumn import com.artemchep.keyguard.ui.icons.IconBox import com.artemchep.keyguard.ui.skeleton.SkeletonItem import com.artemchep.keyguard.ui.toolbar.CustomToolbar +import com.artemchep.keyguard.ui.toolbar.LargeToolbar import com.artemchep.keyguard.ui.toolbar.content.CustomToolbarContent import com.artemchep.keyguard.ui.util.DividerColor import dev.icerock.moko.resources.compose.stringResource @@ -73,7 +76,7 @@ fun EmailRelayListScreen( fun EmailRelayListScreen( loadableState: Loadable, ) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val listRevision = loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.revision @@ -115,18 +118,15 @@ fun EmailRelayListScreen( .nestedScroll(scrollBehavior.nestedScrollConnection), topAppBarScrollBehavior = scrollBehavior, topBar = { - CustomToolbar( + LargeToolbar( + title = { + Text(stringResource(Res.strings.emailrelay_list_header_title)) + }, + navigationIcon = { + NavigationIcon() + }, scrollBehavior = scrollBehavior, - ) { - Column { - CustomToolbarContent( - title = stringResource(Res.strings.emailrelay_list_header_title), - icon = { - NavigationIcon() - }, - ) - } - } + ) }, bottomBar = { val selectionOrNull = diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt index 5b8d9c39..a9c128ed 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt @@ -2,6 +2,7 @@ package com.artemchep.keyguard.feature.generator.emailrelay import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Email @@ -154,6 +155,16 @@ fun produceEmailRelayListState( fun onNew(model: EmailRelay) = onEdit(model, null) + fun onDuplicate(entity: DGeneratorEmailRelay) { + val createdAt = Clock.System.now() + val model = entity.copy( + id = null, + createdDate = createdAt, + ) + addEmailRelay(model) + .launchIn(appScope) + } + fun onDelete( emailRelayIds: Set, ) { @@ -258,6 +269,12 @@ fun produceEmailRelayListState( .partially1(it), ) } + this += FlatItemAction( + icon = Icons.Outlined.CopyAll, + title = translate(Res.strings.duplicate), + onClick = ::onDuplicate + .partially1(it), + ) this += FlatItemAction( icon = Icons.Outlined.Delete, title = translate(Res.strings.delete), diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt index 9c1ff219..b9c0cc3c 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/SettingPaneContent.kt @@ -78,6 +78,7 @@ import com.artemchep.keyguard.feature.home.settings.component.settingSubscriptio import com.artemchep.keyguard.feature.home.settings.component.settingThemeUseAmoledDarkProvider import com.artemchep.keyguard.feature.home.settings.component.settingTwoPanelLayoutLandscapeProvider import com.artemchep.keyguard.feature.home.settings.component.settingTwoPanelLayoutPortraitProvider +import com.artemchep.keyguard.feature.home.settings.component.settingUrlOverrideProvider import com.artemchep.keyguard.feature.home.settings.component.settingUseExternalBrowserProvider import com.artemchep.keyguard.feature.home.settings.component.settingVaultLockAfterRebootProvider import com.artemchep.keyguard.feature.home.settings.component.settingVaultLockAfterScreenOffProvider @@ -147,6 +148,7 @@ object Setting { const val LAUNCH_YUBIKEY = "launch_yubikey" const val DATA_SAFETY = "data_safety" const val FEATURES_OVERVIEW = "features_overview" + const val URL_OVERRIDE = "url_override" const val RATE_APP = "rate_app" const val CONCEAL = "conceal" const val MARKDOWN = "markdown" @@ -221,6 +223,7 @@ val hub = mapOf SettingComponent>( Setting.LAUNCH_APP_PICKER to ::settingLaunchAppPicker, Setting.DATA_SAFETY to ::settingDataSafetyProvider, Setting.FEATURES_OVERVIEW to ::settingFeaturesOverviewProvider, + Setting.URL_OVERRIDE to ::settingUrlOverrideProvider, Setting.RATE_APP to ::settingRateAppProvider, Setting.DIVIDER to ::settingSectionProvider, Setting.CONCEAL to ::settingConcealFieldsProvider, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingUrlOverride.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingUrlOverride.kt new file mode 100644 index 00000000..533658cc --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/component/SettingUrlOverride.kt @@ -0,0 +1,67 @@ +package com.artemchep.keyguard.feature.home.settings.component + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import com.artemchep.keyguard.feature.navigation.LocalNavigationController +import com.artemchep.keyguard.feature.navigation.NavigationIntent +import com.artemchep.keyguard.feature.urloverride.UrlOverrideListRoute +import com.artemchep.keyguard.res.Res +import com.artemchep.keyguard.ui.FlatItem +import com.artemchep.keyguard.ui.icons.ChevronIcon +import com.artemchep.keyguard.ui.icons.icon +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.flowOf +import org.kodein.di.DirectDI + +fun settingUrlOverrideProvider( + directDI: DirectDI, +) = settingUrlOverrideProvider() + +fun settingUrlOverrideProvider(): SettingComponent = kotlin.run { + val item = SettingIi( + search = SettingIi.Search( + group = "about", + tokens = listOf( + "url", + "override", + "placeholder", + "scheme", + ), + ), + ) { + val navigationController by rememberUpdatedState(LocalNavigationController.current) + SettingUrlOverride( + onClick = { + val intent = NavigationIntent.NavigateToRoute( + route = UrlOverrideListRoute, + ) + navigationController.queue(intent) + }, + ) + } + flowOf(item) +} + +@Composable +private fun SettingUrlOverride( + onClick: (() -> Unit)?, +) { + FlatItem( + leading = icon(Icons.Outlined.Link), + trailing = { + ChevronIcon() + }, + title = { + Text( + text = stringResource(Res.strings.pref_item_url_override_title), + ) + }, + onClick = onClick, + ) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/other/OtherSettingsScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/other/OtherSettingsScreen.kt index 7fcd3fef..476aade6 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/other/OtherSettingsScreen.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/other/OtherSettingsScreen.kt @@ -18,6 +18,12 @@ fun OtherSettingsScreen() { SettingPaneItem.Item(Setting.FEATURES_OVERVIEW), ), ), + SettingPaneItem.Group( + key = "other", + list = listOf( + SettingPaneItem.Item(Setting.URL_OVERRIDE), + ), + ), SettingPaneItem.Group( key = "security", list = listOf( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewUriItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewUriItem.kt index 4700a6fa..12efe252 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewUriItem.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewUriItem.kt @@ -2,30 +2,51 @@ package com.artemchep.keyguard.feature.home.vault.component import androidx.compose.foundation.background 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.FlowRow 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.foundation.layout.widthIn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface 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.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily 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 com.artemchep.keyguard.feature.home.vault.model.VaultViewItem +import com.artemchep.keyguard.ui.ContextItem import com.artemchep.keyguard.ui.DisabledEmphasisAlpha +import com.artemchep.keyguard.ui.DropdownMenuItemFlat +import com.artemchep.keyguard.ui.DropdownMinWidth +import com.artemchep.keyguard.ui.DropdownScopeImpl import com.artemchep.keyguard.ui.ExpandedIfNotEmpty import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow import com.artemchep.keyguard.ui.FlatDropdown import com.artemchep.keyguard.ui.FlatItemTextContent +import com.artemchep.keyguard.ui.MediumEmphasisAlpha +import com.artemchep.keyguard.ui.icons.IconSmallBox import com.artemchep.keyguard.ui.theme.combineAlpha import com.artemchep.keyguard.ui.theme.warning import com.artemchep.keyguard.ui.theme.warningContainer @@ -44,11 +65,40 @@ fun VaultViewUriItem( content = { FlatItemTextContent( title = { - Text( - text = item.title, - overflow = TextOverflow.Ellipsis, - maxLines = 5, - ) + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(1f), + text = item.title, + overflow = TextOverflow.Ellipsis, + maxLines = 5, + ) + ExpandedIfNotEmptyForRow( + item.matchTypeTitle, + ) { matchTypeTitle -> + Text( + modifier = Modifier + .widthIn(max = 128.dp) + .border( + Dp.Hairline, + DividerColor, + MaterialTheme.shapes.small, + ) + .padding( + horizontal = 8.dp, + vertical = 4.dp, + ), + text = matchTypeTitle, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.End, + maxLines = 2, + ) + } + } }, text = if (item.text != null) { // composable @@ -101,34 +151,113 @@ fun VaultViewUriItem( ) } } - }, - trailing = { - ExpandedIfNotEmptyForRow( - item.matchTypeTitle, - ) { matchTypeTitle -> - Text( - modifier = Modifier - .widthIn(max = 128.dp) - .padding( - top = 8.dp, - bottom = 8.dp, - ) - .border( - Dp.Hairline, - DividerColor, - MaterialTheme.shapes.small, - ) - .padding( - horizontal = 8.dp, - vertical = 4.dp, - ), - text = matchTypeTitle, - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.End, - maxLines = 2, - ) + + var selectedDropdown by remember { + mutableStateOf>(emptyList()) + } + if (item.overrides.isNotEmpty()) FlowRow( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item.overrides.forEach { override -> + val updatedDropdownState = rememberUpdatedState(override.dropdown) + UrlOverrideItem( + title = override.title, + text = override.text, + onClick = { + selectedDropdown = updatedDropdownState.value + }, + ) + } + } + + // Inject the dropdown popup to the bottom of the + // content. + val onDismissRequest = { + selectedDropdown = emptyList() + } + DropdownMenu( + expanded = selectedDropdown.isNotEmpty(), + onDismissRequest = onDismissRequest, + modifier = Modifier + .widthIn(min = DropdownMinWidth), + ) { + val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest) + selectedDropdown.forEach { action -> + scope.DropdownMenuItemFlat( + action = action, + ) + } } }, dropdown = item.dropdown, ) } + +@Composable +private fun UrlOverrideItem( + modifier: Modifier = Modifier, + title: String, + text: String, + onClick: () -> Unit, +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.small, + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding( + start = 8.dp, + end = 8.dp, + top = 8.dp, + bottom = 8.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(16.dp), + ) { + IconSmallBox( + main = Icons.Outlined.Terminal, + ) + } + Spacer( + modifier = Modifier + .width(8.dp), + ) + Text( + modifier = Modifier + .widthIn(max = 128.dp) + .alignByBaseline(), + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer( + modifier = Modifier + .width(8.dp), + ) + Text( + modifier = Modifier + .widthIn(max = 128.dp) + .alignByBaseline(), + text = text, + color = LocalContentColor.current + .combineAlpha(MediumEmphasisAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultViewItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultViewItem.kt index cbfd7a3b..23a5c71c 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultViewItem.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultViewItem.kt @@ -214,8 +214,19 @@ sealed interface VaultViewItem { * to the item. */ val dropdown: List = emptyList(), + val overrides: List = emptyList(), ) : VaultViewItem { - companion object + companion object; + + data class Override( + val title: String, + val text: String, + /** + * List of the callable actions appended + * to the item. + */ + val dropdown: List = emptyList(), + ) } data class Totp( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultViewStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultViewStateProducer.kt index 6ba9a91f..bfa9115c 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultViewStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultViewStateProducer.kt @@ -1,7 +1,6 @@ package com.artemchep.keyguard.feature.home.vault.screen import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -20,7 +19,6 @@ import androidx.compose.material.icons.outlined.PhoneIphone import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.Textsms -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Switch import androidx.compose.runtime.Composable @@ -47,6 +45,7 @@ import com.artemchep.keyguard.common.model.DAccount import com.artemchep.keyguard.common.model.DCollection import com.artemchep.keyguard.common.model.DFilter import com.artemchep.keyguard.common.model.DFolderTree +import com.artemchep.keyguard.common.model.DGlobalUrlOverride import com.artemchep.keyguard.common.model.DOrganization import com.artemchep.keyguard.common.model.DSecret import com.artemchep.keyguard.common.model.DownloadAttachmentRequest @@ -65,9 +64,17 @@ import com.artemchep.keyguard.common.model.formatH import com.artemchep.keyguard.common.model.titleH import com.artemchep.keyguard.common.service.clipboard.ClipboardService import com.artemchep.keyguard.common.service.download.DownloadManager +import com.artemchep.keyguard.common.service.execute.ExecuteCommand import com.artemchep.keyguard.common.service.extract.LinkInfoExtractor import com.artemchep.keyguard.common.service.extract.LinkInfoRegistry import com.artemchep.keyguard.common.service.passkey.PassKeyService +import com.artemchep.keyguard.common.service.placeholder.impl.CipherPlaceholder +import com.artemchep.keyguard.common.service.placeholder.impl.CommentPlaceholder +import com.artemchep.keyguard.common.service.placeholder.impl.CustomPlaceholder +import com.artemchep.keyguard.common.service.placeholder.impl.DateTimePlaceholder +import com.artemchep.keyguard.common.service.placeholder.impl.TextTransformPlaceholder +import com.artemchep.keyguard.common.service.placeholder.impl.UrlPlaceholder +import com.artemchep.keyguard.common.service.placeholder.placeholderFormat import com.artemchep.keyguard.common.service.twofa.TwoFaService import com.artemchep.keyguard.common.usecase.AddCipherOpenedHistory import com.artemchep.keyguard.common.usecase.ChangeCipherNameById @@ -98,6 +105,7 @@ import com.artemchep.keyguard.common.usecase.GetMarkdown import com.artemchep.keyguard.common.usecase.GetOrganizations import com.artemchep.keyguard.common.usecase.GetPasswordStrength import com.artemchep.keyguard.common.usecase.GetTotpCode +import com.artemchep.keyguard.common.usecase.GetUrlOverrides import com.artemchep.keyguard.common.usecase.GetWebsiteIcons import com.artemchep.keyguard.common.usecase.MoveCipherToFolderById import com.artemchep.keyguard.common.usecase.PasskeyTargetCheck @@ -209,6 +217,7 @@ fun vaultViewScreenState( getWebsiteIcons = instance(), getTotpCode = instance(), getPasswordStrength = instance(), + getUrlOverrides = instance(), passkeyTargetCheck = instance(), cipherUnsecureUrlCheck = instance(), cipherUnsecureUrlAutoFix = instance(), @@ -219,6 +228,7 @@ fun vaultViewScreenState( changeCipherPasswordById = instance(), checkPasswordLeak = instance(), retryCipher = instance(), + executeCommand = instance(), copyCipherById = instance(), restoreCipherById = instance(), trashCipherById = instance(), @@ -250,7 +260,14 @@ fun vaultViewScreenState( private class Holder( val uri: DSecret.Uri, val info: List, -) + val overrides: List = emptyList(), +) { + data class Override( + val override: DGlobalUrlOverride, + val uri: String, + val info: List, + ) +} @Composable fun vaultViewScreenState( @@ -270,6 +287,7 @@ fun vaultViewScreenState( getWebsiteIcons: GetWebsiteIcons, getTotpCode: GetTotpCode, getPasswordStrength: GetPasswordStrength, + getUrlOverrides: GetUrlOverrides, passkeyTargetCheck: PasskeyTargetCheck, cipherUnsecureUrlCheck: CipherUnsecureUrlCheck, cipherUnsecureUrlAutoFix: CipherUnsecureUrlAutoFix, @@ -280,6 +298,7 @@ fun vaultViewScreenState( changeCipherPasswordById: ChangeCipherPasswordById, checkPasswordLeak: CheckPasswordLeak, retryCipher: RetryCipher, + executeCommand: ExecuteCommand, copyCipherById: CopyCipherById, restoreCipherById: RestoreCipherById, trashCipherById: TrashCipherById, @@ -408,6 +427,7 @@ fun vaultViewScreenState( getAppIcons(), getWebsiteIcons(), getCanWrite(), + getUrlOverrides(), ) { array -> val accountOrNull = array[0] as DAccount? val secretOrNull = array[1] as DSecret? @@ -419,6 +439,7 @@ fun vaultViewScreenState( val appIcons = array[7] as Boolean val websiteIcons = array[8] as Boolean val canAddSecret = array[9] as Boolean + val urlOverrides = array[10] as List val content = when { accountOrNull == null || secretOrNull == null -> VaultViewState.Content.NotFound @@ -447,15 +468,69 @@ fun vaultViewScreenState( val canEdit = canAddSecret && secretOrNull.canEdit() && !hasCanNotWriteCiphers val canDelete = canAddSecret && secretOrNull.canDelete() && !hasCanNotWriteCiphers + val placeholders = listOf( + CipherPlaceholder(secretOrNull), + CommentPlaceholder(), + CustomPlaceholder(secretOrNull), + DateTimePlaceholder(), + TextTransformPlaceholder(), + ) val extractors = LinkInfoRegistry(linkInfoExtractors) val cipherUris = secretOrNull .uris .map { uri -> - val extra = extractors.process(uri) - Holder( - uri = uri, - info = extra, - ) + when (uri.match) { + // Regular expressions may use the {} control + // symbols already. Since we do not want to break + // existing data we ignore the placeholders and overrides. + DSecret.Uri.MatchType.RegularExpression -> { + val extra = extractors.process(uri) + Holder( + uri = uri, + info = extra, + ) + } + else -> { + val newUriString = uri.uri.placeholderFormat(placeholders) + val newUri = uri.copy(uri = newUriString) + + // Process URL overrides + val urlOverridePlaceholders by lazy { + placeholders + listOf( + UrlPlaceholder(newUriString), + ) + } + val urlOverrideList = urlOverrides + .filter { override -> + override.regex + .matches(newUriString) + } + .map { override -> + val command = override.command + .placeholderFormat( + placeholders = urlOverridePlaceholders, + ) + val extra = extractors.process( + DSecret.Uri( + uri = command, + match = DSecret.Uri.MatchType.Exact, + ), + ) + Holder.Override( + override = override, + uri = command, + info = extra, + ) + } + + val extra = extractors.process(newUri) + Holder( + uri = newUri, + info = extra, + overrides = urlOverrideList, + ) + } + } } val icon = secretOrNull.toVaultItemIcon( appIcons = appIcons, @@ -605,6 +680,7 @@ fun vaultViewScreenState( cipherFieldSwitchToggle = cipherFieldSwitchToggle, checkPasswordLeak = checkPasswordLeak, retryCipher = retryCipher, + executeCommand = executeCommand, markdown = markdown, concealFields = concealFields || secretOrNull.reprompt, websiteIcons = websiteIcons, @@ -655,6 +731,7 @@ private fun RememberStateFlowScope.oh( cipherFieldSwitchToggle: CipherFieldSwitchToggle, checkPasswordLeak: CheckPasswordLeak, retryCipher: RetryCipher, + executeCommand: ExecuteCommand, markdown: Boolean, concealFields: Boolean, websiteIcons: Boolean, @@ -1295,7 +1372,7 @@ private fun RememberStateFlowScope.oh( linkedApps .mapIndexed { index, holder -> val id = "link.app.$index" - val item = aaaa( + val item = createUriItem( canEdit = canEdit, contentColor = contentColor, disabledContentColor = disabledContentColor, @@ -1303,6 +1380,7 @@ private fun RememberStateFlowScope.oh( cipherUnsecureUrlCheck = cipherUnsecureUrlCheck, cipherUnsecureUrlAutoFix = cipherUnsecureUrlAutoFix, getJustDeleteMeByUrl = getJustDeleteMeByUrl, + executeCommand = executeCommand, holder = holder, id = id, accountId = account.accountId(), @@ -1335,7 +1413,7 @@ private fun RememberStateFlowScope.oh( linkedWebsites .mapIndexed { index, holder -> val id = "link.website.$index" - val item = aaaa( + val item = createUriItem( canEdit = canEdit, contentColor = contentColor, disabledContentColor = disabledContentColor, @@ -1343,6 +1421,7 @@ private fun RememberStateFlowScope.oh( cipherUnsecureUrlCheck = cipherUnsecureUrlCheck, cipherUnsecureUrlAutoFix = cipherUnsecureUrlAutoFix, getJustDeleteMeByUrl = getJustDeleteMeByUrl, + executeCommand = executeCommand, holder = holder, id = id, accountId = account.accountId(), @@ -1741,7 +1820,7 @@ private fun RememberStateFlowScope.oh( } } -private suspend fun RememberStateFlowScope.aaaa( +private suspend fun RememberStateFlowScope.createUriItem( canEdit: Boolean, contentColor: Color, disabledContentColor: Color, @@ -1749,12 +1828,35 @@ private suspend fun RememberStateFlowScope.aaaa( cipherUnsecureUrlCheck: CipherUnsecureUrlCheck, cipherUnsecureUrlAutoFix: CipherUnsecureUrlAutoFix, getJustDeleteMeByUrl: GetJustDeleteMeByUrl, + executeCommand: ExecuteCommand, holder: Holder, id: String, accountId: String, cipherId: String, copy: CopyText, ): VaultViewItem.Uri { + val overrides = holder + .overrides + .map { + val command = it.uri + val dropdown = createUriItemContextItems( + canEdit = false, + cipherUnsecureUrlCheck = cipherUnsecureUrlCheck, + cipherUnsecureUrlAutoFix = cipherUnsecureUrlAutoFix, + getJustDeleteMeByUrl = getJustDeleteMeByUrl, + executeCommand = executeCommand, + uri = command, + info = it.info, + cipherId = cipherId, + copy = copy, + ) + VaultViewItem.Uri.Override( + title = it.override.name, + text = command, + dropdown = dropdown, + ) + } + val uri = holder.uri val matchTypeTitle = holder.uri.match @@ -1764,6 +1866,18 @@ private suspend fun RememberStateFlowScope.aaaa( translate(it) } + val dropdown = createUriItemContextItems( + canEdit = canEdit, + cipherUnsecureUrlCheck = cipherUnsecureUrlCheck, + cipherUnsecureUrlAutoFix = cipherUnsecureUrlAutoFix, + getJustDeleteMeByUrl = getJustDeleteMeByUrl, + executeCommand = executeCommand, + uri = holder.uri.uri, + info = holder.info, + cipherId = cipherId, + copy = copy, + ) + val platformMarker = holder.info .firstOrNull { it is LinkInfoPlatform } as LinkInfoPlatform? when (platformMarker) { @@ -1772,55 +1886,6 @@ private suspend fun RememberStateFlowScope.aaaa( .firstOrNull { it is LinkInfoAndroid } as LinkInfoAndroid? when (androidMarker) { is LinkInfoAndroid.Installed -> { - val dropdown = buildContextItems { - section { - this += copy.FlatItemAction( - title = translate(Res.strings.copy_package_name), - value = platformMarker.packageName, - ) - } - section { - this += FlatItemAction( - leading = { - Image( - modifier = Modifier - .size(24.dp) - .clip(CircleShape), - painter = androidMarker.icon, - contentDescription = null, - ) - }, - title = translate(Res.strings.uri_action_launch_app_title), - trailing = { - ChevronIcon() - }, - onClick = { - val intent = - NavigationIntent.NavigateToApp(platformMarker.packageName) - navigate(intent) - }, - ) - this += FlatItemAction( - icon = Icons.Outlined.Launch, - title = translate(Res.strings.uri_action_launch_play_store_title), - trailing = { - ChevronIcon() - }, - onClick = { - val intent = - NavigationIntent.NavigateToBrowser(platformMarker.playStoreUrl) - navigate(intent) - }, - ) - } - section { - this += createShareAction( - translator = this@aaaa, - text = platformMarker.playStoreUrl, - navigate = ::navigate, - ) - } - } return VaultViewItem.Uri( id = id, icon = { @@ -1839,35 +1904,6 @@ private suspend fun RememberStateFlowScope.aaaa( } else -> { - val dropdown = buildContextItems { - section { - this += copy.FlatItemAction( - title = translate(Res.strings.copy_package_name), - value = platformMarker.packageName, - ) - } - section { - this += FlatItemAction( - icon = Icons.Outlined.Launch, - title = translate(Res.strings.uri_action_launch_play_store_title), - trailing = { - ChevronIcon() - }, - onClick = { - val intent = - NavigationIntent.NavigateToBrowser(platformMarker.playStoreUrl) - navigate(intent) - }, - ) - } - section { - this += createShareAction( - translator = this@aaaa, - text = platformMarker.playStoreUrl, - navigate = ::navigate, - ) - } - } return VaultViewItem.Uri( id = id, icon = { @@ -1885,14 +1921,6 @@ private suspend fun RememberStateFlowScope.aaaa( } is LinkInfoPlatform.IOS -> { - val dropdown = buildContextItems { - section { - this += copy.FlatItemAction( - title = translate(Res.strings.copy_package_name), - value = platformMarker.packageName, - ) - } - } return VaultViewItem.Uri( id = id, icon = { @@ -1909,99 +1937,14 @@ private suspend fun RememberStateFlowScope.aaaa( is LinkInfoPlatform.Web -> { val url = platformMarker.url.toString() - val isJustDeleteMe = getJustDeleteMeByUrl(url) - .attempt() - .bind() - .getOrNull() val isUnsecure = cipherUnsecureUrlCheck(holder.uri.uri) - val dropdown = buildContextItems { - section { - this += copy.FlatItemAction( - title = translate(Res.strings.copy_url), - value = url, - ) - } - section { - this += FlatItemAction( - icon = Icons.Outlined.Launch, - title = translate(Res.strings.uri_action_launch_browser_title), - text = url, - trailing = { - ChevronIcon() - }, - onClick = { - val intent = NavigationIntent.NavigateToBrowser(url) - navigate(intent) - }, - ) - if ( - url.removeSuffix("/") != - platformMarker.frontPageUrl.toString().removeSuffix("/") - ) { - val launchUrl = platformMarker.frontPageUrl.toString() - this += FlatItemAction( - icon = Icons.Outlined.Launch, - title = translate(Res.strings.uri_action_launch_browser_main_page_title), - text = launchUrl, - trailing = { - ChevronIcon() - }, - onClick = { - val intent = NavigationIntent.NavigateToBrowser(launchUrl) - navigate(intent) - }, - ) - } - } - if (isUnsecure) { - section { - this += FlatItemAction( - icon = Icons.Outlined.AutoAwesome, - title = "Auto-fix unsecure URL", - text = "Changes a protocol to secure variant if it is available", - onClick = if (canEdit) { - // lambda - { - val ff = mapOf( - cipherId to setOf(holder.uri.uri), - ) - cipherUnsecureUrlAutoFix(ff) - .launchIn(appScope) - } - } else { - null - }, - ) - } - } - section { - this += createShareAction( - translator = this@aaaa, - text = uri.uri, - navigate = ::navigate, - ) - } - section { - this += WebsiteLeakRoute.checkBreachesWebsiteActionOrNull( - translator = this@aaaa, - host = platformMarker.url.host, - navigate = ::navigate, - ) - if (isJustDeleteMe != null) { - this += JustDeleteMeServiceViewRoute.justDeleteMeActionOrNull( - translator = this@aaaa, - justDeleteMe = isJustDeleteMe, - navigate = ::navigate, - ) - } - } - } val faviconUrl = FaviconUrl( serverId = accountId, url = url, ).takeIf { websiteIcons } - val warningTitle = "Unsecure".takeIf { isUnsecure } + val warningTitle = translate(Res.strings.uri_unsecure) + .takeIf { isUnsecure } return VaultViewItem.Uri( id = id, icon = { @@ -2037,6 +1980,7 @@ private suspend fun RememberStateFlowScope.aaaa( warningTitle = warningTitle, matchTypeTitle = matchTypeTitle, dropdown = dropdown, + overrides = overrides, ) } @@ -2045,93 +1989,6 @@ private suspend fun RememberStateFlowScope.aaaa( .firstNotNullOfOrNull { it as? LinkInfoLaunch.Allow } val canExecute = holder.info .firstNotNullOfOrNull { it as? LinkInfoExecute.Allow } - val dropdown = buildContextItems { - section { - this += copy.FlatItemAction( - title = translate(Res.strings.copy), - value = uri.uri, - ) - } - section { - if (canExecute != null) { - this += FlatItemAction( - icon = Icons.Outlined.Terminal, - title = "Execute", - trailing = { - ChevronIcon() - }, - onClick = { - val intent = NavigationIntent.NavigateToBrowser(uri.uri) - navigate(intent) - }, - ) - } - if (canLuanch != null) { - if (canLuanch.apps.size > 1) { - this += FlatItemAction( - icon = Icons.Outlined.Launch, - title = translate(Res.strings.uri_action_launch_in_smth_title), - trailing = { - ChevronIcon() - }, - onClick = { - val intent = NavigationIntent.NavigateToBrowser(uri.uri) - navigate(intent) - }, - ) - } else { - val icon = canLuanch.apps.first().icon - this += FlatItemAction( - leading = { - if (icon != null) { - Image( - modifier = Modifier - .size(24.dp) - .clip(CircleShape), - painter = icon, - contentDescription = null, - ) - } else { - Icon(Icons.Outlined.Launch, null) - } - }, - title = translate( - Res.strings.uri_action_launch_in_app_title, - canLuanch.apps.first().label, - ), - trailing = { - ChevronIcon() - }, - onClick = { - val intent = NavigationIntent.NavigateToBrowser(uri.uri) - navigate(intent) - }, - ) - } - } - } - section { - this += LargeTypeRoute.showInLargeTypeActionOrNull( - translator = this@aaaa, - text = uri.uri, - colorize = true, - navigate = ::navigate, - ) - this += LargeTypeRoute.showInLargeTypeActionAndLockOrNull( - translator = this@aaaa, - text = uri.uri, - colorize = true, - navigate = ::navigate, - ) - } - section { - this += createShareAction( - translator = this@aaaa, - text = uri.uri, - navigate = ::navigate, - ) - } - } return VaultViewItem.Uri( id = id, icon = { @@ -2218,6 +2075,310 @@ private suspend fun RememberStateFlowScope.aaaa( } } +private suspend fun RememberStateFlowScope.createUriItemContextItems( + canEdit: Boolean, + cipherUnsecureUrlCheck: CipherUnsecureUrlCheck, + cipherUnsecureUrlAutoFix: CipherUnsecureUrlAutoFix, + getJustDeleteMeByUrl: GetJustDeleteMeByUrl, + executeCommand: ExecuteCommand, + uri: String, + info: List, + cipherId: String, + copy: CopyText, +): List { + val platformMarker = info + .firstOrNull { it is LinkInfoPlatform } as LinkInfoPlatform? + when (platformMarker) { + is LinkInfoPlatform.Android -> { + val androidMarker = info + .firstOrNull { it is LinkInfoAndroid } as LinkInfoAndroid? + when (androidMarker) { + is LinkInfoAndroid.Installed -> { + val dropdown = buildContextItems { + section { + this += copy.FlatItemAction( + title = translate(Res.strings.copy_package_name), + value = platformMarker.packageName, + ) + } + section { + this += FlatItemAction( + leading = { + Image( + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + painter = androidMarker.icon, + contentDescription = null, + ) + }, + title = translate(Res.strings.uri_action_launch_app_title), + trailing = { + ChevronIcon() + }, + onClick = { + val intent = + NavigationIntent.NavigateToApp(platformMarker.packageName) + navigate(intent) + }, + ) + this += FlatItemAction( + icon = Icons.Outlined.Launch, + title = translate(Res.strings.uri_action_launch_play_store_title), + trailing = { + ChevronIcon() + }, + onClick = { + val intent = + NavigationIntent.NavigateToBrowser(platformMarker.playStoreUrl) + navigate(intent) + }, + ) + } + section { + this += createShareAction( + translator = this@createUriItemContextItems, + text = platformMarker.playStoreUrl, + navigate = ::navigate, + ) + } + } + return dropdown + } + + else -> { + val dropdown = buildContextItems { + section { + this += copy.FlatItemAction( + title = translate(Res.strings.copy_package_name), + value = platformMarker.packageName, + ) + } + section { + this += FlatItemAction( + icon = Icons.Outlined.Launch, + title = translate(Res.strings.uri_action_launch_play_store_title), + trailing = { + ChevronIcon() + }, + onClick = { + val intent = + NavigationIntent.NavigateToBrowser(platformMarker.playStoreUrl) + navigate(intent) + }, + ) + } + section { + this += createShareAction( + translator = this@createUriItemContextItems, + text = platformMarker.playStoreUrl, + navigate = ::navigate, + ) + } + } + return dropdown + } + } + } + + is LinkInfoPlatform.IOS -> { + val dropdown = buildContextItems { + section { + this += copy.FlatItemAction( + title = translate(Res.strings.copy_package_name), + value = platformMarker.packageName, + ) + } + } + return dropdown + } + + is LinkInfoPlatform.Web -> { + val url = platformMarker.url.toString() + + val isJustDeleteMe = getJustDeleteMeByUrl(url) + .attempt() + .bind() + .getOrNull() + + val isUnsecure = cipherUnsecureUrlCheck(uri) + val dropdown = buildContextItems { + section { + this += copy.FlatItemAction( + title = translate(Res.strings.copy_url), + value = url, + ) + } + section { + this += FlatItemAction( + icon = Icons.Outlined.Launch, + title = translate(Res.strings.uri_action_launch_browser_title), + text = url, + trailing = { + ChevronIcon() + }, + onClick = { + val intent = NavigationIntent.NavigateToBrowser(url) + navigate(intent) + }, + ) + if ( + url.removeSuffix("/") != + platformMarker.frontPageUrl.toString().removeSuffix("/") + ) { + val launchUrl = platformMarker.frontPageUrl.toString() + this += FlatItemAction( + icon = Icons.Outlined.Launch, + title = translate(Res.strings.uri_action_launch_browser_main_page_title), + text = launchUrl, + trailing = { + ChevronIcon() + }, + onClick = { + val intent = NavigationIntent.NavigateToBrowser(launchUrl) + navigate(intent) + }, + ) + } + } + if (isUnsecure && canEdit) { + section { + this += FlatItemAction( + icon = Icons.Outlined.AutoAwesome, + title = translate(Res.strings.uri_action_autofix_unsecure_title), + text = translate(Res.strings.uri_action_autofix_unsecure_text), + onClick = { + val ff = mapOf( + cipherId to setOf(uri), + ) + cipherUnsecureUrlAutoFix(ff) + .launchIn(appScope) + }, + ) + } + } + section { + this += createShareAction( + translator = this@createUriItemContextItems, + text = uri, + navigate = ::navigate, + ) + } + section { + this += WebsiteLeakRoute.checkBreachesWebsiteActionOrNull( + translator = this@createUriItemContextItems, + host = platformMarker.url.host, + navigate = ::navigate, + ) + if (isJustDeleteMe != null) { + this += JustDeleteMeServiceViewRoute.justDeleteMeActionOrNull( + translator = this@createUriItemContextItems, + justDeleteMe = isJustDeleteMe, + navigate = ::navigate, + ) + } + } + } + return dropdown + } + + else -> { + val canLuanch = info + .firstNotNullOfOrNull { it as? LinkInfoLaunch.Allow } + val canExecute = info + .firstNotNullOfOrNull { it as? LinkInfoExecute.Allow } + val dropdown = buildContextItems { + section { + this += copy.FlatItemAction( + title = translate(Res.strings.copy), + value = uri, + ) + } + section { + if (canExecute != null) { + this += FlatItemAction( + icon = Icons.Outlined.Terminal, + title = translate(Res.strings.execute_command), + trailing = { + ChevronIcon() + }, + onClick = { + executeCommand(canExecute.command) + .launchIn(appScope) + }, + ) + } + if (canLuanch != null) { + if (canLuanch.apps.size > 1) { + this += FlatItemAction( + icon = Icons.Outlined.Launch, + title = translate(Res.strings.uri_action_launch_in_smth_title), + trailing = { + ChevronIcon() + }, + onClick = { + val intent = NavigationIntent.NavigateToBrowser(uri) + navigate(intent) + }, + ) + } else { + val icon = canLuanch.apps.first().icon + this += FlatItemAction( + leading = { + if (icon != null) { + Image( + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + painter = icon, + contentDescription = null, + ) + } else { + Icon(Icons.Outlined.Launch, null) + } + }, + title = translate( + Res.strings.uri_action_launch_in_app_title, + canLuanch.apps.first().label, + ), + trailing = { + ChevronIcon() + }, + onClick = { + val intent = NavigationIntent.NavigateToBrowser(uri) + navigate(intent) + }, + ) + } + } + } + section { + this += LargeTypeRoute.showInLargeTypeActionOrNull( + translator = this@createUriItemContextItems, + text = uri, + colorize = true, + navigate = ::navigate, + ) + this += LargeTypeRoute.showInLargeTypeActionAndLockOrNull( + translator = this@createUriItemContextItems, + text = uri, + colorize = true, + navigate = ::navigate, + ) + } + section { + this += createShareAction( + translator = this@createUriItemContextItems, + text = uri, + navigate = ::navigate, + ) + } + } + return dropdown + } + } +} + fun RememberStateFlowScope.create( copy: CopyText, id: String, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListRoute.kt new file mode 100644 index 00000000..b143eba9 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListRoute.kt @@ -0,0 +1,11 @@ +package com.artemchep.keyguard.feature.urloverride + +import androidx.compose.runtime.Composable +import com.artemchep.keyguard.feature.navigation.Route + +object UrlOverrideListRoute : Route { + @Composable + override fun Content() { + UrlOverrideListScreen() + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListScreen.kt new file mode 100644 index 00000000..55e102ab --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListScreen.kt @@ -0,0 +1,338 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.artemchep.keyguard.feature.urloverride + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.artemchep.keyguard.common.model.Loadable +import com.artemchep.keyguard.common.model.flatMap +import com.artemchep.keyguard.common.model.getOrNull +import com.artemchep.keyguard.feature.EmptyView +import com.artemchep.keyguard.feature.ErrorView +import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor +import com.artemchep.keyguard.feature.navigation.LocalNavigationController +import com.artemchep.keyguard.feature.navigation.NavigationIcon +import com.artemchep.keyguard.feature.navigation.NavigationIntent +import com.artemchep.keyguard.res.Res +import com.artemchep.keyguard.ui.AvatarBuilder +import com.artemchep.keyguard.ui.DefaultFab +import com.artemchep.keyguard.ui.DefaultSelection +import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow +import com.artemchep.keyguard.ui.FabState +import com.artemchep.keyguard.ui.FlatDropdown +import com.artemchep.keyguard.ui.FlatItemTextContent +import com.artemchep.keyguard.ui.ScaffoldLazyColumn +import com.artemchep.keyguard.ui.icons.IconBox +import com.artemchep.keyguard.ui.skeleton.SkeletonItem +import com.artemchep.keyguard.ui.toolbar.LargeToolbar +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.withIndex + +@Composable +fun UrlOverrideListScreen() { + val loadableState = produceUrlOverrideListState( + ) + EmailRelayListScreen( + loadableState = loadableState, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun EmailRelayListScreen( + loadableState: Loadable, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + val listRevision = + loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.revision + val listState = remember { + LazyListState( + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + } + + LaunchedEffect(listRevision) { + // TODO: How do you wait till the layout state start to represent + // the actual data? + val listSize = + loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.items?.size + snapshotFlow { listState.layoutInfo.totalItemsCount } + .withIndex() + .filter { + it.index > 0 || it.value == listSize + } + .first() + + listState.scrollToItem(0, 0) + } + + ScaffoldLazyColumn( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + topAppBarScrollBehavior = scrollBehavior, + topBar = { + LargeToolbar( + title = { + Text(stringResource(Res.strings.urloverride_list_header_title)) + }, + navigationIcon = { + NavigationIcon() + }, + actions = { + val navigationController by rememberUpdatedState(LocalNavigationController.current) + IconButton( + onClick = { + val intent = NavigationIntent.NavigateToBrowser( + url = "https://github.com/AChep/keyguard-app/blob/master/wiki/URL_OVERRIDE.md", + ) + navigationController.queue(intent) + }, + ) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = null, + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + bottomBar = { + val selectionOrNull = + loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.selection + DefaultSelection( + state = selectionOrNull, + ) + }, + floatingActionState = run { + val onClick = + loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.primaryAction + val state = FabState( + onClick = onClick, + model = null, + ) + rememberUpdatedState(newValue = state) + }, + floatingActionButton = { + DefaultFab( + icon = { + IconBox(main = Icons.Outlined.Add) + }, + text = { + Text( + text = stringResource(Res.strings.add), + ) + }, + ) + }, + listState = listState, + ) { + val contentState = loadableState + .flatMap { it.content } + when (contentState) { + is Loadable.Loading -> { + for (i in 1..3) { + item("skeleton.$i") { + SkeletonItem() + } + } + } + + is Loadable.Ok -> { + contentState.value.fold( + ifLeft = { e -> + item("error") { + ErrorView( + text = { + Text(text = "Failed to load URL override list!") + }, + exception = e, + ) + } + }, + ifRight = { content -> + val items = content.items + if (items.isEmpty()) { + item("empty") { + NoItemsPlaceholder() + } + } + + items( + items = items, + key = { it.key }, + ) { item -> + UrlOverrideItem( + modifier = Modifier + .animateItemPlacement(), + item = item, + ) + } + }, + ) + } + } + } +} + +@Composable +private fun NoItemsPlaceholder( + modifier: Modifier = Modifier, +) { + EmptyView( + modifier = modifier, + text = { + Text( + text = stringResource(Res.strings.urloverride_empty_label), + ) + }, + ) +} + +@Composable +private fun UrlOverrideItem( + modifier: Modifier, + item: UrlOverrideListState.Item, +) { + val selectableState by item.selectableState.collectAsState() + val backgroundColor = when { + selectableState.selected -> MaterialTheme.colorScheme.primaryContainer + else -> Color.Unspecified + } + FlatDropdown( + modifier = modifier, + backgroundColor = backgroundColor, + leading = { + val accent = rememberSecretAccentColor( + accentLight = item.accentLight, + accentDark = item.accentDark, + ) + AvatarBuilder( + icon = item.icon, + accent = accent, + active = true, + badge = { + // Do nothing. + }, + ) + }, + content = { + FlatItemTextContent( + title = { + Text(item.title) + }, + text = { + Column { + Spacer( + modifier = Modifier + .height(4.dp), + ) + val codeModifier = Modifier + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .width(14.dp), + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = LocalTextStyle.current.color, + ) + Spacer( + modifier = Modifier + .width(8.dp), + ) + Text( + modifier = codeModifier, + text = item.regex, + fontFamily = FontFamily.Monospace, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + Spacer( + modifier = Modifier + .height(4.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .width(14.dp), + imageVector = Icons.Outlined.Terminal, + contentDescription = null, + tint = LocalTextStyle.current.color, + ) + Spacer( + modifier = Modifier + .width(8.dp), + ) + Text( + modifier = codeModifier, + text = item.command, + fontFamily = FontFamily.Monospace, + overflow = TextOverflow.Ellipsis, + maxLines = 6, + ) + } + } + }, + ) + }, + trailing = { + ExpandedIfNotEmptyForRow( + selectableState.selected.takeIf { selectableState.selecting }, + ) { selected -> + Checkbox( + modifier = Modifier + .padding(start = 16.dp), + checked = selected, + onCheckedChange = null, + ) + } + }, + dropdown = item.dropdown, + onClick = selectableState.onClick, + onLongClick = selectableState.onLongClick, + enabled = true, + ) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListState.kt new file mode 100644 index 00000000..a68f11e2 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListState.kt @@ -0,0 +1,42 @@ +package com.artemchep.keyguard.feature.urloverride + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import arrow.core.Either +import com.artemchep.keyguard.common.model.Loadable +import com.artemchep.keyguard.feature.attachments.SelectableItemState +import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon +import com.artemchep.keyguard.ui.ContextItem +import com.artemchep.keyguard.ui.Selection +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.StateFlow + +@Immutable +data class UrlOverrideListState( + val content: Loadable>, +) { + @Immutable + data class Content( + val revision: Int, + val items: ImmutableList, + val selection: Selection?, + val primaryAction: (() -> Unit)?, + ) { + companion object + } + + @Stable + data class Item( + val key: String, + val title: String, + val regex: AnnotatedString, + val command: AnnotatedString, + val icon: VaultItemIcon, + val accentLight: Color, + val accentDark: Color, + val dropdown: ImmutableList, + val selectableState: StateFlow, + ) +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt new file mode 100644 index 00000000..d6e046ef --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt @@ -0,0 +1,366 @@ +package com.artemchep.keyguard.feature.urloverride + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CopyAll +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Link +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import arrow.core.partially1 +import com.artemchep.keyguard.common.io.launchIn +import com.artemchep.keyguard.common.model.DGlobalUrlOverride +import com.artemchep.keyguard.common.model.Loadable +import com.artemchep.keyguard.common.service.execute.ExecuteCommand +import com.artemchep.keyguard.common.usecase.AddUrlOverride +import com.artemchep.keyguard.common.usecase.GetUrlOverrides +import com.artemchep.keyguard.common.usecase.RemoveUrlOverrideById +import com.artemchep.keyguard.common.util.flow.persistingStateIn +import com.artemchep.keyguard.feature.attachments.SelectableItemState +import com.artemchep.keyguard.feature.attachments.SelectableItemStateRaw +import com.artemchep.keyguard.feature.confirmation.ConfirmationResult +import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute +import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent +import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt +import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon +import com.artemchep.keyguard.feature.navigation.NavigationIntent +import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver +import com.artemchep.keyguard.feature.navigation.state.produceScreenState +import com.artemchep.keyguard.platform.CurrentPlatform +import com.artemchep.keyguard.platform.Platform +import com.artemchep.keyguard.res.Res +import com.artemchep.keyguard.ui.FlatItemAction +import com.artemchep.keyguard.ui.Selection +import com.artemchep.keyguard.ui.buildContextItems +import com.artemchep.keyguard.ui.icons.icon +import com.artemchep.keyguard.ui.selection.selectionHandle +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.datetime.Clock +import org.kodein.di.compose.localDI +import org.kodein.di.direct +import org.kodein.di.instance + +private class UrlOverrideListUiException( + msg: String, + cause: Throwable, +) : RuntimeException(msg, cause) + +@Composable +fun produceUrlOverrideListState( +) = with(localDI().direct) { + produceUrlOverrideListState( + addUrlOverride = instance(), + removeUrlOverrideById = instance(), + getUrlOverrides = instance(), + executeCommand = instance(), + ) +} + +@Composable +fun produceUrlOverrideListState( + addUrlOverride: AddUrlOverride, + removeUrlOverrideById: RemoveUrlOverrideById, + getUrlOverrides: GetUrlOverrides, + executeCommand: ExecuteCommand, +): Loadable = produceScreenState( + key = "urloverride_list", + initial = Loadable.Loading, + args = arrayOf(), +) { + val selectionHandle = selectionHandle("selection") + + fun onEdit(entity: DGlobalUrlOverride?) { + val nameKey = "name" + val nameItem = ConfirmationRoute.Args.Item.StringItem( + key = nameKey, + value = entity?.name.orEmpty(), + title = translate(Res.strings.generic_name), + type = ConfirmationRoute.Args.Item.StringItem.Type.Text, + canBeEmpty = false, + ) + + val regexKey = "regex" + val regexItem = ConfirmationRoute.Args.Item.StringItem( + key = regexKey, + value = entity?.regex?.toString().orEmpty(), + title = translate(Res.strings.regex), + // A hint explains how would a user write a regex that + // matches both HTTPS and HTTP schemes. + hint = "^https?://.*", + description = translate(Res.strings.urloverride_regex_note), + type = ConfirmationRoute.Args.Item.StringItem.Type.Regex, + canBeEmpty = false, + ) + + val commandKey = "command" + val commandItem = ConfirmationRoute.Args.Item.StringItem( + key = commandKey, + value = entity?.command.orEmpty(), + title = translate(Res.strings.command), + // A hint explains how would a user write a command that + // converts all links to use the HTTPS scheme. + hint = "https://{url:rmvscm}", + type = ConfirmationRoute.Args.Item.StringItem.Type.Command, + canBeEmpty = false, + ) + + val items2 = listOf( + nameItem, + regexItem, + commandItem, + ) + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + icon = icon( + main = Icons.Outlined.Link, + secondary = if (entity != null) { + Icons.Outlined.Edit + } else { + Icons.Outlined.Add + }, + ), + title = translate(Res.strings.urloverride_header_title), + items = items2, + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val name = result.data[nameKey] as? String + ?: return@registerRouteResultReceiver + val regex = result.data[regexKey] as? String + ?: return@registerRouteResultReceiver + val placeholder = result.data[commandKey] as? String + ?: return@registerRouteResultReceiver + val createdAt = Clock.System.now() + val model = DGlobalUrlOverride( + id = entity?.id, + name = name, + regex = regex.toRegex(), + command = placeholder, + createdDate = createdAt, + ) + addUrlOverride(model) + .launchIn(appScope) + } + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + } + + fun onNew() = onEdit(null) + + fun onDuplicate(entity: DGlobalUrlOverride) { + val createdAt = Clock.System.now() + val model = entity.copy( + id = null, + createdDate = createdAt, + ) + addUrlOverride(model) + .launchIn(appScope) + } + + fun onDelete( + emailRelayIds: Set, + ) { + val title = if (emailRelayIds.size > 1) { + translate(Res.strings.urloverride_delete_many_confirmation_title) + } else { + translate(Res.strings.urloverride_delete_one_confirmation_title) + } + val intent = createConfirmationDialogIntent( + icon = icon(Icons.Outlined.Delete), + title = title, + ) { + removeUrlOverrideById(emailRelayIds) + .launchIn(appScope) + } + navigate(intent) + } + + val itemsRawFlow = getUrlOverrides() + // Automatically de-select items + // that do not exist. + combine( + itemsRawFlow, + selectionHandle.idsFlow, + ) { items, selectedItemIds -> + val newSelectedItemIds = selectedItemIds + .asSequence() + .filter { id -> + items.any { it.id == id } + } + .toSet() + newSelectedItemIds.takeIf { it.size < selectedItemIds.size } + } + .filterNotNull() + .onEach { ids -> selectionHandle.setSelection(ids) } + .launchIn(screenScope) + val selectionFlow = combine( + itemsRawFlow, + selectionHandle.idsFlow, + ) { items, selectedItemIds -> + val selectedItems = items + .filter { it.id in selectedItemIds } + items to selectedItems + } + .map { (allItems, selectedItems) -> + if (selectedItems.isEmpty()) { + return@map null + } + + val actions = mutableListOf() + actions += FlatItemAction( + leading = icon(Icons.Outlined.Delete), + title = translate(Res.strings.delete), + onClick = { + val ids = selectedItems.mapNotNull { it.id }.toSet() + onDelete(ids) + }, + ) + Selection( + count = selectedItems.size, + actions = actions.toPersistentList(), + onSelectAll = if (selectedItems.size < allItems.size) { + val allIds = allItems + .asSequence() + .mapNotNull { it.id } + .toSet() + selectionHandle::setSelection + .partially1(allIds) + } else { + null + }, + onClear = selectionHandle::clearSelection, + ) + } + val itemsFlow = itemsRawFlow + .map { list -> + list + .map { + val dropdown = buildContextItems { + section { + this += FlatItemAction( + icon = Icons.Outlined.Edit, + title = translate(Res.strings.edit), + onClick = ::onEdit + .partially1(it), + ) + this += FlatItemAction( + icon = Icons.Outlined.CopyAll, + title = translate(Res.strings.duplicate), + onClick = ::onDuplicate + .partially1(it), + ) + this += FlatItemAction( + icon = Icons.Outlined.Delete, + title = translate(Res.strings.delete), + onClick = ::onDelete + .partially1(setOfNotNull(it.id)), + ) + } + } + val icon = VaultItemIcon.TextIcon( + run { + val words = it.name.split(" ") + if (words.size <= 1) { + return@run words.firstOrNull()?.take(2).orEmpty() + } + + words + .take(2) + .joinToString("") { it.take(1) } + }.uppercase(), + ) + + val selectableFlow = selectionHandle + .idsFlow + .map { selectedIds -> + SelectableItemStateRaw( + selecting = selectedIds.isNotEmpty(), + selected = it.id in selectedIds, + ) + } + .distinctUntilChanged() + .map { raw -> + val onClick = if (raw.selecting) { + // lambda + selectionHandle::toggleSelection.partially1(it.id.orEmpty()) + } else { + null + } + val onLongClick = if (raw.selecting) { + null + } else { + // lambda + selectionHandle::toggleSelection.partially1(it.id.orEmpty()) + } + SelectableItemState( + selecting = raw.selecting, + selected = raw.selected, + onClick = onClick, + onLongClick = onLongClick, + ) + } + val selectableStateFlow = + if (list.size >= 100) { + val sharing = SharingStarted.WhileSubscribed(1000L) + selectableFlow.persistingStateIn(this, sharing) + } else { + selectableFlow.stateIn(this) + } + val regex = it.regex.toString().let(::AnnotatedString) + val command = it.command.let(::AnnotatedString) + UrlOverrideListState.Item( + key = it.id.orEmpty(), + title = it.name, + regex = regex, + command = command, + icon = icon, + accentLight = it.accentColor.light, + accentDark = it.accentColor.dark, + dropdown = dropdown, + selectableState = selectableStateFlow, + ) + } + .toPersistentList() + } + .crashlyticsAttempt { e -> + val msg = "Failed to get the URL override list!" + UrlOverrideListUiException( + msg = msg, + cause = e, + ) + } + val contentFlow = combine( + selectionFlow, + itemsFlow, + ) { selection, itemsResult -> + val contentOrException = itemsResult + .map { items -> + UrlOverrideListState.Content( + revision = 0, + items = items, + selection = selection, + primaryAction = ::onNew, + ) + } + Loadable.Ok(contentOrException) + } + contentFlow + .map { content -> + val state = UrlOverrideListState( + content = content, + ) + Loadable.Ok(state) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/GetUrlOverride.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/GetUrlOverride.kt new file mode 100644 index 00000000..f005981b --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/GetUrlOverride.kt @@ -0,0 +1,32 @@ +package com.artemchep.keyguard.provider.bitwarden.usecase + +import com.artemchep.keyguard.common.model.DGlobalUrlOverride +import com.artemchep.keyguard.common.service.urloverride.UrlOverrideRepository +import com.artemchep.keyguard.common.usecase.GetUrlOverrides +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import org.kodein.di.DirectDI +import org.kodein.di.instance +import kotlin.coroutines.CoroutineContext + +/** + * @author Artem Chepurnyi + */ +class GetUrlOverridesImpl( + private val urlOverrideRepository: UrlOverrideRepository, + private val dispatcher: CoroutineContext = Dispatchers.Default, +) : GetUrlOverrides { + constructor(directDI: DirectDI) : this( + urlOverrideRepository = directDI.instance(), + ) + + override fun invoke(): Flow> = urlOverrideRepository + .get() + .map { list -> + list + .sorted() + } + .flowOn(dispatcher) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveUrlOverrideById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveUrlOverrideById.kt new file mode 100644 index 00000000..52707782 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveUrlOverrideById.kt @@ -0,0 +1,30 @@ +package com.artemchep.keyguard.provider.bitwarden.usecase + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.map +import com.artemchep.keyguard.common.service.urloverride.UrlOverrideRepository +import com.artemchep.keyguard.common.usecase.RemoveUrlOverrideById +import org.kodein.di.DirectDI +import org.kodein.di.instance + +/** + * @author Artem Chepurnyi + */ +class RemoveUrlOverrideByIdImpl( + private val urlOverrideRepository: UrlOverrideRepository, +) : RemoveUrlOverrideById { + constructor(directDI: DirectDI) : this( + urlOverrideRepository = directDI.instance(), + ) + + override fun invoke( + urlOverrideIds: Set, + ): IO = performRemoveEmailRelay( + urlOverrideIds = urlOverrideIds, + ).map { Unit } + + private fun performRemoveEmailRelay( + urlOverrideIds: Set, + ) = urlOverrideRepository + .removeByIds(urlOverrideIds) +} diff --git a/common/src/commonMain/resources/MR/base/strings.xml b/common/src/commonMain/resources/MR/base/strings.xml index 9a03a0c9..6fc694d6 100644 --- a/common/src/commonMain/resources/MR/base/strings.xml +++ b/common/src/commonMain/resources/MR/base/strings.xml @@ -83,6 +83,10 @@ Organizations No organizations Miscellaneous + Command + Execute + + Add Add integration Account Accounts @@ -161,6 +165,8 @@ Downloads No downloads No duplicates + Duplicate + Regular expression Encryption Key @@ -252,6 +258,8 @@ Launch with %1$s Launch with… How to delete an account? + Auto-fix unsecure URL + Changes a protocol to secure variant if it is available Unsecure Match app @@ -458,6 +466,15 @@ Email forwarder integration No email forwarders + URL override + URL overrides + URL overrides + The override will be applied to URLs that match the regular expression. + Delete URL override? + Delete URL overrides? + Email forwarder integration + No URL overrides + Create an encrypted vault where the local data will be stored. App password Biometric authentication @@ -847,6 +864,7 @@ Storing a vault key on a disk is a security risk. If the device\'s internal storage is compromised, the attacker will gain access to the local vault data. Permissions Features overview + URL overrides Biometric unlock