feat: Add a wordlist from a URL #88

This commit is contained in:
Artem Chepurnoy 2024-02-16 20:38:28 +02:00
parent e6d74bd143
commit 998c40dfc0
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
12 changed files with 209 additions and 28 deletions

View File

@ -9,6 +9,10 @@ data class AddWordlistRequest(
val uri: String, val uri: String,
) : Wordlist ) : Wordlist
data class FromUrl(
val url: String,
) : Wordlist
data class FromList( data class FromList(
val list: List<String>, val list: List<String>,
) : Wordlist ) : Wordlist

View File

@ -0,0 +1,5 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
interface ReadWordlistFromUrl : (String) -> IO<List<String>>

View File

@ -6,16 +6,19 @@ import com.artemchep.keyguard.common.model.AddWordlistRequest
import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistRepository import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistRepository
import com.artemchep.keyguard.common.usecase.AddWordlist import com.artemchep.keyguard.common.usecase.AddWordlist
import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile
import com.artemchep.keyguard.common.usecase.ReadWordlistFromUrl
import org.kodein.di.DirectDI import org.kodein.di.DirectDI
import org.kodein.di.instance import org.kodein.di.instance
class AddWordlistImpl( class AddWordlistImpl(
private val generatorWordlistRepository: GeneratorWordlistRepository, private val generatorWordlistRepository: GeneratorWordlistRepository,
private val readWordlistFromFile: ReadWordlistFromFile, private val readWordlistFromFile: ReadWordlistFromFile,
private val readWordlistFromUrl: ReadWordlistFromUrl,
) : AddWordlist { ) : AddWordlist {
constructor(directDI: DirectDI) : this( constructor(directDI: DirectDI) : this(
generatorWordlistRepository = directDI.instance(), generatorWordlistRepository = directDI.instance(),
readWordlistFromFile = directDI.instance(), readWordlistFromFile = directDI.instance(),
readWordlistFromUrl = directDI.instance(),
) )
override fun invoke( override fun invoke(
@ -29,8 +32,18 @@ class AddWordlistImpl(
.bind() .bind()
} }
is AddWordlistRequest.Wordlist.FromUrl -> {
val uri = model.wordlist.url
readWordlistFromUrl(uri)
.bind()
}
is AddWordlistRequest.Wordlist.FromList -> model.wordlist.list is AddWordlistRequest.Wordlist.FromList -> model.wordlist.list
} }
val invalidWordlist = wordlist.any { it.length > 512 }
if (invalidWordlist) {
throw IllegalStateException("Failed to parse the wordlist!")
}
generatorWordlistRepository generatorWordlistRepository
.post( .post(
name = name, name = name,

View File

@ -5,7 +5,6 @@ import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.service.text.TextService import com.artemchep.keyguard.common.service.text.TextService
import com.artemchep.keyguard.common.service.text.readFromFileAsText import com.artemchep.keyguard.common.service.text.readFromFileAsText
import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile
import kotlinx.collections.immutable.toImmutableList
import org.kodein.di.DirectDI import org.kodein.di.DirectDI
import org.kodein.di.instance import org.kodein.di.instance
@ -20,15 +19,8 @@ class ReadWordlistFromFileImpl(
uri: String, uri: String,
): IO<List<String>> = ioEffect { ): IO<List<String>> = ioEffect {
val content = textService.readFromFileAsText(uri) val content = textService.readFromFileAsText(uri)
content with(content) {
.lineSequence() ReadWordlistUtil.parseAsWordlist()
.filter { }
it.isNotBlank() &&
!it.startsWith('#') &&
!it.startsWith(';') &&
!it.startsWith('-') &&
!it.startsWith('/')
}
.toImmutableList()
} }
} }

View File

@ -0,0 +1,30 @@
package com.artemchep.keyguard.common.usecase.impl
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.usecase.ReadWordlistFromUrl
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import org.kodein.di.DirectDI
import org.kodein.di.instance
class ReadWordlistFromUrlImpl(
private val httpClient: HttpClient,
) : ReadWordlistFromUrl {
constructor(directDI: DirectDI) : this(
httpClient = directDI.instance("curl"),
)
override fun invoke(
url: String,
): IO<List<String>> = ioEffect {
val request = httpClient
.get(url)
val content = request
.bodyAsText()
with(content) {
ReadWordlistUtil.parseAsWordlist()
}
}
}

