feat: URL override and Placeholders
This commit is contained in:
parent
e8b4e7247c
commit
a412040d7c
|
@ -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:
|
||||
|
|
|
@ -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<DSecret.Uri, LinkInfoExecute> {
|
||||
override val from: KClass<DSecret.Uri> get() = DSecret.Uri::class
|
||||
|
||||
override val to: KClass<LinkInfoExecute> get() = LinkInfoExecute::class
|
||||
|
||||
override fun extractInfo(
|
||||
uri: DSecret.Uri,
|
||||
): IO<LinkInfoExecute> = io(LinkInfoExecute.Deny)
|
||||
|
||||
override fun handles(uri: DSecret.Uri): Boolean = true
|
||||
}
|
|
@ -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> {
|
||||
LinkInfoExtractorExecute()
|
||||
}
|
||||
bindSingleton<TextService> {
|
||||
TextServiceAndroid(
|
||||
directDI = this,
|
||||
|
|
|
@ -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<DGlobalUrlOverride> {
|
||||
val accentColor = run {
|
||||
val colors = generateAccentColors(name)
|
||||
colors
|
||||
}
|
||||
|
||||
override fun compareTo(other: DGlobalUrlOverride): Int {
|
||||
return name.compareTo(other.name, ignoreCase = true)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.artemchep.keyguard.common.service.execute
|
||||
|
||||
import com.artemchep.keyguard.common.io.IO
|
||||
|
||||
interface ExecuteCommand : (String) -> IO<Unit> {
|
||||
val interpreter: String?
|
||||
}
|
|
@ -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<Unit> = ioEffect {
|
||||
requireNotNull(executor) {
|
||||
"Unsupported platform."
|
||||
}
|
||||
.invoke(command)
|
||||
.bind()
|
||||
}
|
||||
}
|
||||
|
||||
private class ExecuteCommandCmd : ExecuteCommand {
|
||||
override val interpreter: String get() = "cmd"
|
||||
|
||||
override fun invoke(command: String): IO<Unit> = 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<Unit> = 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<Unit> = ioEffect {
|
||||
val arr = arrayOf(
|
||||
"sh",
|
||||
"-c",
|
||||
command,
|
||||
)
|
||||
Runtime.getRuntime().exec(arr)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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<String?>?
|
||||
|
||||
interface Factory {
|
||||
|
||||
}
|
||||
}
|
|
@ -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<Placeholder>,
|
||||
) = 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()
|
||||
}
|
||||
},
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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<String?>? = 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 {
|
||||
|
||||
}
|
||||
}
|
|
@ -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<String?>? = when {
|
||||
// Comment; is removed.
|
||||
key.startsWith("c:") ->
|
||||
null.let(::io)
|
||||
// unknown
|
||||
else -> null
|
||||
}
|
||||
}
|
|
@ -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<String?>? = 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
|
||||
}
|
||||
}
|
|
@ -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<String?>? = 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
|
||||
}
|
||||
}
|
|
@ -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<String?>? {
|
||||
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")
|
||||
}
|
||||
}
|
|
@ -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<String?>? = 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 {
|
||||
|
||||
}
|
||||
}
|
|
@ -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<String>,
|
||||
)
|
|
@ -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<DGlobalUrlOverride> {
|
||||
fun removeAll(): IO<Unit>
|
||||
|
||||
fun removeByIds(
|
||||
ids: Set<String>,
|
||||
): IO<Unit>
|
||||
}
|
|
@ -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<List<DGlobalUrlOverride>> =
|
||||
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<Unit> =
|
||||
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<Unit> =
|
||||
daoEffect { dao ->
|
||||
dao.deleteAll()
|
||||
}
|
||||
|
||||
override fun removeByIds(ids: Set<String>): IO<Unit> =
|
||||
daoEffect { dao ->
|
||||
dao.transaction {
|
||||
ids.forEach {
|
||||
val id = it.toLongOrNull()
|
||||
?: return@forEach
|
||||
dao.deleteByIds(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> daoEffect(
|
||||
crossinline block: suspend (UrlOverrideQueries) -> T,
|
||||
): IO<T> = databaseManager
|
||||
.get()
|
||||
.effectMap(dispatcher) { db ->
|
||||
val dao = db.urlOverrideQueries
|
||||
block(dao)
|
||||
}
|
||||
}
|
|
@ -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<Unit>
|
|
@ -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<Boolean>
|
|
@ -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<List<DGlobalUrlOverride>>
|
|
@ -0,0 +1,7 @@
|
|||
package com.artemchep.keyguard.common.usecase
|
||||
|
||||
import com.artemchep.keyguard.common.io.IO
|
||||
|
||||
interface RemoveUrlOverrideById : (
|
||||
Set<String>,
|
||||
) -> IO<Unit>
|
|
@ -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)
|
||||
}
|
|
@ -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<Node> = 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()
|
||||
}
|
|
@ -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<RemoveEmailRelayById> {
|
||||
RemoveEmailRelayByIdImpl(this)
|
||||
}
|
||||
bindSingleton<AddUrlOverride> {
|
||||
AddUrlOverrideImpl(this)
|
||||
}
|
||||
bindSingleton<GetUrlOverrides> {
|
||||
GetUrlOverridesImpl(this)
|
||||
}
|
||||
bindSingleton<RemoveUrlOverrideById> {
|
||||
RemoveUrlOverrideByIdImpl(this)
|
||||
}
|
||||
bindSingleton<GetAccounts> {
|
||||
GetAccountsImpl(this)
|
||||
}
|
||||
|
@ -419,6 +436,9 @@ fun DI.Builder.createSubDi2(
|
|||
bindSingleton<GeneratorEmailRelayRepository> {
|
||||
GeneratorEmailRelayRepositoryImpl(this)
|
||||
}
|
||||
bindSingleton<UrlOverrideRepository> {
|
||||
UrlOverrideRepositoryImpl(this)
|
||||
}
|
||||
bindSingleton<PasswordPwnageDataSourceLocal> {
|
||||
PasswordPwnageDataSourceLocalImpl(this)
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<EmailRelayListState>,
|
||||
) {
|
||||
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 =
|
||||
|
|
|
@ -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<String>,
|
||||
) {
|
||||
|
@ -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),
|
||||
|
|
|
@ -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<String, (DirectDI) -> 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,
|
||||
|
|
|
@ -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<RowScope>(Icons.Outlined.Link),
|
||||
trailing = {
|
||||
ChevronIcon()
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.strings.pref_item_url_override_title),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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<List<ContextItem>>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -214,8 +214,19 @@ sealed interface VaultViewItem {
|
|||
* to the item.
|
||||
*/
|
||||
val dropdown: List<ContextItem> = emptyList(),
|
||||
val overrides: List<Override> = 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<ContextItem> = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
data class Totp(
|
||||
|
|
|
@ -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<LinkInfo>,
|
||||
)
|
||||
val overrides: List<Override> = emptyList(),
|
||||
) {
|
||||
data class Override(
|
||||
val override: DGlobalUrlOverride,
|
||||
val uri: String,
|
||||
val info: List<LinkInfo>,
|
||||
)
|
||||
}
|
||||
|
||||
@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<DGlobalUrlOverride>
|
||||
|
||||
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<LinkInfo>,
|
||||
cipherId: String,
|
||||
copy: CopyText,
|
||||
): List<ContextItem> {
|
||||
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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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<UrlOverrideListState>,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
|
@ -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<Either<Throwable, Content>>,
|
||||
) {
|
||||
@Immutable
|
||||
data class Content(
|
||||
val revision: Int,
|
||||
val items: ImmutableList<Item>,
|
||||
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<ContextItem>,
|
||||
val selectableState: StateFlow<SelectableItemState>,
|
||||
)
|
||||
}
|
|
@ -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<UrlOverrideListState> = 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<String>,
|
||||
) {
|
||||
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<FlatItemAction>()
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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<List<DGlobalUrlOverride>> = urlOverrideRepository
|
||||
.get()
|
||||
.map { list ->
|
||||
list
|
||||
.sorted()
|
||||
}
|
||||
.flowOn(dispatcher)
|
||||
}
|
|
@ -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<String>,
|
||||
): IO<Unit> = performRemoveEmailRelay(
|
||||
urlOverrideIds = urlOverrideIds,
|
||||
).map { Unit }
|
||||
|
||||
private fun performRemoveEmailRelay(
|
||||
urlOverrideIds: Set<String>,
|
||||
) = urlOverrideRepository
|
||||
.removeByIds(urlOverrideIds)
|
||||
}
|
|
@ -83,6 +83,10 @@
|
|||
<string name="organizations">Organizations</string>
|
||||
<string name="organizations_empty_label">No organizations</string>
|
||||
<string name="misc">Miscellaneous</string>
|
||||
<string name="command">Command</string>
|
||||
<string name="execute_command">Execute</string>
|
||||
<!-- Add (something) -->
|
||||
<string name="add">Add</string>
|
||||
<string name="add_integration">Add integration</string>
|
||||
<string name="account">Account</string>
|
||||
<string name="accounts">Accounts</string>
|
||||
|
@ -161,6 +165,8 @@
|
|||
<string name="downloads">Downloads</string>
|
||||
<string name="downloads_empty_label">No downloads</string>
|
||||
<string name="duplicates_empty_label">No duplicates</string>
|
||||
<string name="duplicate">Duplicate</string>
|
||||
<string name="regex">Regular expression</string>
|
||||
<string name="encryption">Encryption</string>
|
||||
<!-- Encryption key -->
|
||||
<string name="encryption_key">Key</string>
|
||||
|
@ -252,6 +258,8 @@
|
|||
<string name="uri_action_launch_in_app_title">Launch with <xliff:g id="app" example="Keyguard">%1$s</xliff:g></string>
|
||||
<string name="uri_action_launch_in_smth_title">Launch with…</string>
|
||||
<string name="uri_action_how_to_delete_account_title">How to delete an account?</string>
|
||||
<string name="uri_action_autofix_unsecure_title">Auto-fix unsecure URL</string>
|
||||
<string name="uri_action_autofix_unsecure_text">Changes a protocol to secure variant if it is available</string>
|
||||
|
||||
<string name="uri_unsecure">Unsecure</string>
|
||||
<string name="uri_match_app_title">Match app</string>
|
||||
|
@ -458,6 +466,15 @@
|
|||
<string name="emailrelay_integration_title">Email forwarder integration</string>
|
||||
<string name="emailrelay_empty_label">No email forwarders</string>
|
||||
|
||||
<string name="urloverride_header_title">URL override</string>
|
||||
<string name="urloverride_list_header_title">URL overrides</string>
|
||||
<string name="urloverride_list_section_title">URL overrides</string>
|
||||
<string name="urloverride_regex_note">The override will be applied to URLs that match the regular expression.</string>
|
||||
<string name="urloverride_delete_one_confirmation_title">Delete URL override?</string>
|
||||
<string name="urloverride_delete_many_confirmation_title">Delete URL overrides?</string>
|
||||
<string name="urloverride_integration_title">Email forwarder integration</string>
|
||||
<string name="urloverride_empty_label">No URL overrides</string>
|
||||
|
||||
<string name="setup_header_text">Create an encrypted vault where the local data will be stored.</string>
|
||||
<string name="setup_field_app_password_label">App password</string>
|
||||
<string name="setup_checkbox_biometric_auth">Biometric authentication</string>
|
||||
|
@ -847,6 +864,7 @@
|
|||
<string name="pref_item_persist_vault_key_note">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.</string>
|
||||
<string name="pref_item_permissions_title">Permissions</string>
|
||||
<string name="pref_item_features_overview_title">Features overview</string>
|
||||
<string name="pref_item_url_override_title">URL overrides</string>
|
||||
<string name="pref_item_biometric_unlock_title">Biometric unlock</string>
|
||||
<!--
|
||||
A title of the system popup that asks a user to use his biometric to later
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import kotlinx.datetime.Instant;
|
||||
|
||||
CREATE TABLE urlOverride (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
regex TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
createdAt INTEGER AS Instant NOT NULL
|
||||
);
|
||||
|
||||
update {
|
||||
UPDATE urlOverride
|
||||
SET
|
||||
name = :name,
|
||||
regex = :regex,
|
||||
command = :command,
|
||||
createdAt = :createdAt
|
||||
WHERE
|
||||
id = :id;
|
||||
}
|
||||
|
||||
insert {
|
||||
INSERT OR IGNORE INTO urlOverride(name, regex, command, createdAt)
|
||||
VALUES (:name, :regex, :command, :createdAt);
|
||||
}
|
||||
|
||||
get:
|
||||
SELECT *
|
||||
FROM urlOverride
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT :limit;
|
||||
|
||||
deleteAll:
|
||||
DELETE FROM urlOverride;
|
||||
|
||||
deleteByIds:
|
||||
DELETE FROM urlOverride
|
||||
WHERE id IN (:ids);
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE urlOverride (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
regex TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
|
@ -1,26 +0,0 @@
|
|||
package com.artemchep.keyguard.copy
|
||||
|
||||
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.model.LinkInfoLaunch
|
||||
import com.artemchep.keyguard.common.service.extract.LinkInfoExtractor
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class LinkInfoExtractorLaunch(
|
||||
) : LinkInfoExtractor<DSecret.Uri, LinkInfoLaunch> {
|
||||
override val from: KClass<DSecret.Uri> get() = DSecret.Uri::class
|
||||
|
||||
override val to: KClass<LinkInfoLaunch> get() = LinkInfoLaunch::class
|
||||
|
||||
override fun extractInfo(
|
||||
uri: DSecret.Uri,
|
||||
): IO<LinkInfoLaunch> = ioEffect<LinkInfoLaunch> {
|
||||
val apps = LinkInfoLaunch.Allow.AppInfo(
|
||||
label = "App",
|
||||
)
|
||||
LinkInfoLaunch.Allow(listOf(apps))
|
||||
}
|
||||
|
||||
override fun handles(uri: DSecret.Uri): Boolean = true
|
||||
}
|
|
@ -10,6 +10,9 @@ import com.artemchep.keyguard.common.service.deeplink.DeeplinkService
|
|||
import com.artemchep.keyguard.common.service.deeplink.impl.DeeplinkServiceImpl
|
||||
import com.artemchep.keyguard.common.service.download.DownloadService
|
||||
import com.artemchep.keyguard.common.service.download.DownloadServiceImpl
|
||||
import com.artemchep.keyguard.common.service.execute.ExecuteCommand
|
||||
import com.artemchep.keyguard.common.service.execute.impl.ExecuteCommandImpl
|
||||
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoExtractorExecute
|
||||
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoPlatformExtractor
|
||||
import com.artemchep.keyguard.common.service.gpmprivapps.PrivilegedAppsService
|
||||
import com.artemchep.keyguard.common.service.gpmprivapps.impl.PrivilegedAppsServiceImpl
|
||||
|
@ -954,6 +957,11 @@ fun globalModuleJvm() = DI.Module(
|
|||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<ExecuteCommand> {
|
||||
ExecuteCommandImpl(
|
||||
directDI = this,
|
||||
)
|
||||
}
|
||||
bindSingleton<WordlistService> {
|
||||
WordlistServiceImpl(
|
||||
directDI = this,
|
||||
|
@ -997,6 +1005,9 @@ fun globalModuleJvm() = DI.Module(
|
|||
bindSingleton<LinkInfoPlatformExtractor> {
|
||||
LinkInfoPlatformExtractor()
|
||||
}
|
||||
bindSingleton<LinkInfoExtractorExecute> {
|
||||
LinkInfoExtractorExecute()
|
||||
}
|
||||
bindSingleton<SimilarityService> {
|
||||
SimilarityServiceJvm(
|
||||
directDI = this,
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
# Placeholders
|
||||
|
||||
Keyguard replaces placeholders when performing an action with the field (copying, opening in a browser and more). The feature is largely based on the [Keepass's specification](https://keepass.info/help/base/placeholders.html).
|
||||
|
||||
**At this moment placeholders are supported in**:
|
||||
|
||||
- URL field;
|
||||
- URL override commands.
|
||||
|
||||
**Basics**:
|
||||
|
||||
- placeholders and their basics parameters are _case-insensitive_;
|
||||
- placeholders are resolved using the shared constant time, all time-sensitive placeholders will be synced;
|
||||
- if no value is found, the placeholder will be replaced with an empty string: `{otp}` will be replaced with an empty string if an entry doesn't have one-time password configured;
|
||||
- if no placeholder is found, the placeholder will be kept in it's original form: `{keyguard}` will be replaced with `{keyguard}`.
|
||||
|
||||
### Types
|
||||
#### Entry Core
|
||||
|
||||
| Placeholder | Description |
|
||||
| :- | :---- |
|
||||
| `uuid` | UUID |
|
||||
| `title` | Title/Name |
|
||||
| `username` | Username |
|
||||
| `password` | Password |
|
||||
| `otp` | One-time password |
|
||||
| `notes` | Notes |
|
||||
| `favorite` | Favorite |
|
||||
|
||||
Example:
|
||||
```
|
||||
> https://example.com?user={username}
|
||||
https://example.com?user=joe
|
||||
```
|
||||
|
||||
#### Entry Custom Field
|
||||
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}`.
|
||||
|
||||
| Placeholder | Description |
|
||||
| :- | :---- |
|
||||
| `s:value` | First of the custom fields named 'value' |
|
||||
|
||||
_Example_:
|
||||
```
|
||||
> https://example.com?license={s:license}
|
||||
https://example.com?license=12345678ABCD
|
||||
```
|
||||
|
||||
#### Entry URL
|
||||
\***URL override specific**\*
|
||||
|
||||
This is useful in URL override command field. You can extract data from the URL for your new *command*.
|
||||
|
||||
Note: `{base}` supports exactly the same parts as `{url}` and is identical to it.
|
||||
|
||||
| Placeholder | Description |
|
||||
| :- | :---- |
|
||||
| `url` | URL: `https://user:pw@keepass.info:80/path/example.php?q=e&s=t` |
|
||||
| `url:rmvscm` | URL without scheme name: `user:pw@keepass.info:80/path/example.php?q=e&s=t` |
|
||||
| `url:scm` | Scheme name: `https` |
|
||||
| `url:host` | Host: `keepass.info` |
|
||||
| `url:port` | Port: `80` |
|
||||
| `url:path` | Path: `/path/example.php` |
|
||||
| `url:query` | Query: `?q=e&s=t` |
|
||||
| `url:userinfo` | User information: `user:pw` |
|
||||
| `url:username` | Username: `user` |
|
||||
| `url:password` | Password: `pw` |
|
||||
|
||||
#### Text transformation
|
||||
|
||||
Convert text to the other representation.
|
||||
|
||||
```
|
||||
t-conv:/value/type/
|
||||
```
|
||||
the first symbol after `:` defines the separator. It may be any symbol except `{` and `}`. Trailing separator symbol is required.
|
||||
|
||||
- `u` or `upper` - transforms 'value' component into the uppercase basing on the English locale;
|
||||
- `l` or `lower` - transforms 'value' component into the lowercase basing on the English locale;
|
||||
- `base64` - encodes 'value' component into the Base64 (no padding, no wrap, URL safe) representation of the text;
|
||||
- `hex` - encodes 'value' component into the HEX (lowercase) representation of the text;
|
||||
- `uri` - encodes 'value' component into the URI representation of the text;
|
||||
- `uri-dec` - decodes 'value' component from the URI representation of the text to the text;
|
||||
|
||||
_Example_:
|
||||
```
|
||||
> https://example.com?user={username}&password={t-conv:/{password}/uri/}
|
||||
https://example.com?user=joe&password=Password1%21
|
||||
```
|
||||
|
||||
|
||||
#### Date-time
|
||||
##### Local
|
||||
|
||||
| Placeholder | Description |
|
||||
| :- | :---- |
|
||||
| `dt_simple` | Current local date/time as a simple, sortable string. For example, for '2024-01-01 17:05:34' the value is `20240101170534`. |
|
||||
| `dt_year` | Year component of the current local date/time |
|
||||
| `dt_month` | Month component of the current local date/time |
|
||||
| `dt_day` | Day component of the current local date/time |
|
||||
| `dt_hour` | Hour component of the current local date/time |
|
||||
| `dt_minute` | Minute component of the current local date/time |
|
||||
| `dt_second` | Second component of the current local date/time |
|
||||
|
||||
##### UTC
|
||||
|
||||
| Placeholder | Description |
|
||||
| :- | :---- |
|
||||
| `dt_utc_simple` | Current UTC date/time as a simple, sortable string. For example, for '2024-01-01 17:05:34' the value is `20240101170534`. |
|
||||
| `dt_utc_year` | Year component of the current UTC date/time |
|
||||
| `dt_utc_month` | Month component of the current UTC date/time |
|
||||
| `dt_utc_day` | Day component of the current UTC date/time |
|
||||
| `dt_utc_hour` | Hour component of the current UTC date/time |
|
||||
| `dt_utc_minute` | Minute component of the current UTC date/time |
|
||||
| `dt_utc_second` | Second component of the current UTC date/time |
|
||||
|
||||
##### Utility
|
||||
|
||||
| Placeholder | Description |
|
||||
| :-- | :---- |
|
||||
| `c:value` | Comment, removed upon transformation |
|
|
@ -0,0 +1,23 @@
|
|||
# URL override
|
||||
|
||||
If you want to extend the default URL functionality, you can add URL overrides. An override has at least:
|
||||
|
||||
- **regex**: the override will be applied to URLs that match the regular expression;
|
||||
- **command**: the new URL that will replace the old one, usually should contain [placeholders](PLACEHOLDERS.md).
|
||||
|
||||
### Example
|
||||
#### FileZilla FTP Client
|
||||
Add a URL to the entry that we be overriden later:
|
||||
```
|
||||
ftp://{username}:{password}@example.com
|
||||
```
|
||||
|
||||
this URL may already work if the FTP client correctly sets up URL protocol handlers. Otherwise, add the following URL override (Linux):
|
||||
|
||||
| Field | Content |
|
||||
| :- |:------------------------|
|
||||
| Regex | `^ftp://.*` |
|
||||
| Command | `cmd://filezilla {url}` |
|
||||
|
||||
when done correctly, all matching URLs will have a button to execute the command, launching the FireZilla client.
|
||||
|
Loading…
Reference in New Issue