feat: URL override and Placeholders

This commit is contained in:
Artem Chepurnoy 2024-01-05 17:54:59 +02:00
parent e8b4e7247c
commit a412040d7c
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
52 changed files with 2690 additions and 454 deletions

View File

@ -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:

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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?
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 {
}
}

View File

@ -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()
}
},
)

View File

@ -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,
)

View File

@ -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 {
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -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 {
}
}

View File

@ -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>,
)

View File

@ -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>
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>>

View File

@ -0,0 +1,7 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
interface RemoveUrlOverrideById : (
Set<String>,
) -> IO<Unit>

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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),

View File

@ -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,
}

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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 =

View File

@ -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),

View File

@ -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,

View File

@ -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,
)
}

View File

@ -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(

View File

@ -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,
)
}
}
}

View File

@ -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(

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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,
)
}

View File

@ -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>,
)
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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

View File

@ -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);

View File

@ -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
);

View File

@ -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
}

View File

@ -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,

121
wiki/PLACEHOLDERS.md Normal file
View File

@ -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 |

23
wiki/URL_OVERRIDE.md Normal file
View File

@ -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.