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,
) : Wordlist
data class FromUrl(
val url: String,
) : Wordlist
data class FromList(
val list: List<String>,
) : 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.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,

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.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<List<String>> = ioEffect {
val content = textService.readFromFileAsText(uri)
content
.lineSequence()
.filter {
it.isNotBlank() &&
!it.startsWith('#') &&
!it.startsWith(';') &&
!it.startsWith('-') &&
!it.startsWith('/')
}
.toImmutableList()
with(content) {
ReadWordlistUtil.parseAsWordlist()
}
}
}

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

View File

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

View File

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

View File

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

View File

@ -520,6 +520,8 @@
<string name="wordlist_delete_many_confirmation_title">Delete the wordlists?</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_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_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.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<ReadWordlistFromUrl> {
ReadWordlistFromUrlImpl(
directDI = this,
)
}
bindSingleton<PutWriteAccess> {
PutWriteAccessImpl(
directDI = this,