From 998c40dfc08bedf62b9730b97a4c48ec39fc34a2 Mon Sep 17 00:00:00 2001 From: Artem Chepurnoy Date: Fri, 16 Feb 2024 20:38:28 +0200 Subject: [PATCH] feat: Add a wordlist from a URL #88 --- .../common/model/AddWordlistRequest.kt | 4 ++ .../common/usecase/ReadWordlistFromUrl.kt | 5 ++ .../common/usecase/impl/AddWordlistImpl.kt | 13 ++++ .../usecase/impl/ReadWordlistFromFileImpl.kt | 14 +---- .../usecase/impl/ReadWordlistFromUrlImpl.kt | 30 +++++++++ .../common/usecase/impl/ReadWordlistUtil.kt | 17 +++++ .../wordlist/list/WordlistListScreen.kt | 46 +++++++++++++- .../wordlist/list/WordlistListState.kt | 2 +- .../list/WordlistListStateProducer.kt | 35 +++++++---- .../generator/wordlist/util/WordlistUtil.kt | 62 ++++++++++++++++++- .../commonMain/resources/MR/base/strings.xml | 2 + .../artemchep/keyguard/di/GlobalModuleJvm.kt | 7 +++ 12 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ReadWordlistFromUrl.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromUrlImpl.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistUtil.kt diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/AddWordlistRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/AddWordlistRequest.kt index 0ece505b..3393ff46 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/AddWordlistRequest.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/AddWordlistRequest.kt @@ -9,6 +9,10 @@ data class AddWordlistRequest( val uri: String, ) : Wordlist + data class FromUrl( + val url: String, + ) : Wordlist + data class FromList( val list: List, ) : Wordlist diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ReadWordlistFromUrl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ReadWordlistFromUrl.kt new file mode 100644 index 00000000..5b6af5c4 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/ReadWordlistFromUrl.kt @@ -0,0 +1,5 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO + +interface ReadWordlistFromUrl : (String) -> IO> diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddWordlistImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddWordlistImpl.kt index b8b0c990..f4d7a8a3 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddWordlistImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/AddWordlistImpl.kt @@ -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.usecase.AddWordlist import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile +import com.artemchep.keyguard.common.usecase.ReadWordlistFromUrl import org.kodein.di.DirectDI import org.kodein.di.instance class AddWordlistImpl( private val generatorWordlistRepository: GeneratorWordlistRepository, private val readWordlistFromFile: ReadWordlistFromFile, + private val readWordlistFromUrl: ReadWordlistFromUrl, ) : AddWordlist { constructor(directDI: DirectDI) : this( generatorWordlistRepository = directDI.instance(), readWordlistFromFile = directDI.instance(), + readWordlistFromUrl = directDI.instance(), ) override fun invoke( @@ -29,8 +32,18 @@ class AddWordlistImpl( .bind() } + is AddWordlistRequest.Wordlist.FromUrl -> { + val uri = model.wordlist.url + readWordlistFromUrl(uri) + .bind() + } + is AddWordlistRequest.Wordlist.FromList -> model.wordlist.list } + val invalidWordlist = wordlist.any { it.length > 512 } + if (invalidWordlist) { + throw IllegalStateException("Failed to parse the wordlist!") + } generatorWordlistRepository .post( name = name, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromFileImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromFileImpl.kt index ed4d2861..bbb45e03 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromFileImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromFileImpl.kt @@ -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.readFromFileAsText import com.artemchep.keyguard.common.usecase.ReadWordlistFromFile -import kotlinx.collections.immutable.toImmutableList import org.kodein.di.DirectDI import org.kodein.di.instance @@ -20,15 +19,8 @@ class ReadWordlistFromFileImpl( uri: String, ): IO> = ioEffect { val content = textService.readFromFileAsText(uri) - content - .lineSequence() - .filter { - it.isNotBlank() && - !it.startsWith('#') && - !it.startsWith(';') && - !it.startsWith('-') && - !it.startsWith('/') - } - .toImmutableList() + with(content) { + ReadWordlistUtil.parseAsWordlist() + } } } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromUrlImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromUrlImpl.kt new file mode 100644 index 00000000..f9f33af0 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistFromUrlImpl.kt @@ -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> = ioEffect { + val request = httpClient + .get(url) + val content = request + .bodyAsText() + with(content) { + ReadWordlistUtil.parseAsWordlist() + } + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistUtil.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistUtil.kt new file mode 100644 index 00000000..4e17eb72 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/ReadWordlistUtil.kt @@ -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 = lineSequence() + .filter { + it.isNotBlank() && + !it.startsWith('#') && + !it.startsWith(';') && + !it.startsWith('-') && + !it.startsWith('/') + } + .toImmutableList() +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt index 904c710b..964d970e 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items 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.HelpOutline import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -28,8 +30,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.runtime.snapshotFlow import androidx.compose.ui.Alignment 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.DefaultFab 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.FlatItemLayout import com.artemchep.keyguard.ui.FlatItemTextContent @@ -108,6 +115,9 @@ fun WordlistListScreen( listState.scrollToItem(0, 0) } + val primaryActionsDropdownVisibleState = remember { + mutableStateOf(false) + } ScaffoldLazyColumn( modifier = Modifier .nestedScroll(scrollBehavior.nestedScrollConnection), @@ -147,8 +157,15 @@ fun WordlistListScreen( ) }, floatingActionState = run { - val onClick = - loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.primaryAction + val actions = loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.primaryActions.orEmpty() + val onClick = if (actions.isNotEmpty()) { + // lambda + { + primaryActionsDropdownVisibleState.value = true + } + } else { + null + } val state = FabState( onClick = onClick, model = null, @@ -159,6 +176,31 @@ fun WordlistListScreen( DefaultFab( icon = { 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( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListState.kt index 4b4f849d..f534dffc 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListState.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListState.kt @@ -21,7 +21,7 @@ data class WordlistListState( val revision: Int, val items: ImmutableList, val selection: Selection?, - val primaryAction: (() -> Unit)?, + val primaryActions: ImmutableList, ) { companion object } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt index d383ca41..d012db40 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt @@ -1,16 +1,13 @@ package com.artemchep.keyguard.feature.generator.wordlist.list 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.Edit -import androidx.compose.material.icons.outlined.ViewList import androidx.compose.runtime.Composable 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.EditWordlistRequest import com.artemchep.keyguard.common.model.Loadable import com.artemchep.keyguard.common.usecase.AddWordlist 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.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.generator.wordlist.WordlistsRoute import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute 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.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.KeyguardWordlist +import com.artemchep.keyguard.ui.icons.KeyguardWebsite import com.artemchep.keyguard.ui.icons.icon import com.artemchep.keyguard.ui.selection.selectionHandle import kotlinx.collections.immutable.toPersistentList @@ -250,6 +243,24 @@ fun produceWordlistListState( 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( selectionFlow, itemsFlow, @@ -260,9 +271,7 @@ fun produceWordlistListState( revision = 0, items = items, selection = selection, - primaryAction = WordlistUtil::onNew - .partially1(this@produceScreenState) - .partially1(addWordlist), + primaryActions = primaryActions, ) } Loadable.Ok(contentOrException) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt index 3537f99f..fdb7dd87 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt @@ -69,7 +69,7 @@ object WordlistUtil { } context(RememberStateFlowScope) - fun onNew( + fun onNewFromFile( addWordlist: AddWordlist, ) { val nameKey = "name" @@ -126,6 +126,66 @@ object WordlistUtil { 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) fun onDeleteByItems( removeWordlistById: RemoveWordlistById, diff --git a/common/src/commonMain/resources/MR/base/strings.xml b/common/src/commonMain/resources/MR/base/strings.xml index 0f580996..e5bca764 100644 --- a/common/src/commonMain/resources/MR/base/strings.xml +++ b/common/src/commonMain/resources/MR/base/strings.xml @@ -520,6 +520,8 @@ Delete the wordlists? Edit a wordlist Add a wordlist + Load from a file + Load from a URL No wordlists Search words diff --git a/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt b/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt index c66ef4b2..f87ff95d 100644 --- a/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt +++ b/common/src/jvmMain/kotlin/com/artemchep/keyguard/di/GlobalModuleJvm.kt @@ -187,6 +187,7 @@ import com.artemchep.keyguard.common.usecase.PutVaultSession import com.artemchep.keyguard.common.usecase.PutWebsiteIcons import com.artemchep.keyguard.common.usecase.PutWriteAccess 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.RequestAppReview 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.PutWriteAccessImpl 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.RequestAppReviewImpl import com.artemchep.keyguard.common.usecase.impl.UnlockUseCaseImpl @@ -846,6 +848,11 @@ fun globalModuleJvm() = DI.Module( directDI = this, ) } + bindSingleton { + ReadWordlistFromUrlImpl( + directDI = this, + ) + } bindSingleton { PutWriteAccessImpl( directDI = this,