diff --git a/app/src/main/kotlin/org/equeim/tremotesf/rpc/PeriodicServerStateUpdater.kt b/app/src/main/kotlin/org/equeim/tremotesf/rpc/PeriodicServerStateUpdater.kt index 5eaafbd6..f843f921 100644 --- a/app/src/main/kotlin/org/equeim/tremotesf/rpc/PeriodicServerStateUpdater.kt +++ b/app/src/main/kotlin/org/equeim/tremotesf/rpc/PeriodicServerStateUpdater.kt @@ -76,10 +76,8 @@ object PeriodicServerStateUpdater { } coroutineScope.launch { - GlobalRpcClient.shouldConnectToServer.collect { - if (!it) { - updatedTorrentsSinceEnablingConnection.set(false) - } + GlobalRpcClient.disconnectedFromServer.collect { + updatedTorrentsSinceEnablingConnection.set(false) } } diff --git a/app/src/main/kotlin/org/equeim/tremotesf/ui/addtorrent/AddTorrentFileModel.kt b/app/src/main/kotlin/org/equeim/tremotesf/ui/addtorrent/AddTorrentFileModel.kt index c0caccb4..714c4b48 100644 --- a/app/src/main/kotlin/org/equeim/tremotesf/ui/addtorrent/AddTorrentFileModel.kt +++ b/app/src/main/kotlin/org/equeim/tremotesf/ui/addtorrent/AddTorrentFileModel.kt @@ -166,7 +166,6 @@ class AddTorrentFileModelImpl( infoHashV1 = parseResult.infoHashV1 trackers = parseResult.trackers - checkIfTorrentExists() if (checkIfTorrentExists()) { return@withContext LoadingState.Aborted } diff --git a/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/FiltersBottomSheet.kt b/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/FiltersBottomSheet.kt index 44407a2b..eee12365 100644 --- a/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/FiltersBottomSheet.kt +++ b/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/FiltersBottomSheet.kt @@ -38,6 +38,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.MutableStateFlow import org.equeim.tremotesf.R import org.equeim.tremotesf.common.AlphanumericComparator +import org.equeim.tremotesf.rpc.normalizePath +import org.equeim.tremotesf.rpc.requests.NormalizedRpcPath import org.equeim.tremotesf.rpc.requests.Torrent import org.equeim.tremotesf.rpc.toNativeSeparators import org.equeim.tremotesf.ui.ComponentPreview @@ -176,16 +178,22 @@ private fun FiltersBottomSheetContent( ) if (labelsEnabled.value) { + val currentLabelFilterString by sortAndFilterSettings.labelFilter.collectAsStateWithLifecycle() + val currentLabelFilter = remember { + derivedStateOf { + calculatedFilters.labels.find { it.label == currentLabelFilterString } + ?: CalculatedFilters.LabelFilter(currentLabelFilterString, 0) + } + } TremotesfComboBox( - currentItem = sortAndFilterSettings.labelFilter.collectAsStateWithLifecycle()::value, - updateCurrentItem = sortAndFilterSettings::setLabelFilter, - items = calculatedFilters.sortedLabels, + currentItem = currentLabelFilter::value, + updateCurrentItem = { sortAndFilterSettings.setLabelFilter(it.label) }, + items = calculatedFilters.labels, itemDisplayString = { - val count = calculatedFilters.labelsCounts.getOrDefault(it, 0) - if (it.isEmpty()) { - stringResource(R.string.torrents_all, count) + if (it.label == "") { + stringResource(R.string.torrents_all, it.torrentsCount) } else { - stringResource(R.string.directories_spinner_text, it, count) + stringResource(R.string.directories_spinner_text, it.label, it.torrentsCount) } }, label = R.string.labels, @@ -193,32 +201,48 @@ private fun FiltersBottomSheetContent( ) } + val currentTrackerFilterString by sortAndFilterSettings.trackerFilter.collectAsStateWithLifecycle() + val currentTrackerFilter = remember { + derivedStateOf { + calculatedFilters.trackers.find { it.trackerSite == currentTrackerFilterString } + ?: CalculatedFilters.TrackerFilter(currentTrackerFilterString, 0) + } + } TremotesfComboBox( - currentItem = sortAndFilterSettings.trackerFilter.collectAsStateWithLifecycle()::value, - updateCurrentItem = sortAndFilterSettings::setTrackerFilter, - items = calculatedFilters.sortedTrackers, + currentItem = currentTrackerFilter::value, + updateCurrentItem = { sortAndFilterSettings.setTrackerFilter(it.trackerSite) }, + items = calculatedFilters.trackers, itemDisplayString = { - val count = calculatedFilters.trackersCounts.getOrDefault(it, 0) - if (it.isEmpty()) { - stringResource(R.string.torrents_all, count) + if (it.trackerSite.isEmpty()) { + stringResource(R.string.torrents_all, it.torrentsCount) } else { - stringResource(R.string.trackers_spinner_text, it, count) + stringResource(R.string.trackers_spinner_text, it.trackerSite, it.torrentsCount) } }, label = R.string.trackers, modifier = Modifier.fillMaxWidth() ) + val currentDirectoryFilter by sortAndFilterSettings.directoryFilter.collectAsStateWithLifecycle() + val currentDirectoryFilterCalculated = remember { + derivedStateOf { + calculatedFilters.directories.find { it.directory == currentDirectoryFilter } + ?: CalculatedFilters.DirectoryFilter( + directory = currentDirectoryFilter, + displayDirectory = currentDirectoryFilter.toNativeSeparators(), + torrentsCount = 0 + ) + } + } TremotesfComboBox( - currentItem = sortAndFilterSettings.directoryFilter.collectAsStateWithLifecycle()::value, - updateCurrentItem = sortAndFilterSettings::setDirectoryFilter, - items = calculatedFilters.sortedDirectories, + currentItem = currentDirectoryFilterCalculated::value, + updateCurrentItem = { sortAndFilterSettings.setDirectoryFilter(it.directory) }, + items = calculatedFilters.directories, itemDisplayString = { - val count = calculatedFilters.directoriesCounts.getOrDefault(it, 0) - if (it.isEmpty()) { - stringResource(R.string.torrents_all, count) + if (it.directory.isEmpty()) { + stringResource(R.string.torrents_all, it.torrentsCount) } else { - stringResource(R.string.directories_spinner_text, it, count) + stringResource(R.string.directories_spinner_text, it.displayDirectory, it.torrentsCount) } }, label = R.string.directories, @@ -265,13 +289,14 @@ private fun SortOrderButtons( private data class CalculatedFilters( val statusFilterModesCounts: Map, - val sortedLabels: List, - val labelsCounts: Map, - val sortedTrackers: List, - val trackersCounts: Map, - val sortedDirectories: List, - val directoriesCounts: Map -) + val labels: List, + val trackers: List, + val directories: List +) { + data class LabelFilter(val label: String, val torrentsCount: Int) + data class TrackerFilter(val trackerSite: String, val torrentsCount: Int) + data class DirectoryFilter(val directory: NormalizedRpcPath, val displayDirectory: String, val torrentsCount: Int) +} private fun calculateFilters( torrents: List, @@ -280,12 +305,12 @@ private fun calculateFilters( ): CalculatedFilters { val modes = mutableMapOf(StatusFilterMode.All to torrents.size) val labels = if (labelsEnabled) { - mutableMapOf("" to torrents.size) + sortedMapOf(comparator, "" to torrents.size) } else { null } - val trackers = mutableMapOf("" to torrents.size) - val directories = mutableMapOf("" to torrents.size) + val trackers = sortedMapOf(comparator, "" to torrents.size) + val directories = sortedMapOf(compareBy(comparator, NormalizedRpcPath::value), NormalizedRpcPath.EMPTY to torrents.size) for (torrent in torrents) { for (mode in STATUS_FILTER_MODES_WITHOUT_ALL) { if (statusFilterAcceptsTorrent(torrent, mode)) { @@ -300,21 +325,24 @@ private fun calculateFilters( for (tracker in torrent.trackerSites) { trackers.compute(tracker, IncrementCount) } - directories.compute(torrent.downloadDirectory.toNativeSeparators(), IncrementCount) + directories.compute(torrent.downloadDirectory, IncrementCount) } return CalculatedFilters( statusFilterModesCounts = modes, - sortedLabels = labels?.keys?.sortedWith(comparator) ?: emptyList(), - labelsCounts = labels ?: emptyMap(), - sortedTrackers = trackers.keys.sortedWith(comparator), - trackersCounts = trackers, - sortedDirectories = directories.keys.sortedWith(comparator), - directoriesCounts = directories + labels = labels?.map { CalculatedFilters.LabelFilter(it.key, it.value) }.orEmpty(), + trackers = trackers.map { CalculatedFilters.TrackerFilter(it.key, it.value) }, + directories = directories.map { + CalculatedFilters.DirectoryFilter( + directory = it.key, + displayDirectory = it.key.toNativeSeparators(), + torrentsCount = it.value + ) + } ) } -private object IncrementCount : BiFunction { - override fun apply(key: Any, count: Int?): Int { +private object IncrementCount : BiFunction { + override fun apply(key: Any?, count: Int?): Int { return (count ?: 0) + 1 } } @@ -333,7 +361,7 @@ private fun FiltersBottomSheetPreview() = ComponentPreview { statusFilterMode = MutableStateFlow(StatusFilterMode.Downloading), labelFilter = MutableStateFlow(""), trackerFilter = MutableStateFlow(""), - directoryFilter = MutableStateFlow(""), + directoryFilter = MutableStateFlow("".normalizePath(null)), isAnySettingChanged = MutableStateFlow(true) ) }, diff --git a/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragment.kt b/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragment.kt index 73ce4423..e56380ab 100644 --- a/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragment.kt +++ b/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragment.kt @@ -412,7 +412,7 @@ private fun ShowNotificationPermissionSnackbar( var showSnackbar: Boolean by rememberSaveable { mutableStateOf(false) } LaunchedEffect(notificationPermissionHelperState, checkNotificationPermission) { if (checkNotificationPermission.filterNotNull() - .first() && notificationPermissionHelperState.permissionGranted + .first() && !notificationPermissionHelperState.permissionGranted ) { onCheckedNotificationPermission() showSnackbar = true diff --git a/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragmentViewModel.kt b/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragmentViewModel.kt index b7fbb929..388b497c 100644 --- a/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragmentViewModel.kt +++ b/app/src/main/kotlin/org/equeim/tremotesf/ui/torrentslistfragment/TorrentsListFragmentViewModel.kt @@ -47,8 +47,10 @@ import org.equeim.tremotesf.rpc.RpcClient import org.equeim.tremotesf.rpc.RpcRequestError import org.equeim.tremotesf.rpc.RpcRequestState import org.equeim.tremotesf.rpc.Server +import org.equeim.tremotesf.rpc.normalizePath import org.equeim.tremotesf.rpc.performPeriodicRequest import org.equeim.tremotesf.rpc.performRecoveringRequestIntoStateFlow +import org.equeim.tremotesf.rpc.requests.NormalizedRpcPath import org.equeim.tremotesf.rpc.requests.Torrent import org.equeim.tremotesf.rpc.requests.TorrentStatus import org.equeim.tremotesf.rpc.requests.TransferRate @@ -165,7 +167,7 @@ class TorrentsListFragmentViewModel(application: Application, savedStateHandle: val statusFilterMode: StateFlow, val labelFilter: StateFlow, val trackerFilter: StateFlow, - val directoryFilter: StateFlow, + val directoryFilter: StateFlow, val isAnySettingChanged: StateFlow ) { fun setSortMode(mode: SortMode) { @@ -188,8 +190,8 @@ class TorrentsListFragmentViewModel(application: Application, savedStateHandle: GlobalScope.launch { Settings.torrentsTrackerFilter.set(tracker) } } - fun setDirectoryFilter(directory: String) { - GlobalScope.launch { Settings.torrentsDirectoryFilter.set(directory) } + fun setDirectoryFilter(directory: NormalizedRpcPath?) { + GlobalScope.launch { Settings.torrentsDirectoryFilter.set(directory?.value.orEmpty()) } } fun reset() { @@ -199,7 +201,7 @@ class TorrentsListFragmentViewModel(application: Application, savedStateHandle: setStatusFilterMode(StatusFilterMode.DEFAULT) setLabelFilter("") setTrackerFilter("") - setDirectoryFilter("") + setDirectoryFilter(null) } } @@ -216,7 +218,11 @@ class TorrentsListFragmentViewModel(application: Application, savedStateHandle: statusFilterMode = Settings.torrentsStatusFilter.flow().stateIn(viewModelScope), labelFilter = Settings.torrentsLabelFilter.flow().stateIn(viewModelScope), trackerFilter = Settings.torrentsTrackerFilter.flow().stateIn(viewModelScope), - directoryFilter = Settings.torrentsDirectoryFilter.flow().stateIn(viewModelScope), + directoryFilter = combine( + Settings.torrentsDirectoryFilter.flow(), + GlobalRpcClient.serverCapabilitiesFlow, + String::normalizePath + ).stateIn(viewModelScope), isAnySettingChanged = combine( nameFilterFlow, Settings.torrentsSortMode.flow(), @@ -356,16 +362,16 @@ class TorrentsListFragmentViewModel(application: Application, savedStateHandle: val torrentOperations: TorrentsOperations = object : TorrentsOperations { override fun start(ids: Set) = - performRequestAndRefresh(R.string.torrents_reannounce_error) { startTorrents(ids) } + performRequestAndRefresh(R.string.torrents_start_error) { startTorrents(ids) } override fun startNow(ids: Set) = - performRequestAndRefresh(R.string.torrents_reannounce_error) { startTorrentsNow(ids) } + performRequestAndRefresh(R.string.torrents_start_error) { startTorrentsNow(ids) } override fun stop(ids: Set) = - performRequestAndRefresh(R.string.torrents_reannounce_error) { stopTorrents(ids) } + performRequestAndRefresh(R.string.torrents_pause_error) { stopTorrents(ids) } override fun verify(ids: Set) = - performRequestAndRefresh(R.string.torrents_reannounce_error) { verifyTorrents(ids) } + performRequestAndRefresh(R.string.torrents_check_error) { verifyTorrents(ids) } override fun reannounce(ids: Set) = performRequestAndRefresh(R.string.torrents_reannounce_error) { reannounceTorrents(ids) } @@ -421,14 +427,14 @@ class TorrentsListFragmentViewModel(application: Application, savedStateHandle: statusFilterMode: StatusFilterMode, labelFilter: String?, trackerFilter: String, - directoryFilter: String, + directoryFilter: NormalizedRpcPath, ): (Torrent) -> Boolean { return { torrent: Torrent -> (nameFilter.isEmpty() || torrent.name.contains(nameFilter, true)) && statusFilterAcceptsTorrent(torrent, statusFilterMode) && (labelFilter.isNullOrEmpty() || torrent.labels.contains(labelFilter)) && (trackerFilter.isEmpty() || (torrent.trackerSites.contains(trackerFilter))) && - (directoryFilter.isEmpty() || torrent.downloadDirectory.value == directoryFilter) + (directoryFilter.isEmpty() || torrent.downloadDirectory == directoryFilter) } } diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/Paths.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/Paths.kt index 123946cd..ab2b813b 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/Paths.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/Paths.kt @@ -21,15 +21,12 @@ fun NormalizedRpcPath.toNativeSeparators(): String = @JvmName("normalizePathImpl") private fun normalizePath(path: String, serverCapabilities: ServerCapabilities?): NormalizedRpcPath { - //Timber.d("Normalizing path $path") if (path.isEmpty()) { - //Timber.d("Empty") - return NormalizedRpcPath(path, null) + return NormalizedRpcPath.EMPTY } var normalized = path.trim() if (normalized.isEmpty()) { - //Timber.d("Blank") - return NormalizedRpcPath(normalized, null) + return NormalizedRpcPath.EMPTY } if (serverCapabilities == null) { return NormalizedRpcPath(normalized, null) @@ -41,7 +38,6 @@ private fun normalizePath(path: String, serverCapabilities: ServerCapabilities?) } } normalized = normalized.collapseRepeatingSeparators(serverCapabilities).dropTrailingSeparator(serverCapabilities) - //Timber.d("Normalized to $normalized") return NormalizedRpcPath(normalized, serverCapabilities.serverOs) } diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcClient.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcClient.kt index c03fada1..f5daf9aa 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcClient.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcClient.kt @@ -5,7 +5,9 @@ package org.equeim.tremotesf.rpc import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -36,8 +38,8 @@ open class RpcClient( protected val coroutineScope: CoroutineScope, private val retryOnConnectionFailure: Boolean = true ) { - private val connectionConfiguration = MutableStateFlow?>(null) - internal fun getConnectionConfiguration(): StateFlow?> = connectionConfiguration + internal val connectionConfiguration: StateFlow?> + field = MutableStateFlow(null) internal val json = Json { ignoreUnknownKeys = true @@ -59,6 +61,9 @@ open class RpcClient( val shouldConnectToServer = MutableStateFlow(true) + val disconnectedFromServer: Flow + field = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + init { coroutineScope.launch { shouldConnectToServer.collect { @@ -66,6 +71,7 @@ open class RpcClient( connectionConfiguration.value?.getOrNull()?.httpClient?.apply { dispatcher.cancelAll() connectionPool.evictAll() + disconnectedFromServer.tryEmit(Unit) } } } @@ -87,6 +93,7 @@ open class RpcClient( dispatcher.cancelAll() dispatcher.executorService.shutdown() connectionPool.evictAll() + disconnectedFromServer.tryEmit(Unit) } sessionId = null serverCapabilitiesResult.value = null diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestError.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestError.kt index 0b9f351b..30b7cbf3 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestError.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestError.kt @@ -210,7 +210,7 @@ fun RpcRequestError.makeDetailedError(client: RpcClient): DetailedRpcRequestErro clientCertificates = response?.run { withPriorResponses.flatMap { it.handshake?.localCertificates.orEmpty() }.toSet().toList() } - ?: client.getConnectionConfiguration().value?.getOrNull()?.clientCertificates.orEmpty(), + ?: client.connectionConfiguration.value?.getOrNull()?.clientCertificates.orEmpty(), requestHeaders = requestHeaders?.toList().orEmpty(), ) } diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestState.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestState.kt index b7d4c643..33cd95ac 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestState.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/RpcRequestState.kt @@ -83,7 +83,7 @@ fun RpcClient.performPeriodicRequest( } while (currentCoroutineContext().isActive) { actuallyPerformRecoveringRequest(performRequest, this) - val updateInterval = getConnectionConfiguration().value?.getOrNull()?.updateInterval + val updateInterval = connectionConfiguration.value?.getOrNull()?.updateInterval if (updateInterval != null) { delay(updateInterval) } else { @@ -167,7 +167,7 @@ private suspend fun RpcClient.actuallyPerformRecoveringRequest( private class StartRequestFromScratch(val nonRecoverableError: RpcRequestState.Error? = null) private fun RpcClient.startRequestFromScratchEvents(): Flow = combine( - getConnectionConfiguration(), + connectionConfiguration, shouldConnectToServer ) { configuration, shouldConnectToServer -> StartRequestFromScratch(getInitialNonRecoverableError(configuration, shouldConnectToServer)) @@ -185,7 +185,7 @@ private fun getInitialNonRecoverableError( } private fun RpcClient.getInitialRequestState(): RpcRequestState = getInitialNonRecoverableError( - getConnectionConfiguration().value, + connectionConfiguration.value, shouldConnectToServer.value ) ?: RpcRequestState.Loading diff --git a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/requests/Types.kt b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/requests/Types.kt index 44d2fef3..09345ddb 100644 --- a/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/requests/Types.kt +++ b/rpc/src/main/kotlin/org/equeim/tremotesf/rpc/requests/Types.kt @@ -48,6 +48,8 @@ data class NormalizedRpcPath internal constructor( val value: String, internal val serverOs: ServerCapabilities.ServerOs?, ) { + fun isEmpty(): Boolean = value.isEmpty() + internal class Serializer(private val serverCapabilities: () -> ServerCapabilities?) : KSerializer { override val descriptor: SerialDescriptor = @@ -58,6 +60,10 @@ data class NormalizedRpcPath internal constructor( override fun serialize(encoder: Encoder, value: NormalizedRpcPath) = encoder.encodeString(value.value) } + + companion object { + val EMPTY = NormalizedRpcPath(value = "", serverOs = null) + } } @JvmInline