add file checksum (#362)

This commit is contained in:
AmirHossein Abdolmotallebi 2025-01-13 13:52:44 +03:30 committed by GitHub
parent 6063d8f420
commit d031450bf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1106 additions and 22 deletions

View File

@ -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<AppEffects> 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<FileChecksumComponentConfig>()
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<Long>) {
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<DownloadItem>,
onDuplicateStrategy: (DownloadItem) -> OnDuplicateStrategy,
@ -881,4 +921,10 @@ interface AddDownloadDialogManager {
)
fun closeAddDownloadDialog(dialogId: String)
}
}
interface FileChecksumDialogManager {
fun openFileChecksumPage(ids: List<Long>)
fun closeFileChecksumPage(dialogId: String)
}

View File

@ -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?,
)
}
}

View File

@ -98,4 +98,4 @@ class EditDownloadComponent(
fun bringToFront() {
sendEffect(EditDownloadPageEffects.BringToFront)
}
}
}

View File

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

View File

@ -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<Long>,
)
class FileChecksumComponent(
ctx: ComponentContext,
val id: String,
val itemIds: List<Long>,
private val closeComponent: () -> Unit,
) : BaseComponent(ctx),
KoinComponent,
ContainsScreenState<FileChecksumUiState> by SupportsScreenState(FileChecksumUiState.default()),
ContainsEffects<FileChecksumUiEffects> by supportEffects() {
val downloadSystem: DownloadSystem by inject()
private var downloadItems: List<DownloadItem> by Delegates.notNull()
private val isChecking = MutableStateFlow(false)
private val selectedDefaultAlgorithm: MutableStateFlow<FileChecksumAlgorithm> =
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<Long>) {
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<DownloadItemWithChecksum>,
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
}

View File

@ -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<DownloadItemWithChecksum> {
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,
)
}
}

View File

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

View File

@ -84,6 +84,7 @@ class DownloadActions(
downloadSystem: DownloadSystem,
downloadDialogManager: DownloadDialogManager,
editDownloadDialogManager: EditDownloadDialogManager,
fileChecksumDialogManager: FileChecksumDialogManager,
val selections: StateFlow<List<IDownloadItemState>>,
private val mainItem: StateFlow<Long?>,
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
}
}
}

View File

@ -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<T>(
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<FileChecksum?>,
describe: (FileChecksum?) -> StringSource,
enabled: StateFlow<Boolean> = DefaultEnabledValue,
visible: StateFlow<Boolean> = DefaultVisibleValue,
) : Configurable<FileChecksum?>(
title = title,
description = description,
backedBy = backedBy,
describe = describe,
enabled = enabled,
visible = visible,
)
class TimeConfigurable(
title: StringSource,
description: StringSource,

View File

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

View File

@ -49,6 +49,7 @@ fun RenderConfigurable(
is DayOfWeekConfigurable -> RenderDayOfWeekConfigurable(cfg,modifier)
is ProxyConfigurable -> RenderProxyConfig(cfg, modifier)
is FileChecksumConfigurable -> RenderFileChecksumConfig(cfg, modifier)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -5,16 +5,22 @@ import kotlinx.coroutines.flow.*
interface ContainsScreenState<ScreenState> {
val state: StateFlow<ScreenState>
fun setState(state:ScreenState)
fun setState(state: ScreenState)
fun setState(updater: (ScreenState) -> ScreenState)
}
class SupportsScreenState<ScreenState>(
initialState:ScreenState
): ContainsScreenState<ScreenState> {
initialState: ScreenState
) : ContainsScreenState<ScreenState> {
private val _state = MutableStateFlow<ScreenState>(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 }
}
}

View File

@ -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}}
update_app_updated_to_version_n=App updated to version {{version}}

View File

@ -221,7 +221,7 @@ fun <T1, T2, T3, T4, T5, T6, T7, R> 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 <reified T, R> combineStateFlows(
noinline transform: (list: Array<T>) -> R
): StateFlow<R> {
return combineStateFlows(listOf(*flows), transform)
}
}