View File

@ -0,0 +1,17 @@
package com.artemchep.keyguard.common.usecase.impl
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
object ReadWordlistUtil {
context(String)
fun parseAsWordlist(): ImmutableList<String> = lineSequence()
.filter {
it.isNotBlank() &&
!it.startsWith('#') &&
!it.startsWith(';') &&
!it.startsWith('-') &&
!it.startsWith('/')
}
.toImmutableList()
}

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -17,6 +18,7 @@ import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -28,8 +30,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -53,6 +57,9 @@ import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.AvatarBuilder import com.artemchep.keyguard.ui.AvatarBuilder
import com.artemchep.keyguard.ui.DefaultFab import com.artemchep.keyguard.ui.DefaultFab
import com.artemchep.keyguard.ui.DefaultSelection import com.artemchep.keyguard.ui.DefaultSelection
import com.artemchep.keyguard.ui.DropdownMenuItemFlat
import com.artemchep.keyguard.ui.DropdownMinWidth
import com.artemchep.keyguard.ui.DropdownScopeImpl
import com.artemchep.keyguard.ui.FabState import com.artemchep.keyguard.ui.FabState
import com.artemchep.keyguard.ui.FlatItemLayout import com.artemchep.keyguard.ui.FlatItemLayout
import com.artemchep.keyguard.ui.FlatItemTextContent import com.artemchep.keyguard.ui.FlatItemTextContent
@ -108,6 +115,9 @@ fun WordlistListScreen(
listState.scrollToItem(0, 0) listState.scrollToItem(0, 0)
} }
val primaryActionsDropdownVisibleState = remember {
mutableStateOf(false)
}
ScaffoldLazyColumn( ScaffoldLazyColumn(
modifier = Modifier modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
@ -147,8 +157,15 @@ fun WordlistListScreen(
) )
}, },
floatingActionState = run { floatingActionState = run {
val onClick = val actions = loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.primaryActions.orEmpty()
loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.primaryAction val onClick = if (actions.isNotEmpty()) {
// lambda
{
primaryActionsDropdownVisibleState.value = true
}
} else {
null
}
val state = FabState( val state = FabState(
onClick = onClick, onClick = onClick,
model = null, model = null,
@ -159,6 +176,31 @@ fun WordlistListScreen(
DefaultFab( DefaultFab(
icon = { icon = {
IconBox(main = Icons.Outlined.Add) IconBox(main = Icons.Outlined.Add)
// Inject the dropdown popup to the bottom of the
// content.
val onDismissRequest = remember(primaryActionsDropdownVisibleState) {
// lambda
{
primaryActionsDropdownVisibleState.value = false
}
}
DropdownMenu(
modifier = Modifier
.widthIn(min = DropdownMinWidth),
expanded = primaryActionsDropdownVisibleState.value,
onDismissRequest = onDismissRequest,
) {
val actions = loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.primaryActions.orEmpty()
val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest)
with(scope) {
actions.forEachIndexed { index, action ->
DropdownMenuItemFlat(
action = action,
)
}
}
}
}, },
text = { text = {
Text( Text(

View File

@ -21,7 +21,7 @@ data class WordlistListState(
val revision: Int, val revision: Int,
val items: ImmutableList<Item>, val items: ImmutableList<Item>,
val selection: Selection?, val selection: Selection?,
val primaryAction: (() -> Unit)?, val primaryActions: ImmutableList<ContextItem>,
) { ) {
companion object companion object
} }

View File

@ -1,16 +1,13 @@
package com.artemchep.keyguard.feature.generator.wordlist.list package com.artemchep.keyguard.feature.generator.wordlist.list
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AddLink
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.ViewList
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import arrow.core.partially1 import arrow.core.partially1
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.AddWordlistRequest
import com.artemchep.keyguard.common.model.DGeneratorWordlist import com.artemchep.keyguard.common.model.DGeneratorWordlist
import com.artemchep.keyguard.common.model.EditWordlistRequest
import com.artemchep.keyguard.common.model.Loadable import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.usecase.AddWordlist import com.artemchep.keyguard.common.usecase.AddWordlist
import com.artemchep.keyguard.common.usecase.EditWordlist import com.artemchep.keyguard.common.usecase.EditWordlist
@ -20,22 +17,18 @@ import com.artemchep.keyguard.common.usecase.RemoveWordlistById
import com.artemchep.keyguard.common.util.flow.persistingStateIn import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.attachments.SelectableItemState import com.artemchep.keyguard.feature.attachments.SelectableItemState
import com.artemchep.keyguard.feature.attachments.SelectableItemStateRaw 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.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.navigation.NavigationIntent 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.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.Selection import com.artemchep.keyguard.ui.Selection
import com.artemchep.keyguard.ui.buildContextItems import com.artemchep.keyguard.ui.buildContextItems
import com.artemchep.keyguard.ui.icons.KeyguardWordlist import com.artemchep.keyguard.ui.icons.KeyguardWebsite
import com.artemchep.keyguard.ui.icons.icon import com.artemchep.keyguard.ui.icons.icon
import com.artemchep.keyguard.ui.selection.selectionHandle import com.artemchep.keyguard.ui.selection.selectionHandle
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -250,6 +243,24 @@ fun produceWordlistListState(
cause = e, cause = e,
) )
} }
val primaryActions = buildContextItems {
section {
this += FlatItemAction(
leading = icon(Icons.Outlined.AttachFile),
title = translate(Res.strings.wordlist_add_wordlist_via_file_title),
onClick = WordlistUtil::onNewFromFile
.partially1(this@produceScreenState)
.partially1(addWordlist),
)
this += FlatItemAction(
leading = icon(Icons.Outlined.KeyguardWebsite),
title = translate(Res.strings.wordlist_add_wordlist_via_url_title),
onClick = WordlistUtil::onNewFromUrl
.partially1(this@produceScreenState)
.partially1(addWordlist),
)
}
}
val contentFlow = combine( val contentFlow = combine(
selectionFlow, selectionFlow,
itemsFlow, itemsFlow,
@ -260,9 +271,7 @@ fun produceWordlistListState(
revision = 0, revision = 0,
items = items, items = items,
selection = selection, selection = selection,
primaryAction = WordlistUtil::onNew primaryActions = primaryActions,
.partially1(this@produceScreenState)
.partially1(addWordlist),
) )
} }
Loadable.Ok(contentOrException) Loadable.Ok(contentOrException)

View File

@ -69,7 +69,7 @@ object WordlistUtil {
} }
context(RememberStateFlowScope) context(RememberStateFlowScope)
fun onNew( fun onNewFromFile(
addWordlist: AddWordlist, addWordlist: AddWordlist,
) { ) {
val nameKey = "name" val nameKey = "name"
@ -126,6 +126,66 @@ object WordlistUtil {
navigate(intent) navigate(intent)
} }
context(RememberStateFlowScope)
fun onNewFromUrl(
addWordlist: AddWordlist,
) {
val nameKey = "name"
val nameItem = ConfirmationRoute.Args.Item.StringItem(
key = nameKey,
value = "",
title = translate(Res.strings.generic_name),
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
canBeEmpty = false,
)
val urlKey = "url"
val urlItem = ConfirmationRoute.Args.Item.StringItem(
key = urlKey,
value = "",
title = translate(Res.strings.url),
type = ConfirmationRoute.Args.Item.StringItem.Type.Command,
canBeEmpty = false,
)
val items = listOfNotNull(
nameItem,
urlItem,
)
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon(
main = Icons.Outlined.KeyguardWordlist,
secondary = Icons.Outlined.Add,
),
title = translate(Res.strings.wordlist_add_wordlist_title),
items = items,
docUrl = null,
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val name = result.data[nameKey] as? String
?: return@registerRouteResultReceiver
val url = result.data[urlKey] as? String
?: return@registerRouteResultReceiver
val wordlist = AddWordlistRequest.Wordlist.FromUrl(
url = url,
)
val request = AddWordlistRequest(
name = name,
wordlist = wordlist,
)
addWordlist(request)
.launchIn(appScope)
}
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
}
context(RememberStateFlowScope) context(RememberStateFlowScope)
fun onDeleteByItems( fun onDeleteByItems(
removeWordlistById: RemoveWordlistById, removeWordlistById: RemoveWordlistById,

View File

@ -520,6 +520,8 @@
<string name="wordlist_delete_many_confirmation_title">Delete the wordlists?</string> <string name="wordlist_delete_many_confirmation_title">Delete the wordlists?</string>
<string name="wordlist_edit_wordlist_title">Edit a wordlist</string> <string name="wordlist_edit_wordlist_title">Edit a wordlist</string>
<string name="wordlist_add_wordlist_title">Add a wordlist</string> <string name="wordlist_add_wordlist_title">Add a wordlist</string>
<string name="wordlist_add_wordlist_via_file_title">Load from a file</string>
<string name="wordlist_add_wordlist_via_url_title">Load from a URL</string>
<string name="wordlist_empty_label">No wordlists</string> <string name="wordlist_empty_label">No wordlists</string>
<string name="wordlist_word_search_placeholder">Search words</string> <string name="wordlist_word_search_placeholder">Search words</string>

View File

@ -187,6 +187,7 @@ import com.artemchep.keyguard.common.usecase.PutVaultSession
import com.artemchep.keyguard.common.usecase.PutWebsiteIcons import com.artemchep.keyguard.common.usecase.PutWebsiteIcons
import com.artemchep.keyguard.common.usecase.PutWriteAccess import com.artemchep.keyguard.common.usecase.PutWriteAccess
import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile
import com.artemchep.keyguard.common.usecase.ReadWordlistFromUrl
import com.artemchep.keyguard.common.usecase.RemoveAttachment import com.artemchep.keyguard.common.usecase.RemoveAttachment
import com.artemchep.keyguard.common.usecase.RequestAppReview import com.artemchep.keyguard.common.usecase.RequestAppReview
import com.artemchep.keyguard.common.usecase.ShowMessage import com.artemchep.keyguard.common.usecase.ShowMessage
@ -303,6 +304,7 @@ import com.artemchep.keyguard.common.usecase.impl.PutVaultSessionImpl
import com.artemchep.keyguard.common.usecase.impl.PutWebsiteIconsImpl import com.artemchep.keyguard.common.usecase.impl.PutWebsiteIconsImpl
import com.artemchep.keyguard.common.usecase.impl.PutWriteAccessImpl import com.artemchep.keyguard.common.usecase.impl.PutWriteAccessImpl
import com.artemchep.keyguard.common.usecase.impl.ReadWordlistFromFileImpl import com.artemchep.keyguard.common.usecase.impl.ReadWordlistFromFileImpl
import com.artemchep.keyguard.common.usecase.impl.ReadWordlistFromUrlImpl
import com.artemchep.keyguard.common.usecase.impl.RemoveAttachmentImpl import com.artemchep.keyguard.common.usecase.impl.RemoveAttachmentImpl
import com.artemchep.keyguard.common.usecase.impl.RequestAppReviewImpl import com.artemchep.keyguard.common.usecase.impl.RequestAppReviewImpl
import com.artemchep.keyguard.common.usecase.impl.UnlockUseCaseImpl import com.artemchep.keyguard.common.usecase.impl.UnlockUseCaseImpl
@ -846,6 +848,11 @@ fun globalModuleJvm() = DI.Module(
directDI = this, directDI = this,
) )
} }
bindSingleton<ReadWordlistFromUrl> {
ReadWordlistFromUrlImpl(
directDI = this,
)
}
bindSingleton<PutWriteAccess> { bindSingleton<PutWriteAccess> {
PutWriteAccessImpl( PutWriteAccessImpl(
directDI = this, directDI = this,