diff --git a/DESIGN.md b/DESIGN.md index d0430bce4..16af3dc4b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -414,12 +414,15 @@ names the sharer. ### Selection **Select** starts selection mode: the top bar turns `surface-container-high` -with close, "_n_ selected", select all and overflow. Checkboxes replace the -file icons and selected rows take a 9% yellow tint. A floating toolbar above -the nav bar holds Move, Download, Watched and the delete action. Each action -shows only when every selected item allows it, under the same rules as the -sheet; delete reads Delete permanently, and confirms, when the account turns -Trash off. +with close, "_n_ selected" and select all. Checkboxes replace the file icons, +the trailing chevron and overflow leave, and selected rows take a 9% yellow +tint. A floating toolbar above the nav bar holds Move, Download, Watched and +the delete action. Each action shows only when every selected item allows it, +under the same rules as the sheet; Watched reads Unwatched when every file is +finished, and delete reads Delete, and confirms with the count, when the +account turns Trash off. Back ends selection before it leaves the folder. In +list-detail, the detail pane shows no file while rows are selected. Downloads +uses the same bar, checkboxes, tint and toolbar tiles. ### Menus @@ -796,7 +799,6 @@ the navigation bar: **Welcome!** as a 40sp medium title on a 48sp line, then counts elsewhere use the UI face. - Transfer cards and Transfer actions show no peer count: the Kotlin SDK's `Transfer` has no peers field. -- Files has no selection mode, so the actions sheet has no Select. - The player has no cast button, and its error has no Download: downloads keep the HLS rendition. - The viewers have no Download: Downloads hold video and audio only. diff --git a/README.md b/README.md index fa9afec6a..79a6c31bb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Mobile, on the emulator harness against the live API: create folders, trash with Undo, delete permanently, search inside a folder, played marks, and Continue watching for videos started on this device - One actions sheet per item: Play or Resume, Download, Share, Open in VLC, - Mark as watched, Rename, Move, Exclusive link and delete + Select, Mark as watched, Rename, Move, Exclusive link and delete +- Files selection: move, download, mark watched or unwatched, and trash or + delete several items at once - Tablets from 840 dp: the list beside the selected file's preview and facts - Images, PDFs and text in an in-app viewer; other types open with another app - Trash: days left per item, restore one or all, delete permanently, empty diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserDelete.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserDelete.kt index d17f8a3d9..050465b50 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserDelete.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserDelete.kt @@ -13,18 +13,18 @@ internal fun FilesBrowserState.reduceDelete(event: FilesBrowserEvent.DeleteEvent } private fun FilesBrowserState.delete(event: FilesBrowserEvent.Delete): FilesBrowserTransition { - val item = current.content.items().firstOrNull { it.id == event.itemId && it.id.value > 0L } - if (event.folderId != current.folder.id || item == null) { + val items = current.content.listedItems(event.itemIds) + if (event.folderId != current.folder.id || items == null) { return FilesBrowserTransition(this, consumed = false) } - val intent = FilesFolderOperationIntent.Delete(item.id, event.mode) + val intent = FilesFolderOperationIntent.Delete(items.map { it.id }, event.mode) val started = startOperation(intent, FilesFolderOperationPhase.DELETING) val effect = started.effect return if (effect == null) { started } else { val updated = started.state.current.copy( - deleteOutcome = FilesDeleteOutcome(effect.requestId, intent, item.name), + deleteOutcome = FilesDeleteOutcome(effect.requestId, intent, items.first().name), moveOutcome = null, ) started.copy(state = started.state.copy(stack = started.state.stack.replaceLast(updated))) @@ -60,7 +60,7 @@ internal fun FilesBrowserState.deleteFinished(event: FilesBrowserEvent.DeleteFin ) FilesBrowserTransition( state = copy(stack = stack.replaceAt(index, updated), nextRequestValue = nextRequestValue + 1), - effect = FilesBrowserEffect.CheckDelete(outcome.intent.itemId, requestId), + effect = FilesBrowserEffect.CheckDelete(outcome.intent.itemIds, requestId), ) } } @@ -73,15 +73,19 @@ private fun FilesBrowserState.deleteChecked(event: FilesBrowserEvent.DeleteCheck val folder = stack.getOrNull(index) val outcome = folder?.deleteOutcome if (folder == null || outcome == null) return FilesBrowserTransition(this, consumed = false) - val failure = event.result.deleteReadFailure(outcome.intent.itemId) - val unavailable = failure is PutioFailure.ApiRejected && - failure.statusCode == HTTP_NOT_FOUND && failure.httpStatusCode == HTTP_NOT_FOUND - return if (failure != null && !unavailable) { + val reads = outcome.intent.itemIds.zipReads(event.results) + .map { (id, result) -> id to result.deleteReadFailure(id) } + val remaining = reads.filterNot { (_, failure) -> failure.isNotFound }.mapTo(mutableSetOf()) { it.first } + val failure = reads.mapNotNull { (_, failure) -> failure?.takeUnless { it.isNotFound } }.verdict() + return if (failure != null) { loadFailed(FilesBrowserEvent.LoadFailed(event.requestId, failure)) } else { val requestId = FilesRequestId(nextRequestValue) val updated = folder.copy( - deleteOutcome = outcome.copy(status = outcome.checkedStatus(unavailable)), + deleteOutcome = outcome.copy( + status = outcome.checkedStatus(unavailable = remaining.isEmpty()), + remainingIds = remaining, + ), operation = FilesFolderOperation.Loading(requestId, outcome.intent, FilesFolderOperationPhase.RELOADING), needsReload = false, ) @@ -110,6 +114,9 @@ private fun FilesBrowserState.trashUndone(outcome: FilesDeleteOutcome): FilesBro return FilesBrowserTransition(copy(stack = stack.replaceAt(index, undone))) } +private val PutioFailure?.isNotFound: Boolean + get() = this is PutioFailure.ApiRejected && statusCode == HTTP_NOT_FOUND && httpStatusCode == HTTP_NOT_FOUND + private fun PutioResult.deleteReadFailure(expectedId: FilesItemId): PutioFailure? = when (this) { is PutioResult.Failure -> failure is PutioResult.Success -> if (value.id == expectedId) null else @@ -127,7 +134,7 @@ public val FilesFolderOperation.pendingDelete: FilesFolderOperationIntent.Delete get() = pendingIntent() internal fun FilesBrowserState.isDeleteTargetBlocked(itemId: FilesItemId): Boolean = stack.any { - it.operation.pendingDelete?.itemId == itemId + it.operation.pendingDelete?.itemIds?.contains(itemId) == true } private const val HTTP_NOT_FOUND = 404 diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserMove.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserMove.kt index 9aa3dbd69..be2141131 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserMove.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserMove.kt @@ -14,23 +14,24 @@ public val FilesBrowserState.canStartMove: Boolean stack.dropLast(1).all { it.operation == FilesFolderOperation.Idle } private fun FilesBrowserState.move(event: FilesBrowserEvent.Move): FilesBrowserTransition { - val item = current.content.items().firstOrNull { it.id == event.itemId && it.id.value > 0L } - if (event.folderId != current.folder.id || item == null || !canStartMove) { + val items = current.content.listedItems(event.itemIds) + if (event.folderId != current.folder.id || items == null || !canStartMove) { return FilesBrowserTransition(this, consumed = false) } - val sameParent = event.destinationId == current.folder.id || event.destinationId == item.parentId - val invalidDestination = event.destinationId.value < 0L || sameParent || event.destinationId == item.id + val destination = event.destinationId + val invalidDestination = destination.value < 0L || destination == current.folder.id || + items.any { destination == it.parentId || destination == it.id } return if (invalidDestination) { FilesBrowserTransition(this, consumed = false) } else { - val intent = FilesFolderOperationIntent.Move(item.id, event.destinationId) + val intent = FilesFolderOperationIntent.Move(items.map { it.id }, destination) val started = startOperation(intent, FilesFolderOperationPhase.MOVING) val effect = started.effect if (effect == null) { started } else { val updated = started.state.current.copy( - moveOutcome = FilesMoveOutcome(effect.requestId, intent, item.name), deleteOutcome = null, + moveOutcome = FilesMoveOutcome(effect.requestId, intent, items.first().name), deleteOutcome = null, ) // Either destination or source ancestors can have changed once the POST is submitted. val ancestors = started.state.stack.dropLast(1).map { @@ -70,7 +71,7 @@ internal fun FilesBrowserState.moveFinished(event: FilesBrowserEvent.MoveFinishe ) FilesBrowserTransition( copy(stack = stack.replaceAt(index, updated), nextRequestValue = nextRequestValue + 1), - FilesBrowserEffect.CheckMove(outcome.intent.itemId, requestId), + FilesBrowserEffect.CheckMove(outcome.intent.itemIds, requestId), ) } } @@ -83,19 +84,22 @@ private fun FilesBrowserState.moveChecked(event: FilesBrowserEvent.MoveChecked): val folder = stack.getOrNull(index) val outcome = folder?.moveOutcome if (folder == null || outcome == null) return FilesBrowserTransition(this, consumed = false) - val failure = event.result.moveReadFailure(outcome.intent.itemId) + val reads = outcome.intent.itemIds.zipReads(event.results) + val failure = reads.mapNotNull { (id, result) -> result.moveReadFailure(id) }.verdict() return if (failure != null) { loadFailed(FilesBrowserEvent.LoadFailed(event.requestId, failure)) } else { - val item = (event.result as? PutioResult.Success)?.value + val notMoved = reads.filter { (_, result) -> + (result as? PutioResult.Success)?.value?.parentId != outcome.intent.destinationId + }.mapTo(mutableSetOf()) { it.first } val status = when { !outcome.errors.isNullOrEmpty() -> FilesMoveStatus.REJECTED - item?.parentId == outcome.intent.destinationId -> FilesMoveStatus.MOVED + notMoved.isEmpty() -> FilesMoveStatus.MOVED else -> FilesMoveStatus.STILL_PRESENT } val requestId = FilesRequestId(nextRequestValue) val updated = folder.copy( - moveOutcome = outcome.copy(status = status), + moveOutcome = outcome.copy(status = status, remainingIds = notMoved), operation = FilesFolderOperation.Loading(requestId, outcome.intent, FilesFolderOperationPhase.RELOADING), needsReload = false, ) @@ -118,9 +122,17 @@ private fun PutioResult.moveReadFailure(expectedId: FilesItemId): Put public val FilesFolderOperation.pendingMove: FilesFolderOperationIntent.Move? get() = pendingIntent() -internal fun FilesMoveOutcome.afterFolderPage(page: FilesPage): FilesMoveOutcome = - if (status == FilesMoveStatus.MOVED && page.items.any { it.id == intent.itemId }) { - copy(status = FilesMoveStatus.STILL_PRESENT) - } else { - this +/** + * A page that lists a moved item again: the move left it here, whatever the check said. A refused + * move keeps its status and counts the item too. + */ +internal fun FilesMoveOutcome.afterFolderPage(page: FilesPage): FilesMoveOutcome { + val listed = page.items.mapNotNullTo(mutableSetOf()) { item -> item.id.takeIf { it in intent.itemIds } } + return when { + remainingIds.containsAll(listed) -> this + status == FilesMoveStatus.MOVED || status == FilesMoveStatus.STILL_PRESENT -> + copy(status = FilesMoveStatus.STILL_PRESENT, remainingIds = remainingIds + listed) + status == FilesMoveStatus.REJECTED -> copy(remainingIds = remainingIds + listed) + else -> this } +} diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperationResults.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperationResults.kt index a5d2b6c7c..78bb733b9 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperationResults.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperationResults.kt @@ -1,5 +1,8 @@ package io.putdotio.android.files +import io.putdotio.android.PutioFailure +import io.putdotio.android.PutioResult + internal fun FilesBrowserState.mutationSucceeded( requestId: FilesRequestId, ): FilesBrowserTransition { @@ -64,9 +67,32 @@ internal fun FilesFolderState.replaceFirstPage( ) } -internal fun FilesDeleteOutcome.afterFolderPage(page: FilesPage): FilesDeleteOutcome = - if (status == FilesDeleteStatus.NO_LONGER_AVAILABLE && page.items.any { it.id == intent.itemId }) { - copy(status = FilesDeleteStatus.STILL_PRESENT) +/** + * A page that lists a deleted item again: it is still here, whatever the check said. An incomplete + * request keeps its status and counts the item too. + */ +internal fun FilesDeleteOutcome.afterFolderPage(page: FilesPage): FilesDeleteOutcome { + val listed = page.items.mapNotNullTo(mutableSetOf()) { item -> item.id.takeIf { it in intent.itemIds } } + return when { + remainingIds.containsAll(listed) -> this + status == FilesDeleteStatus.NO_LONGER_AVAILABLE || status == FilesDeleteStatus.STILL_PRESENT -> + copy(status = FilesDeleteStatus.STILL_PRESENT, remainingIds = remainingIds + listed) + status == FilesDeleteStatus.SKIPPED || status == FilesDeleteStatus.TOO_LARGE_FOR_TRASH -> + copy(remainingIds = remainingIds + listed) + else -> this + } +} + +/** The failure a check reports: a rejected session first, since it decides whether the session stays. */ +internal fun List.verdict(): PutioFailure? = + firstOrNull { it is PutioFailure.AuthenticationRequired } ?: firstOrNull() + +/** One read per item, in order; a check that read some other number of items read nothing. */ +internal fun List.zipReads( + results: List>, +): List>> = + if (results.size == size) { + zip(results) } else { - this + map { it to PutioResult.Failure(PutioFailure.Unexpected(IllegalStateException("Item reads did not match"))) } } diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperations.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperations.kt index af0c36046..c1cf86632 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperations.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserOperations.kt @@ -131,15 +131,22 @@ private fun effectFor( is FilesFolderOperationIntent.Rename -> FilesBrowserEffect.Rename(intent.itemId, intent.name, requestId) is FilesFolderOperationIntent.Move -> if (phase == FilesFolderOperationPhase.MOVING) { - FilesBrowserEffect.Move(intent.itemId, intent.destinationId, requestId) + FilesBrowserEffect.Move(intent.itemIds, intent.destinationId, requestId) } else { - FilesBrowserEffect.CheckMove(intent.itemId, requestId) + FilesBrowserEffect.CheckMove(intent.itemIds, requestId) } is FilesFolderOperationIntent.Delete -> if (phase == FilesFolderOperationPhase.DELETING) { - FilesBrowserEffect.Delete(intent.itemId, intent.mode, requestId) + FilesBrowserEffect.Delete(intent.itemIds, intent.mode, requestId) } else { - FilesBrowserEffect.CheckDelete(intent.itemId, requestId) + FilesBrowserEffect.CheckDelete(intent.itemIds, requestId) } } } + +/** Each of [itemIds], once, as the listing has it; null unless every one is listed and real. */ +internal fun FilesContent.listedItems(itemIds: List): List? { + val listed = items().filter { it.id.value > 0L }.associateBy { it.id } + val found = itemIds.mapNotNull(listed::get) + return found.takeIf { it.isNotEmpty() && it.size == itemIds.size && itemIds.toSet().size == itemIds.size } +} diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserState.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserState.kt index 2f0c84bcc..6a519746a 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserState.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesBrowserState.kt @@ -4,6 +4,11 @@ import io.putdotio.android.PutioFailure import io.putdotio.android.PutioResult import io.putdotio.sdk.files.FileDeleteResult import io.putdotio.sdk.files.FileMoveError +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit @JvmInline public value class FilesRequestId( @@ -70,12 +75,29 @@ public sealed interface FilesFolderOperationIntent { val name: String, ) : FilesFolderOperationIntent - public data class Move(val itemId: FilesItemId, val destinationId: FilesItemId) : FilesFolderOperationIntent + /** Moves [itemIds], all listed in the folder, into [destinationId] in one request. */ + public data class Move( + val itemIds: List, + val destinationId: FilesItemId, + ) : FilesFolderOperationIntent { + public constructor(itemId: FilesItemId, destinationId: FilesItemId) : this(listOf(itemId), destinationId) + + init { + require(itemIds.isNotEmpty()) { "A move needs at least one item" } + } + } + /** Deletes [itemIds], all listed in the folder, in one request. */ public data class Delete( - val itemId: FilesItemId, + val itemIds: List, val mode: FilesDeleteMode, - ) : FilesFolderOperationIntent + ) : FilesFolderOperationIntent { + public constructor(itemId: FilesItemId, mode: FilesDeleteMode) : this(listOf(itemId), mode) + + init { + require(itemIds.isNotEmpty()) { "A delete needs at least one item" } + } + } } public enum class FilesFolderOperationPhase { @@ -209,21 +231,29 @@ public sealed interface FilesBrowserEvent { public sealed interface DeleteEvent : ItemMutationEvent + /** Deletes [itemIds] from [folderId], which must be the current folder and list each of them. */ public data class Delete( val folderId: FilesItemId, - val itemId: FilesItemId, + val itemIds: List, val mode: FilesDeleteMode, - ) : DeleteEvent + ) : DeleteEvent { + public constructor(folderId: FilesItemId, itemId: FilesItemId, mode: FilesDeleteMode) : + this(folderId, listOf(itemId), mode) + } public data class DeleteFinished( val requestId: FilesRequestId, val result: PutioResult, ) : DeleteEvent + /** One read per deleted item, in the delete's order. */ public data class DeleteChecked( val requestId: FilesRequestId, - val result: PutioResult, - ) : DeleteEvent + val results: List>, + ) : DeleteEvent { + public constructor(requestId: FilesRequestId, result: PutioResult) : + this(requestId, listOf(result)) + } /** * The screen announced this settled outcome on its own, so it is not announced again. An @@ -243,21 +273,29 @@ public sealed interface FilesBrowserEvent { public sealed interface MoveEvent : ItemMutationEvent + /** Moves [itemIds] from [folderId], which must be the current folder and list each of them. */ public data class Move( val folderId: FilesItemId, - val itemId: FilesItemId, + val itemIds: List, val destinationId: FilesItemId, - ) : MoveEvent + ) : MoveEvent { + public constructor(folderId: FilesItemId, itemId: FilesItemId, destinationId: FilesItemId) : + this(folderId, listOf(itemId), destinationId) + } public data class MoveFinished( val requestId: FilesRequestId, val result: PutioResult>, ) : MoveEvent + /** One read per moved item, in the move's order. */ public data class MoveChecked( val requestId: FilesRequestId, - val result: PutioResult, - ) : MoveEvent + val results: List>, + ) : MoveEvent { + public constructor(requestId: FilesRequestId, result: PutioResult) : + this(requestId, listOf(result)) + } /** Copies are put.io's background work, so they live beside the folder stack, not on a folder. */ public sealed interface CopyEvent : ItemMutationEvent @@ -333,24 +371,26 @@ public sealed interface FilesBrowserEffect { ) : FilesBrowserEffect public data class Delete( - val itemId: FilesItemId, + val itemIds: List, val mode: FilesDeleteMode, override val requestId: FilesRequestId, ) : FilesBrowserEffect public data class Move( - val itemId: FilesItemId, + val itemIds: List, val destinationId: FilesItemId, override val requestId: FilesRequestId, ) : FilesBrowserEffect + /** Reads each moved item back, to learn where it is now. */ public data class CheckMove( - val itemId: FilesItemId, + val itemIds: List, override val requestId: FilesRequestId, ) : FilesBrowserEffect + /** Reads each deleted item back, to learn whether it is gone. */ public data class CheckDelete( - val itemId: FilesItemId, + val itemIds: List, override val requestId: FilesRequestId, ) : FilesBrowserEffect @@ -446,13 +486,13 @@ internal suspend fun FilesRepository.execute(effect: FilesBrowserEffect): FilesB is FilesBrowserEffect.LoadFolder -> loadFolder(effect.folderId).toLoadEvent(effect.requestId) is FilesBrowserEffect.LoadNextPage -> loadNextPage(effect.cursor).toLoadEvent(effect.requestId) is FilesBrowserEffect.Move -> - FilesBrowserEvent.MoveFinished(effect.requestId, move(effect.itemId, effect.destinationId)) + FilesBrowserEvent.MoveFinished(effect.requestId, move(effect.itemIds, effect.destinationId)) is FilesBrowserEffect.CheckMove -> - FilesBrowserEvent.MoveChecked(effect.requestId, resolveItem(effect.itemId)) + FilesBrowserEvent.MoveChecked(effect.requestId, resolveItems(effect.itemIds)) is FilesBrowserEffect.Delete -> - FilesBrowserEvent.DeleteFinished(effect.requestId, delete(effect.itemId, effect.mode)) + FilesBrowserEvent.DeleteFinished(effect.requestId, delete(effect.itemIds, effect.mode)) is FilesBrowserEffect.CheckDelete -> - FilesBrowserEvent.DeleteChecked(effect.requestId, resolveItem(effect.itemId)) + FilesBrowserEvent.DeleteChecked(effect.requestId, resolveItems(effect.itemIds)) is FilesBrowserEffect.CopyEffect -> executeCopy(effect) is FilesBrowserEffect.Rename -> when (val renamed = rename(effect.itemId, effect.name)) { @@ -466,6 +506,17 @@ internal suspend fun FilesRepository.execute(effect: FilesBrowserEffect): FilesB } } +/** + * put.io has no batch read, so each item is read on its own, a few at a time; the results keep the + * items' order. + */ +private suspend fun FilesRepository.resolveItems(itemIds: List): List> { + val permits = Semaphore(CHECK_CONCURRENCY) + return coroutineScope { + itemIds.map { id -> async { permits.withPermit { resolveItem(id) } } }.awaitAll() + } +} + private fun PutioResult.toLoadEvent(requestId: FilesRequestId): FilesBrowserEvent = when (this) { is PutioResult.Success -> FilesBrowserEvent.LoadSucceeded(requestId, value) @@ -473,3 +524,4 @@ private fun PutioResult.toLoadEvent(requestId: FilesRequestId): Files } private const val INITIAL_REQUEST_VALUE = 1L +private const val CHECK_CONCURRENCY = 4 diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesDeleteModels.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesDeleteModels.kt index bcdc1b701..2799e8bce 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesDeleteModels.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesDeleteModels.kt @@ -22,13 +22,19 @@ public enum class FilesDeleteStatus { public data class FilesDeleteOutcome( val requestId: FilesRequestId, val intent: FilesFolderOperationIntent.Delete, + /** The first item's name; a delete of several names them by count. */ val itemName: String, val response: FileDeleteResult? = null, val failure: PutioFailure? = null, val status: FilesDeleteStatus = FilesDeleteStatus.CHECKING, /** The screen announced it on its own; it stays for a later page to correct. */ val announced: Boolean = false, -) + /** The items the check still found, and those a later page lists again. */ + val remainingIds: Set = emptySet(), +) { + val itemCount: Int get() = intent.itemIds.size + val remainingCount: Int get() = remainingIds.size +} /** put.io refuses a Trash move of a folder over its item limit with this error type, before changing anything. */ internal val PutioFailure?.isTrashChildrenLimit: Boolean diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationController.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationController.kt index d9e3056e0..6dd402e1e 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationController.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationController.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch public class FilesMoveDestinationController private constructor( - sourceItem: FilesItem?, + sourceItems: List, sourceFolderId: FilesItemId?, private val repository: FilesRepository, parentScope: CoroutineScope, @@ -29,20 +29,30 @@ public class FilesMoveDestinationController private constructor( repository: FilesRepository, parentScope: CoroutineScope, startPath: List = emptyList(), - ) : this(sourceItem, sourceFolderId, repository, parentScope, startPath, Unit) + ) : this(listOf(sourceItem), sourceFolderId, repository, parentScope, startPath, Unit) + + /** Moves several items of [sourceFolderId] together; none of them is a destination. */ + public constructor( + sourceItems: List, + sourceFolderId: FilesItemId, + repository: FilesRepository, + parentScope: CoroutineScope, + startPath: List = emptyList(), + ) : this(sourceItems, sourceFolderId, repository, parentScope, startPath, Unit) /** Picks a folder for new content; every folder, root included, is a valid destination. */ public constructor( repository: FilesRepository, parentScope: CoroutineScope, startPath: List = emptyList(), - ) : this(null, null, repository, parentScope, startPath, Unit) + ) : this(emptyList(), null, repository, parentScope, startPath, Unit) init { require( - sourceItem == null && sourceFolderId == null || - sourceItem != null && sourceItem.id.value > 0L && sourceFolderId != null && sourceFolderId.value >= 0L, - ) { "Move picker requires a non-root source" } + sourceItems.isEmpty() && sourceFolderId == null || + sourceItems.isNotEmpty() && sourceItems.all { it.id.value > 0L } && + sourceFolderId != null && sourceFolderId.value >= 0L, + ) { "Move picker requires non-root sources" } require(startPath.all { it.id.value > 0L }) { "A start path lies below root" } } @@ -53,7 +63,7 @@ public class FilesMoveDestinationController private constructor( FilesRequestId(1L), ) private val mutableState = MutableStateFlow(FilesMoveDestinationState( - sourceItem, sourceFolderId, + sourceItems, sourceFolderId, (listOf(FilesFolder.Root) + startPath).dropLast(1).map(::unreadFolder) + FilesMoveDestinationFolder( startPath.lastOrNull() ?: FilesFolder.Root, diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationStart.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationStart.kt index 264f49eb4..57940b760 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationStart.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationStart.kt @@ -22,14 +22,14 @@ internal fun FilesMoveDestinationState.completeRememberedRead( /** * Each remembered folder is checked when first read: it takes its current name, one moved elsewhere - * keeps only root above it, and one now directly inside the item being moved restarts at root. + * keeps only root above it, and one now directly inside an item being moved restarts at root. */ private fun FilesMoveDestinationState.reconcileRemembered( request: FilesMoveDestinationRequest, result: PutioResult.Success, ): FilesMoveDestinationTransition { val listed = result.value.parent?.takeIf { it.id == current.folder.id } - if (sourceItem != null && listed?.parentId == sourceItem.id) return restartAtRoot() + if (listed?.parentId?.let(::isSource) == true) return restartAtRoot() val read = current.copy( folder = current.folder.copy(name = listed?.name ?: current.folder.name), remembered = false, diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationState.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationState.kt index 9db7ff435..72f70dcd7 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationState.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveDestinationState.kt @@ -11,10 +11,13 @@ public data class FilesMoveDestinationFolder internal constructor( internal val remembered: Boolean = false, ) -/** A folder picker; without a [sourceItem] it picks a destination for new content, such as a transfer. */ +/** + * A folder picker for [sourceItems], all listed in [sourceFolderId]; without source items it picks a + * destination for new content, such as a transfer. + */ @ConsistentCopyVisibility public data class FilesMoveDestinationState internal constructor( - val sourceItem: FilesItem?, + val sourceItems: List, val sourceFolderId: FilesItemId?, internal val stack: List, internal val nextRequestValue: Long, @@ -25,12 +28,15 @@ public data class FilesMoveDestinationState internal constructor( val path: List get() = stack.map { it.folder } val canNavigateBack: Boolean get() = stack.size > 1 val canMoveHere: Boolean get() = current.folder.id.value >= 0L && - (sourceItem == null || sourceItem.id.value > 0L && current.folder.id != sourceFolderId && - current.folder.id != sourceItem.parentId && current.folder.id != sourceItem.id) && + (sourceItems.isEmpty() || current.folder.id != sourceFolderId && sourceItems.all { + it.id.value > 0L && current.folder.id != it.parentId && current.folder.id != it.id + }) && (current.content is FilesContent.Ready || current.content is FilesContent.Empty) - public fun canOpenFolder(itemId: FilesItemId): Boolean = itemId.value > 0L && itemId != sourceItem?.id && + public fun canOpenFolder(itemId: FilesItemId): Boolean = itemId.value > 0L && !isSource(itemId) && stack.none { it.folder.id == itemId } && current.content.items().any { it.id == itemId && it.isFolder } + + internal fun isSource(itemId: FilesItemId): Boolean = sourceItems.any { it.id == itemId } } public sealed interface FilesMoveDestinationEvent { @@ -120,7 +126,7 @@ private fun FilesMoveDestinationState.openCreatedFolder(folder: FilesItem): File private fun FilesMoveDestinationState.acceptsCreatedFolder(folder: FilesItem): Boolean { val listed = current.content is FilesContent.Ready || current.content is FilesContent.Empty val newChild = folder.isFolder && folder.id.value > 0L && folder.parentId == current.folder.id - return listed && newChild && folder.id != sourceItem?.id && stack.none { it.folder.id == folder.id } + return listed && newChild && !isSource(folder.id) && stack.none { it.folder.id == folder.id } } private fun FilesMoveDestinationState.loadDestinationPage(retry: Boolean): FilesMoveDestinationTransition { diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveModels.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveModels.kt index 801496ee1..2dc5f9735 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveModels.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveModels.kt @@ -14,8 +14,14 @@ public enum class FilesMoveStatus { public data class FilesMoveOutcome( val requestId: FilesRequestId, val intent: FilesFolderOperationIntent.Move, + /** The first item's name; a move of several names them by count. */ val itemName: String, val errors: List? = null, val failure: PutioFailure? = null, val status: FilesMoveStatus = FilesMoveStatus.CHECKING, -) + /** The items the check found outside the destination, and those a later page lists again. */ + val remainingIds: Set = emptySet(), +) { + val itemCount: Int get() = intent.itemIds.size + val remainingCount: Int get() = remainingIds.size +} diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveTargetMemory.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveTargetMemory.kt index 15ba9d35c..16a7b4ba8 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveTargetMemory.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesMoveTargetMemory.kt @@ -9,10 +9,12 @@ public data class FilesMoveTargetMemory( val remember: Boolean = false, val lastTarget: List = emptyList(), ) { - /** Where the picker opens, below root; a move never opens inside the item it moves. */ - public fun startPath(sourceItem: FilesItem?): List = when { + /** Where the picker opens, below root; a move never opens inside an item it moves. */ + public fun startPath(sourceItem: FilesItem?): List = startPath(listOfNotNull(sourceItem)) + + public fun startPath(sourceItems: List): List = when { !remember -> emptyList() - sourceItem != null && lastTarget.any { it.id == sourceItem.id } -> emptyList() + lastTarget.any { target -> sourceItems.any { it.id == target.id } } -> emptyList() else -> lastTarget } diff --git a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesRepository.kt b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesRepository.kt index f49f0dd6c..a496513ed 100644 --- a/domain/files/src/main/kotlin/io/putdotio/android/files/FilesRepository.kt +++ b/domain/files/src/main/kotlin/io/putdotio/android/files/FilesRepository.kt @@ -26,7 +26,8 @@ public interface FilesRepository : FilesCopyRepository { cursor: FilesCursor? = null, ): PutioResult - public suspend fun move(itemId: FilesItemId, destinationId: FilesItemId): PutioResult> + /** Moves [itemIds] into [destinationId] in one request; put.io answers with the items it refused. */ + public suspend fun move(itemIds: List, destinationId: FilesItemId): PutioResult> public suspend fun persistSort( folderId: FilesItemId, @@ -35,7 +36,8 @@ public interface FilesRepository : FilesCopyRepository { public suspend fun rename(itemId: FilesItemId, name: String): PutioResult - public suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode): PutioResult + /** Deletes [itemIds] in one request. */ + public suspend fun delete(itemIds: List, mode: FilesDeleteMode): PutioResult public suspend fun resolveItem(itemId: FilesItemId): PutioResult } @@ -59,8 +61,8 @@ public interface FilesItemResolver { internal class SdkFilesMutations( val rename: suspend (Long, String) -> Unit, - val delete: suspend (Long, Boolean) -> FileDeleteResult, - val move: suspend (Long, Long) -> List, + val delete: suspend (List, Boolean) -> FileDeleteResult, + val move: suspend (List, Long) -> List, val createFolder: suspend (String, Long) -> PutioFile = { _, _ -> error("Folder creation is not wired") }, ) @@ -114,10 +116,8 @@ public class SdkFilesRepository internal constructor( client.files.rename(fileId, name) Unit }, - delete = { fileId, skipTrash -> - client.files.delete(fileIds = listOf(fileId), skipTrash = skipTrash) - }, - move = { fileId, destinationId -> client.files.move(listOf(fileId), destinationId) }, + delete = { fileIds, skipTrash -> client.files.delete(fileIds = fileIds, skipTrash = skipTrash) }, + move = { fileIds, destinationId -> client.files.move(fileIds, destinationId) }, createFolder = { name, parentId -> client.files.createFolder(name = name, parentId = parentId) }, ), copies = SdkFilesCopies( @@ -158,14 +158,17 @@ public class SdkFilesRepository internal constructor( } override suspend fun move( - itemId: FilesItemId, + itemIds: List, destinationId: FilesItemId, ): PutioResult> = putioRequest { - // A lone zero is a bulk selector, never a single source item. - require(itemId.value > 0L && destinationId.value >= 0L && itemId != destinationId) { - "Move requires one positive source ID and a different nonnegative destination ID" + // A zero is a bulk selector, never a source item. + require( + itemIds.isNotEmpty() && itemIds.all { it.value > 0L } && destinationId.value >= 0L && + destinationId !in itemIds, + ) { + "Move requires positive source IDs and a different nonnegative destination ID" } - mutations.move(itemId.value, destinationId.value) + mutations.move(itemIds.map(FilesItemId::value), destinationId.value) } override suspend fun persistSort( @@ -188,11 +191,11 @@ public class SdkFilesRepository internal constructor( mutations.createFolder(trimmed, parentId.value).toFilesItem() } - override suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode): PutioResult = + override suspend fun delete(itemIds: List, mode: FilesDeleteMode): PutioResult = putioRequest { - // A lone zero means every root item at the API boundary. - require(itemId.value > 0L) { "Delete requires one positive file ID" } - mutations.delete(itemId.value, mode == FilesDeleteMode.PERMANENT) + // A zero means every root item at the API boundary. + require(itemIds.isNotEmpty() && itemIds.all { it.value > 0L }) { "Delete requires positive file IDs" } + mutations.delete(itemIds.map(FilesItemId::value), mode == FilesDeleteMode.PERMANENT) } } diff --git a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteControllerTest.kt b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteControllerTest.kt index bb8ffc174..2d7701b76 100644 --- a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteControllerTest.kt +++ b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteControllerTest.kt @@ -126,12 +126,12 @@ class FilesDeleteControllerTest { } } override suspend fun delete( - itemId: FilesItemId, + itemIds: List, mode: FilesDeleteMode, ): PutioResult { - assertEquals(item.id, itemId) + assertEquals(listOf(item.id), itemIds) assertEquals(FilesDeleteMode.TRASH, mode) - posts += itemId + posts += itemIds postStarted.complete(Unit) return postResult.await() } @@ -163,7 +163,7 @@ class FilesDeleteControllerTest { return if (reads.size == 1) failure("read offline") else PutioResult.Success(item) } override suspend fun delete( - itemId: FilesItemId, + itemIds: List, mode: FilesDeleteMode, ): PutioResult { deletes += 1 diff --git a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteReducerTest.kt b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteReducerTest.kt index ac79b3bb2..cc961180b 100644 --- a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteReducerTest.kt +++ b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesDeleteReducerTest.kt @@ -24,14 +24,14 @@ class FilesDeleteReducerTest { fun rejectsSentinelsMissingItemsStaleFoldersAndDuplicateSubmission() { val root = loadedRoot(listOf(item, file(0L), file(-1L))) val invalid = listOf( - event.copy(itemId = FilesItemId(0L)), event.copy(itemId = FilesItemId(-1L)), - event.copy(itemId = FilesItemId(99L)), event.copy(folderId = FilesItemId(99L)), + event.copy(itemIds = listOf(FilesItemId(0L))), event.copy(itemIds = listOf(FilesItemId(-1L))), + event.copy(itemIds = listOf(FilesItemId(99L))), event.copy(folderId = FilesItemId(99L)), ) invalid.forEach { assertFalse(FilesBrowserReducer.reduce(root, it).consumed) } for (mode in FilesDeleteMode.entries) { val deleting = FilesBrowserReducer.reduce(root, event.copy(mode = mode)) val effect = deleting.effect as FilesBrowserEffect.Delete - assertEquals(item.id, effect.itemId) + assertEquals(listOf(item.id), effect.itemIds) assertEquals(mode, effect.mode) val conflicts = listOf( event, FilesBrowserEvent.Refresh, FilesBrowserEvent.SelectSort(FilesSort.SIZE_ASCENDING), @@ -58,7 +58,7 @@ class FilesDeleteReducerTest { val deleting = FilesBrowserReducer.reduce(paging.state, event) val checking = acknowledge(deleting) val check = checking.effect as FilesBrowserEffect.CheckDelete - assertEquals(item.id, check.itemId) + assertEquals(listOf(item.id), check.itemIds) assertEquals(listOf(item), checking.state.current.content.items()) assertFalse(FilesBrowserReducer.reduce(checking.state, FilesBrowserEvent.LoadSucceeded( checkNotNull(paging.effect).requestId, FilesPage(listOf(file(8L)), null), @@ -154,7 +154,7 @@ class FilesDeleteReducerTest { )) assertEquals( - outcome.copy(status = FilesDeleteStatus.STILL_PRESENT, announced = true), + outcome.copy(status = FilesDeleteStatus.STILL_PRESENT, announced = true, remainingIds = setOf(item.id)), appended.state.current.deleteOutcome, ) } @@ -188,7 +188,7 @@ class FilesDeleteReducerTest { checkNotNull(paging.effect).requestId, FilesPage(listOf(item), null), )) assertEquals( - previousOutcome.copy(status = FilesDeleteStatus.STILL_PRESENT), + previousOutcome.copy(status = FilesDeleteStatus.STILL_PRESENT, remainingIds = setOf(item.id)), appended.state.current.deleteOutcome, ) assertEquals(listOf(file(8L), item), appended.state.current.content.items()) @@ -332,7 +332,9 @@ class FilesDeleteReducerTest { val child = FilesBrowserReducer.reduce(opening.state, FilesBrowserEvent.LoadSucceeded( checkNotNull(opening.effect).requestId, FilesPage(listOf(file(8L)), null), )).state - val childDelete = FilesBrowserReducer.reduce(child, event.copy(folderId = item.id, itemId = FilesItemId(8L))) + val childDelete = FilesBrowserReducer.reduce( + child, event.copy(folderId = item.id, itemIds = listOf(FilesItemId(8L))), + ) val checking = acknowledge(childDelete) val checkFailed = checked(checking, PutioResult.Failure(unexpected("offline"))) val reloading = checked(checking, PutioResult.Success(file(8L))) diff --git a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesManyItemsReducerTest.kt b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesManyItemsReducerTest.kt new file mode 100644 index 000000000..cbef582af --- /dev/null +++ b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesManyItemsReducerTest.kt @@ -0,0 +1,204 @@ +package io.putdotio.android.files + +import io.putdotio.android.PutioFailure +import io.putdotio.android.PutioResult +import io.putdotio.android.toPutioFailure +import io.putdotio.sdk.errors.PutioApiErrorEnvelope +import io.putdotio.sdk.errors.PutioApiException +import io.putdotio.sdk.errors.PutioConfigurationException +import io.putdotio.sdk.errors.PutioRequestData +import io.putdotio.sdk.files.FileDeleteResult +import io.putdotio.sdk.files.FileMoveError +import io.putdotio.sdk.files.PutioFileType +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** A selection deletes or moves its items in one request and checks each of them before the reload. */ +class FilesManyItemsReducerTest { + private val first = file(7L) + private val second = file(8L) + private val other = file(9L) + private val ids = listOf(first.id, second.id) + + @Test + fun deleteTakesOnlyListedDistinctRealItemsAndBlocksEachOfThem() { + val root = loadedRoot() + val invalid = listOf( + listOf(first.id, FilesItemId(99L)), listOf(first.id, first.id), emptyList(), + listOf(first.id, FilesItemId(0L)), + ) + invalid.forEach { itemIds -> + val event = FilesBrowserEvent.Delete(FilesFolder.Root.id, itemIds, FilesDeleteMode.TRASH) + assertFalse(FilesBrowserReducer.reduce(root, event).consumed) + } + val deleting = FilesBrowserReducer.reduce(root, deleteBoth()) + assertEquals(ids, (deleting.effect as FilesBrowserEffect.Delete).itemIds) + // Neither item opens while the delete settles. + ids.forEach { + assertFalse(FilesBrowserReducer.reduce(deleting.state, FilesBrowserEvent.OpenFolder(it)).consumed) + } + assertEquals(2, deleting.state.current.deleteOutcome?.itemCount) + } + + @Test + fun deleteIsConfirmedOnlyWhenEveryItemIsGoneAndCountsTheOnesLeft() { + val checking = acknowledge(FilesBrowserReducer.reduce(loadedRoot(), deleteBoth())) + assertEquals(ids, (checking.effect as FilesBrowserEffect.CheckDelete).itemIds) + + val gone = checkDelete(checking, listOf(notFound(), notFound())).state.current.deleteOutcome + assertEquals(FilesDeleteStatus.NO_LONGER_AVAILABLE, gone?.status) + assertEquals(0, gone?.remainingCount) + + val partly = checkDelete(checking, listOf(notFound(), PutioResult.Success(second))).state.current.deleteOutcome + assertEquals(FilesDeleteStatus.STILL_PRESENT, partly?.status) + assertEquals(1, partly?.remainingCount) + + // A check that answered for some other number of items proves nothing, so it can be retried. + val mismatched = checkDelete(checking, listOf(notFound())) + assertTrue(mismatched.state.current.operation is FilesFolderOperation.Failed) + } + + @Test + fun aConfirmedDeleteOfSeveralTurnsStillPresentWhenTheReloadListsOne() { + val checking = acknowledge(FilesBrowserReducer.reduce(loadedRoot(), deleteBoth())) + val reloading = checkDelete(checking, listOf(notFound(), notFound())) + val reloaded = FilesBrowserReducer.reduce(reloading.state, FilesBrowserEvent.LoadSucceeded( + checkNotNull(reloading.effect).requestId, FilesPage(listOf(second, other), null), + )).state.current.deleteOutcome + assertEquals(FilesDeleteStatus.STILL_PRESENT, reloaded?.status) + assertEquals(1, reloaded?.remainingCount) + } + + @Test + fun laterPagesAddEachItemTheyListAgainOnce() { + val checking = acknowledge(FilesBrowserReducer.reduce(loadedRoot(), deleteBoth())) + val reloading = checkDelete(checking, listOf(notFound(), notFound())) + val firstPage = FilesBrowserReducer.reduce(reloading.state, FilesBrowserEvent.LoadSucceeded( + checkNotNull(reloading.effect).requestId, FilesPage(listOf(first), FilesCursor("next")), + )).state + val paging = FilesBrowserReducer.reduce(firstPage, FilesBrowserEvent.LoadNextPage) + val secondPage = FilesBrowserReducer.reduce(paging.state, FilesBrowserEvent.LoadSucceeded( + checkNotNull(paging.effect).requestId, FilesPage(listOf(first, second), null), + )).state.current.deleteOutcome + assertEquals(FilesDeleteStatus.STILL_PRESENT, secondPage?.status) + assertEquals(ids.toSet(), secondPage?.remainingIds) + } + + @Test + fun aRejectedSessionWinsOverOtherCheckFailures() { + val checking = acknowledge(FilesBrowserReducer.reduce(loadedRoot(), deleteBoth())) + val offline = PutioResult.Failure(PutioFailure.NetworkUnavailable(IllegalStateException("offline"))) + val rejected = PutioResult.Failure(PutioFailure.AuthenticationRequired(PutioConfigurationException("401"))) + val failed = checkDelete(checking, listOf(offline, rejected)).state.current.operation + assertTrue((failed as FilesFolderOperation.Failed).failure is PutioFailure.AuthenticationRequired) + } + + @Test + fun moveRefusesADestinationThatIsOneOfTheItemsOrTheirFolder() { + val root = loadedRoot() + listOf(first.id, second.id, FilesFolder.Root.id).forEach { destination -> + assertFalse(FilesBrowserReducer.reduce(root, moveBoth(destination)).consumed) + } + val moving = FilesBrowserReducer.reduce(root, moveBoth(other.id)) + val effect = moving.effect as FilesBrowserEffect.Move + assertEquals(ids, effect.itemIds) + assertEquals(other.id, effect.destinationId) + } + + @Test + fun moveIsMovedOnlyWhenEveryItemIsInTheDestination() { + val moving = FilesBrowserReducer.reduce(loadedRoot(), moveBoth(other.id)) + val checking = FilesBrowserReducer.reduce(moving.state, FilesBrowserEvent.MoveFinished( + checkNotNull(moving.effect).requestId, PutioResult.Success(emptyList()), + )) + assertEquals(ids, (checking.effect as FilesBrowserEffect.CheckMove).itemIds) + val moved = checkMove(checking, listOf(first, second).map { PutioResult.Success(it.copy(parentId = other.id)) }) + assertEquals(FilesMoveStatus.MOVED, moved.state.current.moveOutcome?.status) + val partly = checkMove(checking, listOf( + PutioResult.Success(first.copy(parentId = other.id)), PutioResult.Success(second), + )).state.current.moveOutcome + assertEquals(FilesMoveStatus.STILL_PRESENT, partly?.status) + assertEquals(1, partly?.remainingCount) + } + + @Test + fun theControllerSendsOneRequestAndReadsEachItemBack() = runBlocking { + val deletes = mutableListOf>() + val reads = mutableListOf() + val repository = object : StubFilesRepository() { + var loads = 0 + override suspend fun loadFolder(folderId: FilesItemId): PutioResult { + loads += 1 + val items = if (loads == 1) listOf(first, second, other) else listOf(other) + return PutioResult.Success(FilesPage(items, null)) + } + override suspend fun delete( + itemIds: List, + mode: FilesDeleteMode, + ): PutioResult { + deletes += itemIds + return PutioResult.Success(FileDeleteResult(status = "OK")) + } + override suspend fun resolveItem(itemId: FilesItemId): PutioResult { + synchronized(reads) { reads += itemId } + return notFound() + } + override suspend fun move(itemIds: List, destinationId: FilesItemId): + PutioResult> = error("Unexpected move") + } + val controller = FilesBrowserController(repository, this) + try { + withTimeout(5_000) { controller.state.first { it.current.content is FilesContent.Ready } } + assertTrue(controller.dispatch(deleteBoth())) + val settled = withTimeout(5_000) { + controller.state.first { it.current.deleteOutcome?.status == FilesDeleteStatus.NO_LONGER_AVAILABLE && + it.current.operation == FilesFolderOperation.Idle } + } + assertEquals(listOf(ids), deletes) + assertEquals(ids.toSet(), reads.toSet()) + assertEquals(listOf(other), settled.current.content.items()) + } finally { + controller.close() + } + } + + private fun deleteBoth() = FilesBrowserEvent.Delete(FilesFolder.Root.id, ids, FilesDeleteMode.TRASH) + + private fun moveBoth(destination: FilesItemId) = FilesBrowserEvent.Move(FilesFolder.Root.id, ids, destination) + + private fun acknowledge(deleting: FilesBrowserTransition) = + FilesBrowserReducer.reduce(deleting.state, FilesBrowserEvent.DeleteFinished( + checkNotNull(deleting.effect).requestId, PutioResult.Success(FileDeleteResult(status = "OK")), + )) + + private fun checkDelete(checking: FilesBrowserTransition, results: List>) = + FilesBrowserReducer.reduce( + checking.state, FilesBrowserEvent.DeleteChecked(checkNotNull(checking.effect).requestId, results), + ) + + private fun checkMove(checking: FilesBrowserTransition, results: List>) = + FilesBrowserReducer.reduce( + checking.state, FilesBrowserEvent.MoveChecked(checkNotNull(checking.effect).requestId, results), + ) + + private fun loadedRoot(): FilesBrowserState { + val started = FilesBrowserReducer.start() + return FilesBrowserReducer.reduce(started.state, FilesBrowserEvent.LoadSucceeded( + checkNotNull(started.effect).requestId, FilesPage(listOf(first, second, other, file(0L)), null), + )).state + } + + private fun file(id: Long) = + FilesItem(FilesItemId(id), FilesItemId(0L), "folder-$id", PutioFileType.FOLDER, 1L, "2026-09-06") + + private fun notFound(): PutioResult = PutioResult.Failure(PutioApiException( + request = PutioRequestData("GET", "https://api.put.io/v2/files/7"), + resolvedStatusCode = 404, httpStatusCode = 404, resolvedErrorType = null, + envelope = PutioApiErrorEnvelope(statusCode = 404), responseBody = "{}", message = "Not found", + ).toPutioFailure()) +} diff --git a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveControllerTest.kt b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveControllerTest.kt index a02287cd5..c19697d7b 100644 --- a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveControllerTest.kt +++ b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveControllerTest.kt @@ -38,7 +38,7 @@ class FilesMoveControllerTest { try { awaitCancellation() } finally { pagingCancelled.complete(Unit) } } override suspend fun move( - itemId: FilesItemId, + itemIds: List, destinationId: FilesItemId, ): PutioResult> { moves += 1 diff --git a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveReducerTest.kt b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveReducerTest.kt index 78e36b18f..c7add000e 100644 --- a/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveReducerTest.kt +++ b/domain/files/src/test/kotlin/io/putdotio/android/files/FilesMoveReducerTest.kt @@ -22,14 +22,14 @@ class FilesMoveReducerTest { fun guardsInvalidSelectionsAndDuplicateSubmissionWithoutReplacingSourceState() { val root = loadedRoot(listOf(item, file(0L), file(-1L))) val invalid = listOf( - move.copy(itemId = FilesItemId(0L)), move.copy(itemId = FilesItemId(-1L)), - move.copy(itemId = FilesItemId(99L)), move.copy(folderId = FilesItemId(8L)), + move.copy(itemIds = listOf(FilesItemId(0L))), move.copy(itemIds = listOf(FilesItemId(-1L))), + move.copy(itemIds = listOf(FilesItemId(99L))), move.copy(folderId = FilesItemId(8L)), move.copy(destinationId = FilesItemId(-1L)), move.copy(destinationId = FilesFolder.Root.id), move.copy(destinationId = item.id), ) invalid.forEach { assertFalse(FilesBrowserReducer.reduce(root, it).consumed) } val moving = FilesBrowserReducer.reduce(root, move) - assertEquals(item.id, (moving.effect as FilesBrowserEffect.Move).itemId) + assertEquals(listOf(item.id), (moving.effect as FilesBrowserEffect.Move).itemIds) assertEquals(root.path, moving.state.path) assertFalse(FilesBrowserReducer.reduce(moving.state, move).consumed) assertFalse(FilesBrowserReducer.reduce(moving.state, FilesBrowserEvent.Refresh).consumed) @@ -76,7 +76,7 @@ class FilesMoveReducerTest { assertEquals(listOf(item), failed.current.content.items()) assertFalse(FilesBrowserReducer.reduce(failed, move).consumed) val retry = FilesBrowserReducer.reduce(failed, FilesBrowserEvent.Retry) - assertEquals(item.id, (retry.effect as FilesBrowserEffect.CheckMove).itemId) + assertEquals(listOf(item.id), (retry.effect as FilesBrowserEffect.CheckMove).itemIds) val recovered = check(retry, PutioResult.Success(item.copy(parentId = move.destinationId))) assertEquals( FilesMoveStatus.MOVED, diff --git a/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesDeleteRepositoryTest.kt b/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesDeleteRepositoryTest.kt index e8210d243..168cec1c6 100644 --- a/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesDeleteRepositoryTest.kt +++ b/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesDeleteRepositoryTest.kt @@ -16,23 +16,25 @@ import io.putdotio.android.PutioResult class SdkFilesDeleteRepositoryTest { @Test - fun sendsOnlyOnePositiveIdWithExplicitTrashModeAndRetainsSkippedResult() = runBlocking { - val calls = mutableListOf>() + fun sendsOnlyPositiveIdsInOneRequestWithExplicitTrashModeAndRetainsSkippedResult() = runBlocking { + val calls = mutableListOf, Boolean>>() val result = FileDeleteResult(cursor = "skipped-folder", skipped = 1, status = "OK") - val repository = repository { id, skipTrash -> - calls += id to skipTrash + val repository = repository { ids, skipTrash -> + calls += ids to skipTrash result } for (mode in FilesDeleteMode.entries) { - val response = repository.delete(FilesItemId(7L), mode) as PutioResult.Success + val response = repository.delete(listOf(FilesItemId(7L)), mode) as PutioResult.Success assertSame(result, response.value) } - assertEquals(listOf(7L to false, 7L to true), calls) - for (id in listOf(0L, -1L)) { - val response = repository.delete(FilesItemId(id), FilesDeleteMode.TRASH) as PutioResult.Failure + repository.delete(listOf(FilesItemId(7L), FilesItemId(8L)), FilesDeleteMode.TRASH) + assertEquals(listOf(listOf(7L) to false, listOf(7L) to true, listOf(7L, 8L) to false), calls) + // A zero or negative ID is a bulk selector at the API boundary, and an empty list deletes nothing. + for (ids in listOf(listOf(0L), listOf(-1L), listOf(7L, 0L), emptyList())) { + val response = repository.delete(ids.map(::FilesItemId), FilesDeleteMode.TRASH) as PutioResult.Failure assertTrue(response.failure.cause is IllegalArgumentException) } - assertEquals(2, calls.size) + assertEquals(3, calls.size) } @Test @@ -46,7 +48,7 @@ class SdkFilesDeleteRepositoryTest { ) val wrapped = PutioOperationException("files", "delete", null, null, error) val failure = (repository { _, _ -> throw wrapped }.delete( - FilesItemId(7L), FilesDeleteMode.TRASH, + listOf(FilesItemId(7L)), FilesDeleteMode.TRASH, ) as PutioResult.Failure).failure assertSame(wrapped, failure.cause) when (code) { @@ -60,13 +62,13 @@ class SdkFilesDeleteRepositoryTest { ) val wrapped = PutioOperationException("files", "delete", null, null, invalid) val failure = (repository { _, _ -> throw wrapped }.delete( - FilesItemId(7L), FilesDeleteMode.TRASH, + listOf(FilesItemId(7L)), FilesDeleteMode.TRASH, ) as PutioResult.Failure).failure assertTrue(failure is PutioFailure.InvalidResponse) assertSame(wrapped, failure.cause) } - private fun repository(deleteFile: suspend (Long, Boolean) -> FileDeleteResult) = SdkFilesRepository( + private fun repository(deleteFile: suspend (List, Boolean) -> FileDeleteResult) = SdkFilesRepository( listFolder = { _, _ -> error("Unexpected folder load") }, continueListing = { _, _ -> error("Unexpected page") }, setSort = { _, _ -> error("Unexpected sort") }, diff --git a/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesMoveRepositoryTest.kt b/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesMoveRepositoryTest.kt index 4b54547ba..5366c2ab4 100644 --- a/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesMoveRepositoryTest.kt +++ b/domain/files/src/test/kotlin/io/putdotio/android/files/SdkFilesMoveRepositoryTest.kt @@ -16,17 +16,23 @@ import io.putdotio.android.PutioResult class SdkFilesMoveRepositoryTest { @Test - fun moveSendsOneSourceIdAndPreservesEveryPerItemError() = runBlocking { + fun moveSendsTheSourceIdsInOneRequestAndPreservesEveryPerItemError() = runBlocking { val errors = listOf(FileMoveError("FUTURE_ERROR", 999L, null, 409)) - val calls = mutableListOf>() - val repository = repository(move = { source, destination -> calls += source to destination; errors }) - val result = repository.move(FilesItemId(7L), FilesFolder.Root.id) as PutioResult.Success + val calls = mutableListOf, Long>>() + val repository = repository(move = { sources, destination -> calls += sources to destination; errors }) + val result = repository.move(listOf(FilesItemId(7L)), FilesFolder.Root.id) as PutioResult.Success assertSame(errors, result.value) - assertEquals(listOf(7L to 0L), calls) - for ((source, destination) in listOf(0L to 9L, -1L to 9L, 7L to -1L, 7L to 7L)) { - assertTrue(repository.move(FilesItemId(source), FilesItemId(destination)) is PutioResult.Failure) + repository.move(listOf(FilesItemId(7L), FilesItemId(8L)), FilesItemId(9L)) + assertEquals(listOf(listOf(7L) to 0L, listOf(7L, 8L) to 9L), calls) + val refused = listOf( + listOf(0L) to 9L, listOf(-1L) to 9L, listOf(7L) to -1L, listOf(7L) to 7L, + listOf(7L, 9L) to 9L, listOf(7L, 0L) to 9L, emptyList() to 9L, + ) + for ((sources, destination) in refused) { + val moved = repository.move(sources.map(::FilesItemId), FilesItemId(destination)) + assertTrue(moved is PutioResult.Failure) } - assertEquals(1, calls.size) + assertEquals(2, calls.size) } @Test @@ -62,7 +68,7 @@ class SdkFilesMoveRepositoryTest { } private fun repository( - move: suspend (Long, Long) -> List = { _, _ -> error("Unexpected move") }, + move: suspend (List, Long) -> List = { _, _ -> error("Unexpected move") }, list: suspend (Long, FilesListQuery) -> FilesListResponse = { _, _ -> error("Unexpected listing") }, continueList: suspend (String, FilesContinueQuery) -> FilesListResponse = { _, _ -> error("Unexpected continuation") diff --git a/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/FilesStateFixtures.kt b/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/FilesStateFixtures.kt index b16c8b34e..e864c7a57 100644 --- a/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/FilesStateFixtures.kt +++ b/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/FilesStateFixtures.kt @@ -17,7 +17,8 @@ public fun filesMoveDestinationState( sourceFolderId: FilesItemId?, stack: List, nextRequestValue: Long, -): FilesMoveDestinationState = FilesMoveDestinationState(sourceItem, sourceFolderId, stack, nextRequestValue) +): FilesMoveDestinationState = + FilesMoveDestinationState(listOfNotNull(sourceItem), sourceFolderId, stack, nextRequestValue) /** [copy] for tests outside this module; request ids keep counting from this state. */ public fun FilesBrowserState.copyForTest( diff --git a/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/StubFilesRepository.kt b/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/StubFilesRepository.kt index 9e7687f40..2553cd6db 100644 --- a/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/StubFilesRepository.kt +++ b/domain/files/src/testFixtures/kotlin/io/putdotio/android/files/StubFilesRepository.kt @@ -16,10 +16,10 @@ public abstract class StubFilesRepository : FilesRepository { error("Unexpected sort") override suspend fun rename(itemId: FilesItemId, name: String): PutioResult = error("Unexpected rename") - override suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode): PutioResult = + override suspend fun delete(itemIds: List, mode: FilesDeleteMode): PutioResult = error("Unexpected delete") override suspend fun move( - itemId: FilesItemId, + itemIds: List, destinationId: FilesItemId, ): PutioResult> = error("Unexpected move") diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/FilesMoveNavigationUiProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/FilesMoveNavigationUiProofTest.kt index 517abdcd4..f5696d09a 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/FilesMoveNavigationUiProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/FilesMoveNavigationUiProofTest.kt @@ -268,7 +268,7 @@ private class RootMoveBackPreview { fun finishReadback() { val checking = effects.last() as FilesBrowserEffect.CheckMove - assertEquals(item.id, checking.itemId) + assertEquals(listOf(item.id), checking.itemIds) dispatch(FilesBrowserEvent.MoveChecked(checking.requestId, PutioResult.Success(item.copy(parentId = FilesItemId(8))))) val loading = effects.last() as FilesBrowserEffect.LoadFolder diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileMoveTargetProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileMoveTargetProofTest.kt index 0f0065a08..82d77b3a0 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileMoveTargetProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileMoveTargetProofTest.kt @@ -263,10 +263,10 @@ private class MoveTargetRepository : FilesRepository { } override suspend fun move( - itemId: FilesItemId, + itemIds: List, destinationId: FilesItemId, ): PutioResult> { - moves += itemId to destinationId + moves += itemIds.single() to destinationId return if (destinationId == NESTED.id) { PutioResult.Failure(intoItselfRefusal().toPutioFailure()) } else { @@ -288,7 +288,7 @@ private class MoveTargetRepository : FilesRepository { override suspend fun loadNextPage(cursor: FilesCursor) = error("No paging") override suspend fun persistSort(folderId: FilesItemId, sort: FilesSort) = error("No sort") override suspend fun rename(itemId: FilesItemId, name: String) = error("No rename") - override suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode) = error("No delete") + override suspend fun delete(itemIds: List, mode: FilesDeleteMode) = error("No delete") } private const val DIALOG_EXIT_MILLIS = 1_000L diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileTabletPowerProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileTabletPowerProofTest.kt index e929369c3..49f46211e 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileTabletPowerProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileTabletPowerProofTest.kt @@ -550,9 +550,9 @@ class MobileTabletPowerProofTest { return PutioResult.Success(FilesPage(if (folderId == SHOWS.id) listOf(EPISODE) else root.toList(), null)) } - override suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode): PutioResult { - deleted = deleted + (itemId to mode) - root.removeAll { it.id == itemId } + override suspend fun delete(itemIds: List, mode: FilesDeleteMode): PutioResult { + deleted = deleted + itemIds.map { it to mode } + root.removeAll { it.id in itemIds } return PutioResult.Success(FileDeleteResult(status = "OK")) } @@ -564,7 +564,10 @@ class MobileTabletPowerProofTest { override suspend fun loadMoveDestinations(folderId: FilesItemId, cursor: FilesCursor?) = error("No move") override suspend fun persistSort(folderId: FilesItemId, sort: FilesSort) = error("No sort") override suspend fun rename(itemId: FilesItemId, name: String) = error("No rename") - override suspend fun move(itemId: FilesItemId, destinationId: FilesItemId): PutioResult> = + override suspend fun move( + itemIds: List, + destinationId: FilesItemId, + ): PutioResult> = error("No move") override suspend fun startCopy(itemId: FilesItemId, destinationId: FilesItemId) = error("No copy") override suspend fun checkCopy(copyId: FilesCopyId) = error("No copy") diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt index ff0e0144e..ddb664f14 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt @@ -53,6 +53,7 @@ import io.putdotio.android.files.MobileFileOpener import io.putdotio.android.files.MobileContinueWatching import io.putdotio.android.files.MobileFilesActionsController import io.putdotio.android.files.MobileFilesItemActions +import io.putdotio.android.files.FilesSelection import io.putdotio.android.files.MobileFilesRoute import io.putdotio.android.files.MobileMoveTargetStore import io.putdotio.android.playback.MobilePlaybackViewModel @@ -160,6 +161,7 @@ internal fun MobileNavHost( subtitleLanguages: FilesSubtitleLanguages? = null, filesActions: MobileFilesActionsController? = null, folderCreator: FilesFolderCreator? = null, + filesSelection: FilesSelection? = null, ) { val currentTransfersState by rememberUpdatedState(transfersState) val currentTransfersSessionId by rememberUpdatedState(transfersSessionId) @@ -251,6 +253,7 @@ internal fun MobileNavHost( resumePlayback = accountSettingsState.confirmedResumePlayback() != false, itemActions = filesItemActions, folderCreator = folderCreator, + selection = filesSelection, ) } } diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileSelection.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileSelection.kt new file mode 100644 index 000000000..a6bf6e9e3 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileSelection.kt @@ -0,0 +1,238 @@ +package io.putdotio.android + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import io.putdotio.android.design.PutioDesignTokens +import io.putdotio.android.design.putioMono + +/** + * A list's multi-selection by row ID. Select can open it empty; a row that leaves the list leaves + * the selection too, and an empty list ends it. + */ +@Stable +internal class MobileSelection(selecting: Boolean = false, selected: List = emptyList()) { + private var selecting by mutableStateOf(selecting) + private var selectedIds by mutableStateOf(selected) + + /** The selected rows still in [selectable], in the order they were selected. */ + fun selected(selectable: Set): Set = selectedIds.filterTo(linkedSetOf()) { it in selectable } + + fun isActive(selectable: Set): Boolean = + (selecting || selected(selectable).isNotEmpty()) && selectable.isNotEmpty() + + fun start() { + selecting = true + } + + fun toggle(id: Long) { + selecting = true + selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id + } + + fun selectAll(selectable: Collection) { + selecting = true + selectedIds = selectable.toList() + } + + fun end() { + selecting = false + selectedIds = emptyList() + } + + /** Forgets rows no longer in [selectable], so one that comes back is not checked again. */ + fun retain(selectable: Set) { + val kept = selectedIds.filter { it in selectable } + if (kept.size != selectedIds.size) selectedIds = kept + } + + companion object { + val Saver: Saver = Saver( + save = { listOf(it.selecting, it.selectedIds.toLongArray()) }, + restore = { saved -> + val values = saved as List<*> + MobileSelection(values[0] as Boolean, (values[1] as LongArray).toList()) + }, + ) + } +} + +/** + * The bar that replaces a list's top bar in selection: close, the count in mono and select all, on + * `surface-container-high`. The status bar keeps the page colour. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MobileSelectionBar( + count: Int, + onSelectAll: () -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.windowInsetsPadding(TopAppBarDefaults.windowInsets)) { + TopAppBar( + title = { Text(mobileSelectedCount(count)) }, + navigationIcon = { + IconButton(onClick = onClear) { + Icon( + painterResource(R.drawable.ic_ph_x), + contentDescription = stringResource(R.string.mobile_selection_clear), + ) + } + }, + actions = { + IconButton(onClick = onSelectAll) { + Icon( + painterResource(R.drawable.ic_ph_checks), + contentDescription = stringResource(R.string.mobile_selection_select_all), + ) + } + }, + windowInsets = WindowInsets(0), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } +} + +/** "3 selected", its count in mono. */ +@Composable +private fun mobileSelectedCount(count: Int): AnnotatedString { + val text = pluralStringResource(R.plurals.mobile_selection_count, count, count) + val number = count.toString() + val at = text.indexOf(number) + val mono = MaterialTheme.typography.titleLarge.putioMono().toSpanStyle() + return if (at < 0) { + AnnotatedString(text) + } else { + buildAnnotatedString { + append(text.substring(0, at)) + withStyle(mono) { append(number) } + append(text.substring(at + number.length)) + } + } +} + +/** A selectable row's checkbox in place of its leading glyph: yellow checked, `solid` outline unchecked. */ +@Composable +internal fun MobileSelectionCheckbox(checked: Boolean, modifier: Modifier = Modifier) { + Checkbox( + checked = checked, + onCheckedChange = null, + colors = CheckboxDefaults.colors(uncheckedColor = PutioDesignTokens.solid), + modifier = modifier, + ) +} + +/** A selected row's 9% yellow tint. */ +internal val MobileSelectedRowTint: Color = PutioDesignTokens.yellowSolid.copy(alpha = 0.09f) + +/** + * The selection's floating toolbar: Material 3's floating toolbar is still internal here, so this + * is its tonal container, without a shadow, holding [MobileToolbarTile]s. + */ +@Composable +internal fun MobileSelectionToolbar(modifier: Modifier = Modifier, tiles: @Composable RowScope.() -> Unit) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(TOOLBAR_CORNER), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + // Large text can make the tiles wider than the screen; the row then scrolls, keeping each reachable. + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()).padding(TOOLBAR_PADDING), + horizontalArrangement = Arrangement.spacedBy(TOOLBAR_GAP), + content = tiles, + ) + } +} + +/** An icon over a label-md word, in a target of at least 64x52dp. */ +@Composable +internal fun MobileToolbarTile( + @DrawableRes icon: Int, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String = label, + color: Color = LocalContentColor.current, + enabled: Boolean = true, +) { + val tint = if (enabled) color else color.copy(alpha = DISABLED_ALPHA) + Column( + modifier = modifier + // At least 64x52dp; a large font grows the tile instead of clipping its word. + .defaultMinSize(TILE_WIDTH, TILE_HEIGHT) + .clip(RoundedCornerShape(TILE_CORNER)) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics { contentDescription = description } + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically), + ) { + Icon(painterResource(icon), contentDescription = null, tint = tint, modifier = Modifier.size(TILE_ICON)) + Text(label, style = MaterialTheme.typography.labelMedium, color = tint, maxLines = 1) + } +} + +/** How far the floating toolbar sits above the bottom of its list. */ +internal val MobileSelectionToolbarOffset = 8.dp + +/** Space under the toolbar's own height, so a list's last row ends above it with a gap. */ +internal val MobileSelectionToolbarClearance = MobileSelectionToolbarOffset + 16.dp + +private const val DISABLED_ALPHA = 0.38f +private val TOOLBAR_PADDING = 6.dp +private val TOOLBAR_GAP = 4.dp +private val TOOLBAR_CORNER = 28.dp +private val TILE_WIDTH = 64.dp +private val TILE_HEIGHT = 52.dp +private val TILE_CORNER = 22.dp +private val TILE_ICON = 22.dp diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt index e868bcbe7..032c0942b 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt @@ -81,9 +81,12 @@ import io.putdotio.android.files.isFilesListDetail import io.putdotio.android.files.showsFilesListDetail import io.putdotio.android.files.MobileContinueWatching import io.putdotio.android.files.MobileFilesActionsController +import io.putdotio.android.files.FilesSelection +import io.putdotio.android.files.MOBILE_FILES_SELECTION_BAR_TAG import io.putdotio.android.files.MobileFilesBarActions import io.putdotio.android.files.MobileFilesSearchBar import io.putdotio.android.files.MobileFolderTopBar +import io.putdotio.android.files.rememberFilesSelection import io.putdotio.android.files.MobileNewFolderHost import io.putdotio.android.files.rememberNewFolderRequest import io.putdotio.android.files.mobileMessage @@ -258,6 +261,13 @@ internal fun MobileShell( val downloadsState = downloadsController?.state?.collectAsStateWithLifecycle()?.value ?: DownloadsState() // The Downloads top bar and list share one selection: in selection its bar replaces the title. val downloadsSelection = rememberDownloadsSelection() + // So do Files' bar and folder; its selection belongs to the folder it started in. + val filesSelection = rememberFilesSelection(sessionId, filesState) + val filesSelecting = filesSelection.isShown( + filesState, + onFiles = selectedDestination == MobileDestination.Files, + searchOpen = searchOpen, + ) val trashState = trashController?.state?.collectAsStateWithLifecycle()?.value TrashRestoreEffects(trashState, onFilesEvent) val shareNavigationBlocked = shareNavigationBlocked(filesState, trashState, transfersState, nowPlayingPending) @@ -330,7 +340,7 @@ internal fun MobileShell( searchForward = false } }, - ) + ).endingSelection(filesSelecting, filesSelection::end) val onFilesBack = filesBack.onBack val filesBackPreview = rememberMobileBackPreview() MobilePredictiveBackHandler( @@ -419,6 +429,7 @@ internal fun MobileShell( downloads = downloadsController?.let { MobileDownloadsBar(downloadsState, downloadsSelection, it::dispatch) }, + filesSelection = filesSelection.takeIf { filesSelecting }, ) } val paneTopBar: @Composable () -> Unit = { @@ -476,6 +487,7 @@ internal fun MobileShell( subtitleLanguages = subtitleLanguages, filesActions = filesActions, folderCreator = folderCreator, + filesSelection = filesSelection, ) } } @@ -793,7 +805,14 @@ private fun TransferFileNavigationEffect( } /** Who takes Back on Files, whether the gesture may preview it, and what it does. */ -private class FilesBack(val ownsBack: Boolean, val previewable: Boolean, val onBack: () -> Unit) +private class FilesBack(val ownsBack: Boolean, val previewable: Boolean, val onBack: () -> Unit) { + /** + * A Files selection on screen takes Back first and ends; the folder stays, so nothing previews. + * Search covers the selection, so it is not on screen then. + */ + fun endingSelection(selecting: Boolean, end: () -> Unit): FilesBack = + if (selecting) FilesBack(ownsBack = true, previewable = false, onBack = end) else this +} /** * Back closes Search, then leaves a folder; a folder opened from Search, History or Transfers @@ -1043,6 +1062,8 @@ private fun MobileTopBar( tabActions: (@Composable RowScope.() -> Unit)? = null, downloads: MobileDownloadsBar? = null, subpageActions: (@Composable RowScope.() -> Unit)? = null, + /** Set while Files is selecting; its bar then replaces the folder's or the root's. */ + filesSelection: FilesSelection? = null, ) { val subpageTitle = accountSubpageTitle(route) when { @@ -1070,6 +1091,12 @@ private fun MobileTopBar( tabActions?.invoke(this) } searchOpen -> searchBar(openNavigation?.let { { MobileNavigationMenuButton(it) } }) + filesSelection != null -> MobileSelectionBar( + count = filesSelection.selected(filesState).size, + onSelectAll = { filesSelection.selectAll(filesState) }, + onClear = filesSelection::end, + modifier = Modifier.testTag(MOBILE_FILES_SELECTION_BAR_TAG), + ) else -> MobileFilesTopBar(filesState, onFilesBack, onFilesEvent, onOpenSearch, onNewFolder, openNavigation) } } diff --git a/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt b/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt index d4f5c6b39..4344109e9 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt @@ -25,7 +25,7 @@ import io.putdotio.android.downloads.OfflinePositionsResume import io.putdotio.android.downloads.OfflineResume import io.putdotio.android.files.FilesBrowserController import io.putdotio.android.files.FilesBrowserEvent -import io.putdotio.android.files.FilesDeleteStatus +import io.putdotio.android.files.FilesDeleteOutcome import io.putdotio.android.files.FilesItemId import io.putdotio.android.files.MobileContinueWatching import io.putdotio.android.files.MobileContinueWatchingStore @@ -38,6 +38,7 @@ import io.putdotio.android.files.SdkFilesSubtitleLanguages import io.putdotio.android.files.SdkFilesStreamUrls import io.putdotio.android.files.SdkFilesWatchedRepository import io.putdotio.android.files.authoritativeSessionFailure +import io.putdotio.android.files.confirmedGone import io.putdotio.android.history.HistoryEvent import io.putdotio.android.history.SdkHistoryRepository import io.putdotio.android.history.authoritativeSessionFailure @@ -457,7 +458,7 @@ private fun SavedPositionEffects( filesController.state .map { state -> state.stack.mapNotNull { folder -> - folder.deleteOutcome?.takeIf { it.status == FilesDeleteStatus.NO_LONGER_AVAILABLE } + folder.deleteOutcome?.takeIf { it.confirmedGone().isNotEmpty() } } } .distinctUntilChanged() diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsChrome.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsChrome.kt index d3fb2f808..5ad9147cb 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsChrome.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsChrome.kt @@ -1,38 +1,26 @@ package io.putdotio.android.downloads -import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -42,20 +30,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import io.putdotio.android.MobileSelection +import io.putdotio.android.MobileSelectionBar +import io.putdotio.android.MobileSelectionToolbar import io.putdotio.android.MobileTabTitle +import io.putdotio.android.MobileToolbarTile import io.putdotio.android.R import io.putdotio.android.design.PutioDesignTokens import io.putdotio.android.design.putioMono @@ -75,48 +60,27 @@ internal const val MOBILE_DOWNLOADS_SELECTION_DELETE_TAG = "mobile-downloads-sel * long press opens one with its row; rows that leave the store, or start deleting, leave it. */ @Stable -internal class DownloadsSelection(selecting: Boolean = false, selected: List = emptyList()) { - private var selecting by mutableStateOf(selecting) - private var selectedIds by mutableStateOf(selected) +internal class DownloadsSelection(private val selection: MobileSelection = MobileSelection()) { + fun selected(state: DownloadsState): Set = + selection.selected(selectable(state)).mapTo(linkedSetOf(), ::FilesItemId) - fun selected(state: DownloadsState): Set { - val selectable = selectable(state) - return selectedIds.map(::FilesItemId).filterTo(mutableSetOf()) { it in selectable } - } + fun isActive(state: DownloadsState): Boolean = selection.isActive(selectable(state)) - fun isActive(state: DownloadsState): Boolean = - (selecting || selected(state).isNotEmpty()) && selectable(state).isNotEmpty() + fun start() = selection.start() - fun start() { - selecting = true - } + fun toggle(fileId: FilesItemId) = selection.toggle(fileId.value) - fun toggle(fileId: FilesItemId) { - selecting = true - val id = fileId.value - selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id - } - - fun selectAll(state: DownloadsState) { - selecting = true - selectedIds = selectable(state).map { it.value } - } + fun selectAll(state: DownloadsState) = selection.selectAll(selectable(state)) - fun end() { - selecting = false - selectedIds = emptyList() - } + fun end() = selection.end() - private fun selectable(state: DownloadsState): Set = - state.entries.map { it.fileId }.filterTo(mutableSetOf()) { it !in state.removing } + private fun selectable(state: DownloadsState): Set = + state.entries.filter { it.fileId !in state.removing }.mapTo(linkedSetOf()) { it.fileId.value } companion object { val Saver: Saver = Saver( - save = { listOf(it.selecting, it.selectedIds.toLongArray()) }, - restore = { saved -> - val values = saved as List<*> - DownloadsSelection(values[0] as Boolean, (values[1] as LongArray).toList()) - }, + save = { with(MobileSelection.Saver) { save(it.selection) } }, + restore = { saved -> MobileSelection.Saver.restore(saved)?.let(::DownloadsSelection) }, ) } } @@ -141,11 +105,11 @@ internal fun MobileDownloadsTopBar( ) { var choosingConcurrency by rememberSaveable { mutableStateOf(false) } if (selection.isActive(state)) { - DownloadsSelectionBar( + MobileSelectionBar( count = selection.selected(state).size, onSelectAll = { selection.selectAll(state) }, onClear = selection::end, - modifier = modifier, + modifier = modifier.testTag(MOBILE_DOWNLOADS_SELECTION_BAR_TAG), ) } else { MobileTabTitle(title = title, summary = summary, hasActions = true, modifier = modifier) { @@ -210,61 +174,6 @@ private fun DownloadsOverflow( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DownloadsSelectionBar( - count: Int, - onSelectAll: () -> Unit, - onClear: () -> Unit, - modifier: Modifier = Modifier, -) { - // The status bar keeps the page colour; only the bar itself takes the selection tone. - Box(modifier.windowInsetsPadding(TopAppBarDefaults.windowInsets)) { - TopAppBar( - title = { Text(selectedCount(count)) }, - navigationIcon = { - IconButton(onClick = onClear) { - Icon( - painterResource(R.drawable.ic_ph_x), - contentDescription = stringResource(R.string.mobile_downloads_selection_clear), - ) - } - }, - actions = { - IconButton(onClick = onSelectAll) { - Icon( - painterResource(R.drawable.ic_ph_checks), - contentDescription = stringResource(R.string.mobile_downloads_select_all), - ) - } - }, - windowInsets = WindowInsets(0), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - modifier = Modifier.testTag(MOBILE_DOWNLOADS_SELECTION_BAR_TAG), - ) - } -} - -/** "3 selected", its count in mono. */ -@Composable -private fun selectedCount(count: Int): AnnotatedString { - val text = pluralStringResource(R.plurals.mobile_downloads_selected, count, count) - val number = count.toString() - val at = text.indexOf(number) - val mono = MaterialTheme.typography.titleLarge.putioMono().toSpanStyle() - return if (at < 0) { - AnnotatedString(text) - } else { - buildAnnotatedString { - append(text.substring(0, at)) - withStyle(mono) { append(number) } - append(text.substring(at + number.length)) - } - } -} - /** * The selection's floating toolbar: Pause and Resume each show when a selected row allows them * and act on those rows; Remove asks before it removes every selected local copy. @@ -279,71 +188,32 @@ internal fun DownloadsSelectionToolbar( val rows = selected.mapNotNull(state::entry) val pausable = rows.filter { it.canPause }.mapTo(mutableSetOf()) { it.fileId } val resumable = rows.filter { it.isPausedByUser }.mapTo(mutableSetOf()) { it.fileId } - // Material 3's floating toolbar is still internal here; this is its tonal container, without a shadow. - Surface( - modifier = modifier, - shape = RoundedCornerShape(TOOLBAR_CORNER), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Row( - modifier = Modifier.padding(TOOLBAR_PADDING), - horizontalArrangement = Arrangement.spacedBy(TOOLBAR_GAP), - ) { - if (pausable.isNotEmpty()) { - ToolbarTile( - icon = R.drawable.ic_ph_pause, - label = stringResource(R.string.mobile_downloads_pause), - onClick = { onEvent(DownloadsEvent.Pause(pausable)) }, - modifier = Modifier.testTag(MOBILE_DOWNLOADS_SELECTION_PAUSE_TAG), - ) - } - if (resumable.isNotEmpty()) { - ToolbarTile( - icon = R.drawable.ic_ph_play, - label = stringResource(R.string.mobile_downloads_resume), - onClick = { onEvent(DownloadsEvent.Resume(resumable)) }, - modifier = Modifier.testTag(MOBILE_DOWNLOADS_SELECTION_RESUME_TAG), - ) - } - ToolbarTile( - icon = R.drawable.ic_ph_device_mobile_slash, - label = stringResource(R.string.mobile_downloads_remove_short), - description = stringResource(R.string.mobile_downloads_remove), - color = PutioDesignTokens.redTextSecondary, - enabled = selected.isNotEmpty(), - onClick = { onEvent(DownloadsEvent.RequestRemoval(selected)) }, - modifier = Modifier.testTag(MOBILE_DOWNLOADS_SELECTION_DELETE_TAG), + MobileSelectionToolbar(modifier) { + if (pausable.isNotEmpty()) { + MobileToolbarTile( + icon = R.drawable.ic_ph_pause, + label = stringResource(R.string.mobile_downloads_pause), + onClick = { onEvent(DownloadsEvent.Pause(pausable)) }, + modifier = Modifier.testTag(MOBILE_DOWNLOADS_SELECTION_PAUSE_TAG), ) } - } -} - -/** An icon over a label-sm word, in a target of at least 64x52dp. */ -@Composable -private fun ToolbarTile( - @DrawableRes icon: Int, - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - description: String = label, - color: Color = LocalContentColor.current, - enabled: Boolean = true, -) { - val tint = if (enabled) color else color.copy(alpha = DISABLED_ALPHA) - Column( - modifier = modifier - // At least 64x52dp; a large font grows the tile instead of clipping its word. - .defaultMinSize(TILE_WIDTH, TILE_HEIGHT) - .clip(RoundedCornerShape(TILE_CORNER)) - .clickable(enabled = enabled, role = Role.Button, onClick = onClick) - .semantics { contentDescription = description } - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically), - ) { - Icon(painterResource(icon), contentDescription = null, tint = tint, modifier = Modifier.size(TILE_ICON)) - Text(label, style = MaterialTheme.typography.labelMedium, color = tint, maxLines = 1) + if (resumable.isNotEmpty()) { + MobileToolbarTile( + icon = R.drawable.ic_ph_play, + label = stringResource(R.string.mobile_downloads_resume), + onClick = { onEvent(DownloadsEvent.Resume(resumable)) }, + modifier = Modifier.testTag(MOBILE_DOWNLOADS_SELECTION_RESUME_TAG), + ) + } + MobileToolbarTile( + icon = R.drawable.ic_ph_device_mobile_slash, + label = stringResource(R.string.mobile_downloads_remove_short), + description = stringResource(R.string.mobile_downloads_remove), + color = PutioDesignTokens.redTextSecondary, + enabled = selected.isNotEmpty(), + onClick = { onEvent(DownloadsEvent.RequestRemoval(selected)) }, + modifier = Modifier.testTag(MOBILE_DOWNLOADS_SELECTION_DELETE_TAG), + ) } } @@ -383,11 +253,3 @@ private fun ConcurrencyDialog(current: Int, onChoose: (Int) -> Unit, onDismiss: ) } -private const val DISABLED_ALPHA = 0.38f -private val TOOLBAR_PADDING = 6.dp -private val TOOLBAR_GAP = 4.dp -private val TOOLBAR_CORNER = 28.dp -private val TILE_WIDTH = 64.dp -private val TILE_HEIGHT = 52.dp -private val TILE_CORNER = 22.dp -private val TILE_ICON = 22.dp diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsScreen.kt index 439339659..90c67d74b 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadsScreen.kt @@ -20,8 +20,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -64,6 +62,10 @@ import io.putdotio.android.MobileEmptyState import io.putdotio.android.MobileMiddleCutText import io.putdotio.android.MobileRow import io.putdotio.android.MobileSectionLabel +import io.putdotio.android.MobileSelectedRowTint +import io.putdotio.android.MobileSelectionCheckbox +import io.putdotio.android.MobileSelectionToolbarClearance +import io.putdotio.android.MobileSelectionToolbarOffset import io.putdotio.android.R import io.putdotio.android.design.FileTypeIcon import io.putdotio.android.design.PutioDesignTokens @@ -100,7 +102,7 @@ internal fun MobileDownloadsScreen( val sheetEntry = state.entries.firstOrNull { it.fileId.value == sheetFileId } val inSelection = selection.isActive(state) var toolbarHeight by remember { mutableIntStateOf(0) } - val toolbarClearance = with(LocalDensity.current) { toolbarHeight.toDp() } + TOOLBAR_CLEARANCE + val toolbarClearance = with(LocalDensity.current) { toolbarHeight.toDp() } + MobileSelectionToolbarClearance val selected = selection.selected(state) LifecycleStartEffect(onEvent) { onEvent(DownloadsEvent.Shown) @@ -154,7 +156,7 @@ internal fun MobileDownloadsScreen( onEvent = onEvent, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = TOOLBAR_OFFSET) + .padding(bottom = MobileSelectionToolbarOffset) .onSizeChanged { toolbarHeight = it.height }, ) } @@ -258,11 +260,7 @@ private fun MobileDownloadRow( MobileRow( leading = { if (selected != null) { - Checkbox( - checked = selected, - onCheckedChange = null, - colors = CheckboxDefaults.colors(uncheckedColor = PutioDesignTokens.solid), - ) + MobileSelectionCheckbox(checked = selected) } else { FileTypeIcon(entry.type, Modifier.size(FILE_ICON_SIZE)) } @@ -274,7 +272,7 @@ private fun MobileDownloadRow( } else { null }, - containerColor = if (selected == true) SELECTED_TINT else Color.Transparent, + containerColor = if (selected == true) MobileSelectedRowTint else Color.Transparent, // A completed copy plays from its row, so the row is the player's starting bounds. modifier = Modifier.playbackContainer(entry.fileId.value, playable = entry.isCompleted) .downloadRowInteraction( @@ -634,7 +632,3 @@ private const val SEPARATOR = " · " private const val VALUE_ONE = "\u0001" private const val VALUE_TWO = "\u0002" private val FILE_ICON_SIZE = 26.dp -private val SELECTED_TINT = PutioDesignTokens.yellowSolid.copy(alpha = 0.09f) -private val TOOLBAR_OFFSET = 24.dp -/** Space under the toolbar's own height, so the last row ends above it with a gap. */ -private val TOOLBAR_CLEARANCE = TOOLBAR_OFFSET + 16.dp diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileContinueWatching.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileContinueWatching.kt index 795d33483..261f5c3d6 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileContinueWatching.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileContinueWatching.kt @@ -134,7 +134,7 @@ internal class MobileContinueWatching( * fresh read of each entry finds: [recheckAfterDeletes] runs it. */ fun removed(deletes: List) { - val fresh = deletes.filter { handledDeletes.add(it.requestId) }.map { it.intent.itemId } + val fresh = deletes.filter { handledDeletes.add(it.requestId) }.flatMap { it.confirmedGone() } val entryIds = mutableEntries.value.mapTo(mutableSetOf()) { it.fileId } fresh.forEach { removed(it) } if (fresh.any { it !in entryIds }) recheckRequests.value += 1 @@ -289,3 +289,13 @@ private val PutioFailure.isNotFound: Boolean private const val HTTP_NOT_FOUND = 404 private const val COMPLETED_FRACTION = 0.95 private const val FINISHED_WITHIN_SECONDS = 10.0 + +/** The items a settled delete's read-back found gone, a partial delete's included. */ +internal fun FilesDeleteOutcome.confirmedGone(): List = when (status) { + FilesDeleteStatus.NO_LONGER_AVAILABLE, + FilesDeleteStatus.STILL_PRESENT, + FilesDeleteStatus.SKIPPED, + FilesDeleteStatus.TOO_LARGE_FOR_TRASH, + -> intent.itemIds - remainingIds + FilesDeleteStatus.CHECKING, FilesDeleteStatus.UNKNOWN -> emptyList() +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt index 13ed4bb26..4315739f6 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt @@ -97,8 +97,10 @@ internal const val MOBILE_FILES_DELETE_ACTION_TAG = "mobile-files-delete-action" */ internal class MobileFilesItemActions( val onOpenInVlc: ((FilesItem) -> Unit)? = null, + /** Starts selection with the item; only a sheet opened from the item's own listing offers it. */ val onSelect: ((FilesItem) -> Unit)? = null, - val onSetWatched: ((FilesItem, Boolean) -> Unit)? = null, + /** Marks one file from the sheet, or the selection's files, watched or unwatched. */ + val onSetWatched: ((List, Boolean) -> Unit)? = null, /** Restores a confirmed Trash move from the snackbar's Undo. */ val onUndoTrash: ((FilesDeleteOutcome) -> Unit)? = null, /** Actions that did not go through, and a copied link, for the Files snackbar. */ @@ -108,6 +110,15 @@ internal class MobileFilesItemActions( ) { /** The resume setting [onSetWatched] needs is also what makes playback start from a saved position. */ val resumes: Boolean get() = onSetWatched != null + + fun withSelect(onSelect: ((FilesItem) -> Unit)?): MobileFilesItemActions = MobileFilesItemActions( + onOpenInVlc = onOpenInVlc, + onSelect = onSelect, + onSetWatched = onSetWatched, + onUndoTrash = onUndoTrash, + notices = notices, + onLinkCopied = onLinkCopied, + ) } /** @@ -562,7 +573,7 @@ private fun MobileFilesActionRows( MobileFilesActionRow(R.drawable.ic_ph_traffic_cone, R.string.mobile_files_open_in_vlc, MOBILE_FILES_VLC_ACTION_TAG, onClick = then(it)) } - itemActions.onSelect?.takeIf { listed }?.let { + itemActions.onSelect?.takeIf { listed && listingActions }?.let { MobileFilesActionRow(R.drawable.ic_ph_check_square, R.string.mobile_files_select, MOBILE_FILES_SELECT_ACTION_TAG, onClick = then(it)) } @@ -579,20 +590,22 @@ private fun MobileFilesActionRows( * Mark as unwatched once it is finished, or played with no known length, which drops the position. */ @Composable -private fun MobileFilesWatchedRow(item: FilesItem, setWatched: (FilesItem, Boolean) -> Unit, onDismiss: () -> Unit) { - if (!item.isPlayable) return - val progress = item.playback - val duration = progress?.durationSeconds - // Played with no known length can only be cleared, since watched is a position at the end. - val finished = progress != null && progress.isWatched && (duration == null || !progress.resumesPlayback()) - // Watched is the position at the file's end, so it needs a known length. - if (!finished && duration == null) return +private fun MobileFilesWatchedRow( + item: FilesItem, + setWatched: (List, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + val finished = when (item.watchedChoice()) { + true -> false + false -> true + null -> return + } MobileFilesActionRow( if (finished) R.drawable.ic_ph_eye_slash else R.drawable.ic_ph_eye, if (finished) R.string.mobile_files_mark_unwatched else R.string.mobile_files_mark_watched, MOBILE_FILES_WATCHED_ACTION_TAG, ) { - setWatched(item, !finished) + setWatched(listOf(item), !finished) onDismiss() } } @@ -743,6 +756,23 @@ internal fun MobileFilesKeyDelete( } } +/** + * True when a video or audio file can be marked watched, false when it can be marked unwatched, + * null when neither: watched is the saved position at the end, so it needs a known length, and a + * file played with no known length can only be cleared. + */ +internal fun FilesItem.watchedChoice(): Boolean? { + if (!isPlayable) return null + val progress = playback + val duration = progress?.durationSeconds + val finished = progress != null && progress.isWatched && (duration == null || !progress.resumesPlayback()) + return when { + finished -> false + duration == null -> null + else -> true + } +} + /** * Whether opening the file offers to resume: a saved position more than [FINISHED_WITHIN_SECONDS] * before the end, the player's own rule; with no known length, any saved position. diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActionsController.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActionsController.kt index 0632c6ab2..bbc4006f4 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActionsController.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActionsController.kt @@ -3,17 +3,22 @@ package io.putdotio.android.files import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import androidx.annotation.StringRes import androidx.core.net.toUri import io.putdotio.android.PutioFailure import io.putdotio.android.PutioResult import io.putdotio.android.R import io.putdotio.android.apiReason +import io.putdotio.android.trash.TrashBulkSelection import io.putdotio.android.trash.TrashRepository import io.putdotio.sdk.files.PutioFileType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow @@ -22,15 +27,24 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch -/** A line the Files snackbar shows: an item action that did not go through, or a copied link. */ +/** + * A line the Files snackbar shows: an item action that did not go through, or a copied link. With + * a [count], [message] is a plural that names how many items instead of [name]. + */ internal class MobileFilesNotice( - @param:StringRes val message: Int, + val message: Int, val name: String, val failure: PutioFailure? = null, + val count: Int? = null, ) { fun text(context: Context): String { - val reason = failure?.let { it.apiReason ?: context.getString(it.mobileMessageResource()) } - return context.getString(message, name, reason.orEmpty()).trim() + val reason = failure?.let { it.apiReason ?: context.getString(it.mobileMessageResource()) }.orEmpty() + val text = if (count == null) { + context.getString(message, name, reason) + } else { + context.resources.getQuantityString(message, count, count, reason) + } + return text.trim() } } @@ -56,7 +70,9 @@ internal class MobileFilesActionsController( private val onAuthenticationRequired: suspend () -> Unit, private val handoff: MobileVlcHandoff, ) { - private val watchedJobs = mutableMapOf() + private val watchedJobs = mutableMapOf>>() + // put.io has no bulk watched write in the Kotlin SDK yet, so a selection writes file by file. + private val watchedPermits = Semaphore(WATCHED_CONCURRENCY) private var vlcHandoff: Job? = null private val mutableNotices = MutableSharedFlow( extraBufferCapacity = 1, @@ -66,53 +82,71 @@ internal class MobileFilesActionsController( /** What went wrong, for the Files snackbar; nothing is replayed to a later screen. */ val notices: SharedFlow = mutableNotices.asSharedFlow() - fun itemActions(watchedToggle: Boolean, onSelect: ((FilesItem) -> Unit)? = null): MobileFilesItemActions = + fun itemActions(watchedToggle: Boolean): MobileFilesItemActions = MobileFilesItemActions( onOpenInVlc = { openInVlc(it.id, it.name) }, - onSelect = onSelect, - onSetWatched = if (watchedToggle) ::setWatched else null, + onSetWatched = if (watchedToggle) { items, watched -> setWatched(items, watched) } else null, onUndoTrash = ::undoTrash, notices = notices, onLinkCopied = { mutableNotices.tryEmit(MobileFilesNotice(R.string.mobile_public_links_link_copied, "")) }, ) + fun setWatched(item: FilesItem, watched: Boolean) = setWatched(listOf(item), watched) + + /** + * The sheet's watched toggle, or the selection's Watched. put.io has no bulk write in the Kotlin + * SDK yet, so each file is written on its own, a few at a time; each one put.io takes settles its + * row and Continue watching at once. One line then names the file it refused, or counts them. + */ + fun setWatched(items: List, watched: Boolean) { + val writes = items.mapNotNull { item -> writeWatched(item, watched)?.let { item to it } } + if (writes.isEmpty()) return + // A 401 is the session's verdict: it signs out as soon as its write answers, not after the rest. + writes.forEach { (_, write) -> + scope.launch { + val failure = (write.await() as? PutioResult.Failure)?.failure + if (failure is PutioFailure.AuthenticationRequired) onAuthenticationRequired() + } + } + scope.launch { + val refused = writes.mapNotNull { (item, write) -> + (write.await() as? PutioResult.Failure)?.failure + ?.takeUnless { it is PutioFailure.AuthenticationRequired } + ?.let { item to it } + } + val (item, failure) = refused.firstOrNull() ?: return@launch + report(watchedRefusal(watched, item, failure, refused.size)) + } + } + /** * Watched is the file's saved position at its end; unwatched drops the position. A newer choice * for the same file supersedes an unfinished one, so a slow write cannot settle the row last. + * Null when the file has no known end to save. */ - fun setWatched(item: FilesItem, watched: Boolean) { - val seconds = if (watched) item.playback?.durationSeconds ?: return else 0.0 + private fun writeWatched(item: FilesItem, watched: Boolean): Deferred>? { + val seconds = if (watched) item.playback?.durationSeconds ?: return null else 0.0 // A write already sent can't be called back, so a newer choice waits for it: put.io applies // them in order, and each settles the row in that order, so the last one put.io took shows. val previous = watchedJobs[item.id] // Started lazily so the write is registered before its body can compare itself. - val job = scope.launch(start = CoroutineStart.LAZY) { + val write = scope.async(start = CoroutineStart.LAZY) { previous?.join() - val result = if (watched) { - sources.watched.setPosition(item.id, seconds) - } else { - sources.watched.clearPosition(item.id) + val result = watchedPermits.withPermit { + if (watched) sources.watched.setPosition(item.id, seconds) else sources.watched.clearPosition(item.id) } // A closed session settles nothing: a late 401 must not reject the session after it. ensureActive() if (watchedJobs[item.id] === coroutineContext[Job]) watchedJobs.remove(item.id) - when (result) { - is PutioResult.Success -> { - onFilesEvent(FilesBrowserEvent.PlaybackPositionReported(item.id, seconds)) - onPositionSaved(item.id, seconds) - } - is PutioResult.Failure -> { - val message = if (watched) { - R.string.mobile_files_mark_watched_failed - } else { - R.string.mobile_files_mark_unwatched_failed - } - report(message, item.name, result.failure) - } + if (result is PutioResult.Success) { + onFilesEvent(FilesBrowserEvent.PlaybackPositionReported(item.id, seconds)) + onPositionSaved(item.id, seconds) } + result } - watchedJobs[item.id] = job - job.start() + watchedJobs[item.id] = write + write.start() + return write } /** Hands VLC the original with the account's download token, never the session's access token. */ @@ -129,47 +163,96 @@ internal class MobileFilesActionsController( FilesStreamUrlResult.DownloadTokenUnavailable -> mutableNotices.tryEmit(MobileFilesNotice(R.string.mobile_files_vlc_unavailable, name)) is FilesStreamUrlResult.Failure -> - report(R.string.mobile_files_vlc_failed, name, stream.failure) + report(MobileFilesNotice(R.string.mobile_files_vlc_failed, name, stream.failure)) } } } /** - * Restores a Trash move the snackbar announced. put.io queues the restore, so the folder reads - * again now and lists the item once put.io has put it back. + * Restores a Trash move the snackbar announced, every item of it in one request. put.io can answer + * before it has put every item back, so each is read until it is, and only then the folder. */ fun undoTrash(outcome: FilesDeleteOutcome) { if (outcome.intent.mode != FilesDeleteMode.TRASH) return + val itemIds = outcome.intent.itemIds scope.launch { - val result = sources.trash.restore(outcome.intent.itemId) + val result = if (itemIds.size == 1) { + sources.trash.restore(itemIds.single()) + } else { + sources.trash.restoreAll(TrashBulkSelection(cursor = null, itemIds = itemIds)) + } ensureActive() when (result) { is PutioResult.Success -> { + val rejected = awaitRestored(itemIds) + ensureActive() + if (rejected != null) { + onAuthenticationRequired() + return@launch + } // Another delete may have replaced the outcome meanwhile; the item still comes back somewhere. if (!onFilesEvent(FilesBrowserEvent.TrashUndone(outcome))) { onFilesEvent(FilesBrowserEvent.InvalidateAllFolders) } onFilesEvent(FilesBrowserEvent.ReloadIfStale) } - is PutioResult.Failure -> report(R.string.mobile_files_undo_failed, outcome.itemName, result.failure) + is PutioResult.Failure -> report( + if (itemIds.size == 1) { + MobileFilesNotice(R.string.mobile_files_undo_failed, outcome.itemName, result.failure) + } else { + MobileFilesNotice(R.plurals.mobile_files_undo_failed_many, "", result.failure, itemIds.size) + }, + ) + } + } + } + + /** + * Reads each restored item until put.io serves it again, a few rounds at most; the folder reads + * after either way. A 401 ends the reads and is returned, since it is the session's verdict. + */ + private suspend fun awaitRestored(itemIds: List): PutioFailure.AuthenticationRequired? { + var pending = itemIds + var rejected: PutioFailure.AuthenticationRequired? = null + var round = 0 + while (pending.isNotEmpty() && rejected == null && round < RESTORE_READBACK_ROUNDS) { + if (round > 0) delay(RESTORE_READBACK_DELAY_MS) + round += 1 + pending = pending.filterNot { id -> + rejected == null && when (val read = sources.trash.resolveItem(id)) { + is PutioResult.Success -> read.value.id == id + is PutioResult.Failure -> { + rejected = read.failure as? PutioFailure.AuthenticationRequired + false + } + } } } + return rejected } fun close() { scope.cancel() } - private suspend fun report(@StringRes message: Int, name: String, failure: PutioFailure) { + private suspend fun report(notice: MobileFilesNotice) { // A 401 is the session's verdict; the shell signs out instead of explaining it here. - if (failure is PutioFailure.AuthenticationRequired) { + if (notice.failure is PutioFailure.AuthenticationRequired) { onAuthenticationRequired() } else { - mutableNotices.tryEmit(MobileFilesNotice(message, name, failure)) + mutableNotices.tryEmit(notice) } } } +/** One refused watched write is named; several are counted. */ +private fun watchedRefusal(watched: Boolean, item: FilesItem, failure: PutioFailure, count: Int) = when { + count == 1 && watched -> MobileFilesNotice(R.string.mobile_files_mark_watched_failed, item.name, failure) + count == 1 -> MobileFilesNotice(R.string.mobile_files_mark_unwatched_failed, item.name, failure) + watched -> MobileFilesNotice(R.plurals.mobile_files_mark_watched_failed_many, "", failure, count) + else -> MobileFilesNotice(R.plurals.mobile_files_mark_unwatched_failed_many, "", failure, count) +} + /** Opens a stream in VLC; false when VLC is not installed. */ internal fun interface MobileVlcHandoff { fun open(streamUrl: String, name: String): Boolean @@ -198,3 +281,8 @@ internal val FilesItem.opensInVlc: Boolean get() = type == PutioFileType.VIDEO && id.value > 0L internal const val VLC_PACKAGE = "org.videolan.vlc" + +/** Watched writes in flight at once for a selection; the rest wait their turn. */ +private const val WATCHED_CONCURRENCY = 4 +private const val RESTORE_READBACK_ROUNDS = 5 +private const val RESTORE_READBACK_DELAY_MS = 400L diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesDeleteConfirmation.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesDeleteConfirmation.kt index fd146ce0d..6f37cd9be 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesDeleteConfirmation.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesDeleteConfirmation.kt @@ -70,9 +70,9 @@ internal fun MobileFilesTrashLimitStatus( onEvent: (FilesBrowserEvent) -> Unit, ) { var confirming by rememberSaveable(outcome.requestId.value) { mutableStateOf(false) } - // A refresh that no longer lists the folder leaves nothing for the reducer to delete; a - // rename keeps the outcome, so the dialog names the folder as it is listed now. - val listed = folder.content.items().firstOrNull { it.id == outcome.intent.itemId } + // A refresh that no longer lists an item leaves nothing for the reducer to delete; a rename + // keeps the outcome, so the dialog names the items as they are listed now. + val listed = folder.content.items().filter { it.id in outcome.intent.itemIds }.takeIf { it.isNotEmpty() } Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { Text( text = stringResource(R.string.mobile_files_trash_limit_title), @@ -89,17 +89,33 @@ internal fun MobileFilesTrashLimitStatus( } } if (confirming && listed != null) { - val delete = FilesBrowserEvent.Delete(folder.folder.id, listed.id, FilesDeleteMode.PERMANENT) + val delete = FilesBrowserEvent.Delete(folder.folder.id, listed.map { it.id }, FilesDeleteMode.PERMANENT) AlertDialog( onDismissRequest = { confirming = false }, - title = { Text(stringResource(R.string.mobile_files_trash_limit_title)) }, + title = { + Text( + if (listed.size > 1) { + pluralStringResource(R.plurals.mobile_files_delete_permanently_title, listed.size, listed.size) + } else { + stringResource(R.string.mobile_files_trash_limit_title) + }, + ) + }, text = { Column( modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text(stringResource(R.string.mobile_files_trash_limit_message)) - Text(listed.name, style = MaterialTheme.typography.titleSmall) + Text( + stringResource( + if (listed.size > 1) { + R.string.mobile_files_trash_limit_message_many + } else { + R.string.mobile_files_trash_limit_message + }, + ), + ) + listed.forEach { Text(it.name, style = MaterialTheme.typography.titleSmall) } } }, confirmButton = { diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesMoveStatus.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesMoveStatus.kt index 753bd99b6..2dd640b3d 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesMoveStatus.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesMoveStatus.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion @@ -16,28 +17,17 @@ import androidx.compose.ui.unit.dp import io.putdotio.android.PutioFailure import io.putdotio.android.R import io.putdotio.android.design.PutioDesignTokens +import io.putdotio.sdk.files.FileMoveError internal const val MOBILE_FILES_MOVE_OUTCOME_TAG = "mobile-files-move-outcome" @Composable internal fun MobileFilesMoveStatus(outcome: FilesMoveOutcome) { - val collision = outcome.errors?.singleOrNull()?.takeIf { - it.id == outcome.intent.itemId.value && it.errorType == "NAME_ALREADY_EXIST" - } - val message = when (outcome.status) { - FilesMoveStatus.MOVED -> R.string.mobile_files_move_moved - FilesMoveStatus.STILL_PRESENT -> R.string.mobile_files_move_still_present - FilesMoveStatus.REJECTED -> if (collision == null) R.string.mobile_files_move_rejected - else R.string.mobile_files_move_collision - FilesMoveStatus.CHECKING, FilesMoveStatus.UNKNOWN -> null - } + val message = outcome.statusMessage() if (message != null) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) .testTag(MOBILE_FILES_MOVE_OUTCOME_TAG).semantics { liveRegion = LiveRegionMode.Polite }) { - Text( - stringResource(message, collision?.name ?: outcome.itemName), - style = MaterialTheme.typography.bodyMedium, - ) + Text(message, style = MaterialTheme.typography.bodyMedium) if (outcome.status != FilesMoveStatus.MOVED) { outcome.failure?.let { Text(it.moveFailureMessage(), color = PutioDesignTokens.redTextSecondary, @@ -48,6 +38,52 @@ internal fun MobileFilesMoveStatus(outcome: FilesMoveOutcome) { } } +/** The settled move's line: the item by name, or several by count; a lone name clash names it. */ +@Composable +private fun FilesMoveOutcome.statusMessage(): String? { + val collision = errors?.singleOrNull()?.takeIf { error -> + intent.itemIds.any { it.value == error.id } && error.errorType == "NAME_ALREADY_EXIST" + } + val many = itemCount > 1 + return when (status) { + FilesMoveStatus.MOVED -> if (many) { + pluralStringResource(R.plurals.mobile_files_move_moved_many, itemCount, itemCount) + } else { + stringResource(R.string.mobile_files_move_moved, itemName) + } + FilesMoveStatus.STILL_PRESENT -> if (many) { + pluralStringResource(R.plurals.mobile_files_move_still_present_many, remainingCount, remainingCount) + } else { + stringResource(R.string.mobile_files_move_still_present, itemName) + } + FilesMoveStatus.REJECTED -> rejectedMessage(collision, many) + FilesMoveStatus.CHECKING, FilesMoveStatus.UNKNOWN -> null + } +} + +/** A refused move: a clash names its item when it is the one refusal; several count. */ +@Composable +private fun FilesMoveOutcome.rejectedMessage(collision: FileMoveError?, many: Boolean): String { + // An unnamed clash among several items can't be named: the first item's name may not be its. + val clashName = collision?.name ?: itemName.takeUnless { many } + return when { + collision != null && clashName != null && (!many || refusedCount == 1) -> + stringResource(R.string.mobile_files_move_collision, clashName) + many -> pluralStringResource(R.plurals.mobile_files_move_rejected_many, refusedCount, refusedCount) + else -> stringResource(R.string.mobile_files_move_rejected, itemName) + } +} + +/** + * The items put.io's answer refused, by ID, and any the check or a later page found outside the + * destination; when neither names one, every item counts. + */ +private val FilesMoveOutcome.refusedCount: Int + get() { + val refused = intent.itemIds.filter { id -> errors.orEmpty().any { it.id == id.value } }.toSet() + return (refused + remainingIds).size.takeIf { it > 0 } ?: itemCount + } + // put.io answers a move with a bare 403 only when the destination is the item or a folder inside // it; the app never sends the negative IDs that also get one. @Composable diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt index 591d1e4a4..59d056897 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt @@ -2,6 +2,7 @@ package io.putdotio.android.files import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState @@ -55,6 +56,8 @@ internal fun MobileFilesRoute( itemActions: MobileFilesItemActions = MobileFilesItemActions(), /** Lets the Move and Make a copy pickers create a folder; null hides their New folder. */ folderCreator: FilesFolderCreator? = null, + /** The current folder's selection, shared with the shell's top bar; null leaves Select out. */ + selection: FilesSelection? = null, ) { val context = LocalContext.current // Opening a folder pushes along the shared axis and Back pops; the leaving folder keeps its own state. @@ -62,9 +65,13 @@ internal fun MobileFilesRoute( val currentKey = folderKey(state) val items = state.current.content.items() val showsDetail = listPaneTopBar != null + // While rows are being selected the detail pane shows no file: a tap checks a row, not opens it. + val selecting = selection?.isActive(state) == true // The file the detail pane shows; another folder starts with none. var detailId by rememberSaveable(currentKey.first, currentKey.second) { mutableStateOf(null) } var viewerId by rememberSaveable { mutableStateOf(null) } + // Select from a viewer's actions closes the viewer, so the selection shows. + LaunchedEffect(selecting) { if (selecting) viewerId = null } val actionsRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val openWithAnotherApp: (FilesItem) -> Unit = { item -> fileOpener?.openWithAnotherApp(context, item) } val openFile = fileOpenAction(onPlayMedia, onView = { viewerId = it.id.value }, onOther = openWithAnotherApp) @@ -72,7 +79,7 @@ internal fun MobileFilesRoute( MobileFilesOpening( onOpenFile = if (showsDetail) { item -> detailId = item.id.value } else openFile, showsDetail = showsDetail, - selectedFileId = detailId?.takeIf { showsDetail }?.let(::FilesItemId), + selectedFileId = detailId?.takeIf { showsDetail && !selecting }?.let(::FilesItemId), actionsRequests = actionsRequests, ) } @@ -104,6 +111,7 @@ internal fun MobileFilesRoute( fileOpening = fileOpening, itemActions = itemActions, folderCreator = folderCreator, + selection = selection, ) if (listPaneTopBar == null) { folders(foldersArgs) @@ -112,7 +120,7 @@ internal fun MobileFilesRoute( topBar = listPaneTopBar, list = { folders(foldersArgs) }, detail = { - val shown = items.firstOrNull { it.id.value == detailId && !it.isFolder } + val shown = items.firstOrNull { it.id.value == detailId && !it.isFolder }?.takeUnless { selecting } MobileFilesDetailPane( item = shown, folderPath = state.folderPath() ?: stringResource(R.string.mobile_destination_files), @@ -210,6 +218,7 @@ private data class MobileFilesFoldersArgs( val fileOpening: MobileFilesOpening?, val itemActions: MobileFilesItemActions, val folderCreator: FilesFolderCreator?, + val selection: FilesSelection?, ) @Composable @@ -242,6 +251,8 @@ private fun MobileFilesFolders(args: MobileFilesFoldersArgs) = with(args) { fileOpening = fileOpening?.takeUnless { leaving }, itemActions = itemActions, folderCreator = folderCreator, + // The leaving folder's rows show no selection; the selection belongs to the folder on screen. + selection = selection?.takeUnless { leaving }, ) } } @@ -265,11 +276,13 @@ private fun MobileFilesFolder( fileOpening: MobileFilesOpening?, itemActions: MobileFilesItemActions, folderCreator: FilesFolderCreator?, + selection: FilesSelection?, ) { key(repository, state.current.folder.id.value) { - var movingItemId by rememberSaveable { mutableStateOf(null) } - val movingItem = (state.current.content as? FilesContent.Ready)?.items - ?.firstOrNull { it.id.value == movingItemId } + // One item from its sheet, or the selection from the toolbar; the picker moves them together. + var movingItemIds by rememberSaveable { mutableStateOf(emptyList()) } + val movingItems = state.listedAll(movingItemIds) + val startMove = movePicker(repository, state) { items -> movingItemIds = items.map { it.id.value } } var copyingItemId by rememberSaveable { mutableStateOf(null) } val copyingItem = (state.current.content as? FilesContent.Ready)?.items ?.firstOrNull { it.id.value == copyingItemId && it.canMakeCopy } @@ -281,8 +294,7 @@ private fun MobileFilesFolder( onEvent = { onEvent(it) }, onPlayMedia = onPlayMedia, confirmedTrashEnabled = confirmedTrashEnabled, - onMoveItem = - if (repository == null || !state.canStartMove) null else { item -> movingItemId = item.id.value }, + onMoveItem = startMove?.let { start -> { item -> start(listOf(item)) } }, downloads = downloads, onDownloadItem = onDownloadItem, onShareItem = onShareItem, @@ -293,15 +305,17 @@ private fun MobileFilesFolder( onNewFolder = onNewFolder, fileOpening = fileOpening, itemActions = itemActions, + selection = selection, + onMoveItems = startMove, ) // A folder made in the picker is new to every cached listing of its parent. Those listings // read again once the picker closes: a reload now could drop the item it is moving or copying. val createdInPicker = remember { PickerCreatedFolders() } val pickerFolders = MobileMovePickerFolders(folderCreator, onAuthenticationRequired, createdInPicker::add) val closePicker = { createdInPicker.invalidate(onEvent) } - if (movingItem != null && repository != null) { + if (movingItems != null && repository != null) { MobileFilesMoveSession( - item = movingItem, + items = movingItems, sourceFolderId = state.current.folder.id, repository = repository, canSubmit = state.canStartMove, @@ -309,9 +323,11 @@ private fun MobileFilesFolder( onAuthenticationRequired = onAuthenticationRequired, onEvent = onEvent, onDismiss = { - movingItemId = null + movingItemIds = emptyList() closePicker() }, + // Moved items leave the folder, so their selection ends with the move. + onMoved = { selection?.end() }, folders = pickerFolders, ) } @@ -347,6 +363,19 @@ private fun MobileFilesFolder( } } +/** Opens the Move picker for one item or several, while this folder can start a move. */ +private fun movePicker( + repository: FilesRepository?, + state: FilesBrowserState, + open: (List) -> Unit, +): ((List) -> Unit)? = open.takeIf { repository != null && state.canStartMove } + +/** Every item of [ids] in the current listing, or null: the picker never moves fewer than it says. */ +private fun FilesBrowserState.listedAll(ids: List): List? { + val listed = (current.content as? FilesContent.Ready)?.items.orEmpty().filter { it.id.value in ids } + return listed.takeIf { it.isNotEmpty() && it.size == ids.size } +} + /** * The move picker without a source item, so every folder of the viewer's own, root included, is a * destination. Like Move, it opens at root unless "Remember target folder" is on. @@ -364,7 +393,7 @@ private fun MobileFilesCopySession( folders: MobileMovePickerFolders, ) { val scope = rememberCoroutineScope() - val memory = rememberMoveTargetMemory(targetStore, item.id) + val memory = rememberMoveTargetMemory(targetStore, listOf(item.id)) val controller = remember(item.id, repository) { FilesMoveDestinationController(repository, scope, memory.value.startPath(sourceItem = null)) } @@ -408,7 +437,7 @@ private fun MobileFilesCopySession( @Composable private fun MobileFilesMoveSession( - item: FilesItem, + items: List, sourceFolderId: FilesItemId, repository: FilesRepository, canSubmit: Boolean, @@ -416,12 +445,14 @@ private fun MobileFilesMoveSession( onAuthenticationRequired: suspend () -> Unit, onEvent: (FilesBrowserEvent) -> Boolean, onDismiss: () -> Unit, + onMoved: () -> Unit, folders: MobileMovePickerFolders, ) { val scope = rememberCoroutineScope() - val memory = rememberMoveTargetMemory(targetStore, item.id) - val controller = remember(item.id, sourceFolderId, repository) { - FilesMoveDestinationController(item, sourceFolderId, repository, scope, memory.value.startPath(item)) + val itemIds = items.map { it.id } + val memory = rememberMoveTargetMemory(targetStore, itemIds) + val controller = remember(itemIds, sourceFolderId, repository) { + FilesMoveDestinationController(items, sourceFolderId, repository, scope, memory.value.startPath(items)) } var finished by remember(controller) { mutableStateOf(false) } DisposableEffect(controller) { @@ -445,8 +476,9 @@ private fun MobileFilesMoveSession( val current = controller.state.value if (!finished && canSubmit && current.confirmable) { finished = true - if (onEvent(FilesBrowserEvent.Move(sourceFolderId, item.id, current.current.folder.id))) { + if (onEvent(FilesBrowserEvent.Move(sourceFolderId, itemIds, current.current.folder.id))) { memory.choose(targetStore, current.path) + onMoved() // The session-owned browser retains the submitted operation after the picker closes. onDismiss() } else { @@ -454,6 +486,7 @@ private fun MobileFilesMoveSession( } } }, + title = pluralStringResource(R.plurals.mobile_files_move_title, items.size, items.size), rememberTarget = targetStore?.let { memory.value.remember }, onRememberTargetChange = { memory.setRemember(targetStore, it) }, folders = folders, @@ -467,9 +500,9 @@ private val FilesMoveDestinationState.confirmable: Boolean @Composable private fun rememberMoveTargetMemory( store: FilesMoveTargetStore?, - itemId: FilesItemId, + itemIds: List, ): MutableState = - remember(store, itemId) { mutableStateOf(store?.read() ?: FilesMoveTargetMemory()) } + remember(store, itemIds) { mutableStateOf(store?.read() ?: FilesMoveTargetMemory()) } private fun MutableState.setRemember(store: FilesMoveTargetStore?, enabled: Boolean) { value = value.copy(remember = enabled) diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt index ac2f666ff..b0a0b78f1 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRow.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -36,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.putdotio.android.MobileMiddleCutText import io.putdotio.android.MobileRow +import io.putdotio.android.MobileSelectedRowTint +import io.putdotio.android.MobileSelectionCheckbox import io.putdotio.android.R import io.putdotio.android.design.FileTypeIcon import io.putdotio.android.design.PutioDesignTokens @@ -51,7 +54,8 @@ internal const val MOBILE_FILES_DOWNLOAD_TAG = "mobile-files-download" /** * A file row: a fixed 64dp line with the type glyph, the name cut in the middle on one line, and * meta (size in mono, date, then the played eye and the on-device mark when true). Folders end in - * a chevron and files in an overflow button; long-press opens the same actions either way. + * a chevron and files in an overflow button; long-press opens the same actions either way. In + * selection a checkbox takes the glyph's place, the trailing slot empties, and a tap toggles the row. */ @Composable internal fun MobileFilesRow( @@ -68,7 +72,14 @@ internal fun MobileFilesRow( place: MobileFilesRowPlace = MobileFilesRowPlace.Listing, /** The file the tablet's detail pane shows, tinted more faintly than an outside open. */ selected: Boolean = false, + /** Null outside selection; otherwise whether the row is checked. */ + checked: Boolean? = null, + onCheckedChange: (() -> Unit)? = null, ) { + if (checked != null) { + MobileFilesSelectableRow(item, checked, onCheckedChange, downloadStatus, place, modifier) + return + } val actionsLabel = stringResource(R.string.mobile_files_actions, item.name) // Touch never focuses a row; a keyboard-focused one is outlined so it reads at a glance. var focused by remember { mutableStateOf(false) } @@ -141,6 +152,38 @@ internal fun MobileFilesRow( ) } +/** + * A row in selection: a checkbox in the glyph's place, nothing trailing, the 9% yellow tint when + * checked, and a tap that checks or clears it. + */ +@Composable +private fun MobileFilesSelectableRow( + item: FilesItem, + checked: Boolean, + onCheckedChange: (() -> Unit)?, + downloadStatus: DownloadStatus?, + place: MobileFilesRowPlace, + modifier: Modifier, +) { + var focused by remember { mutableStateOf(false) } + val outline = MaterialTheme.colorScheme.primary + MobileRow( + leading = { MobileSelectionCheckbox(checked) }, + headline = { MobileMiddleCutText(text = item.name) }, + meta = { MobileFilesMeta(item, onDevice = downloadStatus is DownloadStatus.Completed, place = place) }, + containerColor = if (checked) MobileSelectedRowTint else Color.Transparent, + modifier = modifier + .playbackContainer(item.id.value, playable = item.isPlayable) + .onFocusChanged { focused = it.isFocused } + .focusOutline(focused, outline) + .then( + onCheckedChange?.let { toggle -> + Modifier.toggleable(value = checked, role = Role.Checkbox, onValueChange = { toggle() }) + } ?: Modifier, + ), + ) +} + /** Where a row is listed: in a folder, which dates it, or in search results, which name its folder. */ internal sealed interface MobileFilesRowPlace { data object Listing : MobileFilesRowPlace diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt index 0afff1428..113bb337d 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -52,6 +53,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.LiveRegionMode @@ -110,6 +112,10 @@ internal fun MobileFilesScreen( /** How a tap opens a file beyond playing media; null leaves only media playable. */ fileOpening: MobileFilesOpening? = null, itemActions: MobileFilesItemActions = MobileFilesItemActions(), + /** The folder's selection, which the shell's top bar shares; null leaves Select out of the sheet. */ + selection: FilesSelection? = null, + /** Opens the Move picker for the selected items; null hides the toolbar's Move. */ + onMoveItems: ((List) -> Unit)? = null, ) { val current = state.current val atRoot = current.folder.id == FilesFolder.Root.id @@ -169,6 +175,8 @@ internal fun MobileFilesScreen( onNewFolder = onNewFolder, fileOpening = fileOpening, itemActions = itemActions, + selection = selection, + onMoveItems = onMoveItems, ) } } @@ -252,9 +260,14 @@ private fun MobileRefreshableFilesContent( onNewFolder: (() -> Unit)?, fileOpening: MobileFilesOpening?, itemActions: MobileFilesItemActions, + selection: FilesSelection?, + onMoveItems: ((List) -> Unit)?, ) { val operation = state.current.operation val currentOperation by rememberUpdatedState(operation) + val selecting = selection.isActiveIn(state) + val currentState by rememberUpdatedState(state) + val sheetActions = itemActions.selectingIn(selection) { currentState } var selectedItemId by rememberSaveable { mutableStateOf(null) } // Delete on a focused row goes straight to the sheet's delete step, in the mode the setting had then. var keyDelete by rememberSaveable { mutableStateOf(null) } @@ -264,12 +277,14 @@ private fun MobileRefreshableFilesContent( LaunchedEffect(selectedItemId, selectedItem) { if (selectedItem == null) selectedItemId = null } - MobileFilesActionsRequests(fileOpening, items) { id -> + // While rows are selected, the detail pane and the viewer open no sheet. + MobileFilesActionsRequests(fileOpening, items, enabled = !selecting) { id -> keyDelete = null selectedItemId = id.value } MobileFilesKeyboardEffect( selectionOpen = selectedItemId != null, + selecting = selecting, operation = operation, focusedItem = items.firstOrNull { it.id.value == focusedItemId }, confirmedTrashEnabled = confirmedTrashEnabled, @@ -313,12 +328,13 @@ private fun MobileRefreshableFilesContent( canStartCopy = state.canStartCopy, onPublicLinkItem = onPublicLinkItem, onPlayItem = onPlayMedia, - itemActions = itemActions, + itemActions = sheetActions, sharedBy = state.sharedByName(), ) } } } + val listHeaders = headers.unlessSelecting(selecting) val isRefreshing = operation is FilesFolderOperation.Loading && operation.intent == FilesFolderOperationIntent.Refresh @@ -332,6 +348,8 @@ private fun MobileRefreshableFilesContent( } } + var toolbarHeight by remember { mutableIntStateOf(0) } + val toolbarClearance = selectionToolbarClearance(toolbarHeight) val snackbarHostState = remember { SnackbarHostState() } MobileFilesTrashedAnnouncement(state.current, snackbarHostState, onEvent, itemActions.onUndoTrash) MobileFilesNotices(itemActions.notices, snackbarHostState) @@ -380,11 +398,11 @@ private fun MobileRefreshableFilesContent( state.stack.size, state.current.folder.id.value, state.current.viewportGeneration, - headers.itemCount, + listHeaders.itemCount, ) { MobileFilesList( content = content, - headers = headers, + headers = listHeaders, onPlayEntry = { onPlayMedia(it.toFilesItem()) }, revealItemId = state.current.revealItemId, pagingEnabled = operation == FilesFolderOperation.Idle, @@ -403,6 +421,7 @@ private fun MobileRefreshableFilesContent( // A touch drag starts after the long press opened the sheet; the drag replaces it. onDragStarted = dismissSelection, fileOpening = fileOpening, + selection = selection.listSelection(state, toolbarClearance), ) } @@ -413,6 +432,20 @@ private fun MobileRefreshableFilesContent( } MobileFilesFolderStatus(state, onEvent) } + MobileFilesSelectionToolbarSlot( + state = state, + selection = selection, + downloads = downloads, + confirmedTrashEnabled = confirmedTrashEnabled, + actions = MobileFilesSelectionSources( + onMove = onMoveItems, + onDownload = onDownloadItem, + onSetWatched = itemActions.onSetWatched, + onEvent = onEvent, + ), + onHeightChanged = { toolbarHeight = it }, + modifier = Modifier.align(Alignment.BottomCenter), + ) MobileSnackbarHost( hostState = snackbarHostState, modifier = Modifier @@ -430,13 +463,14 @@ private fun MobileRefreshableFilesContent( @Composable private fun MobileFilesKeyboardEffect( selectionOpen: Boolean, + selecting: Boolean, operation: FilesFolderOperation, focusedItem: FilesItem?, confirmedTrashEnabled: Boolean?, onRefresh: () -> Unit, onDelete: (FilesItem, FilesDeleteMode?) -> Unit, ) { - val idle = !selectionOpen && operation.canStartOperation + val idle = !selectionOpen && !selecting && operation.canStartOperation MobileUnhandledKeyEffect(MobileShortcutScope.Files) { command -> when (command) { MobileKeyCommand.Refresh -> true.also { if (idle) onRefresh() } @@ -496,7 +530,12 @@ private fun MobileFilesTrashedAnnouncement( val trashed = current.deleteOutcome?.takeIf { current.operation == FilesFolderOperation.Idle && it.isConfirmedTrash && !it.announced } - val message = stringResource(R.string.mobile_files_trash_done) + val count = trashed?.itemCount ?: 1 + val message = if (count > 1) { + pluralStringResource(R.plurals.mobile_files_trash_done_many, count, count) + } else { + stringResource(R.string.mobile_files_trash_done) + } val undo = stringResource(R.string.mobile_files_undo) val currentOnEvent by rememberUpdatedState(onEvent) val currentOnUndo by rememberUpdatedState(onUndoTrash) @@ -516,18 +555,20 @@ private fun MobileFilesTrashedAnnouncement( } } -/** Opens the sheet for a listed file the detail pane or the viewer asks for. */ +/** Opens the sheet for a listed file the detail pane or the viewer asks for, while [enabled]. */ @Composable private fun MobileFilesActionsRequests( fileOpening: MobileFilesOpening?, items: List, + enabled: Boolean, onOpenSheet: (FilesItemId) -> Unit, ) { val currentItems by rememberUpdatedState(items) + val currentEnabled by rememberUpdatedState(enabled) val currentOnOpenSheet by rememberUpdatedState(onOpenSheet) val requests = fileOpening?.actionsRequests ?: return LaunchedEffect(requests) { - requests.collect { id -> if (currentItems.any { it.id == id }) currentOnOpenSheet(id) } + requests.collect { id -> if (currentEnabled && currentItems.any { it.id == id }) currentOnOpenSheet(id) } } } @@ -717,12 +758,7 @@ private fun FilesFolderOperation.Failed.retryLabel(): Int = @Composable private fun MobileFilesDeleteStatus(outcome: FilesDeleteOutcome) { - val message = when (outcome.status) { - FilesDeleteStatus.NO_LONGER_AVAILABLE -> R.string.mobile_files_delete_unavailable - FilesDeleteStatus.STILL_PRESENT -> R.string.mobile_files_delete_still_present - FilesDeleteStatus.SKIPPED -> R.string.mobile_files_delete_skipped - FilesDeleteStatus.CHECKING, FilesDeleteStatus.UNKNOWN, FilesDeleteStatus.TOO_LARGE_FOR_TRASH -> null - } + val message = outcome.statusMessage() if (message != null) { Column( modifier = Modifier @@ -730,7 +766,7 @@ private fun MobileFilesDeleteStatus(outcome: FilesDeleteOutcome) { .padding(horizontal = 16.dp, vertical = 8.dp) .semantics { liveRegion = LiveRegionMode.Polite }, ) { - Text(stringResource(message, outcome.itemName), style = MaterialTheme.typography.bodyMedium) + Text(message, style = MaterialTheme.typography.bodyMedium) if (outcome.status != FilesDeleteStatus.NO_LONGER_AVAILABLE) { outcome.failure?.let { Text( @@ -744,6 +780,35 @@ private fun MobileFilesDeleteStatus(outcome: FilesDeleteOutcome) { } } +/** The settled delete's line: the item by name, or several by count. */ +@Composable +private fun FilesDeleteOutcome.statusMessage(): String? { + val many = itemCount > 1 + return when (status) { + FilesDeleteStatus.NO_LONGER_AVAILABLE -> if (many) { + pluralStringResource(R.plurals.mobile_files_delete_unavailable_many, itemCount, itemCount) + } else { + stringResource(R.string.mobile_files_delete_unavailable, itemName) + } + FilesDeleteStatus.STILL_PRESENT -> if (many) { + pluralStringResource(R.plurals.mobile_files_delete_still_present_many, remainingCount, remainingCount) + } else { + stringResource(R.string.mobile_files_delete_still_present, itemName) + } + FilesDeleteStatus.SKIPPED -> if (many) { + // put.io left the request incomplete; the read-back says which items stayed, if any did. + if (remainingCount > 0) { + pluralStringResource(R.plurals.mobile_files_delete_skipped_many, remainingCount, remainingCount) + } else { + pluralStringResource(R.plurals.mobile_files_delete_unavailable_many, itemCount, itemCount) + } + } else { + stringResource(R.string.mobile_files_delete_skipped, itemName) + } + FilesDeleteStatus.CHECKING, FilesDeleteStatus.UNKNOWN, FilesDeleteStatus.TOO_LARGE_FOR_TRASH -> null + } +} + @Composable private fun MobileFilesList( content: FilesContent.Ready, @@ -761,6 +826,7 @@ private fun MobileFilesList( fileDragOut: MobileFileDragOut?, onDragStarted: () -> Unit, fileOpening: MobileFilesOpening?, + selection: MobileFilesListSelection? = null, ) { val viewport = content.viewport // The viewport counts rows; headers sit above them and show again when the list is back at its top. @@ -774,7 +840,7 @@ private fun MobileFilesList( val currentHeaderCount by rememberUpdatedState(headerCount) val keyboardFocus = rememberFilesKeyboardFocus(content.items, viewport.firstVisibleItemIndex) val currentOnEvent by rememberUpdatedState(onEvent) - val pendingItemId = operation.pendingItemId() + val pendingItemIds = operation.pendingItemIds() val actionsEnabled = operation.canStartOperation LaunchedEffect(listState) { @@ -808,6 +874,9 @@ private fun MobileFilesList( modifier = Modifier .fillMaxSize() .testTag(MOBILE_FILES_LIST_TAG), + // In selection the rows start 8dp under the bar, and the last one scrolls clear of the floating + // toolbar, however tall large fonts make it. + contentPadding = selection?.let { PaddingValues(top = 8.dp, bottom = it.bottomClearance) } ?: PaddingValues(), ) { mobileFilesHeaderItems(headers, onPlayEntry) itemsIndexed( @@ -833,8 +902,11 @@ private fun MobileFilesList( downloadStatus = downloads.rowStatus(item.id), onActions = if (hasActions(item)) { { onActions(item) } } else null, actionsEnabled = actionsEnabled, - onClick = if (item.id == pendingItemId) null else rowClick(item, fileOpening, onEvent, onPlayMedia), + onClick = rowClick(item, fileOpening, onEvent, onPlayMedia) + .takeUnless { item.id in pendingItemIds }, onClickLabel = rowClickLabel(item, fileOpening), + checked = selection?.let { item.id in it.checked }, + onCheckedChange = selection?.takeIf { item.id.value > 0L }?.let { { it.onToggle(item) } }, ) } } @@ -979,17 +1051,18 @@ private fun MobileFilesListRow( ) } -private fun FilesFolderOperation.pendingItemId(): FilesItemId? { +private fun FilesFolderOperation.pendingItemIds(): Set { val (intent, phase) = when (this) { is FilesFolderOperation.Loading -> intent to phase is FilesFolderOperation.Failed -> intent to phase - FilesFolderOperation.Idle -> return null + FilesFolderOperation.Idle -> return emptySet() } return when (intent) { - is FilesFolderOperationIntent.Rename -> intent.itemId.takeIf { phase == FilesFolderOperationPhase.RELOADING } - is FilesFolderOperationIntent.Delete -> intent.itemId - is FilesFolderOperationIntent.Move -> intent.itemId - else -> null + is FilesFolderOperationIntent.Rename -> + setOfNotNull(intent.itemId.takeIf { phase == FilesFolderOperationPhase.RELOADING }) + is FilesFolderOperationIntent.Delete -> intent.itemIds.toSet() + is FilesFolderOperationIntent.Move -> intent.itemIds.toSet() + else -> emptySet() } } diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesSelection.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesSelection.kt new file mode 100644 index 000000000..fb25e35ee --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesSelection.kt @@ -0,0 +1,444 @@ +package io.putdotio.android.files + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import io.putdotio.android.MobileSelection +import io.putdotio.android.MobileSelectionToolbar +import io.putdotio.android.MobileSelectionToolbarClearance +import io.putdotio.android.MobileSelectionToolbarOffset +import io.putdotio.android.MobileToolbarTile +import io.putdotio.android.R +import io.putdotio.android.design.PutioDesignTokens +import io.putdotio.android.downloads.DownloadStatus +import io.putdotio.android.downloads.DownloadsState + +internal const val MOBILE_FILES_SELECTION_BAR_TAG = "mobile-files-selection-bar" +internal const val MOBILE_FILES_SELECTION_TOOLBAR_TAG = "mobile-files-selection-toolbar" +internal const val MOBILE_FILES_SELECTION_MOVE_TAG = "mobile-files-selection-move" +internal const val MOBILE_FILES_SELECTION_DOWNLOAD_TAG = "mobile-files-selection-download" +internal const val MOBILE_FILES_SELECTION_WATCHED_TAG = "mobile-files-selection-watched" +internal const val MOBILE_FILES_SELECTION_DELETE_TAG = "mobile-files-selection-delete" + +/** + * Files selection, shared by the top bar and the folder's list. It belongs to the folder it started + * in: another folder, or the same folder reached again, shows none. Rows that leave the listing + * leave the selection. + */ +@Stable +internal class FilesSelection( + private val selection: MobileSelection = MobileSelection(), + scope: FilesSelectionScope? = null, +) { + private var scope by mutableStateOf(scope) + + fun isActive(state: FilesBrowserState): Boolean = + scope == state.selectionScope() && selection.isActive(state.selectableIds()) + + /** Whether the selection is on screen: Files is the tab, and Search doesn't cover it. */ + fun isShown(state: FilesBrowserState, onFiles: Boolean, searchOpen: Boolean): Boolean = + onFiles && !searchOpen && isActive(state) + + /** The selected rows, in the listing's order. */ + fun selected(state: FilesBrowserState): List { + if (scope != state.selectionScope()) return emptyList() + val ids = selection.selected(state.selectableIds()) + return state.current.content.items().filter { it.id.value in ids } + } + + /** Starts selection in the current folder with [item] selected, as the sheet's Select does. */ + fun start(state: FilesBrowserState, item: FilesItem) { + val current = state.selectionScope() + if (scope != current) { + selection.end() + scope = current + } + if (item.id.value in state.selectableIds() && item.id.value !in selection.selected(state.selectableIds())) { + selection.toggle(item.id.value) + } + selection.start() + } + + fun toggle(state: FilesBrowserState, item: FilesItem) { + if (scope == state.selectionScope()) selection.toggle(item.id.value) + } + + fun selectAll(state: FilesBrowserState) { + if (scope == state.selectionScope()) selection.selectAll(state.selectableIds()) + } + + fun end() { + selection.end() + scope = null + } + + /** + * Ends the selection once another folder shows, its folder reads afresh (an outside open can + * replace it in place), or it lists nothing; and forgets rows that left the listing, so neither + * comes back checked later. + */ + fun reconcile(state: FilesBrowserState) { + val current = scope ?: return + val selectable = state.selectableIds() + val listed = state.current.content is FilesContent.Ready || state.current.content is FilesContent.Empty + when { + current != state.selectionScope() -> end() + state.current.content is FilesContent.Loading -> end() + listed && selectable.isEmpty() -> end() + listed -> selection.retain(selectable) + } + } + + companion object { + val Saver: Saver = Saver( + save = { selection -> + val scope = selection.scope + listOf( + with(MobileSelection.Saver) { save(selection.selection) }, + scope?.depth ?: -1, + scope?.folderId ?: -1L, + ) + }, + restore = { saved -> + val values = saved as List<*> + val depth = values[1] as Int + FilesSelection( + MobileSelection.Saver.restore(checkNotNull(values[0])) ?: MobileSelection(), + if (depth < 0) null else FilesSelectionScope(depth, values[2] as Long), + ) + }, + ) + } +} + +/** The folder a selection belongs to, by its place in the stack and its ID. */ +internal data class FilesSelectionScope(val depth: Int, val folderId: Long) + +private fun FilesBrowserState.selectionScope() = FilesSelectionScope(stack.size, current.folder.id.value) + +private fun FilesBrowserState.selectableIds(): Set = + current.content.items().filter { it.id.value > 0L }.mapTo(linkedSetOf()) { it.id.value } + +/** The session's Files selection, kept in step with the folder on screen and its rows. */ +@Composable +internal fun rememberFilesSelection(key: Any?, state: FilesBrowserState): FilesSelection { + val selection = rememberSaveable(key, saver = FilesSelection.Saver) { FilesSelection() } + LaunchedEffect(selection, state.stack.size, state.current.folder.id, state.current.content) { + selection.reconcile(state) + } + return selection +} + +/** + * What the toolbar offers for [items]: each action only when every item allows it, under the + * sheet's own rules. [watched] is true for Mark as watched, false for Mark as unwatched. + */ +internal data class FilesSelectionActions( + val move: Boolean, + val download: Boolean, + val watched: Boolean?, + val delete: Boolean, +) { + val any: Boolean get() = move || download || watched != null || delete +} + +internal fun List.selectionActions( + canMove: Boolean, + canDownload: Boolean, + canSetWatched: Boolean, +): FilesSelectionActions { + if (isEmpty()) return FilesSelectionActions(move = false, download = false, watched = null, delete = false) + val owned = all { it.acceptsOwnerActions && it.id.value > 0L } + return FilesSelectionActions( + move = canMove && owned, + download = canDownload && all { it.isPlayable && it.id.value > 0L }, + watched = if (canSetWatched && owned) selectionWatchedChoice() else null, + delete = owned, + ) +} + +/** + * Unwatched when every item is finished; otherwise Watched when every item has a known length to + * save as its end; otherwise neither, as for a file the sheet offers no choice for. + */ +private fun List.selectionWatchedChoice(): Boolean? { + val choices = map { it.watchedChoice() } + return when { + choices.any { it == null } -> null + choices.all { it == false } -> false + all { it.playback?.durationSeconds != null } -> true + else -> null + } +} + +/** What the toolbar runs; each null action stays hidden. */ +internal class MobileFilesSelectionHandlers( + val onMove: ((List) -> Unit)?, + /** Takes the selected files that are not on this device or on their way. */ + val onDownload: ((List) -> Unit)?, + val onSetWatched: ((List, Boolean) -> Unit)?, + val onDelete: (List, FilesDeleteMode) -> Unit, +) + +/** + * The floating toolbar over a Files selection: Move, Download, Watched and the delete action, each + * when every selected row allows it. Move to trash goes at once; Delete permanently asks first, + * naming the count. + */ +@Composable +internal fun MobileFilesSelectionToolbar( + items: List, + downloads: DownloadsState, + operation: FilesFolderOperation, + confirmedTrashEnabled: Boolean?, + handlers: MobileFilesSelectionHandlers, + modifier: Modifier = Modifier, +) { + val actions = items.selectionActions( + canMove = handlers.onMove != null, + canDownload = handlers.onDownload != null, + canSetWatched = handlers.onSetWatched != null, + ) + val label = pluralStringResource(R.plurals.mobile_files_selection_actions, items.size, items.size) + // Download leaves the files already here or on their way as they are. + val notHere = items.filter { (downloads.entry(it.id)?.status).downloadable } + MobileSelectionToolbar( + modifier.testTag(MOBILE_FILES_SELECTION_TOOLBAR_TAG).semantics { contentDescription = label }, + ) { + val canStart = operation.canStartOperation + if (actions.move) MoveTile(enabled = canStart) { handlers.onMove?.invoke(items) } + if (actions.download) DownloadTile { handlers.onDownload?.invoke(notHere) } + actions.watched?.let { watched -> WatchedTile(watched) { handlers.onSetWatched?.invoke(items, watched) } } + if (actions.delete) { + DeleteAction(items, operation.canStartOperation, confirmedTrashEnabled, handlers.onDelete) + } + } +} + +/** + * The toolbar's delete: Move to trash goes at once; Delete permanently asks first, naming the count. + * Both follow the latest Trash setting, as the sheet does. + */ +@Composable +private fun DeleteAction( + items: List, + canStart: Boolean, + confirmedTrashEnabled: Boolean?, + onDelete: (List, FilesDeleteMode) -> Unit, +) { + var confirmingPermanentDelete by rememberSaveable { mutableStateOf(false) } + // Move to trash is sent after the next composition, against the latest Trash setting: once + // Trash is off, put.io applies a Trash request as permanent deletion. + var trashTapped by remember { mutableStateOf(false) } + val currentTrashEnabled by rememberUpdatedState(confirmedTrashEnabled) + val currentItems by rememberUpdatedState(items) + // A Trash setting that changes while the confirm is open withdraws it. + LaunchedEffect(confirmedTrashEnabled) { + if (confirmedTrashEnabled != false) confirmingPermanentDelete = false + } + if (trashTapped) { + SideEffect { + trashTapped = false + if (currentTrashEnabled == true) onDelete(currentItems, FilesDeleteMode.TRASH) + } + } + // An unknown Trash setting can't say which delete this would be. + DeleteTile(permanent = confirmedTrashEnabled == false, enabled = canStart && confirmedTrashEnabled != null) { + when (currentTrashEnabled) { + true -> trashTapped = true + false -> confirmingPermanentDelete = true + null -> Unit + } + } + if (confirmingPermanentDelete && confirmedTrashEnabled == false) { + MobileFilesDeleteConfirmation( + enabled = canStart, + count = items.size, + onDismiss = { confirmingPermanentDelete = false }, + onConfirm = { + confirmingPermanentDelete = false + if (currentTrashEnabled == false) onDelete(currentItems, FilesDeleteMode.PERMANENT) + }, + ) + } +} + +@Composable +private fun MoveTile(enabled: Boolean, onClick: () -> Unit) { + MobileToolbarTile( + icon = R.drawable.ic_ph_folder_simple_dashed, + label = stringResource(R.string.mobile_files_move), + enabled = enabled, + onClick = onClick, + modifier = Modifier.testTag(MOBILE_FILES_SELECTION_MOVE_TAG), + ) +} + +@Composable +private fun DownloadTile(onClick: () -> Unit) { + MobileToolbarTile( + icon = R.drawable.ic_ph_arrow_circle_down, + label = stringResource(R.string.mobile_files_download_action), + onClick = onClick, + modifier = Modifier.testTag(MOBILE_FILES_SELECTION_DOWNLOAD_TAG), + ) +} + +/** Watched, or Unwatched when every selected file is finished. */ +@Composable +private fun WatchedTile(watched: Boolean, onClick: () -> Unit) { + MobileToolbarTile( + icon = if (watched) R.drawable.ic_ph_eye else R.drawable.ic_ph_eye_slash, + label = stringResource(if (watched) R.string.mobile_files_watched else R.string.mobile_files_unwatched), + description = stringResource( + if (watched) R.string.mobile_files_mark_watched else R.string.mobile_files_mark_unwatched, + ), + onClick = onClick, + modifier = Modifier.testTag(MOBILE_FILES_SELECTION_WATCHED_TAG), + ) +} + +/** Trash, or Delete when the account turns Trash off; red, as the sheet's delete row is. */ +@Composable +private fun DeleteTile(permanent: Boolean, enabled: Boolean, onClick: () -> Unit) { + MobileToolbarTile( + icon = R.drawable.ic_ph_trash, + label = stringResource(if (permanent) R.string.mobile_files_delete else R.string.mobile_files_trash_short), + description = stringResource( + if (permanent) R.string.mobile_files_delete_permanently else R.string.mobile_files_trash, + ), + color = PutioDesignTokens.redTextSecondary, + enabled = enabled, + onClick = onClick, + modifier = Modifier.testTag(MOBILE_FILES_SELECTION_DELETE_TAG), + ) +} + +/** Where the toolbar's actions go; each one ends the selection once sent. */ +internal class MobileFilesSelectionSources( + val onMove: ((List) -> Unit)?, + val onDownload: ((FilesItem) -> Unit)?, + val onSetWatched: ((List, Boolean) -> Unit)?, + val onEvent: (FilesBrowserEvent) -> Unit, +) + +/** + * The floating toolbar while rows are selected, reporting its height so the list's last row + * clears it; nothing, and no height, otherwise. + */ +@Composable +internal fun MobileFilesSelectionToolbarSlot( + state: FilesBrowserState, + selection: FilesSelection?, + downloads: DownloadsState, + confirmedTrashEnabled: Boolean?, + actions: MobileFilesSelectionSources, + onHeightChanged: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val currentState by rememberUpdatedState(state) + val items = selection?.selected(state).orEmpty() + // Rows that share no action, such as an owned file beside a friend's folder, get no empty toolbar. + val offered = items.selectionActions( + canMove = actions.onMove != null, + canDownload = actions.onDownload != null, + canSetWatched = actions.onSetWatched != null, + ).any + if (selection == null || !offered) { + SideEffect { onHeightChanged(0) } + return + } + // Each action ends the selection as it is sent; a second tap queued in the same frame finds none. + val sendOnce = { send: () -> Unit -> + if (selection.isActive(currentState)) { + send() + selection.end() + } + } + val handlers = MobileFilesSelectionHandlers( + onMove = actions.onMove, + onDownload = actions.onDownload?.let { download -> + { chosen -> sendOnce { chosen.forEach(download) } } + }, + onSetWatched = actions.onSetWatched?.let { setWatched -> + { chosen, watched -> sendOnce { setWatched(chosen, watched) } } + }, + onDelete = { chosen, mode -> + // The reducer refuses a delete while another operation runs; the selection then stays. + if (currentState.current.operation.canStartOperation) { + val folderId = currentState.current.folder.id + sendOnce { actions.onEvent(FilesBrowserEvent.Delete(folderId, chosen.map { it.id }, mode)) } + } + }, + ) + MobileFilesSelectionToolbar( + items = items, + downloads = downloads, + operation = state.current.operation, + confirmedTrashEnabled = confirmedTrashEnabled, + handlers = handlers, + modifier = modifier + .padding(bottom = MobileSelectionToolbarOffset) + .onSizeChanged { onHeightChanged(it.height) }, + ) +} + +/** The list in selection: which rows are checked, how a tap toggles one, and the toolbar's room. */ +internal class MobileFilesListSelection( + val checked: Set, + val onToggle: (FilesItem) -> Unit, + val bottomClearance: Dp, +) + +/** Whether there is a selection and it is on in [state]'s folder. */ +internal fun FilesSelection?.isActiveIn(state: FilesBrowserState): Boolean = this?.isActive(state) == true + +/** Selection keeps to the rows: Continue watching and the folder's count line step aside. */ +internal fun MobileFilesHeaders.unlessSelecting(selecting: Boolean): MobileFilesHeaders = + if (selecting) MobileFilesHeaders() else this + +/** The room the list leaves under its last row for a toolbar [heightPx] tall. */ +@Composable +internal fun selectionToolbarClearance(heightPx: Int): Dp = + with(LocalDensity.current) { heightPx.toDp() } + MobileSelectionToolbarClearance + +/** What the list needs while rows are selected in [state]'s folder; null otherwise. */ +internal fun FilesSelection?.listSelection( + state: FilesBrowserState, + bottomClearance: Dp, +): MobileFilesListSelection? { + val selection = this?.takeIf { it.isActive(state) } ?: return null + return MobileFilesListSelection( + checked = selection.selected(state).mapTo(mutableSetOf()) { it.id }, + onToggle = { item -> selection.toggle(state, item) }, + bottomClearance = bottomClearance, + ) +} + +/** The sheet's Select, which starts selection in the folder [state] reads, when there is a selection. */ +internal fun MobileFilesItemActions.selectingIn( + selection: FilesSelection?, + state: () -> FilesBrowserState, +): MobileFilesItemActions = selection?.let { withSelect { item -> it.start(state(), item) } } ?: this + +/** A file not downloaded yet, or whose download failed or went missing, can start one. */ +private val DownloadStatus?.downloadable: Boolean + get() = this == null || this is DownloadStatus.Failed || this == DownloadStatus.Missing diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 8796a51e0..48f4e61d7 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -43,6 +43,7 @@ We couldn’t send these files to trash This folder contains too many files. Would you want to delete it PERMANENTLY? Delete permanently + At least one of these folders contains too many files. Would you want to delete all of these items PERMANENTLY? The request was incomplete. “%1$s” was not confirmed removed. Actions for %1$s Rename @@ -528,9 +529,9 @@ Downloaded file Select - Select all - Cancel selection - + Select all + Cancel selection + %1$d selected %1$d selected @@ -744,6 +745,53 @@ Share Open in VLC Select + + Unwatched + Trash + + Actions for %1$d item + Actions for %1$d items + + + Moved %1$d item to Trash + Moved %1$d items to Trash + + + Couldn’t restore %1$d item from Trash. %2$s + Couldn’t restore %1$d items from Trash. %2$s + + + Couldn’t mark %1$d item as watched. %2$s + Couldn’t mark %1$d items as watched. %2$s + + + Couldn’t mark %1$d item as unwatched. %2$s + Couldn’t mark %1$d items as unwatched. %2$s + + + %1$d item is no longer available in Files. + %1$d items are no longer available in Files. + + + %1$d item is still in Files. Select it to try again. + %1$d items are still in Files. Select them to try again. + + + The request was incomplete. %1$d item was not confirmed removed. + The request was incomplete. %1$d items were not confirmed removed. + + + %1$d item is in the selected folder. + %1$d items are in the selected folder. + + + %1$d item is not in the selected folder. You can choose a destination again. + %1$d items are not in the selected folder. You can choose a destination again. + + + Couldn’t move %1$d item. Choose another folder and try again. + Couldn’t move %1$d items. Choose another folder and try again. + Mark as watched Mark as unwatched Exclusive link diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileFilesDeleteTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesDeleteTest.kt index fb72e1a9f..b39b775cf 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileFilesDeleteTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesDeleteTest.kt @@ -143,7 +143,7 @@ class MobileFilesDeleteTest { compose.onNodeWithText("Delete").assertDoesNotExist() compose.runOnIdle { val deletion = effects.single() as FilesBrowserEffect.Delete - assertEquals(item.id, deletion.itemId) + assertEquals(listOf(item.id), deletion.itemIds) assertEquals(FilesDeleteMode.PERMANENT, deletion.mode) assertEquals( FilesFolderOperation.Loading( diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileFilesMoveNavigationTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesMoveNavigationTest.kt index b7a088b86..824fa0a3e 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileFilesMoveNavigationTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesMoveNavigationTest.kt @@ -274,7 +274,7 @@ private class RootMoveBackPreview(startMove: Boolean = true) { fun finishReadback() { val checking = effects.last() as FilesBrowserEffect.CheckMove - assertEquals(item.id, checking.itemId) + assertEquals(listOf(item.id), checking.itemIds) dispatch(FilesBrowserEvent.MoveChecked(checking.requestId, PutioResult.Success(item.copy(parentId = FilesItemId(8))))) val loading = effects.last() as FilesBrowserEffect.LoadFolder diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileFilesSelectionTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesSelectionTest.kt new file mode 100644 index 000000000..39d36f8bb --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileFilesSelectionTest.kt @@ -0,0 +1,287 @@ +package io.putdotio.android + +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.auth.MobileAccount +import io.putdotio.android.auth.MobileAuthSessionId +import io.putdotio.android.design.PutioTheme +import io.putdotio.android.files.FilesBrowserEffect +import io.putdotio.android.files.FilesBrowserEvent +import io.putdotio.android.files.FilesBrowserReducer +import io.putdotio.android.files.FilesBrowserState +import io.putdotio.android.files.FilesContent +import io.putdotio.android.files.FilesDeleteMode +import io.putdotio.android.files.FilesFolder +import io.putdotio.android.files.FilesItem +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.files.FilesPage +import io.putdotio.android.files.FilesPaging +import io.putdotio.android.files.FilesSelection +import io.putdotio.android.files.copyForTest +import io.putdotio.android.files.FilesPlaybackProgress +import io.putdotio.android.files.FilesRequestId +import io.putdotio.android.files.MOBILE_FILES_SELECTION_BAR_TAG +import io.putdotio.android.files.MOBILE_FILES_SELECTION_DELETE_TAG +import io.putdotio.android.files.MOBILE_FILES_SELECTION_TOOLBAR_TAG +import io.putdotio.android.files.selectionActions +import io.putdotio.android.playback.PlaybackNextResult +import io.putdotio.android.playback.PlaybackRepository +import io.putdotio.android.playback.PlaybackRepositoryResult +import io.putdotio.android.playback.PlaybackResolution +import io.putdotio.android.playback.PlaybackTarget +import io.putdotio.android.settings.DefaultAccountSettingsPreferences +import io.putdotio.android.settings.readyAccountSettingsState +import io.putdotio.android.settings.readyAndroidAppConfigState +import io.putdotio.sdk.files.PutioFileType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Files selection as the shell draws it: Select from the sheet, the bar, the toolbar and Back. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], qualifiers = "en-rUS-w400dp-h900dp") +class MobileFilesSelectionTest { + @get:Rule + val compose = createComposeRule() + + private val events = mutableListOf() + private lateinit var backOwner: OnBackPressedDispatcherOwner + + @Test + fun selectFromTheSheetReplacesTheFolderBarAndBackEndsItBeforeLeavingTheFolder() { + setShell() + + selectFromTheSheet(SINTEL) + compose.onNodeWithTag(MOBILE_FILES_SELECTION_BAR_TAG).assertExists() + compose.onNodeWithText("1 selected").assertExists() + compose.onNodeWithText(SERIES.name).performClick() + compose.onNodeWithText("2 selected").assertExists() + compose.onNodeWithContentDescription("Select all").performClick() + compose.onNodeWithText("3 selected").assertExists() + assertTrue(events.none { it is FilesBrowserEvent.OpenFolder }) + + compose.runOnIdle { backOwner.onBackPressedDispatcher.onBackPressed() } + compose.onNodeWithTag(MOBILE_FILES_SELECTION_BAR_TAG).assertDoesNotExist() + compose.onNodeWithTag(MOBILE_FILES_SELECTION_TOOLBAR_TAG).assertDoesNotExist() + compose.onNodeWithText("Blue Lights").assertExists() + assertTrue(events.none { it == FilesBrowserEvent.NavigateBack }) + + compose.runOnIdle { backOwner.onBackPressedDispatcher.onBackPressed() } + compose.runOnIdle { assertEquals(FilesBrowserEvent.NavigateBack, events.last()) } + } + + @Test + fun trashSendsEveryCheckedRowInOneDeleteAndEndsTheSelection() { + setShell() + + selectFromTheSheet(SINTEL) + compose.onNodeWithText(SERIES.name).performClick() + compose.onNodeWithContentDescription("Move to trash").performClick() + + compose.runOnIdle { + val delete = events.filterIsInstance().single() + // In the listing's order, whatever order they were checked in. + val expected = FilesBrowserEvent.Delete(FOLDER.id, listOf(SERIES.id, SINTEL.id), FilesDeleteMode.TRASH) + assertEquals(expected, delete) + } + compose.onNodeWithTag(MOBILE_FILES_SELECTION_BAR_TAG).assertDoesNotExist() + } + + @Test + fun withTrashOffTheToolbarDeletesPermanentlyAfterAConfirmThatNamesTheCount() { + setShell(trashEnabled = false) + + selectFromTheSheet(SINTEL) + compose.onNodeWithText(FINISHED.name).performClick() + compose.onNodeWithTag(MOBILE_FILES_SELECTION_DELETE_TAG).performClick() + compose.onNodeWithText("Delete 2 items permanently?").assertExists() + assertTrue(events.none { it is FilesBrowserEvent.Delete }) + compose.onNode(hasText("Delete") and hasAnyAncestor(isDialog())).performClick() + + compose.runOnIdle { + assertEquals( + FilesBrowserEvent.Delete(FOLDER.id, listOf(SINTEL.id, FINISHED.id), FilesDeleteMode.PERMANENT), + events.filterIsInstance().single(), + ) + } + } + + @Test + fun eachToolbarActionShowsOnlyWhenEverySelectedRowAllowsIt() { + val shared = SINTEL.copy(id = FilesItemId(12L), isShared = true) + val unknownLength = SINTEL.copy(id = FilesItemId(13L), playback = null) + fun actions(vararg items: FilesItem) = + items.toList().selectionActions(canMove = true, canDownload = true, canSetWatched = true) + + val mixed = actions(SERIES, SINTEL) + assertTrue(mixed.move && mixed.delete) + assertFalse(mixed.download) + assertNull(mixed.watched) + + val videos = actions(SINTEL, FINISHED) + assertTrue(videos.download) + // One is finished and one isn't: Watched saves both ends. + assertEquals(true, videos.watched) + assertEquals(false, actions(FINISHED, FINISHED.copy(id = FilesItemId(14L))).watched) + // Watched is a saved end, so every file needs a known length. + assertNull(actions(SINTEL, unknownLength).watched) + + val withShared = actions(SINTEL, shared) + assertFalse(withShared.move || withShared.delete) + assertNull(withShared.watched) + assertTrue(withShared.download) + + // A friend's folder beside an owned file shares no action, so the toolbar shows none. + assertFalse(actions(SERIES.copy(id = FilesItemId(15L), isShared = true), SINTEL).any) + + val offline = listOf(SINTEL).selectionActions(canMove = false, canDownload = false, canSetWatched = false) + assertFalse(offline.move || offline.download) + assertNull(offline.watched) + } + + @Test + fun theSelectionEndsInAnotherFolderAndForgetsRowsThatLeft() { + val folder = folderState() + val selection = FilesSelection() + selection.start(folder, SINTEL) + selection.toggle(folder, FINISHED) + + // A row leaves the listing, then comes back: it is not checked again. + val without = folder.withItems(SERIES, SINTEL) + selection.reconcile(without) + selection.reconcile(folder) + assertEquals(listOf(SINTEL), selection.selected(folder)) + + // The same folder read afresh, as an outside open that replaces it does, ends it too. + val again = FilesSelection() + again.start(folder, SINTEL) + again.reconcile(folder.copyForTest(stack = folder.stack.dropLast(1) + folder.current.copy( + content = FilesContent.Loading(FilesRequestId(99L)), + ))) + assertFalse(again.isActive(folder)) + + // Another folder ends it, so coming back shows none. + val root = FilesBrowserReducer.reduce(folder, FilesBrowserEvent.NavigateBack).state + selection.reconcile(root) + assertFalse(selection.isActive(folder)) + assertTrue(selection.selected(folder).isEmpty()) + } + + @Test + fun theSelectionSurvivesASavedStateRestore() { + val folder = folderState() + val selection = FilesSelection() + selection.start(folder, SINTEL) + selection.toggle(folder, FINISHED) + + val saved = with(FilesSelection.Saver) { SaverScope { true }.save(selection) } + val restored = FilesSelection.Saver.restore(checkNotNull(saved)) + + assertEquals(listOf(SINTEL, FINISHED), restored?.selected(folder)) + assertTrue(restored?.isActive(folder) == true) + } + + @Test + @Config(qualifiers = "en-rUS-w1280dp-h800dp") + fun onATabletTheDetailPaneShowsNoFileWhileRowsAreSelected() { + setShell() + + compose.onNodeWithText(SINTEL.name).performClick() + compose.onNodeWithText("No file selected.").assertDoesNotExist() + selectFromTheSheet(FINISHED) + + compose.onNodeWithText("1 selected").assertExists() + compose.onNodeWithText("No file selected.").assertExists() + } + + private fun selectFromTheSheet(item: FilesItem) { + compose.onNodeWithText(item.name).performTouchInput { longClick() } + compose.onNodeWithText("Select").performClick() + compose.onNodeWithText("Select").assertDoesNotExist() + } + + private fun setShell(trashEnabled: Boolean = true) { + compose.setContent { + val owner = checkNotNull(LocalOnBackPressedDispatcherOwner.current) + SideEffect { backOwner = owner } + PutioTheme { + MobileShell( + playbackPlayerFactory = NoAudioSessionFactory, + filesState = folderState(), + accountSettingsState = readyAccountSettingsState( + DefaultAccountSettingsPreferences.copy(trashEnabled = trashEnabled), + ), + appConfigState = readyAndroidAppConfigState(), + account = MobileAccount(userId = 42L, username = "user", email = "user@example.com"), + playbackRepository = NoPlayback, + sessionId = MobileAuthSessionId(1L), + onFilesEvent = { + events += it + true + }, + onAccountSettingsEvent = {}, + onPlaybackAuthenticationRequired = {}, + onSignOut = {}, + ) + } + } + } + + private companion object { + val FOLDER = file(7L, "Blue Lights", PutioFileType.FOLDER, parent = FilesFolder.Root.id) + val SERIES = file(8L, "Series", PutioFileType.FOLDER) + val SINTEL = file(9L, "Sintel.2010.1080p.mkv", PutioFileType.VIDEO, FilesPlaybackProgress(0.0, 600.0)) + val FINISHED = file(10L, "Big_Buck_Bunny.mp4", PutioFileType.VIDEO, FilesPlaybackProgress(600.0, 600.0)) + + fun file( + id: Long, + name: String, + type: PutioFileType, + playback: FilesPlaybackProgress? = null, + parent: FilesItemId = FilesItemId(7L), + ) = FilesItem(FilesItemId(id), parent, name, type, 1_000_000L, "2026-10-13T00:00:00", playback = playback) + + fun FilesBrowserState.withItems(vararg items: FilesItem): FilesBrowserState = + copyForTest( + stack = stack.dropLast(1) + + current.copy(content = FilesContent.Ready(items.toList(), FilesPaging.Complete)), + ) + + fun folderState(): FilesBrowserState { + val start = FilesBrowserReducer.start() + val root = FilesBrowserReducer.reduce(start.state, FilesBrowserEvent.LoadSucceeded( + (start.effect as FilesBrowserEffect.LoadFolder).requestId, FilesPage(listOf(FOLDER), null), + )).state + val opened = FilesBrowserReducer.reduce(root, FilesBrowserEvent.OpenFolder(FOLDER.id)) + val page = FilesPage(listOf(SERIES, SINTEL, FINISHED), null, parent = FOLDER, total = 3) + return FilesBrowserReducer.reduce(opened.state, FilesBrowserEvent.LoadSucceeded( + (opened.effect as FilesBrowserEffect.LoadFolder).requestId, page, + )).state + } + + val NoPlayback = object : PlaybackRepository { + override suspend fun resolve(target: PlaybackTarget): PlaybackRepositoryResult = + error("No playback in these tests") + + override suspend fun findNextVideo(target: PlaybackTarget) = PlaybackNextResult.Ended + } + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileShellDownloadsTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileShellDownloadsTest.kt index ae2446547..801ecaaf1 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileShellDownloadsTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileShellDownloadsTest.kt @@ -74,9 +74,9 @@ class MobileShellDownloadsTest { private val repository = object : StubFilesRepository() { override suspend fun loadFolder(folderId: FilesItemId) = error("Unexpected folder load") - override suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode): PutioResult { - remoteDeletes += itemId - return super.delete(itemId, mode) + override suspend fun delete(itemIds: List, mode: FilesDeleteMode): PutioResult { + remoteDeletes += itemIds + return super.delete(itemIds, mode) } } diff --git a/mobile/src/test/kotlin/io/putdotio/android/files/MobileContinueWatchingTest.kt b/mobile/src/test/kotlin/io/putdotio/android/files/MobileContinueWatchingTest.kt index 11e1a7bad..319673738 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/files/MobileContinueWatchingTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/files/MobileContinueWatchingTest.kt @@ -113,6 +113,22 @@ class MobileContinueWatchingTest { assertTrue(row.entries.value.isEmpty()) } + @Test + fun aPartlyFinishedBulkDeleteTakesOnlyTheVideosItRemoved() { + listOf(Tears, Sintel, Flaky).forEach { id -> + row.opened(id, "video-${id.value}.mkv", 600.0) + row.positionSaved(id, 60.0) + } + val partly = deleted(1L, Tears).copy( + intent = FilesFolderOperationIntent.Delete(listOf(Tears, Sintel, Flaky), FilesDeleteMode.TRASH), + status = FilesDeleteStatus.STILL_PRESENT, + remainingIds = setOf(Flaky), + ) + row.removed(listOf(partly)) + + assertEquals(listOf(Flaky), row.entries.value.map { it.fileId }) + } + @Test fun aDeletedFolderTakesTheVideosPutioNoLongerHas() = runBlocking { listOf(Tears, Sintel, Flaky).forEach { id -> diff --git a/mobile/src/test/kotlin/io/putdotio/android/files/MobileFilesActionsControllerTest.kt b/mobile/src/test/kotlin/io/putdotio/android/files/MobileFilesActionsControllerTest.kt index c522aaf0f..e085c7ba2 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/files/MobileFilesActionsControllerTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/files/MobileFilesActionsControllerTest.kt @@ -13,6 +13,7 @@ import io.putdotio.sdk.files.PutioFileType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope @@ -87,6 +88,36 @@ class MobileFilesActionsControllerTest { assertTrue(events.isEmpty()) } + @Test + fun aSelectionMarksEachFileAFewAtATimeAndNamesItsFailuresOnce() = runTest { + val controller = controller() + val notices = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { controller.notices.toList(notices) } + val videos = (1L..6L).map { video.copy(id = FilesItemId(100L + it), name = "Episode $it.mkv") } + var inFlight = 0 + var mostInFlight = 0 + watched.next = { + inFlight += 1 + mostInFlight = maxOf(mostInFlight, inFlight) + delay(10) + inFlight -= 1 + PutioResult.Success(Unit) + } + watched.failing = setOf(videos[1].id, videos[4].id) + controller.setWatched(videos, watched = true) + advanceUntilIdle() + + assertEquals(videos.map { it.id }, watched.positions.map { it.first }) + // The Kotlin SDK has no bulk write yet: at most four writes are in flight at once. + assertEquals(4, mostInFlight) + // Every write put.io took settles its row and Continue watching; the two it refused do not. + val settled = videos.map { it.id } - watched.failing + assertEquals(settled.map { FilesBrowserEvent.PlaybackPositionReported(it, 3_000.0) }, events) + val notice = notices.single() + assertEquals(R.plurals.mobile_files_mark_watched_failed_many, notice.message) + assertEquals(2, notice.count) + } + @Test fun aNewerWatchedChoiceWaitsForTheOneSentAndBothSettleInOrder() = runTest { val controller = controller() @@ -177,6 +208,73 @@ class MobileFilesActionsControllerTest { assertEquals(setOf("title"), intent.extras?.keySet()) } + @Test + fun undoOfSeveralRestoresThemAllInOneRequest() = runTest { + val controller = controller() + val ids = listOf(video.id, FilesItemId(8L)) + val outcome = FilesDeleteOutcome( + FilesRequestId(4L), + FilesFolderOperationIntent.Delete(ids, FilesDeleteMode.TRASH), + video.name, + status = FilesDeleteStatus.NO_LONGER_AVAILABLE, + ) + controller.undoTrash(outcome) + advanceUntilIdle() + assertEquals(listOf(TrashBulkSelection(cursor = null, itemIds = ids)), trash.restoredAll) + assertEquals(listOf(FilesBrowserEvent.TrashUndone(outcome), FilesBrowserEvent.ReloadIfStale), events) + } + + @Test + fun undoReadsTheFolderOnlyOnceEveryRestoredItemIsBack() = runTest { + val controller = controller() + val late = FilesItemId(8L) + val ids = listOf(video.id, late) + // put.io answered the restore before it had put the second item back; a read that answers for + // another item proves nothing either. + trash.notYetBack[late] = 1 + trash.answeredAs[video.id] = FilesItemId(9L) + val outcome = FilesDeleteOutcome( + FilesRequestId(4L), + FilesFolderOperationIntent.Delete(ids, FilesDeleteMode.TRASH), + video.name, + status = FilesDeleteStatus.NO_LONGER_AVAILABLE, + ) + controller.undoTrash(outcome) + runCurrent() + assertEquals(emptyList(), events) + advanceUntilIdle() + assertEquals(listOf(video.id, late, video.id, late), trash.resolved) + assertEquals(listOf(FilesBrowserEvent.TrashUndone(outcome), FilesBrowserEvent.ReloadIfStale), events) + } + + @Test + fun undoReadsAFewRoundsAtMostAndStopsAtA401() = runTest { + val controller = controller() + val late = FilesItemId(8L) + val outcome = FilesDeleteOutcome( + FilesRequestId(4L), + FilesFolderOperationIntent.Delete(listOf(video.id, late), FilesDeleteMode.TRASH), + video.name, + status = FilesDeleteStatus.NO_LONGER_AVAILABLE, + ) + // An item put.io never serves again: the reads stop and the folder reads anyway. + trash.notYetBack[late] = Int.MAX_VALUE + controller.undoTrash(outcome) + advanceUntilIdle() + assertEquals(listOf(video.id) + List(5) { late }, trash.resolved) + assertEquals(listOf(FilesBrowserEvent.TrashUndone(outcome), FilesBrowserEvent.ReloadIfStale), events) + + // A rejected session ends the reads at once; the shell signs out instead of reloading. + events.clear() + trash.resolved.clear() + trash.rejecting = true + controller.undoTrash(outcome) + advanceUntilIdle() + assertEquals(listOf(video.id), trash.resolved) + assertEquals(emptyList(), events) + assertEquals(1, rejected) + } + @Test fun undoRestoresTheTrashMoveAndReadsTheFolderAgain() = runTest { val controller = controller() @@ -219,6 +317,7 @@ class MobileFilesActionsControllerTest { val cleared = mutableListOf() var result: PutioResult = PutioResult.Success(Unit) var next: (suspend () -> PutioResult)? = null + var failing: Set = emptySet() /** Calls in the order they were sent. */ val answered = mutableListOf() @@ -227,7 +326,8 @@ class MobileFilesActionsControllerTest { positions += itemId to seconds answered += "set" // Like a blocking network call, a scripted answer returns even after cancellation. - return next?.let { answer -> withContext(NonCancellable) { answer() } } ?: result + val answer = next?.let { answer -> withContext(NonCancellable) { answer() } } ?: result + return if (itemId in failing) PutioResult.Failure(PutioFailure.NetworkUnavailable(Exception())) else answer } override suspend fun clearPosition(itemId: FilesItemId): PutioResult { @@ -239,14 +339,41 @@ class MobileFilesActionsControllerTest { private class RecordingTrash : TrashRepository { val restored = mutableListOf() + val restoredAll = mutableListOf() var result: PutioResult = PutioResult.Success(Unit) + val resolved = mutableListOf() + + /** How many more reads of an item still find it in Trash. */ + val notYetBack = mutableMapOf() + + /** Every read answers that the session was rejected. */ + var rejecting = false + + /** Items whose first read answers with another item's id. */ + val answeredAs = mutableMapOf() override suspend fun restore(itemId: FilesItemId): PutioResult = result.also { restored += itemId } override suspend fun load(): PutioResult = error("unused") override suspend fun loadNextPage(cursor: FilesCursor): PutioResult = error("unused") - override suspend fun resolveItem(itemId: FilesItemId): PutioResult = error("unused") + override suspend fun resolveItem(itemId: FilesItemId): PutioResult { + resolved += itemId + val left = notYetBack[itemId] ?: 0 + if (left > 0) notYetBack[itemId] = left - 1 + return when { + rejecting -> + PutioResult.Failure(PutioFailure.AuthenticationRequired(PutioConfigurationException("401"))) + left > 0 -> PutioResult.Failure(PutioFailure.Unexpected(IllegalStateException("still in Trash"))) + else -> { + val answered = answeredAs.remove(itemId) ?: itemId + PutioResult.Success( + FilesItem(answered, FilesItemId(0L), "item", PutioFileType.TEXT, 1L, "2026-10-25"), + ) + } + } + } override suspend fun deleteItem(itemId: FilesItemId): PutioResult = error("unused") - override suspend fun restoreAll(selection: TrashBulkSelection): PutioResult = error("unused") + override suspend fun restoreAll(selection: TrashBulkSelection): PutioResult = + result.also { restoredAll += selection } override suspend fun empty(): PutioResult = error("unused") } diff --git a/tv/src/androidTest/kotlin/io/putdotio/android/tv/TvProofSession.kt b/tv/src/androidTest/kotlin/io/putdotio/android/tv/TvProofSession.kt index 5865491e7..33ac423f5 100644 --- a/tv/src/androidTest/kotlin/io/putdotio/android/tv/TvProofSession.kt +++ b/tv/src/androidTest/kotlin/io/putdotio/android/tv/TvProofSession.kt @@ -185,14 +185,14 @@ internal class ProofFilesRepository( override suspend fun loadMoveDestinations(folderId: FilesItemId, cursor: FilesCursor?) = error("No moves") - override suspend fun move(itemId: FilesItemId, destinationId: FilesItemId): + override suspend fun move(itemIds: List, destinationId: FilesItemId): PutioResult> = error("No moves") override suspend fun persistSort(folderId: FilesItemId, sort: FilesSort) = error("No sorting") override suspend fun rename(itemId: FilesItemId, name: String) = error("No renames") - override suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode): + override suspend fun delete(itemIds: List, mode: FilesDeleteMode): PutioResult = error("No deletes") override suspend fun resolveItem(itemId: FilesItemId) = error("No checks") diff --git a/tv/src/main/kotlin/io/putdotio/android/tv/files/TvFilesScreen.kt b/tv/src/main/kotlin/io/putdotio/android/tv/files/TvFilesScreen.kt index 9162c4f89..e93986ae4 100644 --- a/tv/src/main/kotlin/io/putdotio/android/tv/files/TvFilesScreen.kt +++ b/tv/src/main/kotlin/io/putdotio/android/tv/files/TvFilesScreen.kt @@ -407,7 +407,7 @@ private fun TvFilesTrashLimitStatus( var confirming by rememberSaveable(outcome.requestId.value) { mutableStateOf(false) } // A refresh that no longer lists the folder leaves nothing for the reducer to delete; a // rename keeps the outcome, so the dialog names the folder as it is listed now. - val listed = current.content.items().firstOrNull { it.id == outcome.intent.itemId } + val listed = current.content.items().firstOrNull { it.id in outcome.intent.itemIds } TvFilesNotice( text = stringResource(R.string.tv_files_trash_limit_title), action = stringResource(R.string.tv_files_action_delete).takeIf { listed != null },