diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt index a2c9c4c..4905c4a 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt @@ -8,6 +8,8 @@ import com.abdownloadmanager.desktop.pages.batchdownload.BatchDownloadComponent import com.abdownloadmanager.desktop.pages.category.CategoryComponent import com.abdownloadmanager.desktop.pages.category.CategoryDialogManager import com.abdownloadmanager.desktop.pages.editdownload.EditDownloadComponent +import com.abdownloadmanager.desktop.pages.filehash.FileChecksumComponent +import com.abdownloadmanager.desktop.pages.filehash.FileChecksumComponentConfig import com.abdownloadmanager.desktop.pages.home.HomeComponent import com.abdownloadmanager.desktop.pages.queue.QueuesComponent import com.abdownloadmanager.desktop.pages.settings.SettingsComponent @@ -80,6 +82,7 @@ class AppComponent( AddDownloadDialogManager, CategoryDialogManager, EditDownloadDialogManager, + FileChecksumDialogManager, NotificationSender, DownloadItemOpener, ContainsEffects by supportEffects(), @@ -120,6 +123,7 @@ class AppComponent( downloadItemOpener = this, downloadDialogManager = this, addDownloadDialogManager = this, + fileChecksumDialogManager = this, categoryDialogManager = this, notificationSender = this, editDownloadDialogManager = this, @@ -680,6 +684,42 @@ class AppComponent( } } + private val fileChecksumPagesControl = SlotNavigation() + val openedFileChecksumDialog = childSlot( + key = "openedFileChecksumPage", + source = fileChecksumPagesControl, + serializer = null, + childFactory = { config, ctx -> + FileChecksumComponent( + ctx = ctx, + id = config.id, + itemIds = config.itemIds, + closeComponent = { + closeFileChecksumPage(config.id) + } + ) + } + ).subscribeAsStateFlow() + + override fun openFileChecksumPage(ids: List) { + scope.launch { + val instance = openedFileChecksumDialog.value.child?.instance + if (instance?.itemIds == ids) { + instance.bringToFront() + } else { + fileChecksumPagesControl.navigate { + FileChecksumComponentConfig(itemIds = ids) + } + } + } + } + + override fun closeFileChecksumPage(dialogId: String) { + scope.launch { + fileChecksumPagesControl.dismiss() + } + } + fun addDownload( items: List, onDuplicateStrategy: (DownloadItem) -> OnDuplicateStrategy, @@ -881,4 +921,10 @@ interface AddDownloadDialogManager { ) fun closeAddDownloadDialog(dialogId: String) -} \ No newline at end of file +} + +interface FileChecksumDialogManager { + fun openFileChecksumPage(ids: List) + + fun closeFileChecksumPage(dialogId: String) +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt index 4362d36..d67ea04 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/addDownload/single/AddSingleDownloadComponent.kt @@ -8,6 +8,8 @@ import com.abdownloadmanager.desktop.pages.settings.configurable.StringConfigura import com.abdownloadmanager.desktop.repository.AppRepository import com.abdownloadmanager.desktop.utils.* import androidx.compose.runtime.* +import com.abdownloadmanager.desktop.pages.settings.configurable.FileChecksumConfigurable +import com.abdownloadmanager.desktop.pages.settings.configurable.widgets.RenderFileChecksumConfig import com.abdownloadmanager.shared.utils.mvi.ContainsEffects import com.abdownloadmanager.shared.utils.mvi.supportEffects import com.abdownloadmanager.resources.Res @@ -214,6 +216,7 @@ class AddSingleDownloadComponent( //extra settings private var threadCount = MutableStateFlow(null as Int?) private var speedLimit = MutableStateFlow(0L) + private var fileChecksum = MutableStateFlow(null as FileChecksum?) val downloadItem = combineStateFlows( @@ -222,7 +225,8 @@ class AddSingleDownloadComponent( this.name, this.length, this.speedLimit, - this.threadCount + this.threadCount, + this.fileChecksum, ) { credentials, folder, @@ -230,6 +234,7 @@ class AddSingleDownloadComponent( length, speedLimit, threadCount, + fileChecksum, -> DownloadItem( id = -1, @@ -242,7 +247,8 @@ class AddSingleDownloadComponent( completeTime = null, status = DownloadStatus.Added, preferredConnectionCount = threadCount, - speedLimit = speedLimit + speedLimit = speedLimit, + fileChecksum = fileChecksum?.toString() ).withCredentials(credentials) } @@ -262,6 +268,12 @@ class AddSingleDownloadComponent( ).asStringSource() } ), + FileChecksumConfigurable( + Res.string.download_item_settings_file_checksum.asStringSource(), + Res.string.download_item_settings_file_checksum_description.asStringSource(), + backedBy = fileChecksum, + describe = { "".asStringSource() } + ), IntConfigurable( Res.string.settings_download_thread_count.asStringSource(), Res.string.settings_download_thread_count_description.asStringSource(), @@ -425,4 +437,4 @@ fun interface OnRequestDownloadSingleItem { onDuplicateStrategy: OnDuplicateStrategy, categoryId: Long?, ) -} \ No newline at end of file +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadComponent.kt index 23e3bb3..23390f6 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadComponent.kt @@ -98,4 +98,4 @@ class EditDownloadComponent( fun bringToFront() { sendEffect(EditDownloadPageEffects.BringToFront) } -} \ No newline at end of file +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadState.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadState.kt index b77e3b5..4deb56b 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadState.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/editdownload/EditDownloadState.kt @@ -1,14 +1,12 @@ package com.abdownloadmanager.desktop.pages.editdownload +import com.abdownloadmanager.desktop.pages.settings.configurable.FileChecksumConfigurable import com.abdownloadmanager.desktop.pages.settings.configurable.IntConfigurable import com.abdownloadmanager.desktop.pages.settings.configurable.SpeedLimitConfigurable import com.abdownloadmanager.desktop.pages.settings.configurable.StringConfigurable import com.abdownloadmanager.desktop.repository.AppRepository -import com.abdownloadmanager.shared.utils.FileNameValidator -import com.abdownloadmanager.shared.utils.LinkChecker -import com.abdownloadmanager.shared.utils.convertPositiveSpeedToHumanReadable import com.abdownloadmanager.resources.Res -import com.abdownloadmanager.shared.utils.isValidUrl +import com.abdownloadmanager.shared.utils.* import ir.amirab.downloader.connection.DownloaderClient import ir.amirab.downloader.downloaditem.DownloadCredentials import ir.amirab.downloader.downloaditem.DownloadItem @@ -169,6 +167,25 @@ class EditDownloadState( else convertPositiveSpeedToHumanReadable(it, appRepository.speedUnit.value).asStringSource() } ), + FileChecksumConfigurable( + Res.string.download_item_settings_file_checksum.asStringSource(), + Res.string.download_item_settings_file_checksum_description.asStringSource(), + backedBy = editedDownloadItem.mapTwoWayStateFlow( + map = { + it.fileChecksum?.let { + runCatching { + FileChecksum.fromString(it) + }.onFailure { + println(it.printStackTrace()) + }.getOrNull() + } + }, + unMap = { + copy(fileChecksum = it?.toString()) + } + ), + describe = { "".asStringSource() } + ), IntConfigurable( Res.string.settings_download_thread_count.asStringSource(), Res.string.settings_download_thread_count_description.asStringSource(), @@ -335,4 +352,4 @@ class EditDownloadState( scheduleRefresh(alsoRecheckLink = false) }.launchIn(scope) } -} \ No newline at end of file +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumComponent.kt new file mode 100644 index 0000000..12368e4 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumComponent.kt @@ -0,0 +1,268 @@ +package com.abdownloadmanager.desktop.pages.filehash + +import androidx.compose.runtime.Immutable +import com.abdownloadmanager.shared.utils.* +import com.abdownloadmanager.shared.utils.mvi.ContainsEffects +import com.abdownloadmanager.shared.utils.mvi.ContainsScreenState +import com.abdownloadmanager.shared.utils.mvi.SupportsScreenState +import com.abdownloadmanager.shared.utils.mvi.supportEffects +import com.arkivanov.decompose.ComponentContext +import ir.amirab.downloader.downloaditem.DownloadItem +import ir.amirab.downloader.downloaditem.DownloadStatus +import ir.amirab.util.ifThen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.UUID +import kotlin.properties.Delegates + +data class FileChecksumComponentConfig( + val id: String = UUID.randomUUID().toString(), + val itemIds: List, +) + +class FileChecksumComponent( + ctx: ComponentContext, + val id: String, + val itemIds: List, + private val closeComponent: () -> Unit, +) : BaseComponent(ctx), + KoinComponent, + ContainsScreenState by SupportsScreenState(FileChecksumUiState.default()), + ContainsEffects by supportEffects() { + val downloadSystem: DownloadSystem by inject() + + private var downloadItems: List by Delegates.notNull() + + private val isChecking = MutableStateFlow(false) + private val selectedDefaultAlgorithm: MutableStateFlow = + MutableStateFlow(FileChecksumAlgorithm.default()) + + fun onAlgorithmChange(algorithm: FileChecksumAlgorithm) { + this.selectedDefaultAlgorithm.update { algorithm } + } + + fun isDefaultAlgorithmNeeded(): Boolean { + return state.value.items.any { + it.savedChecksum == null + } + } + + init { + scope.launch { + load(itemIds) + setup() + + if (!isDefaultAlgorithmNeeded()) { + // user don't need to manually set checksum algorithm + // start checking immediately + startCheck() + } + } + isChecking.onEach { isChecking -> + setState { fileChecksumUiState -> + fileChecksumUiState.copy(isChecking = isChecking) + } + }.launchIn(scope) + selectedDefaultAlgorithm.onEach { algorithm -> + setState { fileChecksumUiState -> + fileChecksumUiState.copy( + // reset checksum algorithm + items = fileChecksumUiState.items.map { itemWithChecksum -> + itemWithChecksum.copy( + algorithm = getChecksumAlgorithmForItem(itemWithChecksum.downloadItem) + ) + }, + defaultAlgorithm = algorithm + ) + } + }.launchIn(scope) + } + + private fun setup() { + setState { + it.copy( + items = downloadItems.map { downloadItem -> + val savedChecksum = downloadItem.fileChecksum?.let { fc -> + FileChecksum.fromString(fc) + } + DownloadItemWithChecksum( + downloadItem = downloadItem, + checksumStatus = ChecksumStatus.Waiting, + algorithm = savedChecksum?.algorithm ?: selectedDefaultAlgorithm.value.algorithm, + savedChecksum = savedChecksum?.value, + calculatedChecksum = null, + ) + }, + ) + } + } + + private suspend fun load(items: List) { + downloadItems = items.mapNotNull { + downloadSystem.getDownloadItemById(it) + } + } + + private suspend fun startCheck() { + // clean old statuses + setup() + + isChecking.update { true } + try { + withContext(Dispatchers.IO) { + for (item in downloadItems) { + processItem(item) + } + } + } finally { + isChecking.update { false } + } + } + + private fun processItem(item: DownloadItem) { + val file = downloadSystem.getDownloadFile(item) + if (item.status != DownloadStatus.Completed) { + scope.launch { + updateItemStatus(item.id, ChecksumStatus.Error.DownloadNotFinished) + } + return + } + if (!file.isFile) { + scope.launch { + updateItemStatus(item.id, ChecksumStatus.Error.FileNotFound) + } + return + } + try { + val algorithm = getChecksumAlgorithmForItem(item) + val hash = HashUtil.fileHash( + algorithm = algorithm, + file = file, + onNewPercent = { percent -> + scope.launch { + updateItemStatus(item.id, ChecksumStatus.Checking(percent)) + } + } + ) + val savedChecksum = FileChecksum.fromNullableString(item.fileChecksum) + val calculatedChecksum = FileChecksum(algorithm, hash) + val newStatus = if (savedChecksum == null) { + ChecksumStatus.Finished.Done + } else { + if (savedChecksum == calculatedChecksum) { + ChecksumStatus.Finished.Matches + } else { + ChecksumStatus.Finished.NotMatches + } + } + scope.launch { + updateItem(item.id) { + it.copy( + checksumStatus = newStatus, + calculatedChecksum = hash, + ) + } + } + } catch (e: Exception) { + scope.launch { + updateItemStatus(item.id, ChecksumStatus.Error.Exception(e)) + } + } + } + + private fun getChecksumAlgorithmForItem(downloadItem: DownloadItem): String { + return downloadItem.fileChecksum?.let { + FileChecksum.fromString(it).algorithm + } ?: selectedDefaultAlgorithm.value.algorithm + } + private fun updateItem(id: Long, updater: (DownloadItemWithChecksum) -> DownloadItemWithChecksum) { + setState { + it.copy( + items = it.items.map { itemWithChecksum -> + itemWithChecksum.ifThen(itemWithChecksum.downloadItem.id == id) { + updater(itemWithChecksum) + } + } + ) + } + } + private fun updateItemStatus(id: Long, status: ChecksumStatus) { + updateItem(id) { + it.copy(checksumStatus = status) + } + } + + fun onRequestClose() { + closeComponent() + } + + fun onRequestStartCheck() { + scope.launch { + startCheck() + } + } + + fun bringToFront() { + sendEffect(FileChecksumUiEffects.BringToFront) + } +} + +@Immutable +sealed interface ChecksumStatus { + sealed interface Finished : ChecksumStatus { + data object Matches : Finished + data object NotMatches : Finished + + // just finished there is no saved checksum to compare it + data object Done : Finished + } + + data class Checking(val percent: Int) : ChecksumStatus + sealed interface Error : ChecksumStatus { + data object FileNotFound : Error + data object DownloadNotFinished : Error + data class Exception(val t: Throwable) : Error + } + + data object Waiting : ChecksumStatus +} + +@Immutable +data class DownloadItemWithChecksum( + val downloadItem: DownloadItem, + val checksumStatus: ChecksumStatus, + val algorithm: String, + val savedChecksum: String?, + val calculatedChecksum: String?, +) { + val isProcessing = checksumStatus is ChecksumStatus.Checking + val isError = checksumStatus is ChecksumStatus.Error +} + +@Immutable +data class FileChecksumUiState( + val items: List, + val isChecking: Boolean, + val defaultAlgorithm: FileChecksumAlgorithm, +) { + + companion object { + fun default() = FileChecksumUiState( + items = emptyList(), + isChecking = false, + defaultAlgorithm = FileChecksumAlgorithm.default(), + ) + } +} + +@Immutable +sealed interface FileChecksumUiEffects { + data object BringToFront : FileChecksumUiEffects +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumPage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumPage.kt new file mode 100644 index 0000000..6c7ffa7 --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumPage.kt @@ -0,0 +1,423 @@ +package com.abdownloadmanager.desktop.pages.filehash + +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.abdownloadmanager.desktop.pages.settings.configurable.widgets.RenderSpinner +import com.abdownloadmanager.desktop.utils.ClipboardUtil +import com.abdownloadmanager.desktop.window.custom.WindowTitle +import com.abdownloadmanager.resources.Res +import com.abdownloadmanager.shared.ui.widget.ActionButton +import com.abdownloadmanager.shared.ui.widget.Help +import com.abdownloadmanager.shared.ui.widget.Text +import com.abdownloadmanager.shared.ui.widget.Tooltip +import com.abdownloadmanager.shared.ui.widget.customtable.* +import com.abdownloadmanager.shared.ui.widget.customtable.styled.MyStyledTableHeader +import com.abdownloadmanager.shared.utils.FileChecksumAlgorithm +import com.abdownloadmanager.shared.utils.div +import com.abdownloadmanager.shared.utils.rememberDotLoading +import com.abdownloadmanager.shared.utils.ui.WithContentColor +import com.abdownloadmanager.shared.utils.ui.icon.MyIcons +import com.abdownloadmanager.shared.utils.ui.myColors +import com.abdownloadmanager.shared.utils.ui.theme.myTextSizes +import com.abdownloadmanager.shared.utils.ui.widget.MyIcon +import ir.amirab.util.compose.IconSource +import ir.amirab.util.compose.StringSource +import ir.amirab.util.compose.asStringSource +import ir.amirab.util.compose.resources.myStringResource + +@Composable +fun FileChecksumPage(component: FileChecksumComponent) { + WindowTitle(myStringResource(Res.string.file_checksum_page)) + val horizontalPadding = 16.dp + Column { + Table( + modifier = Modifier.weight(1f).fillMaxWidth(), + list = component.state.collectAsState().value.items, + tableState = remember { + TableState(FileChecksumTableCells.cells) + }, + wrapHeader = { + MyStyledTableHeader( + itemHorizontalPadding = horizontalPadding, + content = it, + ) + }, + wrapItem = { index, item, content -> + Box(Modifier.padding(horizontal = horizontalPadding).let { + val mutableInteractionSource = remember { MutableInteractionSource() } + it.indication(mutableInteractionSource, LocalIndication.current) + .hoverable(mutableInteractionSource) + } + + .padding(vertical = 8.dp)) { + content() + } + }, + renderCell = { cell, item -> + when (cell) { + FileChecksumTableCells.Name -> { + FileChecksumTableCellRenderers.RenderName(item) + } + + FileChecksumTableCells.Status -> { + FileChecksumTableCellRenderers.RenderStatus(item) + } + + FileChecksumTableCells.Algorithm -> { + FileChecksumTableCellRenderers.RenderAlgorithm(item) + } + + FileChecksumTableCells.CalculatedChecksum -> { + FileChecksumTableCellRenderers.RenderCalculatedChecksum(item) + } + + FileChecksumTableCells.SavedChecksum -> { + FileChecksumTableCellRenderers.RenderSavedChecksum(item) + } + } + }) + Actions( + Modifier, + component, + ) + } +} + +@Composable +private fun Actions( + modifier: Modifier, + component: FileChecksumComponent, +) { + val uiState by component.state.collectAsState() + Column(modifier) { + Spacer( + Modifier.fillMaxWidth().height(1.dp).background(myColors.onBackground / 0.15f) + ) + Row( + Modifier.fillMaxWidth().background(myColors.surface / 0.5f).padding(horizontal = 16.dp) + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = myStringResource(Res.string.file_checksum_page_file_checksum_default_algorithm) + ) + Spacer(Modifier.width(8.dp)) + Help( + myStringResource(Res.string.file_checksum_page_file_checksum_default_algorithm_help) + ) + } + Spacer(Modifier.size(8.dp)) + RenderSpinner( + modifier = Modifier, + possibleValues = FileChecksumAlgorithm.all(), + value = uiState.defaultAlgorithm, + enabled = !uiState.isChecking, + onSelect = { + component.onAlgorithmChange(it) + }, + render = { + Text(it.algorithm) + }) + } + Spacer(Modifier.weight(1f)) + Row { + ActionButton( + myStringResource(Res.string.start), + onClick = component::onRequestStartCheck, + enabled = !uiState.isChecking + ) + Spacer(Modifier.width(8.dp)) + ActionButton( + myStringResource(Res.string.close), + onClick = component::onRequestClose, + ) + } + } + } + +} + + +private data object FileChecksumTableCellRenderers { + @Composable + fun RenderName(item: DownloadItemWithChecksum) { + SimpleText(item.downloadItem.name) + } + + @Composable + fun RenderStatus(item: DownloadItemWithChecksum) { + when (val status = item.checksumStatus) { + is ChecksumStatus.Checking -> { + RenderCheckingStatus(status.percent) + } + + ChecksumStatus.Error.DownloadNotFinished -> { + RenderErrorStatus(myStringResource(Res.string.download_not_finished)) + } + + is ChecksumStatus.Error.Exception -> { + RenderErrorStatus(status.t.localizedMessage ?: status.t::class.simpleName.orEmpty()) + } + + ChecksumStatus.Error.FileNotFound -> { + RenderErrorStatus(myStringResource(Res.string.file_not_found)) + } + + is ChecksumStatus.Finished -> { + RenderFinishedStatus( + status = status, + ) + } + + ChecksumStatus.Waiting -> { + RenderWaitingStatus() + } + } + } + + @Composable + fun RenderAlgorithm(item: DownloadItemWithChecksum) { + SimpleText(item.algorithm) + } + + @Composable + private fun CopyableText(text: String) { + Tooltip( + Res.string.copy_to_clipboard.asStringSource() + ) { + SimpleText( + text, + Modifier.clickable { + ClipboardUtil.copy(text) + }, + ) + } + } + + @Composable + fun RenderCalculatedChecksum(item: DownloadItemWithChecksum) { + if (item.calculatedChecksum != null) { + CopyableText(item.calculatedChecksum) + } else if (item.isProcessing) { + //shimmer + ShimmerEffect( + centerColor = myColors.onBackground / 0.4f, + surroundingColor = myColors.onBackground / 0.1f, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .height(myTextSizes.base.value.dp) + ) + } else if (item.isError) { + SimpleText("!") + } + } + + @Composable + fun RenderSavedChecksum(item: DownloadItemWithChecksum) { + CopyableText(item.savedChecksum.orEmpty()) + } + + @Composable + private fun ShimmerEffect( + modifier: Modifier = Modifier, + centerColor: Color = Color.Gray, + surroundingColor: Color = Color.Gray, + ) { + val transition = rememberInfiniteTransition() + val translateAnim = transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 3000, + easing = LinearEasing + ) + ) + ) + + val brush = Brush.linearGradient( + colors = listOf( + surroundingColor, + centerColor, + surroundingColor, + ), + start = Offset(0f, 0f), + end = Offset(translateAnim.value, 0f) + ) + + Box( + modifier = modifier + .background(brush = brush) + ) + } + + @Composable + private fun RenderErrorStatus(message: String) { + IconWithText( + icon = MyIcons.info, + text = message, + color = myColors.error, + ) + } + + @Composable + private fun RenderFinishedStatus( + status: ChecksumStatus.Finished, + ) { + val text: StringSource + val color: Color + val icon: IconSource + when (status) { + ChecksumStatus.Finished.Done -> { + text = Res.string.done.asStringSource() + icon = MyIcons.check + color = myColors.info + } + + ChecksumStatus.Finished.Matches -> { + text = Res.string.matches.asStringSource() + icon = MyIcons.check + color = myColors.success + } + + ChecksumStatus.Finished.NotMatches -> { + text = Res.string.not_matches.asStringSource() + icon = MyIcons.info + color = myColors.warning + } + } + IconWithText( + icon = icon, + text = text.rememberString(), + color = color, + ) + } + + @Composable + private fun IconWithText( + icon: IconSource, + text: String, + color: Color, + ) { + WithContentColor(color) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MyIcon( + icon, + modifier = Modifier.size(16.dp), + contentDescription = null, + ) + Spacer(Modifier.width(2.dp)) + SimpleText(text) + } + } + } + + @Composable + private fun RenderCheckingStatus(percent: Int) { + Column { + ProgressStatus(percent, myColors.primaryGradient) + } + } + + @Composable + private fun RenderWaitingStatus() { + Row { + SimpleText("${myStringResource(Res.string.waiting)} ${rememberDotLoading()}") + } + } + + @Composable + private fun ProgressStatus( + percent: Int?, + background: Brush = myColors.primaryGradient, + ) { + Box( + Modifier.fillMaxWidth().clip(CircleShape).background(myColors.surface) + ) { + if (percent != null) { + val w = (percent / 100f).coerceIn(0f..1f) + Spacer( + Modifier.height(5.dp).fillMaxWidth( + animateFloatAsState( + w, tween(100) + ).value + ).background(background) + ) + } + } + } + + @Composable + private fun SimpleText(string: String, modifier: Modifier = Modifier) { + Text( + string, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private sealed class FileChecksumTableCells : TableCell { + data object Name : FileChecksumTableCells() { + override val id: String = "name" + override val name: StringSource = Res.string.name.asStringSource() + override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 300.dp) + } + + data object Status : FileChecksumTableCells() { + override val id: String = "status" + override val name: StringSource = Res.string.status.asStringSource() + override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 150.dp) + } + + data object Algorithm : FileChecksumTableCells() { + override val id: String = "algorithm" + override val name: StringSource = Res.string.checksum_algorithm.asStringSource() + override val size: CellSize = CellSize.Resizeable(60.dp..300.dp, 60.dp) + } + + data object SavedChecksum : FileChecksumTableCells() { + override val id: String = "saved_checksum" + override val name: StringSource = Res.string.saved_checksum.asStringSource() + override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 150.dp) + } + + data object CalculatedChecksum : FileChecksumTableCells() { + override val id: String = "calculated_checksum" + override val name: StringSource = Res.string.calculated_checksum.asStringSource() + override val size: CellSize = CellSize.Resizeable(100.dp..1000.dp, 150.dp) + } + + companion object { + val cells = listOf( + Name, + Status, + Algorithm, + CalculatedChecksum, + SavedChecksum, + ) + } +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumWindow.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumWindow.kt new file mode 100644 index 0000000..e4e299f --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/filehash/FileChecksumWindow.kt @@ -0,0 +1,40 @@ +package com.abdownloadmanager.desktop.pages.filehash + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import com.abdownloadmanager.desktop.AppComponent +import com.abdownloadmanager.desktop.window.custom.CustomWindow +import com.abdownloadmanager.shared.utils.ui.theme.LocalUiScale +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.router.slot.ChildSlot +import ir.amirab.util.desktop.screen.applyUiScale + +@Composable +fun FileChecksumWindow( + component: AppComponent +) { + component.openedFileChecksumDialog.collectAsState().value.child?.instance?.let { + FileChecksumWindow(it) + } +} + +@Composable +fun FileChecksumWindow( + component: FileChecksumComponent +) { + val uiScale = LocalUiScale.current + CustomWindow( + state = rememberWindowState( + position = WindowPosition.Aligned(Alignment.Center), + size = DpSize(900.dp, 400.dp).applyUiScale(uiScale) + ), + onCloseRequest = component::onRequestClose + ) { + FileChecksumPage(component) + } +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt index 2543500..636ee33 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt @@ -84,6 +84,7 @@ class DownloadActions( downloadSystem: DownloadSystem, downloadDialogManager: DownloadDialogManager, editDownloadDialogManager: EditDownloadDialogManager, + fileChecksumDialogManager: FileChecksumDialogManager, val selections: StateFlow>, private val mainItem: StateFlow, private val queueManager: QueueManager, @@ -236,6 +237,18 @@ class DownloadActions( downloadDialogManager.openDownloadDialog(id) } } + private val fileChecksumAction = simpleAction( + title = Res.string.file_checksum.asStringSource(), MyIcons.info, + checkEnable = selections.mapStateFlow { list -> + list.any { iiDownloadItemState -> + iiDownloadItemState.isFinished() + } + } + ) { + fileChecksumDialogManager.openFileChecksumPage( + selections.value.map { it.id } + ) + } private val moveToQueueItems = MenuItem.SubMenu( title = Res.string.move_to_queue.asStringSource(), @@ -290,6 +303,7 @@ class DownloadActions( separator() +(copyDownloadLinkAction) +editDownloadAction + +fileChecksumAction +(openDownloadDialogAction) } } @@ -405,6 +419,7 @@ class HomeComponent( private val downloadDialogManager: DownloadDialogManager, private val editDownloadDialogManager: EditDownloadDialogManager, private val addDownloadDialogManager: AddDownloadDialogManager, + private val fileChecksumDialogManager: FileChecksumDialogManager, private val categoryDialogManager: CategoryDialogManager, private val notificationSender: NotificationSender, ) : BaseComponent(ctx), @@ -917,6 +932,7 @@ class HomeComponent( downloadSystem = downloadSystem, downloadDialogManager = downloadDialogManager, editDownloadDialogManager = editDownloadDialogManager, + fileChecksumDialogManager = fileChecksumDialogManager, selections = selectionListItems, mainItem = mainItem, queueManager = queueManager, @@ -998,4 +1014,4 @@ class HomeComponent( private var homeComponentCreationCount = 0 val CATEGORIES_SIZE_RANGE = 0.dp..500.dp } -} \ No newline at end of file +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt index 1e8f31f..c54fa6b 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/Configurable.kt @@ -1,6 +1,8 @@ package com.abdownloadmanager.desktop.pages.settings.configurable import com.abdownloadmanager.desktop.pages.settings.ThemeInfo +import com.abdownloadmanager.shared.utils.FileChecksum +import com.abdownloadmanager.shared.utils.FileChecksumAlgorithm import com.abdownloadmanager.shared.utils.proxy.ProxyData import ir.amirab.util.compose.StringSource import kotlinx.coroutines.flow.MutableStateFlow @@ -26,7 +28,7 @@ sealed class Configurable( if (validate(value)) { // don't use update function here maybe this is a mappedByTwoWayMutableStateFlow // IMPROVE - backedBy.value=value + backedBy.value = value return true } return false @@ -141,7 +143,7 @@ class FloatConfigurable( describe = describe, enabled = enabled, visible = visible, -){ +) { enum class RenderMode { TextField, } @@ -262,6 +264,22 @@ class SpeedLimitConfigurable( visible = visible, ) +class FileChecksumConfigurable( + title: StringSource, + description: StringSource, + backedBy: MutableStateFlow, + describe: (FileChecksum?) -> StringSource, + enabled: StateFlow = DefaultEnabledValue, + visible: StateFlow = DefaultVisibleValue, +) : Configurable( + title = title, + description = description, + backedBy = backedBy, + describe = describe, + enabled = enabled, + visible = visible, +) + class TimeConfigurable( title: StringSource, description: StringSource, diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/FileChecksum.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/FileChecksum.kt new file mode 100644 index 0000000..7e2af4b --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/FileChecksum.kt @@ -0,0 +1,88 @@ +package com.abdownloadmanager.desktop.pages.settings.configurable.widgets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import com.abdownloadmanager.desktop.pages.settings.configurable.FileChecksumConfigurable +import com.abdownloadmanager.resources.Res +import com.abdownloadmanager.shared.ui.widget.* +import com.abdownloadmanager.shared.utils.FileChecksum +import com.abdownloadmanager.shared.utils.FileChecksumAlgorithm +import ir.amirab.util.compose.resources.myStringResource + +@Composable +fun RenderFileChecksumConfig(cfg: FileChecksumConfigurable, modifier: Modifier) { + val value by cfg.stateFlow.collectAsState() + val setValue = cfg::set + + val enabled = isConfigEnabled() + val hasFileChecksum = value != null + ConfigTemplate( + modifier, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + TitleAndDescription(cfg, true) + } + }, + nestedContent = { + Column(Modifier.align(Alignment.End)) { + AnimatedVisibility( + hasFileChecksum, + ) { + value?.let { value -> + Row( + Modifier + .padding(vertical = 8.dp) + ) { + MyTextField( + text = value.value, + onTextChange = { + setValue(value.copy(value = it)) + }, + shape = RectangleShape, + textPadding = PaddingValues(4.dp), + enabled = enabled, + modifier = Modifier.weight(1f), + placeholder = myStringResource(Res.string.file_checksum), + ) + RenderSpinner( + possibleValues = FileChecksumAlgorithm + .all() + .map { it.algorithm }, + value = value.algorithm, + modifier = Modifier, + enabled = enabled, + onSelect = { + setValue(value.copy(algorithm = it)) + } + ) { + Text(it) + } + } + } + } + } + }, + value = { + CheckBox( + value = hasFileChecksum, + enabled = enabled, + onValueChange = { + if (it) { + setValue( + FileChecksum( + FileChecksumAlgorithm.default().algorithm, + "", + ) + ) + } else { + setValue(null) + } + }) + } + ) +} diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt index 07a7e11..102cbae 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/settings/configurable/widgets/RenderConfigurable.kt @@ -49,6 +49,7 @@ fun RenderConfigurable( is DayOfWeekConfigurable -> RenderDayOfWeekConfigurable(cfg,modifier) is ProxyConfigurable -> RenderProxyConfig(cfg, modifier) + is FileChecksumConfigurable -> RenderFileChecksumConfig(cfg, modifier) } } } diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt index adba089..787ce65 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt @@ -25,6 +25,7 @@ import com.abdownloadmanager.desktop.pages.category.ShowCategoryDialogs import com.abdownloadmanager.desktop.pages.confirmexit.ConfirmExit import com.abdownloadmanager.desktop.pages.credits.translators.ShowTranslators import com.abdownloadmanager.desktop.pages.editdownload.EditDownloadWindow +import com.abdownloadmanager.desktop.pages.filehash.FileChecksumWindow import com.abdownloadmanager.desktop.pages.home.HomeWindow import com.abdownloadmanager.desktop.pages.settings.ThemeManager import com.abdownloadmanager.desktop.pages.updater.ShowUpdaterDialog @@ -93,6 +94,7 @@ object Ui : KoinComponent { ShowAddDownloadDialogs(appComponent) ShowDownloadDialogs(appComponent) ShowCategoryDialogs(appComponent) + FileChecksumWindow(appComponent) ShowUpdaterDialog(appComponent.updater) ShowAboutDialog(appComponent) NewQueueDialog(appComponent) diff --git a/downloader/core/src/main/kotlin/ir/amirab/downloader/downloaditem/DownloadItem.kt b/downloader/core/src/main/kotlin/ir/amirab/downloader/downloaditem/DownloadItem.kt index 43d211a..efdd284 100644 --- a/downloader/core/src/main/kotlin/ir/amirab/downloader/downloaditem/DownloadItem.kt +++ b/downloader/core/src/main/kotlin/ir/amirab/downloader/downloaditem/DownloadItem.kt @@ -24,6 +24,8 @@ data class DownloadItem( var status: DownloadStatus = DownloadStatus.Added, var preferredConnectionCount: Int? = null, var speedLimit: Long = 0,//0 is unlimited + + var fileChecksum: String? = null, ) : IDownloadCredentials { companion object { const val LENGTH_UNKNOWN = -1L @@ -51,6 +53,8 @@ fun DownloadItem.applyFrom(other: DownloadItem) { status = other.status preferredConnectionCount = other.preferredConnectionCount speedLimit = other.speedLimit + + fileChecksum = other.fileChecksum } fun DownloadItem.withCredentials(credentials: IDownloadCredentials) = apply { diff --git a/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadStateUtil.kt b/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadStateUtil.kt index cf4b2b5..0a69988 100644 --- a/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadStateUtil.kt +++ b/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadStateUtil.kt @@ -3,11 +3,21 @@ package ir.amirab.downloader.monitor import ir.amirab.downloader.downloaditem.DownloadJobStatus fun IDownloadItemState.statusOrFinished(): DownloadJobStatus { - return (this as? ProcessingDownloadItemState)?.status?:DownloadJobStatus.Finished + return (this as? ProcessingDownloadItemState)?.status ?: DownloadJobStatus.Finished } + +fun IDownloadItemState.isFinished(): Boolean { + return this is CompletedDownloadItemState +} + +fun IDownloadItemState.isNotFinished(): Boolean { + return this is ProcessingDownloadItemState +} + fun IDownloadItemState.speedOrNull(): Long? { return (this as? ProcessingDownloadItemState)?.speed } + fun IDownloadItemState.remainingOrNull(): Long? { return (this as? ProcessingDownloadItemState)?.remainingTime -} \ No newline at end of file +} diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/shared/utils/HashUtil.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/shared/utils/HashUtil.kt new file mode 100644 index 0000000..180ac70 --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/shared/utils/HashUtil.kt @@ -0,0 +1,116 @@ +package com.abdownloadmanager.shared.utils + +import ir.amirab.downloader.utils.calcPercent +import java.io.File +import java.io.InputStream +import java.security.MessageDigest + +sealed class FileChecksumAlgorithm( + val algorithm: String, +) { + data object MD5 : FileChecksumAlgorithm("MD5") + data object SHA1 : FileChecksumAlgorithm("SHA-1") + data object SHA256 : FileChecksumAlgorithm("SHA-256") + data object SHA512 : FileChecksumAlgorithm("SHA-512") + + companion object { + fun default() = SHA256 + fun all() = listOf( + MD5, + SHA1, + SHA256, + SHA512, + ) + } +} + +data class FileChecksum( + val algorithm: String, + val value: String, +) { + + override fun toString(): String { + return "$algorithm:$value" + } + + companion object { + fun fromString(string: String): FileChecksum { + val segments = string.split(":") + require(segments.size == 2) { + "Invalid checksum string: $string it should be in format algorithm:value" + } + return FileChecksum( + algorithm = segments[0], + value = segments[1], + ) + } + + fun fromNullableString(string: String?): FileChecksum? { + return string?.let { + fromString(it) + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FileChecksum + return algorithm.equals(other.algorithm, true) && value.equals(other.value, true) + } + + override fun hashCode(): Int { + var result = algorithm.hashCode() + result = 31 * result + value.hashCode() + return result + } +} + +object HashUtil { + fun hash( + algorithm: String, + inputStream: InputStream, + size: Long, + onNewPercent: (Int) -> Unit, + ): String { + val messageDigest = MessageDigest.getInstance(algorithm) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var processedBytes = 0L + var lastPercent = 0 + while (true) { + val readCount = inputStream.read(buffer) + if (readCount == -1) { + break + } + messageDigest.update(buffer, 0, readCount) + processedBytes += readCount + val newPercent = calcPercent(processedBytes, size) + if (newPercent != lastPercent) { + onNewPercent(newPercent) + lastPercent = newPercent + } + } + return messageDigest + .digest() + .joinToString("") { + "%02x".format(it) + } + } + + fun fileHash( + algorithm: String, + file: File, + onNewPercent: (Int) -> Unit + ): String { + val fileSize = file.length() + return file.inputStream().use { + hash( + algorithm = algorithm, + inputStream = it, + size = fileSize, + onNewPercent = onNewPercent + ) + } + } +} diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/shared/utils/mvi/ContainsScreenState.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/shared/utils/mvi/ContainsScreenState.kt index f8fbd5e..ebe3ddb 100644 --- a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/shared/utils/mvi/ContainsScreenState.kt +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/shared/utils/mvi/ContainsScreenState.kt @@ -5,16 +5,22 @@ import kotlinx.coroutines.flow.* interface ContainsScreenState { val state: StateFlow - fun setState(state:ScreenState) + fun setState(state: ScreenState) + fun setState(updater: (ScreenState) -> ScreenState) } class SupportsScreenState( - initialState:ScreenState -): ContainsScreenState { + initialState: ScreenState +) : ContainsScreenState { private val _state = MutableStateFlow(initialState) override val state = _state.asStateFlow() + + override fun setState(updater: (ScreenState) -> ScreenState) { + _state.update(updater) + } + override fun setState(state: ScreenState) { - _state.update { state } + setState { state } } } diff --git a/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties b/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties index aabc944..baf6161 100644 --- a/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties +++ b/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties @@ -232,6 +232,23 @@ download_item_settings_username_description=Provide a username if the link is a download_item_settings_password_description=Provide a password if the link is a protected resource download_item_settings_download_page=Download Page download_item_settings_download_page_description=The webpage where this download was initiated +download_item_settings_file_checksum=File Checksum +download_item_settings_file_checksum_description=A hash string which can be used to check if file is downloaded correctly +file_checksum=File Checksum +file_checksum_page=File Checksum Checker +file_checksum_page_file_checksum_default_algorithm=Default Algorithm +file_checksum_page_file_checksum_default_algorithm_help=The default algorithm used to calculate the checksum of the file if the file checksum is not provided +start=Start +calculated_checksum=Calculated Checksum +saved_checksum=Saved Checksum +checksum_algorithm=Algorithm +file_not_found=File not found +download_not_finished=Download not finished +done=Done +waiting=Waiting +matches=Matches +not_matches=Not Matches +copy_to_clipboard=Copy To Clipboard username=Username password=Password average_speed=Average Speed @@ -328,4 +345,4 @@ update_check_for_update=Check for Update update_checking_for_update=Checking for Update update_no_update=You are using the latest version update_check_error=Error while checking for update -update_app_updated_to_version_n=App updated to version {{version}} \ No newline at end of file +update_app_updated_to_version_n=App updated to version {{version}} diff --git a/shared/utils/src/main/kotlin/ir/amirab/util/flow/StateFlowUtil.kt b/shared/utils/src/main/kotlin/ir/amirab/util/flow/StateFlowUtil.kt index 92ca886..3ac32ba 100644 --- a/shared/utils/src/main/kotlin/ir/amirab/util/flow/StateFlowUtil.kt +++ b/shared/utils/src/main/kotlin/ir/amirab/util/flow/StateFlowUtil.kt @@ -221,7 +221,7 @@ fun combineStateFlows( getValue = { transform(a.value, b.value, c.value, d.value, e.value, f.value, g.value) }, - flow = combine(a, b, c, d, e, f) { array -> + flow = combine(a, b, c, d, e, f, g) { array -> @Suppress("UNCHECKED_CAST") transform( array[0] as T1, @@ -260,4 +260,4 @@ inline fun combineStateFlows( noinline transform: (list: Array) -> R ): StateFlow { return combineStateFlows(listOf(*flows), transform) -} \ No newline at end of file +}