diff --git a/Android/qsferaApp/build.gradle b/Android/qsferaApp/build.gradle index bbaade7f9..9e86f239b 100644 --- a/Android/qsferaApp/build.gradle +++ b/Android/qsferaApp/build.gradle @@ -137,8 +137,8 @@ android { testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner" - versionCode = 34 - versionName = "1.3.6" + versionCode = 35 + versionName = "1.3.7" buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\"" buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\"" diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt index 8ec04a608..837c5744c 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt @@ -5,11 +5,12 @@ */ package eu.qsfera.android.presentation.cloud +import android.content.ActivityNotFoundException +import android.content.ClipData import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle -import android.os.Parcelable import android.view.View import android.widget.EditText import android.widget.ImageView @@ -18,12 +19,14 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView +import androidx.core.content.FileProvider import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetDialog import eu.qsfera.android.R +import eu.qsfera.android.data.ClientManager import eu.qsfera.android.domain.files.FileRepository import eu.qsfera.android.domain.files.model.FileListOption import eu.qsfera.android.lib.common.QSferaAccount @@ -35,7 +38,6 @@ import eu.qsfera.android.presentation.settings.SettingsActivity import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester import eu.qsfera.android.presentation.transfers.TransfersViewModel import eu.qsfera.android.ui.activity.FileActivity -import eu.qsfera.android.ui.activity.FileDisplayActivity import eu.qsfera.android.ui.activity.UploadListActivity import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView @@ -44,8 +46,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream class CloudHomeActivity : FileActivity() { + private val clientManager: ClientManager by inject() private val fileRepository: FileRepository by inject() private val transfersViewModel: TransfersViewModel by viewModel() @@ -55,6 +61,12 @@ class CloudHomeActivity : FileActivity() { private lateinit var toolbarTitle: TextView private lateinit var toolbarSearchButton: View private lateinit var toolbarSearch: SearchView + private lateinit var selectionToolbar: View + private lateinit var selectionCount: TextView + private lateinit var selectionProgress: View + private lateinit var selectionSend: View + private lateinit var selectionDelete: View + private lateinit var selectionMore: View private lateinit var bottomNavigation: BottomNavigationView private var uploadsWereRunning = false @@ -76,6 +88,7 @@ class CloudHomeActivity : FileActivity() { setContentView(R.layout.activity_cloud_home) bindViews() setupToolbar() + setupSelectionToolbar() setupBottomNavigation() if (savedInstanceState == null) { @@ -83,7 +96,12 @@ class CloudHomeActivity : FileActivity() { .replace(R.id.cloud_content, CloudHubFragment.newInstance(currentSection)) .commitNow() } - selectSection(currentSection, updateNavigation = true) + if (savedInstanceState == null) { + selectSection(currentSection, updateNavigation = true) + } else { + showRootTitle(currentSection) + bottomNavigation.menu.findItem(currentSection.menuResource)?.isChecked = true + } loadAvatar() observeUploadCompletion() @@ -92,6 +110,10 @@ class CloudHomeActivity : FileActivity() { findViewById(R.id.cloud_toolbar).updateLayoutParams { height = resources.getDimensionPixelSize(R.dimen.cloud_toolbar_height) + insets.top } + selectionToolbar.updatePadding(top = insets.top) + selectionToolbar.updateLayoutParams { + height = resources.getDimensionPixelSize(R.dimen.cloud_toolbar_height) + insets.top + } findViewById(R.id.cloud_bottom_spacer).updateLayoutParams { height = insets.bottom } @@ -108,9 +130,141 @@ class CloudHomeActivity : FileActivity() { toolbarTitle = findViewById(R.id.cloud_toolbar_title) toolbarSearchButton = findViewById(R.id.cloud_toolbar_search_button) toolbarSearch = findViewById(R.id.cloud_toolbar_search) + selectionToolbar = findViewById(R.id.cloud_selection_toolbar) + selectionCount = findViewById(R.id.cloud_selection_count) + selectionProgress = findViewById(R.id.cloud_selection_progress) + selectionSend = findViewById(R.id.cloud_selection_send) + selectionDelete = findViewById(R.id.cloud_selection_delete) + selectionMore = findViewById(R.id.cloud_selection_more) bottomNavigation = findViewById(R.id.cloud_bottom_navigation) } + private fun setupSelectionToolbar() { + findViewById(R.id.cloud_selection_close).setOnClickListener { cloudFragment()?.clearSelection() } + selectionSend.setOnClickListener { cloudFragment()?.shareSelectedMedia() } + selectionDelete.setOnClickListener { cloudFragment()?.confirmDeleteSelectedMedia() } + selectionMore.setOnClickListener { showMediaActionsSheet() } + } + + fun showMediaSelection(count: Int) { + selectionToolbar.visibility = if (count > 0) View.VISIBLE else View.GONE + if (count > 0) { + selectionCount.text = resources.getQuantityString(R.plurals.items_selected_count, count, count) + } else { + setSelectionBusy(false) + } + } + + fun setSelectionBusy(busy: Boolean) { + selectionProgress.visibility = if (busy) View.VISIBLE else View.GONE + listOf(selectionSend, selectionDelete, selectionMore).forEach { action -> + action.isEnabled = !busy + action.alpha = if (busy) 0.45f else 1f + } + } + + private fun showMediaActionsSheet() { + val fragment = cloudFragment() ?: return + val dialog = BottomSheetDialog(this) + val content = layoutInflater.inflate( + R.layout.sheet_cloud_media_actions, + findViewById(android.R.id.content), + false, + ) + dialog.setContentView(content) + content.findViewById(R.id.cloud_media_actions_title).text = selectionCount.text + content.findViewById(R.id.cloud_media_action_send).setOnClickListener { + dialog.dismiss() + fragment.shareSelectedMedia() + } + content.findViewById(R.id.cloud_media_action_select_all).setOnClickListener { + dialog.dismiss() + fragment.selectAllVisibleMedia() + } + content.findViewById(R.id.cloud_media_action_delete).setOnClickListener { + dialog.dismiss() + fragment.confirmDeleteSelectedMedia() + } + dialog.show() + } + + fun shareMedia(items: List) { + if (items.isEmpty()) return + setSelectionBusy(true) + Toast.makeText(this, R.string.cloud_selection_preparing, Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + val result = runCatching { downloadMediaToShareCache(items) } + setSelectionBusy(false) + result.onSuccess { files -> + val uris = files.map { file -> + FileProvider.getUriForFile( + this@CloudHomeActivity, + getString(R.string.file_provider_authority), + file, + ) + } + val mimeType = when { + items.size == 1 -> items.first().mimeType + items.all(CloudMediaItem::isImage) -> "image/*" + items.all(CloudMediaItem::isVideo) -> "video/*" + else -> "*/*" + } + val sendIntent = if (uris.size == 1) { + Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris.first()) + } else { + Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + }.apply { + type = mimeType + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + clipData = ClipData.newUri(contentResolver, items.first().name, uris.first()).also { clip -> + uris.drop(1).forEach { clip.addItem(ClipData.Item(it)) } + } + } + startActivity(Intent.createChooser(sendIntent, getString(R.string.activity_chooser_send_file_title))) + cloudFragment()?.clearSelection() + }.onFailure { + Toast.makeText(this@CloudHomeActivity, R.string.cloud_selection_share_error, Toast.LENGTH_LONG).show() + } + } + } + + private suspend fun downloadMediaToShareCache(items: List): List = withContext(Dispatchers.IO) { + val currentAccount = AccountUtils.getCurrentQSferaAccount(this@CloudHomeActivity) + val client = clientManager.getClientForCoilThumbnails(currentAccount.name) + val authorization = client.credentials?.headerAuth.orEmpty() + val shareDirectory = File(externalCacheDir ?: cacheDir, SHARE_CACHE_DIRECTORY).apply { mkdirs() } + shareDirectory.listFiles()?.filter { it.isFile }?.forEach { it.delete() } + val usedNames = mutableSetOf() + items.mapIndexed { index, media -> + val safeName = media.name.replace(INVALID_FILE_NAME_CHARS, "_").take(MAX_SHARED_FILE_NAME_LENGTH) + .ifBlank { "file-$index" } + var uniqueName = safeName + var suffix = 2 + while (!usedNames.add(uniqueName.lowercase())) { + val extension = safeName.substringAfterLast('.', missingDelimiterValue = "") + val stem = safeName.substringBeforeLast('.', missingDelimiterValue = safeName) + uniqueName = if (extension.isBlank()) "$stem ($suffix)" else "$stem ($suffix).$extension" + suffix++ + } + val destination = File(shareDirectory, uniqueName) + val requestBuilder = Request.Builder().url(contentUri(media, currentAccount)) + if (authorization.isNotBlank()) requestBuilder.header("Authorization", authorization) + client.okHttpClient.newCall(requestBuilder.build()).execute().use { response -> + check(response.isSuccessful) { "WebDAV download failed with HTTP ${response.code}" } + val body = response.body ?: error("WebDAV response has no body") + FileOutputStream(destination).use { output -> body.byteStream().use { it.copyTo(output) } } + } + destination + } + } + + private fun contentUri(media: CloudMediaItem, currentAccount: android.accounts.Account): String = + if (media.webDavHref.isNotBlank()) { + ThumbnailsRequester.getContentUriForWebDavHref(media.webDavHref, currentAccount) + } else { + ThumbnailsRequester.getContentUriForFile(media.toOCFile(currentAccount.name), currentAccount) + } + private fun observeUploadCompletion() { transfersViewModel.workInfosListLiveData.observe(this) { workInfos -> val uploadsAreRunning = workInfos.isNotEmpty() @@ -211,6 +365,10 @@ class CloudHomeActivity : FileActivity() { @Deprecated("Deprecated in Java") override fun onBackPressed() { + if (cloudFragment()?.hasSelection() == true) { + cloudFragment()?.clearSelection() + return + } if (this::toolbarSearch.isInitialized && toolbarSearch.visibility == View.VISIBLE) { closeSearch() return @@ -328,7 +486,7 @@ class CloudHomeActivity : FileActivity() { dialog.setContentView(content) content.findViewById(R.id.cloud_more_storage).setOnClickListener { dialog.dismiss() - selectSection(CloudSection.FILES, updateNavigation = true) + showFilesMode(CloudFilesMode.ALL) } content.findViewById(R.id.cloud_more_transfers).setOnClickListener { dialog.dismiss() @@ -336,15 +494,15 @@ class CloudHomeActivity : FileActivity() { } content.findViewById(R.id.cloud_more_offline).setOnClickListener { dialog.dismiss() - openFileList(FileListOption.AV_OFFLINE) + showFilesMode(CloudFilesMode.OFFLINE) } content.findViewById(R.id.cloud_more_shares).setOnClickListener { dialog.dismiss() - openFileList(FileListOption.SHARED_BY_LINK) + showFilesMode(CloudFilesMode.SHARES) } content.findViewById(R.id.cloud_more_spaces).setOnClickListener { dialog.dismiss() - openFileList(FileListOption.SPACES_LIST) + showFilesMode(CloudFilesMode.SPACES) } content.findViewById(R.id.cloud_more_settings).setOnClickListener { dialog.dismiss() @@ -399,10 +557,32 @@ class CloudHomeActivity : FileActivity() { } fun openStorageItem(item: CloudStorageItem) { - startActivity(Intent(this, FileDisplayActivity::class.java).apply { - putExtra(FileActivity.EXTRA_FILE, item.toOCFile()) - putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, FileListOption.ALL_FILES as Parcelable) - }) + if (item.isImage || item.isVideo) { + startActivity(CloudMediaPreviewActivity.createIntent(this, item.toCloudMedia())) + return + } + Toast.makeText(this, R.string.cloud_selection_preparing, Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + val result = runCatching { downloadMediaToShareCache(listOf(item.toCloudMedia())).single() } + result.onSuccess { file -> + val uri = FileProvider.getUriForFile( + this@CloudHomeActivity, + getString(R.string.file_provider_authority), + file, + ) + val viewIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, item.mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + try { + startActivity(viewIntent) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this@CloudHomeActivity, R.string.cloud_file_open_error, Toast.LENGTH_LONG).show() + } + }.onFailure { + Toast.makeText(this@CloudHomeActivity, R.string.cloud_file_open_error, Toast.LENGTH_LONG).show() + } + } } override fun onAccountSet(stateWasRecovered: Boolean) { @@ -412,14 +592,28 @@ class CloudHomeActivity : FileActivity() { cloudFragment()?.onAccountChanged() } - override fun navigateToOption(fileListOption: FileListOption) { - openFileList(fileListOption) + override fun restart() { + startActivity(createIntent(this, currentSection)) + finish() } - private fun openFileList(option: FileListOption) { - startActivity(Intent(this, FileDisplayActivity::class.java).apply { - putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, option as Parcelable) - }) + override fun navigateToOption(fileListOption: FileListOption) { + showFilesMode( + when (fileListOption) { + FileListOption.ALL_FILES -> CloudFilesMode.ALL + FileListOption.AV_OFFLINE -> CloudFilesMode.OFFLINE + FileListOption.SHARED_BY_LINK -> CloudFilesMode.SHARES + FileListOption.SPACES_LIST -> CloudFilesMode.SPACES + } + ) + } + + internal fun showFilesMode(mode: CloudFilesMode) { + currentSection = CloudSection.FILES + closeSearch() + showRootTitle(CloudSection.FILES) + bottomNavigation.menu.findItem(CloudSection.FILES.menuResource)?.isChecked = true + cloudFragment()?.showFilesMode(mode) } private fun cloudFragment(): CloudHubFragment? = @@ -428,6 +622,9 @@ class CloudHomeActivity : FileActivity() { companion object { private const val EXTRA_SECTION = "cloud_section" private const val STATE_SECTION = "cloud_state_section" + private const val SHARE_CACHE_DIRECTORY = "cloud-share" + private const val MAX_SHARED_FILE_NAME_LENGTH = 180 + private val INVALID_FILE_NAME_CHARS = Regex("[\\\\/:*?\"<>|\\p{Cc}]") fun createIntent(context: Context, section: CloudSection): Intent = Intent(context, CloudHomeActivity::class.java).apply { diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt index a28429694..3908c11dc 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt @@ -7,6 +7,7 @@ package eu.qsfera.android.presentation.cloud import android.accounts.Account import android.view.LayoutInflater +import android.view.HapticFeedbackConstants import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -24,6 +25,7 @@ import eu.qsfera.android.utils.MimetypeIconUtil internal class CloudHubAdapter( private var account: Account, private val onMediaClick: (CloudMediaItem) -> Unit, + private val onMediaLongClick: (CloudMediaItem) -> Unit, private val onAlbumClick: (String) -> Unit, private val onStorageClick: (CloudStorageItem) -> Unit, private val onShortcutClick: (CloudShortcut) -> Unit, @@ -33,6 +35,7 @@ internal class CloudHubAdapter( private val onFeedGroupClick: (String) -> Unit, ) : RecyclerView.Adapter() { private var rows: List = emptyList() + private var selectedMediaKeys: Set = emptySet() fun updateAccount(newAccount: Account) { if (account == newAccount) return @@ -57,6 +60,17 @@ internal class CloudHubAdapter( result.dispatchUpdatesTo(this) } + fun updateMediaSelection(newSelection: Set) { + if (selectedMediaKeys == newSelection) return + val changedKeys = (selectedMediaKeys - newSelection) + (newSelection - selectedMediaKeys) + selectedMediaKeys = newSelection + rows.forEachIndexed { index, row -> + if (row is CloudHubRow.Media && row.item.selectionKey in changedKeys) { + notifyItemChanged(index) + } + } + } + override fun getItemCount(): Int = rows.size override fun getItemViewType(position: Int): Int = when (rows[position]) { @@ -189,22 +203,44 @@ internal class CloudHubAdapter( private fun bindMedia(view: View, item: CloudMediaItem) { val image = view.findViewById(R.id.cloud_media_image) val video = view.findViewById(R.id.cloud_media_video) + val selectionScrim = view.findViewById(R.id.cloud_media_selection_scrim) + val selectionCheck = view.findViewById(R.id.cloud_media_selection_check) + val selected = item.selectionKey in selectedMediaKeys video.visibility = if (item.isVideo) View.VISIBLE else View.GONE + selectionScrim.visibility = if (selected) View.VISIBLE else View.GONE + selectionCheck.visibility = if (selected) View.VISIBLE else View.GONE + view.isActivated = selected loadMediaImage(image, item, PREVIEW_MEDIUM) view.contentDescription = item.name view.setOnClickListener { onMediaClick(item) } + view.setOnLongClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + onMediaLongClick(item) + true + } } private fun loadMediaImage(image: ImageView, item: CloudMediaItem, size: Int) { if (item.isImage) { image.scaleType = ImageView.ScaleType.CENTER_CROP - image.load( - previewUri(item, size), - ThumbnailsRequester.getContentAddressedImageLoader(account), - ) { - placeholder(R.drawable.cloud_media_placeholder) - error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) - crossfade(true) + val loader = ThumbnailsRequester.getContentAddressedImageLoader(account) + val requestKey = item.selectionKey + image.setTag(R.id.cloud_media_image, requestKey) + val preview = runCatching { previewUri(item, size) }.getOrNull() + if (preview == null) { + loadOriginalMediaImage(image, item, requestKey) + } else { + image.load(preview, loader) { + placeholder(R.drawable.cloud_media_placeholder) + crossfade(true) + listener( + onError = { _, _ -> + if (image.getTag(R.id.cloud_media_image) == requestKey) { + loadOriginalMediaImage(image, item, requestKey) + } + } + ) + } } } else { image.dispose() @@ -213,6 +249,26 @@ internal class CloudHubAdapter( } } + private fun loadOriginalMediaImage(image: ImageView, item: CloudMediaItem, requestKey: String) { + val originalUri = runCatching { contentUri(item) }.getOrNull() + if (originalUri == null) { + image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) + return + } + image.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) { + placeholder(R.drawable.cloud_media_placeholder) + error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) + memoryCacheKey("$originalUri#${item.etag}") + diskCacheKey("$originalUri#${item.etag}") + crossfade(true) + listener( + onStart = { + if (image.getTag(R.id.cloud_media_image) != requestKey) image.dispose() + } + ) + } + } + private fun bindStorage(view: View, item: CloudStorageItem) { view.findViewById(R.id.cloud_storage_title).text = item.name view.findViewById(R.id.cloud_storage_icon).setImageResource( @@ -232,15 +288,7 @@ internal class CloudHubAdapter( val cover = view.findViewById(R.id.cloud_album_cover) val coverItem = album.cover if (coverItem?.isImage == true) { - cover.scaleType = ImageView.ScaleType.CENTER_CROP - cover.load( - previewUri(coverItem, PREVIEW_MEDIUM), - ThumbnailsRequester.getContentAddressedImageLoader(account), - ) { - placeholder(R.drawable.cloud_media_placeholder) - error(R.drawable.ic_qsfera_folder) - crossfade(true) - } + loadMediaImage(cover, coverItem, PREVIEW_MEDIUM) } else { cover.dispose() cover.scaleType = ImageView.ScaleType.CENTER_INSIDE @@ -262,6 +310,13 @@ internal class CloudHubAdapter( ThumbnailsRequester.getPreviewUriForFile(item.toOCFile(account.name), account, item.etag, size, size) } + private fun contentUri(item: CloudMediaItem): String = + if (item.webDavHref.isNotBlank()) { + ThumbnailsRequester.getContentUriForWebDavHref(item.webDavHref, account) + } else { + ThumbnailsRequester.getContentUriForFile(item.toOCFile(account.name), account) + } + private fun bindAction(view: View, action: CloudHubRow.Action) { view.findViewById(R.id.cloud_action_icon).setImageResource(action.icon) view.findViewById(R.id.cloud_action_title).text = action.title diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt index c62bc1f13..7beed5c95 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt @@ -6,11 +6,12 @@ package eu.qsfera.android.presentation.cloud import android.content.Intent +import android.os.Build import android.os.Bundle -import android.os.Parcelable import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -22,21 +23,21 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton import eu.qsfera.android.R import eu.qsfera.android.data.ClientManager import eu.qsfera.android.data.executeRemoteOperation -import eu.qsfera.android.domain.files.model.FileListOption import eu.qsfera.android.domain.files.FileRepository +import eu.qsfera.android.domain.files.model.OCFile +import eu.qsfera.android.domain.spaces.SpacesRepository import eu.qsfera.android.domain.transfers.TransferRepository import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy import eu.qsfera.android.lib.common.accounts.AccountUtils as LibraryAccountUtils import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest import eu.qsfera.android.lib.resources.files.search.MediaSearchType import eu.qsfera.android.presentation.authentication.AccountUtils -import eu.qsfera.android.ui.activity.FileActivity -import eu.qsfera.android.ui.activity.FileDisplayActivity import eu.qsfera.android.ui.activity.UploadListActivity import eu.qsfera.android.utils.MimetypeIconUtil import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject @@ -47,6 +48,7 @@ import java.util.Locale class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private val clientManager: ClientManager by inject() private val fileRepository: FileRepository by inject() + private val spacesRepository: SpacesRepository by inject() private val transferRepository: TransferRepository by inject() private lateinit var section: CloudSection @@ -66,6 +68,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private var reachedEnd = false private var mediaLoaded = false private var filesLoaded = false + private var filesMode = CloudFilesMode.ALL + private var parentFilesMode: CloudFilesMode? = null + private var selection = CloudMediaSelection() var currentFolderPath: String = ROOT_PATH private set @@ -81,15 +86,28 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION)) + val restoredSelection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + savedInstanceState?.getParcelableArrayList(STATE_SELECTION, CloudMediaItem::class.java) + } else { + @Suppress("DEPRECATION") + savedInstanceState?.getParcelableArrayList(STATE_SELECTION) + } + selection = CloudMediaSelection(restoredSelection.orEmpty()) requireActivity().onBackPressedDispatcher.addCallback(this, nestedBackCallback) } + override fun onSaveInstanceState(outState: Bundle) { + outState.putParcelableArrayList(STATE_SELECTION, ArrayList(selection.items)) + super.onSaveInstanceState(outState) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val account = AccountUtils.getCurrentQSferaAccount(requireContext()) adapter = CloudHubAdapter( account = account, - onMediaClick = ::openMedia, + onMediaClick = ::handleMediaClick, + onMediaLongClick = ::toggleMediaSelection, onAlbumClick = ::openAlbum, onStorageClick = ::openStorage, onShortcutClick = ::openShortcut, @@ -124,6 +142,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { setOnClickListener { (activity as? CloudHomeActivity)?.showAddSheet() } } configureSection() + updateSelectionUi() reload() } @@ -133,11 +152,14 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { return } invalidateActiveLoad() - if (currentFolderPath != ROOT_PATH) { + clearSelection() + if (currentFolderPath != ROOT_PATH || newSection == CloudSection.FILES) { filesLoaded = false storageItems = emptyList() } section = newSection + if (newSection == CloudSection.FILES) filesMode = CloudFilesMode.ALL + parentFilesMode = null activeAlbumPath = null activeFeedDate = null currentFolderPath = ROOT_PATH @@ -197,6 +219,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { fun onAccountChanged() { if (!isAdded || !this::adapter.isInitialized) return invalidateActiveLoad() + clearSelection() adapter.updateAccount(AccountUtils.getCurrentQSferaAccount(requireContext())) allMedia = emptyList() storageItems = emptyList() @@ -285,12 +308,14 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { adapter.submitRows(listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), ""))) val requestedPath = currentFolderPath val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name + val requestedMode = filesMode + val requestedSpaceId = currentFolderSpaceId val requestGeneration = ++loadGeneration loadJob = viewLifecycleOwner.lifecycleScope.launch { val result = try { Result.success( withContext(Dispatchers.IO) { - loadStorageFolder(requestedPath, accountName) + loadFilesForMode(requestedMode, requestedPath, requestedSpaceId, accountName) } ) } catch (cancelled: CancellationException) { @@ -298,12 +323,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } catch (throwable: Throwable) { Result.failure(throwable) } - if ( - !isAdded || - requestGeneration != loadGeneration || - section != CloudSection.FILES || - requestedPath != currentFolderPath - ) return@launch + if (!isAdded || isStaleFilesRequest(requestGeneration, requestedPath, requestedMode, requestedSpaceId)) { + return@launch + } isLoading = false refresh.isRefreshing = false result.onSuccess { loadedFolder -> @@ -328,14 +350,73 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } } - private fun loadStorageFolder(requestedPath: String, accountName: String): LoadedStorageFolder { - val rootFolder = fileRepository.getPersonalRootFolderForAccount(accountName) - val cachedFolder = storedFolder(requestedPath, accountName, rootFolder.spaceId) + private fun isStaleFilesRequest( + requestGeneration: Long, + requestedPath: String, + requestedMode: CloudFilesMode, + requestedSpaceId: String?, + ): Boolean = requestGeneration != loadGeneration || + section != CloudSection.FILES || + requestedPath != currentFolderPath || + requestedMode != filesMode || + requestedSpaceId != currentFolderSpaceId + + private suspend fun loadFilesForMode( + mode: CloudFilesMode, + requestedPath: String, + requestedSpaceId: String?, + accountName: String, + ): LoadedStorageFolder = when (mode) { + CloudFilesMode.ALL -> { loadStorageFolder(requestedPath, requestedSpaceId, accountName) } + CloudFilesMode.OFFLINE -> { + LoadedStorageFolder( + items = fileRepository.getFilesAvailableOfflineFromAccount(accountName).map(OCFile::toCloudStorageItem), + spaceId = null, + usedCacheAfterRefreshFailure = false, + ) + } + CloudFilesMode.SHARES -> { + LoadedStorageFolder( + items = fileRepository.getSharedByLinkWithSyncInfoForAccountAsFlow(accountName) + .first() + .map { it.file.toCloudStorageItem() }, + spaceId = null, + usedCacheAfterRefreshFailure = false, + ) + } + CloudFilesMode.SPACES -> { + val refreshFailed = runCatching { spacesRepository.refreshSpacesForAccount(accountName) }.isFailure + LoadedStorageFolder( + items = spacesRepository.getPersonalAndProjectSpacesForAccount(accountName) + .filterNot { it.isDisabled } + .map { space -> + CloudStorageItem( + remotePath = ROOT_PATH, + mimeType = "DIR", + size = space.quota?.used ?: 0L, + modifiedAt = 0L, + owner = accountName, + etag = space.root.eTag.orEmpty(), + remoteId = space.root.id, + spaceId = space.id, + displayName = space.name, + ) + }, + spaceId = null, + usedCacheAfterRefreshFailure = refreshFailed, + ) + } + } + + private fun loadStorageFolder(requestedPath: String, requestedSpaceId: String?, accountName: String): LoadedStorageFolder { + val personalRoot = fileRepository.getPersonalRootFolderForAccount(accountName) + val effectiveSpaceId = requestedSpaceId ?: personalRoot.spaceId + val cachedFolder = storedFolder(requestedPath, accountName, requestedSpaceId) val refreshFailure = runCatching { - fileRepository.refreshFolder(requestedPath, accountName, rootFolder.spaceId) + fileRepository.refreshFolder(requestedPath, accountName, effectiveSpaceId) }.exceptionOrNull() val refreshedFolder = if (refreshFailure == null) { - storedFolder(requestedPath, accountName, rootFolder.spaceId) + storedFolder(requestedPath, accountName, requestedSpaceId) } else { null } @@ -349,7 +430,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } private fun storedFolder(requestedPath: String, accountName: String, spaceId: String?) = - if (sameRemotePath(requestedPath, ROOT_PATH)) { + if (sameRemotePath(requestedPath, ROOT_PATH) && spaceId == null) { fileRepository.getPersonalRootFolderForAccount(accountName) } else { fileRepository.getFileByRemotePath(requestedPath, accountName, spaceId) @@ -360,19 +441,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { return fileRepository.getFolderContent(storedFolderId) .asSequence() .filterNot { sameRemotePath(it.remotePath, requestedPath) } - .map { remote -> - CloudStorageItem( - remotePath = remote.remotePath, - mimeType = remote.mimeType, - size = remote.length, - modifiedAt = remote.modificationTimestamp, - owner = remote.owner, - etag = remote.remoteEtag.orEmpty().ifBlank { remote.etag.orEmpty() }, - remoteId = remote.remoteId, - permissions = remote.permissions, - spaceId = remote.spaceId, - ) - } + .map(OCFile::toCloudStorageItem) .sortedWith(compareByDescending { it.isFolder }.thenBy { it.name.lowercase() }) .toList() } @@ -465,6 +534,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { CloudSection.MORE -> emptyList() } adapter.submitRows(rows) + adapter.updateMediaSelection(selection.keys) } private fun filteredMedia(): List = allMedia.filter { media -> @@ -569,8 +639,103 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } } - private fun openMedia(media: CloudMediaItem) { - startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media)) + private fun handleMediaClick(media: CloudMediaItem) { + if (selection.isEmpty) { + startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media)) + } else { + toggleMediaSelection(media) + } + } + + private fun toggleMediaSelection(media: CloudMediaItem) { + selection.toggle(media) + updateSelectionUi() + } + + fun clearSelection() { + if (selection.isEmpty) return + selection.clear() + updateSelectionUi() + } + + fun hasSelection(): Boolean = !selection.isEmpty + + fun selectAllVisibleMedia() { + selection.selectAll(selectableMedia()) + updateSelectionUi() + } + + fun shareSelectedMedia() { + val selected = selection.items + if (selected.isNotEmpty()) { + (activity as? CloudHomeActivity)?.shareMedia(selected) + } + } + + fun confirmDeleteSelectedMedia() { + val selected = selection.items + if (selected.isEmpty()) return + AlertDialog.Builder(requireContext()) + .setMessage(resources.getQuantityString(R.plurals.cloud_selection_delete_confirm, selected.size, selected.size)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.cloud_selection_delete) { _, _ -> deleteMedia(selected) } + .show() + } + + private fun deleteMedia(items: List) { + val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name + (activity as? CloudHomeActivity)?.setSelectionBusy(true) + viewLifecycleOwner.lifecycleScope.launch { + val deletedItems = withContext(Dispatchers.IO) { + items.filter { item -> + runCatching { + val storedFile = fileRepository.getFileByRemotePath(item.remotePath, accountName, item.spaceId) + if (storedFile?.id != null) { + fileRepository.deleteFiles(listOf(storedFile), removeOnlyLocalCopy = false) + } else { + executeRemoteOperation { + clientManager.getFileService(accountName).removeFile( + remotePath = item.remotePath, + spaceWebDavUrl = spacesRepository.getWebDavUrlForSpace(accountName, item.spaceId), + ) + } + } + }.isSuccess + } + } + if (!isAdded) return@launch + (activity as? CloudHomeActivity)?.setSelectionBusy(false) + selection.clear() + allMedia = allMedia.filterNot { candidate -> + deletedItems.any { it.selectionKey == candidate.selectionKey } + } + updateSelectionUi() + if (deletedItems.size == items.size) { + render() + Toast.makeText(requireContext(), R.string.cloud_selection_delete_success, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(requireContext(), R.string.cloud_selection_delete_error, Toast.LENGTH_LONG).show() + reloadMedia() + } + } + } + + private fun selectableMedia(): List { + val media = filteredMedia() + return when { + section == CloudSection.PHOTOS -> { media } + activeFeedDate != null -> { + val dateFormat = DateFormat.getDateInstance(DateFormat.LONG, Locale.getDefault()) + media.filter { dateFormat.format(Date(it.modifiedAt)) == activeFeedDate } + } + activeAlbumPath != null -> { media.filter { it.albumKey == activeAlbumPath } } + else -> { emptyList() } + } + } + + private fun updateSelectionUi() { + if (this::adapter.isInitialized) adapter.updateMediaSelection(selection.keys) + (activity as? CloudHomeActivity)?.showMediaSelection(selection.size) } private fun openFeedGroup(date: String) { @@ -591,6 +756,8 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private fun openStorage(item: CloudStorageItem) { if (item.isFolder) { + parentFilesMode = filesMode.takeUnless { it == CloudFilesMode.ALL } + filesMode = CloudFilesMode.ALL currentFolderPath = item.remotePath.ensureFolderPath() currentFolderSpaceId = item.spaceId filesLoaded = false @@ -603,6 +770,10 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } fun navigateUp() { + if (!selection.isEmpty) { + clearSelection() + return + } when { activeFeedDate != null -> { activeFeedDate = null @@ -620,8 +791,10 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { currentFolderPath = parentFolder(currentFolderPath) filesLoaded = false if (currentFolderPath == ROOT_PATH) { - nestedBackCallback.isEnabled = false - (activity as? CloudHomeActivity)?.restoreSectionTitle() + nestedBackCallback.isEnabled = parentFilesMode != null + if (parentFilesMode == null) { + (activity as? CloudHomeActivity)?.restoreSectionTitle() + } } else { (activity as? CloudHomeActivity)?.showNestedTitle( currentFolderPath.trimEnd('/').substringAfterLast('/') @@ -629,14 +802,46 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } reloadFiles() } + section == CloudSection.FILES && parentFilesMode != null -> { + showFilesMode(parentFilesMode ?: CloudFilesMode.ALL) + } + section == CloudSection.FILES && filesMode != CloudFilesMode.ALL -> { + showFilesMode(CloudFilesMode.ALL) + } } } + internal fun showFilesMode(mode: CloudFilesMode) { + if (!this::adapter.isInitialized) return + invalidateActiveLoad() + clearSelection() + section = CloudSection.FILES + filesMode = mode + parentFilesMode = null + currentFolderPath = ROOT_PATH + currentFolderSpaceId = null + filesLoaded = false + nestedBackCallback.isEnabled = mode != CloudFilesMode.ALL + val title = when (mode) { + CloudFilesMode.ALL -> R.string.cloud_files_title + CloudFilesMode.OFFLINE -> R.string.cloud_files_offline_title + CloudFilesMode.SHARES -> R.string.cloud_files_shares_title + CloudFilesMode.SPACES -> R.string.cloud_files_spaces_title + } + if (mode == CloudFilesMode.ALL) { + (activity as? CloudHomeActivity)?.restoreSectionTitle() + } else { + (activity as? CloudHomeActivity)?.showNestedTitle(getString(title)) + } + configureSection() + reloadFiles() + } + private fun openShortcut(shortcut: CloudShortcut) { when (shortcut) { CloudShortcut.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java)) - CloudShortcut.OFFLINE -> openFileList(FileListOption.AV_OFFLINE) - CloudShortcut.SHARES -> openFileList(FileListOption.SHARED_BY_LINK) + CloudShortcut.OFFLINE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.OFFLINE) + CloudShortcut.SHARES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SHARES) } } @@ -644,25 +849,20 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { when (action) { CloudAction.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java)) CloudAction.SETTINGS -> startActivity(Intent(requireContext(), eu.qsfera.android.presentation.settings.SettingsActivity::class.java)) - CloudAction.STORAGE -> (activity as? CloudHomeActivity)?.navigateToOption(FileListOption.ALL_FILES) - CloudAction.OFFLINE -> openFileList(FileListOption.AV_OFFLINE) - CloudAction.SHARES -> openFileList(FileListOption.SHARED_BY_LINK) - CloudAction.SPACES -> openFileList(FileListOption.SPACES_LIST) + CloudAction.STORAGE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.ALL) + CloudAction.OFFLINE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.OFFLINE) + CloudAction.SHARES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SHARES) + CloudAction.SPACES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SPACES) } } - private fun openFileList(option: FileListOption) { - startActivity(Intent(requireContext(), FileDisplayActivity::class.java).apply { - putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, option as Parcelable) - }) - } - companion object { private const val ARG_SECTION = "cloud_section" private const val GRID_SPANS = 6 private const val MEDIA_PAGE_SIZE = 200 private const val LOAD_MORE_THRESHOLD = 18 private const val ROOT_PATH = "/" + private const val STATE_SELECTION = "cloud_media_selection" fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply { arguments = bundleOf(ARG_SECTION to section.wireValue) @@ -680,6 +880,18 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } } +private fun OCFile.toCloudStorageItem(): CloudStorageItem = CloudStorageItem( + remotePath = remotePath, + mimeType = mimeType, + size = length, + modifiedAt = modificationTimestamp, + owner = owner, + etag = remoteEtag.orEmpty().ifBlank { etag.orEmpty() }, + remoteId = remoteId, + permissions = permissions, + spaceId = spaceId, +) + private data class LoadedMediaPage( val items: List, val rawResultCount: Int, diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt index 7b06ddccd..1dcb31bef 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt @@ -61,14 +61,30 @@ class CloudMediaPreviewActivity : AppCompatActivity() { val account = AccountUtils.getCurrentQSferaAccount(this) val photo = findViewById(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE } val progress = findViewById(R.id.cloud_preview_progress) + val loader = ThumbnailsRequester.getContentAddressedImageLoader(account) photo.load( previewUri(media, account, 2560, 2560), - ThumbnailsRequester.getContentAddressedImageLoader(account), + loader, ) { crossfade(true) listener( onSuccess = { _, _ -> progress.visibility = View.GONE }, - onError = { _, _ -> progress.visibility = View.GONE }, + onError = { _, _ -> + val originalUri = runCatching { contentUri(media, account) }.getOrNull() + if (originalUri == null) { + progress.visibility = View.GONE + } else { + photo.load(originalUri, loader) { + memoryCacheKey("$originalUri#${media.etag}") + diskCacheKey("$originalUri#${media.etag}") + crossfade(true) + listener( + onSuccess = { _, _ -> progress.visibility = View.GONE }, + onError = { _, _ -> progress.visibility = View.GONE }, + ) + } + } + }, ) } } @@ -82,7 +98,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() { val prepared = runCatching { withContext(Dispatchers.IO) { val client = clientManager.getClientForCoilThumbnails(account.name) - val contentUrl = previewUri(media, account, 1, 1).substringBefore('?') + val contentUrl = contentUri(media, account) contentUrl to client.credentials?.headerAuth.orEmpty() } }.getOrNull() ?: run { @@ -127,6 +143,13 @@ class CloudMediaPreviewActivity : AppCompatActivity() { ) } + private fun contentUri(media: CloudMediaItem, account: Account): String = + if (media.webDavHref.isNotBlank()) { + ThumbnailsRequester.getContentUriForWebDavHref(media.webDavHref, account) + } else { + ThumbnailsRequester.getContentUriForFile(media.toOCFile(account.name), account) + } + @Suppress("DEPRECATION") private fun mediaExtra(): CloudMediaItem? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(EXTRA_MEDIA, CloudMediaItem::class.java) diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt index 903995858..679ce9400 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt @@ -24,6 +24,7 @@ data class CloudMediaItem( val isVideo: Boolean get() = mimeType.startsWith("video/") val isImage: Boolean get() = mimeType.startsWith("image/") val albumKey: String get() = "${spaceId.orEmpty()}::$parentPath" + val selectionKey: String get() = "${spaceId.orEmpty()}::${webDavHref.ifBlank { remotePath }}" fun toOCFile(owner: String): OCFile = OCFile( owner = owner, @@ -47,8 +48,9 @@ data class CloudStorageItem( val remoteId: String? = null, val permissions: String? = null, val spaceId: String? = null, + val displayName: String? = null, ) { - val name: String get() = remotePath.trimEnd('/').substringAfterLast('/').ifBlank { "/" } + val name: String get() = displayName ?: remotePath.trimEnd('/').substringAfterLast('/').ifBlank { "/" } val isFolder: Boolean get() = mimeType == "DIR" || mimeType == "httpd/unix-directory" val isImage: Boolean get() = mimeType.startsWith("image/") val isVideo: Boolean get() = mimeType.startsWith("video/") @@ -72,9 +74,48 @@ data class CloudStorageItem( size = size, modifiedAt = modifiedAt, etag = etag, + spaceId = spaceId, ) } +internal class CloudMediaSelection(initialItems: Collection = emptyList()) { + private val selected = LinkedHashMap().apply { + initialItems.forEach { put(it.selectionKey, it) } + } + + val size: Int get() = selected.size + val items: List get() = selected.values.toList() + val keys: Set get() = selected.keys.toSet() + val isEmpty: Boolean get() = selected.isEmpty() + + fun contains(item: CloudMediaItem): Boolean = item.selectionKey in selected + + /** Returns true when [item] is selected after the toggle. */ + fun toggle(item: CloudMediaItem): Boolean = if (selected.remove(item.selectionKey) != null) { + false + } else { + selected[item.selectionKey] = item + true + } + + fun selectAll(items: Collection) { + items.forEach { selected[it.selectionKey] = it } + } + + fun remove(items: Collection) { + items.forEach { selected.remove(it.selectionKey) } + } + + fun clear() = selected.clear() +} + +internal enum class CloudFilesMode { + ALL, + OFFLINE, + SHARES, + SPACES, +} + enum class CloudSection(val wireValue: String) { FEED("feed"), FILES("files"), diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt index 73a77eda8..dff7dd8e0 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -112,12 +112,19 @@ object ThumbnailsRequester : KoinComponent { width: Int = 1024, height: Int = 1024, ): String { - val baseUrl = accountBaseUrls.getOrPut(account.name) { - val accountManager = AccountManager.get(appContext) - accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) - ?.trimEnd('/') - .orEmpty() - } + val absoluteHref = getContentUriForWebDavHref(webDavHref, account) + val separator = if ('?' in absoluteHref) '&' else '?' + return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1" + } + + /** + * Returns the authenticated WebDAV content URL without preview parameters. This is used as + * a fallback for formats for which the server cannot generate a thumbnail. Absolute hrefs + * are restricted to the current account origin so a remote search result can never redirect + * the authenticated image loader to another server. + */ + fun getContentUriForWebDavHref(webDavHref: String, account: Account): String { + val baseUrl = getAccountBaseUrl(account) val baseHttpUrl = baseUrl.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid account base URL") val absoluteCandidate = webDavHref.toHttpUrlOrNull() if (absoluteCandidate != null) { @@ -125,24 +132,22 @@ object ThumbnailsRequester : KoinComponent { absoluteCandidate.scheme == baseHttpUrl.scheme && absoluteCandidate.host == baseHttpUrl.host && absoluteCandidate.port == baseHttpUrl.port - ) { "WebDAV preview href must use the account origin" } + ) { "WebDAV href must use the account origin" } } - val absoluteHref = absoluteCandidate?.toString() - ?: "$baseUrl/${webDavHref.trimStart('/')}" - val separator = if ('?' in absoluteHref) '&' else '?' - return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1" + return absoluteCandidate?.toString() ?: "$baseUrl/${webDavHref.trimStart('/')}" + } + + fun getContentUriForFile(file: OCFile, account: Account): String { + val normalizedRemotePath = file.remotePath.orEmpty() + val path = if (normalizedRemotePath.startsWith('/')) normalizedRemotePath else "/$normalizedRemotePath" + return "${getAccountBaseUrl(account)}/webdav${Uri.encode(path, "/")}" } fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String = String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag) private fun getPreviewUri(remotePath: String?, etag: String?, account: Account, width: Int, height: Int): String { - val baseUrl = accountBaseUrls.getOrPut(account.name) { - val accountManager = AccountManager.get(appContext) - accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) - ?.trimEnd('/') - .orEmpty() - } + val baseUrl = getAccountBaseUrl(account) val normalizedRemotePath = remotePath.orEmpty() val path = if (normalizedRemotePath.startsWith("/")) normalizedRemotePath else "/$normalizedRemotePath" val encodedPath = Uri.encode(path, "/") @@ -150,6 +155,13 @@ object ThumbnailsRequester : KoinComponent { return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag.orEmpty()) } + private fun getAccountBaseUrl(account: Account): String = accountBaseUrls.getOrPut(account.name) { + val accountManager = AccountManager.get(appContext) + accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) + ?.trimEnd('/') + .orEmpty() + } + fun getContentAddressedImageLoader(): ImageLoader { val account = AccountUtils.getCurrentQSferaAccount(appContext) return getContentAddressedImageLoader(account) diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_media_selection_badge.xml b/Android/qsferaApp/src/main/res/drawable/cloud_media_selection_badge.xml new file mode 100644 index 000000000..5da000f5e --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_media_selection_badge.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_check.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_check.xml new file mode 100644 index 000000000..fc0e870b8 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_check.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_delete.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_delete.xml new file mode 100644 index 000000000..b97ed2d32 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_delete.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_more_vert.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_more_vert.xml new file mode 100644 index 000000000..49e354825 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_more_vert.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_send.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_send.xml new file mode 100644 index 000000000..8e59c44fa --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_send.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml b/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml index 4fadd3376..ae747a164 100644 --- a/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml +++ b/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml @@ -83,6 +83,93 @@ app:queryHint="@string/actionbar_search" /> + + + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/sheet_cloud_media_actions.xml b/Android/qsferaApp/src/main/res/layout/sheet_cloud_media_actions.xml new file mode 100644 index 000000000..b0c4450c7 --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/sheet_cloud_media_actions.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml b/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml index 2564718fd..e50d9079a 100644 --- a/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml +++ b/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml @@ -78,4 +78,24 @@ Не удалось загрузить файлы Не удалось обновить данные по сети. Показаны сохранённые файлы. Автозагрузка + Выбрано + Закрыть выбор + Отправить + Удалить + Другие действия + Выбрать все + Подготавливаем файлы… + Не удалось подготовить выбранные файлы + + Удалить %1$d выбранный файл из КуСферы? + Удалить %1$d выбранных файла из КуСферы? + Удалить %1$d выбранных файлов из КуСферы? + Удалить %1$d выбранных файла из КуСферы? + + Выбранные файлы удалены + Часть файлов удалить не удалось. Список обновлён. + Нет приложения, которое может открыть этот файл + Офлайн + Ссылки + Пространства diff --git a/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml b/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml index 3f9d86eca..052717204 100644 --- a/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml +++ b/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml @@ -70,4 +70,22 @@ Could not load files Network refresh failed. Showing saved files. Automatic uploads + Selected + Close selection + Send + Delete + More actions + Select all + Preparing files… + Could not prepare the selected files + + Delete %1$d selected file from КуСфера? + Delete %1$d selected files from КуСфера? + + Selected files deleted + Some files could not be deleted. The list has been refreshed. + No app could open this file + Offline + Shared links + Spaces diff --git a/Android/qsferaApp/src/main/res/values/colors.xml b/Android/qsferaApp/src/main/res/values/colors.xml index da23bab0b..abbf78955 100644 --- a/Android/qsferaApp/src/main/res/values/colors.xml +++ b/Android/qsferaApp/src/main/res/values/colors.xml @@ -64,6 +64,7 @@ #20396676 #4021434F + #4D4B7BEC #ECECEC diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaSelectionTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaSelectionTest.kt new file mode 100644 index 000000000..8c40069b5 --- /dev/null +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaSelectionTest.kt @@ -0,0 +1,54 @@ +/** + * qsfera Android client application + * + * Copyright (C) 2026 QSfera. + */ +package eu.qsfera.android.presentation.cloud + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CloudMediaSelectionTest { + + @Test + fun `toggle selects and deselects the same media`() { + val selection = CloudMediaSelection() + val media = media("/Camera/photo.jpg") + + assertTrue(selection.toggle(media)) + assertTrue(selection.contains(media)) + assertFalse(selection.toggle(media)) + assertTrue(selection.isEmpty) + } + + @Test + fun `same path in different spaces remains independently selectable`() { + val first = media("/Camera/photo.jpg", spaceId = "personal") + val second = media("/Camera/photo.jpg", spaceId = "project") + val selection = CloudMediaSelection(listOf(first, second)) + + assertEquals(2, selection.size) + assertEquals(listOf(first, second), selection.items) + } + + @Test + fun `select all keeps existing order and ignores duplicates`() { + val first = media("/Camera/one.jpg") + val second = media("/Camera/two.jpg") + val selection = CloudMediaSelection(listOf(first)) + + selection.selectAll(listOf(first, second, second)) + + assertEquals(listOf(first, second), selection.items) + } + + private fun media(path: String, spaceId: String? = null) = CloudMediaItem( + remotePath = path, + mimeType = "image/jpeg", + size = 1L, + modifiedAt = 1L, + spaceId = spaceId, + ) +}