diff --git a/components/filemanager/impl/build.gradle.kts b/components/filemanager/impl/build.gradle.kts index f5202fd55a..81376d355f 100644 --- a/components/filemanager/impl/build.gradle.kts +++ b/components/filemanager/impl/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.components.bridge.pbutils) implementation(projects.components.filemanager.api) + implementation(projects.components.fmsearch.api) implementation(libs.kotlin.serialization.json) diff --git a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerDecomposeComponentImpl.kt b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerDecomposeComponentImpl.kt index 19c878ad36..c7fe379dcd 100644 --- a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerDecomposeComponentImpl.kt +++ b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerDecomposeComponentImpl.kt @@ -7,6 +7,7 @@ import com.arkivanov.decompose.value.Value import com.flipperdevices.core.di.AppGraph import com.flipperdevices.filemanager.api.navigation.FileManagerDecomposeComponent import com.flipperdevices.filemanager.impl.model.FileManagerNavigationConfig +import com.flipperdevices.fmsearch.api.FMSearchDecomposeComponent import com.flipperdevices.ui.decompose.DecomposeComponent import com.flipperdevices.ui.decompose.DecomposeOnBackParameter import com.flipperdevices.ui.decompose.popOr @@ -14,6 +15,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import me.gulya.anvil.assisted.ContributesAssistedFactory +@Suppress("LongParameterList") @ContributesAssistedFactory(AppGraph::class, FileManagerDecomposeComponent.Factory::class) class FileManagerDecomposeComponentImpl @AssistedInject constructor( @Assisted componentContext: ComponentContext, @@ -21,7 +23,8 @@ class FileManagerDecomposeComponentImpl @AssistedInject constructor( private val fileManagerListingFactory: FileManagerListingComponent.Factory, private val fileManagerUploadingFactory: FileManagerUploadingComponent.Factory, private val fileManagerEditingFactory: FileManagerEditingComponent.Factory, - private val fileManagerDownloadFactory: FileManagerDownloadComponent.Factory + private val fileManagerDownloadFactory: FileManagerDownloadComponent.Factory, + private val searchFileManagerFactory: FMSearchDecomposeComponent.Factory ) : FileManagerDecomposeComponent(), ComponentContext by componentContext { @@ -61,5 +64,11 @@ class FileManagerDecomposeComponentImpl @AssistedInject constructor( config, onBack = { navigation.popOr(onBack::invoke) } ) + + is FileManagerNavigationConfig.Search -> searchFileManagerFactory( + componentContext = componentContext, + onBackParameter = { navigation.popOr(onBack::invoke) }, + path = config.path + ) } } diff --git a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerListingComponent.kt b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerListingComponent.kt index 34a729bbde..d995036755 100644 --- a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerListingComponent.kt +++ b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/api/FileManagerListingComponent.kt @@ -53,6 +53,9 @@ class FileManagerListingComponent @AssistedInject constructor( deeplinkContent = content ) ) + }, + onClickToSearch = { + navigation.pushToFront(FileManagerNavigationConfig.Search(config.path)) } ) } diff --git a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/ComposableFileManagerScreen.kt b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/ComposableFileManagerScreen.kt index c0df93a462..77671e1319 100644 --- a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/ComposableFileManagerScreen.kt +++ b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/ComposableFileManagerScreen.kt @@ -33,7 +33,8 @@ fun ComposableFileManagerScreen( onOpenFolder: (FileItem) -> Unit, onDownloadAndShareFile: (FileItem) -> Unit, onOpenEditor: (FileItem) -> Unit, - onUploadFile: (path: String, DeeplinkContent) -> Unit + onUploadFile: (path: String, DeeplinkContent) -> Unit, + onClickToSearch: () -> Unit ) { val fileManagerState by fileManagerViewModel.getFileManagerState().collectAsState() @@ -89,7 +90,8 @@ fun ComposableFileManagerScreen( }, onAddButton = { showAddDialog = true - } + }, + onClickToSearch = onClickToSearch ) } @@ -139,7 +141,8 @@ private fun ComposableFileManagerScreenInternal( onOpenFolder: (FileItem) -> Unit, deepLinkParser: DeepLinkParser, onUploadFile: (DeeplinkContent) -> Unit, - onAddButton: () -> Unit + onAddButton: () -> Unit, + onClickToSearch: () -> Unit ) { val context = LocalContext.current @@ -167,7 +170,8 @@ private fun ComposableFileManagerScreenInternal( onClickUploadButton = { pickFileLauncher.launch("*/*") }, - onClickAddButton = onAddButton + onClickAddButton = onAddButton, + onClickToSearch = onClickToSearch ) } ) { scaffoldPaddings -> diff --git a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/bar/ComposableFileManagerTopBar.kt b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/bar/ComposableFileManagerTopBar.kt index 75eab2dc9a..483bd0bc54 100644 --- a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/bar/ComposableFileManagerTopBar.kt +++ b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/bar/ComposableFileManagerTopBar.kt @@ -19,6 +19,7 @@ fun ComposableFileManagerTopBar( path: String, onClickUploadButton: () -> Unit, onClickAddButton: () -> Unit, + onClickToSearch: () -> Unit, modifier: Modifier = Modifier ) { TopAppBar( @@ -32,8 +33,8 @@ fun ComposableFileManagerTopBar( ) }, actions = { - if (isAbleToSave(path)) { - Row { + Row { + if (isAbleToSave(path)) { IconButton(onClick = onClickAddButton) { Icon( painter = painterResource( @@ -55,6 +56,14 @@ fun ComposableFileManagerTopBar( ) } } + if (path != "/") { + IconButton(onClick = onClickToSearch) { + Icon( + painter = painterResource(DesignSystem.drawable.ic_search), + contentDescription = null + ) + } + } } } ) diff --git a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/list/ComposableFileManagerContent.kt b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/list/ComposableFileManagerContent.kt index e1974f784d..8278b83e27 100644 --- a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/list/ComposableFileManagerContent.kt +++ b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/composable/list/ComposableFileManagerContent.kt @@ -52,7 +52,7 @@ fun ComposableFileManagerContent( private fun ComposableFileManagerPreview() { ComposableFileManagerContent( fileManagerState = FileManagerState( - "/", + "/Test", persistentSetOf( FileItem.DUMMY_FOLDER, FileItem.DUMMY_FILE diff --git a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/model/FileManagerNavigationConfig.kt b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/model/FileManagerNavigationConfig.kt index 7ae58e7979..dc71ed6fcc 100644 --- a/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/model/FileManagerNavigationConfig.kt +++ b/components/filemanager/impl/src/main/java/com/flipperdevices/filemanager/impl/model/FileManagerNavigationConfig.kt @@ -22,4 +22,9 @@ sealed class FileManagerNavigationConfig { val path: String, val shareFile: ShareFile ) : FileManagerNavigationConfig() + + @Serializable + data class Search( + val path: String + ) : FileManagerNavigationConfig() } diff --git a/components/fmsearch/api/.gitignore b/components/fmsearch/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/components/fmsearch/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/components/fmsearch/api/build.gradle.kts b/components/fmsearch/api/build.gradle.kts new file mode 100644 index 0000000000..7f812e28a3 --- /dev/null +++ b/components/fmsearch/api/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("flipper.android-lib") +} + +android.namespace = "com.flipperdevices.fmsearch.api" + +dependencies { + implementation(projects.components.core.ui.decompose) + implementation(libs.bundles.decompose) +} diff --git a/components/fmsearch/api/src/main/kotlin/com/flipperdevices/fmsearch/api/FMSearchDecomposeComponent.kt b/components/fmsearch/api/src/main/kotlin/com/flipperdevices/fmsearch/api/FMSearchDecomposeComponent.kt new file mode 100644 index 0000000000..56140481d2 --- /dev/null +++ b/components/fmsearch/api/src/main/kotlin/com/flipperdevices/fmsearch/api/FMSearchDecomposeComponent.kt @@ -0,0 +1,17 @@ +package com.flipperdevices.fmsearch.api + +import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent + +abstract class FMSearchDecomposeComponent( + componentContext: ComponentContext +) : ScreenDecomposeComponent(componentContext) { + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + onBackParameter: DecomposeOnBackParameter, + path: String + ): FMSearchDecomposeComponent + } +} diff --git a/components/fmsearch/impl/.gitignore b/components/fmsearch/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/components/fmsearch/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/components/fmsearch/impl/build.gradle.kts b/components/fmsearch/impl/build.gradle.kts new file mode 100644 index 0000000000..70f74e0db0 --- /dev/null +++ b/components/fmsearch/impl/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("flipper.android-compose") + id("flipper.anvil") +} + +android.namespace = "com.flipperdevices.fmsearch.impl" + +dependencies { + implementation(projects.components.fmsearch.api) + + implementation(projects.components.core.di) + implementation(projects.components.core.ui.theme) + implementation(projects.components.core.ui.decompose) + implementation(projects.components.core.ui.lifecycle) + implementation(projects.components.core.ui.ktx) + implementation(projects.components.core.ui.res) + implementation(projects.components.core.ui.searchbar) + implementation(projects.components.core.ktx) + + // Compose + implementation(libs.compose.ui) + implementation(libs.compose.tooling) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + + implementation(projects.components.bridge.api) + implementation(projects.components.bridge.service.api) + implementation(projects.components.bridge.pbutils) + + implementation(libs.bundles.decompose) + implementation(libs.kotlin.immutable.collections) +} diff --git a/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/api/FMSearchDecomposeComponentImpl.kt b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/api/FMSearchDecomposeComponentImpl.kt new file mode 100644 index 0000000000..686d0a0316 --- /dev/null +++ b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/api/FMSearchDecomposeComponentImpl.kt @@ -0,0 +1,74 @@ +package com.flipperdevices.fmsearch.impl.api + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.core.ui.lifecycle.viewModelWithFactoryWithoutRemember +import com.flipperdevices.core.ui.searchbar.ComposableSearchBar +import com.flipperdevices.core.ui.theme.LocalPallet +import com.flipperdevices.fmsearch.api.FMSearchDecomposeComponent +import com.flipperdevices.fmsearch.impl.R +import com.flipperdevices.fmsearch.impl.composable.SearchItemComposable +import com.flipperdevices.fmsearch.impl.viewmodel.FMSearchViewModel +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.gulya.anvil.assisted.ContributesAssistedFactory + +@ContributesAssistedFactory(AppGraph::class, FMSearchDecomposeComponent.Factory::class) +class FMSearchDecomposeComponentImpl @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted private val onBackParameter: DecomposeOnBackParameter, + @Assisted private val path: String, + private val searchViewModelFactory: FMSearchViewModel.Factory +) : FMSearchDecomposeComponent(componentContext) { + private val searchViewModel = viewModelWithFactoryWithoutRemember(path) { + searchViewModelFactory(path) + } + + @Composable + override fun Render() { + Column { + ComposableSearchBar( + hint = stringResource(R.string.fm_search_hint), + onBack = onBackParameter::invoke, + onChangeText = searchViewModel::search + ) + val result by searchViewModel.getSearchResult().collectAsState() + LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) { + items(result.items) { item -> + SearchItemComposable( + modifier = Modifier.fillMaxWidth(), + searchItem = item + ) + } + if (result.inProgress) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = LocalPallet.current.accentSecond) + } + } + } + } + } + } +} diff --git a/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/composable/SearchItemComposable.kt b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/composable/SearchItemComposable.kt new file mode 100644 index 0000000000..ab7379b4c8 --- /dev/null +++ b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/composable/SearchItemComposable.kt @@ -0,0 +1,47 @@ +package com.flipperdevices.fmsearch.impl.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.flipperdevices.core.ui.ktx.image.painterResourceByKey +import com.flipperdevices.fmsearch.impl.R +import com.flipperdevices.fmsearch.impl.model.SearchItem + +@Composable +fun SearchItemComposable( + searchItem: SearchItem, + modifier: Modifier = Modifier, +) = Row(modifier) { + Icon( + modifier = Modifier + .padding(all = 8.dp) + .size(48.dp), + painter = painterResourceByKey( + id = if (searchItem.isFolder) { + R.drawable.ic_folder + } else { + R.drawable.ic_file + } + ), + contentDescription = null + ) + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Text( + modifier = Modifier.padding(end = 8.dp), + style = MaterialTheme.typography.h5, + text = searchItem.name + ) + + Text( + style = MaterialTheme.typography.h5, + text = searchItem.path + ) + } +} diff --git a/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/model/SearchItem.kt b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/model/SearchItem.kt new file mode 100644 index 0000000000..e72a136c9e --- /dev/null +++ b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/model/SearchItem.kt @@ -0,0 +1,7 @@ +package com.flipperdevices.fmsearch.impl.model + +data class SearchItem( + val name: String, + val path: String, + val isFolder: Boolean +) diff --git a/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/model/SearchResult.kt b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/model/SearchResult.kt new file mode 100644 index 0000000000..b5c8bf740b --- /dev/null +++ b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/model/SearchResult.kt @@ -0,0 +1,8 @@ +package com.flipperdevices.fmsearch.impl.model + +import kotlinx.collections.immutable.ImmutableList + +data class SearchResult( + val inProgress: Boolean, + val items: ImmutableList +) diff --git a/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/viewmodel/FMSearchViewModel.kt b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/viewmodel/FMSearchViewModel.kt new file mode 100644 index 0000000000..88923fd784 --- /dev/null +++ b/components/fmsearch/impl/src/main/kotlin/com/flipperdevices/fmsearch/impl/viewmodel/FMSearchViewModel.kt @@ -0,0 +1,132 @@ +package com.flipperdevices.fmsearch.impl.viewmodel + +import com.flipperdevices.bridge.api.model.FlipperRequestPriority +import com.flipperdevices.bridge.api.model.wrapToRequest +import com.flipperdevices.bridge.service.api.FlipperServiceApi +import com.flipperdevices.bridge.service.api.provider.FlipperServiceProvider +import com.flipperdevices.core.ktx.jre.FlipperDispatchers +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import com.flipperdevices.fmsearch.impl.model.SearchItem +import com.flipperdevices.fmsearch.impl.model.SearchResult +import com.flipperdevices.protobuf.main +import com.flipperdevices.protobuf.storage.Storage.File.FileType +import com.flipperdevices.protobuf.storage.listRequest +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.time.Duration.Companion.seconds + +@OptIn(FlowPreview::class) +class FMSearchViewModel @AssistedInject constructor( + @Assisted private val startPath: String, + serviceApiProvider: FlipperServiceProvider +) : DecomposeViewModel() { + private val searchRequest = MutableStateFlow("") + private val searchResponse = MutableStateFlow( + SearchResult(inProgress = false, items = persistentListOf()) + ) + + init { + serviceApiProvider.provideServiceApi(this) { serviceApi -> + viewModelScope.launch { + searchRequest + .debounce(1.seconds) + .collectLatest { query -> + search(serviceApi, query) + } + } + } + } + + fun search(query: String) { + runBlocking { searchRequest.emit(query) } + } + + fun getSearchResult() = searchResponse.asStateFlow() + + private suspend fun search( + serviceApi: FlipperServiceApi, + query: String + ) = coroutineScope { + if (query.isEmpty()) { + return@coroutineScope + } + searchResponse.emit(SearchResult(inProgress = true, items = persistentListOf())) + treeRecursive( + scope = this, + serviceApi = serviceApi, + path = startPath + ) { items -> + val result = items.filter { + it.name.contains( + query, + ignoreCase = true + ) + } + searchResponse.update { + it.copy(items = (it.items + result).toImmutableList()) + } + } + searchResponse.update { + it.copy(inProgress = false) + } + } + + private suspend fun treeRecursive( + scope: CoroutineScope, + serviceApi: FlipperServiceApi, + path: String, + onReceiveListing: (List) -> Unit, + ) { + val items = getListing(serviceApi, path) + onReceiveListing(items) + items.filter { it.isFolder }.map { folder -> + scope.async(FlipperDispatchers.workStealingDispatcher) { + treeRecursive(scope, serviceApi, folder.path, onReceiveListing) + } + }.forEach { it.await() } + } + + private suspend fun getListing( + serviceApi: FlipperServiceApi, + requestPath: String + ): List { + val response = serviceApi.requestApi.request( + main { + storageListRequest = listRequest { + path = requestPath + includeMd5 = false + } + }.wrapToRequest(FlipperRequestPriority.FOREGROUND) + ).first() + return response.storageListResponse.fileList.map { + SearchItem( + name = it.name, + path = File(requestPath, it.name).absolutePath, + isFolder = it.type == FileType.DIR + ) + } + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + startPath: String + ): FMSearchViewModel + } +} diff --git a/components/fmsearch/impl/src/main/res/drawable/ic_file.xml b/components/fmsearch/impl/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000000..cf197c58ab --- /dev/null +++ b/components/fmsearch/impl/src/main/res/drawable/ic_file.xml @@ -0,0 +1,11 @@ + + + diff --git a/components/fmsearch/impl/src/main/res/drawable/ic_folder.xml b/components/fmsearch/impl/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000000..177741e3bb --- /dev/null +++ b/components/fmsearch/impl/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/components/fmsearch/impl/src/main/res/values/strings.xml b/components/fmsearch/impl/src/main/res/values/strings.xml new file mode 100644 index 0000000000..feef9c6c53 --- /dev/null +++ b/components/fmsearch/impl/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Start typing to search + \ No newline at end of file diff --git a/components/unhandledexception/toolstab/api/.gitignore b/components/unhandledexception/toolstab/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/components/unhandledexception/toolstab/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/components/unhandledexception/toolstab/api/build.gradle.kts b/components/unhandledexception/toolstab/api/build.gradle.kts new file mode 100644 index 0000000000..f961131410 --- /dev/null +++ b/components/unhandledexception/toolstab/api/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("flipper.android-lib") +} + +android.namespace = "com.flipperdevices.toolstab.api" + +dependencies { + implementation(projects.components.core.ui.decompose) + + implementation(projects.components.deeplink.api) + + implementation(libs.decompose) + + implementation(libs.kotlin.coroutines) +} diff --git a/components/unhandledexception/toolstab/api/src/main/java/com/flipperdevices/toolstab/api/ToolsApi.kt b/components/unhandledexception/toolstab/api/src/main/java/com/flipperdevices/toolstab/api/ToolsApi.kt new file mode 100644 index 0000000000..31fb0a39c1 --- /dev/null +++ b/components/unhandledexception/toolstab/api/src/main/java/com/flipperdevices/toolstab/api/ToolsApi.kt @@ -0,0 +1,8 @@ +package com.flipperdevices.toolstab.api + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface ToolsApi { + fun hasNotification(scope: CoroutineScope): Flow +} diff --git a/components/unhandledexception/toolstab/api/src/main/java/com/flipperdevices/toolstab/api/ToolsDecomposeComponent.kt b/components/unhandledexception/toolstab/api/src/main/java/com/flipperdevices/toolstab/api/ToolsDecomposeComponent.kt new file mode 100644 index 0000000000..cdfc7571d4 --- /dev/null +++ b/components/unhandledexception/toolstab/api/src/main/java/com/flipperdevices/toolstab/api/ToolsDecomposeComponent.kt @@ -0,0 +1,18 @@ +package com.flipperdevices.toolstab.api + +import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.deeplink.model.Deeplink +import com.flipperdevices.ui.decompose.CompositeDecomposeComponent +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter + +abstract class ToolsDecomposeComponent : CompositeDecomposeComponent() { + abstract fun handleDeeplink(deeplink: Deeplink.BottomBar.ToolsTab) + + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + deeplink: Deeplink.BottomBar.ToolsTab?, + onBack: DecomposeOnBackParameter, + ): ToolsDecomposeComponent<*> + } +} diff --git a/components/unhandledexception/toolstab/impl/.gitignore b/components/unhandledexception/toolstab/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/components/unhandledexception/toolstab/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/components/unhandledexception/toolstab/impl/build.gradle.kts b/components/unhandledexception/toolstab/impl/build.gradle.kts new file mode 100644 index 0000000000..8ab960a25d --- /dev/null +++ b/components/unhandledexception/toolstab/impl/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + id("flipper.android-compose") + id("flipper.anvil") + id("kotlinx-serialization") +} + +android.namespace = "com.flipperdevices.toolstab.impl" + +dependencies { + implementation(projects.components.toolstab.api) + + implementation(projects.components.core.di) + implementation(projects.components.core.preference) + implementation(projects.components.core.ui.res) + implementation(projects.components.core.ui.ktx) + implementation(projects.components.core.ui.theme) + implementation(projects.components.core.ui.lifecycle) + implementation(projects.components.core.ui.decompose) + + implementation(libs.appcompat) + + implementation(projects.components.nfc.mfkey32.api) + implementation(projects.components.deeplink.api) + implementation(projects.components.bottombar.api) + implementation(projects.components.rootscreen.api) + implementation(projects.components.info.shared) + + // Compose + implementation(libs.compose.ui) + implementation(libs.compose.tooling) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + implementation(libs.lifecycle.compose) + + implementation(libs.bundles.decompose) + implementation(libs.kotlin.immutable.collections) +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsApiImpl.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsApiImpl.kt new file mode 100644 index 0000000000..e04329e27d --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsApiImpl.kt @@ -0,0 +1,18 @@ +package com.flipperdevices.toolstab.impl.api + +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.nfc.mfkey32.api.MfKey32Api +import com.flipperdevices.toolstab.api.ToolsApi +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +@ContributesBinding(AppGraph::class) +class ToolsApiImpl @Inject constructor( + private val mfKey32Api: MfKey32Api +) : ToolsApi { + override fun hasNotification(scope: CoroutineScope): Flow { + return mfKey32Api.hasNotification() + } +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsDecomposeComponentImpl.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsDecomposeComponentImpl.kt new file mode 100644 index 0000000000..de1a5ab4b8 --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsDecomposeComponentImpl.kt @@ -0,0 +1,63 @@ +package com.flipperdevices.toolstab.impl.api + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.navigate +import com.arkivanov.decompose.value.Value +import com.flipperdevices.bottombar.handlers.ResetTabDecomposeHandler +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.deeplink.model.Deeplink +import com.flipperdevices.nfc.mfkey32.api.MfKey32DecomposeComponent +import com.flipperdevices.toolstab.api.ToolsDecomposeComponent +import com.flipperdevices.toolstab.impl.model.ToolsNavigationConfig +import com.flipperdevices.toolstab.impl.model.toConfigStack +import com.flipperdevices.ui.decompose.DecomposeComponent +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.popOr +import com.flipperdevices.ui.decompose.popToRoot +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.gulya.anvil.assisted.ContributesAssistedFactory + +@ContributesAssistedFactory(AppGraph::class, ToolsDecomposeComponent.Factory::class) +class ToolsDecomposeComponentImpl @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted deeplink: Deeplink.BottomBar.ToolsTab?, + @Assisted private val onBack: DecomposeOnBackParameter, + private val hubMainFactory: ToolsMainScreenDecomposeComponentImpl.Factory, + private val mfKey32Factory: MfKey32DecomposeComponent.Factory +) : ToolsDecomposeComponent(), + ComponentContext by componentContext, + ResetTabDecomposeHandler { + override val stack: Value> = childStack( + source = navigation, + serializer = ToolsNavigationConfig.serializer(), + initialStack = { deeplink.toConfigStack() }, + handleBackButton = true, + childFactory = ::child, + ) + + private fun child( + config: ToolsNavigationConfig, + componentContext: ComponentContext + ): DecomposeComponent = when (config) { + ToolsNavigationConfig.Main -> hubMainFactory( + componentContext = componentContext, + navigation = navigation + ) + + ToolsNavigationConfig.MfKey32 -> mfKey32Factory( + componentContext = componentContext, + onBack = { navigation.popOr(onBack::invoke) } + ) + } + + override fun handleDeeplink(deeplink: Deeplink.BottomBar.ToolsTab) { + navigation.navigate { deeplink.toConfigStack() } + } + + override fun onResetTab() { + navigation.popToRoot() + } +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsMainScreenDecomposeComponentImpl.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsMainScreenDecomposeComponentImpl.kt new file mode 100644 index 0000000000..a768a4c923 --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/api/ToolsMainScreenDecomposeComponentImpl.kt @@ -0,0 +1,47 @@ +package com.flipperdevices.toolstab.impl.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.pushToFront +import com.flipperdevices.core.ui.lifecycle.viewModelWithFactory +import com.flipperdevices.toolstab.impl.composable.ComposableHub +import com.flipperdevices.toolstab.impl.model.ToolsNavigationConfig +import com.flipperdevices.toolstab.impl.viewmodel.ToolsNotificationViewModel +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import javax.inject.Provider + +class ToolsMainScreenDecomposeComponentImpl @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted private val navigation: StackNavigation, + private val toolsNotificationViewModelProvider: Provider +) : ScreenDecomposeComponent(componentContext) { + @Composable + @Suppress("NonSkippableComposable") + override fun Render() { + val nfcAttackViewModel = viewModelWithFactory(key = null) { + toolsNotificationViewModelProvider.get() + } + val hasNotification by nfcAttackViewModel.hasNotificationStateFlow.collectAsState() + + ComposableHub( + hasNotification = hasNotification, + onOpenMfKey32 = { + navigation.pushToFront(ToolsNavigationConfig.MfKey32) + } + ) + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + navigation: StackNavigation + ): ToolsMainScreenDecomposeComponentImpl + } +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/ComposableHub.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/ComposableHub.kt new file mode 100644 index 0000000000..29485e615d --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/ComposableHub.kt @@ -0,0 +1,25 @@ +package com.flipperdevices.toolstab.impl.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipperdevices.core.ui.ktx.OrangeAppBar +import com.flipperdevices.toolstab.impl.R +import com.flipperdevices.toolstab.impl.composable.elements.MifareClassicComposable + +@Composable +fun ComposableHub( + hasNotification: Boolean, + onOpenMfKey32: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier) { + OrangeAppBar( + titleId = R.string.toolstab_title + ) + MifareClassicComposable( + hasMfKey32Notification = hasNotification, + onOpenMfKey32 = onOpenMfKey32 + ) + } +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/elements/MifareClassicComposable.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/elements/MifareClassicComposable.kt new file mode 100644 index 0000000000..0c0d767319 --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/elements/MifareClassicComposable.kt @@ -0,0 +1,125 @@ +package com.flipperdevices.toolstab.impl.composable.elements + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flipperdevices.core.ui.ktx.clickableRipple +import com.flipperdevices.core.ui.theme.LocalPallet +import com.flipperdevices.core.ui.theme.LocalPalletV2 +import com.flipperdevices.core.ui.theme.LocalTypography +import com.flipperdevices.toolstab.impl.R +import com.flipperdevices.core.ui.res.R as DesignSystem + +@Composable +fun MifareClassicComposable( + hasMfKey32Notification: Boolean, + onOpenMfKey32: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.padding(14.dp), + shape = RoundedCornerShape(10.dp) + ) { + Column { + MifareClassicTitle() + MifareClassicMfKey32( + modifier = Modifier.clickableRipple(onClick = onOpenMfKey32), + hasNotification = hasMfKey32Notification + ) + } + } +} + +@Composable +private fun MifareClassicTitle() = Row( + verticalAlignment = Alignment.CenterVertically +) { + Icon( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 6.dp) + .size(24.dp), + painter = painterResource(DesignSystem.drawable.ic_fileformat_nfc), + contentDescription = stringResource(R.string.nfcattack_mifare_classic_title), + tint = LocalPallet.current.text100 + ) + Text( + text = stringResource(R.string.nfcattack_mifare_classic_title), + style = LocalTypography.current.buttonB16, + color = LocalPallet.current.text100 + ) +} + +@Composable +private fun MifareClassicMfKey32( + hasNotification: Boolean, + modifier: Modifier = Modifier +) = Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically +) { + Image( + modifier = Modifier + .padding(start = 12.dp, bottom = 6.dp, top = 6.dp) + .size(64.dp), + painter = painterResource( + if (MaterialTheme.colors.isLight) { + R.drawable.pic_detect_reader + } else { + R.drawable.pic_detect_reader_black + } + ), + contentDescription = stringResource(R.string.nfcattack_mifare_classic_mfkey32_title) + ) + Column( + Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text( + text = stringResource(R.string.nfcattack_mifare_classic_mfkey32_title), + style = LocalTypography.current.bodyM14, + color = LocalPallet.current.text100 + ) + Text( + text = stringResource(R.string.nfcattack_mifare_classic_mfkey32_desc), + style = LocalTypography.current.subtitleR12, + color = LocalPallet.current.text30 + ) + } + + if (hasNotification) { + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(LocalPalletV2.current.action.blackAndWhite.border.whiteOnColor) + .padding(1.dp) + .clip(CircleShape) + .background(LocalPalletV2.current.action.fwUpdate.background.primary.default), + ) + } + + Icon( + modifier = Modifier.padding(start = 8.dp, end = 8.dp), + painter = painterResource(id = DesignSystem.drawable.ic_navigate), + contentDescription = stringResource(R.string.nfcattack_mifare_classic_mfkey32_title), + tint = LocalPallet.current.iconTint30 + ) +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/elements/NfcAttack.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/elements/NfcAttack.kt new file mode 100644 index 0000000000..fbe7b75afb --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/composable/elements/NfcAttack.kt @@ -0,0 +1,23 @@ +package com.flipperdevices.toolstab.impl.composable.elements + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipperdevices.info.shared.ComposableOneRowCard +import com.flipperdevices.toolstab.impl.R +import com.flipperdevices.core.ui.res.R as DesignSystem + +@Composable +fun NfcAttack( + onOpenAttack: () -> Unit, + notificationCount: Int, + modifier: Modifier = Modifier +) { + ComposableOneRowCard( + iconId = DesignSystem.drawable.ic_fileformat_nfc, + onOpen = onOpenAttack, + titleId = R.string.toolstab_hfc_title, + descriptionId = R.string.toolstab_hfc_desc, + notificationCount = notificationCount, + modifier = modifier + ) +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/model/ToolsNavigationConfig.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/model/ToolsNavigationConfig.kt new file mode 100644 index 0000000000..566569fed8 --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/model/ToolsNavigationConfig.kt @@ -0,0 +1,22 @@ +package com.flipperdevices.toolstab.impl.model + +import com.flipperdevices.deeplink.model.Deeplink +import kotlinx.serialization.Serializable + +@Serializable +sealed class ToolsNavigationConfig { + @Serializable + data object Main : ToolsNavigationConfig() + + @Serializable + data object MfKey32 : ToolsNavigationConfig() +} + +fun Deeplink.BottomBar.ToolsTab?.toConfigStack(): List { + val stack = mutableListOf(ToolsNavigationConfig.Main) + when (this) { + is Deeplink.BottomBar.ToolsTab.OpenMfKey -> stack.add(ToolsNavigationConfig.MfKey32) + null -> {} + } + return stack +} diff --git a/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/viewmodel/ToolsNotificationViewModel.kt b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/viewmodel/ToolsNotificationViewModel.kt new file mode 100644 index 0000000000..630680ae02 --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/java/com/flipperdevices/toolstab/impl/viewmodel/ToolsNotificationViewModel.kt @@ -0,0 +1,19 @@ +package com.flipperdevices.toolstab.impl.viewmodel + +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import com.flipperdevices.toolstab.api.ToolsApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +class ToolsNotificationViewModel @Inject constructor( + toolsApi: ToolsApi +) : DecomposeViewModel() { + val hasNotificationStateFlow: StateFlow = toolsApi + .hasNotification(viewModelScope) + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) +} diff --git a/components/unhandledexception/toolstab/impl/src/main/res/drawable/pic_detect_reader.xml b/components/unhandledexception/toolstab/impl/src/main/res/drawable/pic_detect_reader.xml new file mode 100644 index 0000000000..ce175a2f3b --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/res/drawable/pic_detect_reader.xml @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/unhandledexception/toolstab/impl/src/main/res/drawable/pic_detect_reader_black.xml b/components/unhandledexception/toolstab/impl/src/main/res/drawable/pic_detect_reader_black.xml new file mode 100644 index 0000000000..a35ebe3be5 --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/res/drawable/pic_detect_reader_black.xml @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/unhandledexception/toolstab/impl/src/main/res/values/strings.xml b/components/unhandledexception/toolstab/impl/src/main/res/values/strings.xml new file mode 100644 index 0000000000..852063513f --- /dev/null +++ b/components/unhandledexception/toolstab/impl/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Tools + NFC Tools + Calculate MIFARE Classic card keys using Flipper Zero + MIFARE Classic + Mfkey32 (Detect Reader) + Calculate keys from Detect Reader + diff --git a/instances/android/app/build.gradle.kts b/instances/android/app/build.gradle.kts index 9b7c02482e..cde87495cc 100644 --- a/instances/android/app/build.gradle.kts +++ b/instances/android/app/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { implementation(projects.components.filemanager.api) implementation(projects.components.filemanager.impl) + implementation(projects.components.fmsearch.api) + implementation(projects.components.fmsearch.impl) implementation(projects.components.screenstreaming.api) implementation(projects.components.screenstreaming.impl) diff --git a/settings.gradle.kts b/settings.gradle.kts index a0e0d98f57..318dcd2404 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -74,6 +74,8 @@ include( ":components:filemanager:api", ":components:filemanager:impl", + ":components:fmsearch:api", + ":components:fmsearch:impl", ":components:core:di", ":components:core:ktx",