Fix cloud photo rendering and selection actions
Android / test-and-build (push) Successful in 19m51s

This commit is contained in:
Курнат Андрей
2026-07-18 14:21:21 +03:00
parent df9aa657a3
commit 7eaccd88bd
19 changed files with 943 additions and 104 deletions
+2 -2
View File
@@ -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() + "\""
@@ -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()
}
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<View>(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<View>(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<View>(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<TextView>(R.id.cloud_media_actions_title).text = selectionCount.text
content.findViewById<View>(R.id.cloud_media_action_send).setOnClickListener {
dialog.dismiss()
fragment.shareSelectedMedia()
}
content.findViewById<View>(R.id.cloud_media_action_select_all).setOnClickListener {
dialog.dismiss()
fragment.selectAllVisibleMedia()
}
content.findViewById<View>(R.id.cloud_media_action_delete).setOnClickListener {
dialog.dismiss()
fragment.confirmDeleteSelectedMedia()
}
dialog.show()
}
fun shareMedia(items: List<CloudMediaItem>) {
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<CloudMediaItem>): List<File> = 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<String>()
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<View>(R.id.cloud_more_storage).setOnClickListener {
dialog.dismiss()
selectSection(CloudSection.FILES, updateNavigation = true)
showFilesMode(CloudFilesMode.ALL)
}
content.findViewById<View>(R.id.cloud_more_transfers).setOnClickListener {
dialog.dismiss()
@@ -336,15 +494,15 @@ class CloudHomeActivity : FileActivity() {
}
content.findViewById<View>(R.id.cloud_more_offline).setOnClickListener {
dialog.dismiss()
openFileList(FileListOption.AV_OFFLINE)
showFilesMode(CloudFilesMode.OFFLINE)
}
content.findViewById<View>(R.id.cloud_more_shares).setOnClickListener {
dialog.dismiss()
openFileList(FileListOption.SHARED_BY_LINK)
showFilesMode(CloudFilesMode.SHARES)
}
content.findViewById<View>(R.id.cloud_more_spaces).setOnClickListener {
dialog.dismiss()
openFileList(FileListOption.SPACES_LIST)
showFilesMode(CloudFilesMode.SPACES)
}
content.findViewById<View>(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 {
@@ -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<CloudHubAdapter.Holder>() {
private var rows: List<CloudHubRow> = emptyList()
private var selectedMediaKeys: Set<String> = emptySet()
fun updateAccount(newAccount: Account) {
if (account == newAccount) return
@@ -57,6 +60,17 @@ internal class CloudHubAdapter(
result.dispatchUpdatesTo(this)
}
fun updateMediaSelection(newSelection: Set<String>) {
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<ImageView>(R.id.cloud_media_image)
val video = view.findViewById<ImageView>(R.id.cloud_media_video)
val selectionScrim = view.findViewById<View>(R.id.cloud_media_selection_scrim)
val selectionCheck = view.findViewById<View>(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),
) {
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)
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
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<TextView>(R.id.cloud_storage_title).text = item.name
view.findViewById<ImageView>(R.id.cloud_storage_icon).setImageResource(
@@ -232,15 +288,7 @@ internal class CloudHubAdapter(
val cover = view.findViewById<ImageView>(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<ImageView>(R.id.cloud_action_icon).setImageResource(action.icon)
view.findViewById<TextView>(R.id.cloud_action_title).text = action.title
@@ -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<CloudStorageItem> { 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<CloudMediaItem> = allMedia.filter { media ->
@@ -569,8 +639,103 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
}
}
private fun openMedia(media: CloudMediaItem) {
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<CloudMediaItem>) {
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<CloudMediaItem> {
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
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<CloudMediaItem>,
val rawResultCount: Int,
@@ -61,10 +61,22 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
val account = AccountUtils.getCurrentQSferaAccount(this)
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE }
val progress = findViewById<ProgressBar>(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 = { _, _ ->
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 },
@@ -72,6 +84,10 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
)
}
}
},
)
}
}
@OptIn(UnstableApi::class)
private fun showVideo(media: CloudMediaItem) {
@@ -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)
@@ -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<CloudMediaItem> = emptyList()) {
private val selected = LinkedHashMap<String, CloudMediaItem>().apply {
initialItems.forEach { put(it.selectionKey, it) }
}
val size: Int get() = selected.size
val items: List<CloudMediaItem> get() = selected.values.toList()
val keys: Set<String> 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<CloudMediaItem>) {
items.forEach { selected[it.selectionKey] = it }
}
fun remove(items: Collection<CloudMediaItem>) {
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"),
@@ -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)
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/qsfera_blue" />
<stroke android:width="2dp" android:color="@android:color/white" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17 4.83,12 3.41,13.41 9,19 21,7 19.59,5.59z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM8,9h8v10H8V9zM15.5,4l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11C16.5,7.69 17.21,8 18,8c1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92S19.61,16.08 18,16.08z" />
</vector>
@@ -83,6 +83,93 @@
app:queryHint="@string/actionbar_search" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cloud_selection_toolbar"
android:layout_width="0dp"
android:layout_height="72dp"
android:background="@color/qsfera_blue"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/cloud_selection_close"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cloud_selection_close"
android:padding="12dp"
android:src="@drawable/ic_close"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@android:color/white" />
<TextView
android:id="@+id/cloud_selection_count"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/cloud_selection_progress"
app:layout_constraintStart_toEndOf="@id/cloud_selection_close"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/cloud_selection_progress"
android:layout_width="32dp"
android:layout_height="32dp"
android:indeterminateTint="@android:color/white"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/cloud_selection_send"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/cloud_selection_send"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cloud_selection_send"
android:padding="11dp"
android:src="@drawable/ic_cloud_send"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/cloud_selection_delete"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/cloud_selection_delete"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cloud_selection_delete"
android:padding="11dp"
android:src="@drawable/ic_cloud_delete"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/cloud_selection_more"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/cloud_selection_more"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cloud_selection_more"
android:padding="11dp"
android:src="@drawable/ic_cloud_more_vert"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<FrameLayout
android:id="@+id/cloud_content"
android:layout_width="0dp"
@@ -12,6 +12,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:scaleType="centerCrop" />
<ImageView
@@ -23,5 +24,25 @@
android:background="@drawable/cloud_card_background"
android:padding="5dp"
android:src="@drawable/ic_play_arrow"
android:importantForAccessibility="no"
android:visibility="gone" />
<View
android:id="@+id/cloud_media_selection_scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/cloud_media_selection_scrim"
android:visibility="gone" />
<ImageView
android:id="@+id/cloud_media_selection_check"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:background="@drawable/cloud_media_selection_badge"
android:contentDescription="@string/cloud_selection_selected"
android:padding="5dp"
android:src="@drawable/ic_cloud_check"
android:visibility="gone" />
</eu.qsfera.android.presentation.security.passcode.SquareFrameLayout>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/cloud_sheet_background"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="12dp"
android:paddingEnd="18dp"
android:paddingBottom="28dp">
<View
android:layout_width="40dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="14dp"
android:background="@drawable/cloud_sheet_handle" />
<TextView
android:id="@+id/cloud_media_actions_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingBottom="8dp"
android:textColor="@color/qsfera_text_primary"
android:textSize="24sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/cloud_media_action_send"
style="@style/CloudSheetTile"
android:text="@string/cloud_selection_send"
app:drawableTopCompat="@drawable/ic_share_generic_black" />
<TextView
android:id="@+id/cloud_media_action_select_all"
style="@style/CloudSheetTile"
android:text="@string/cloud_selection_select_all"
app:drawableTopCompat="@drawable/ic_select_all" />
<TextView
android:id="@+id/cloud_media_action_delete"
style="@style/CloudSheetTile"
android:text="@string/cloud_selection_delete"
app:drawableTopCompat="@drawable/ic_action_delete_grey" />
</LinearLayout>
</LinearLayout>
@@ -78,4 +78,24 @@
<string name="cloud_files_load_error">Не удалось загрузить файлы</string>
<string name="cloud_files_cached">Не удалось обновить данные по сети. Показаны сохранённые файлы.</string>
<string name="cloud_profile_automatic_uploads">Автозагрузка</string>
<string name="cloud_selection_selected">Выбрано</string>
<string name="cloud_selection_close">Закрыть выбор</string>
<string name="cloud_selection_send">Отправить</string>
<string name="cloud_selection_delete">Удалить</string>
<string name="cloud_selection_more">Другие действия</string>
<string name="cloud_selection_select_all">Выбрать все</string>
<string name="cloud_selection_preparing">Подготавливаем файлы…</string>
<string name="cloud_selection_share_error">Не удалось подготовить выбранные файлы</string>
<plurals name="cloud_selection_delete_confirm">
<item quantity="one">Удалить %1$d выбранный файл из КуСферы?</item>
<item quantity="few">Удалить %1$d выбранных файла из КуСферы?</item>
<item quantity="many">Удалить %1$d выбранных файлов из КуСферы?</item>
<item quantity="other">Удалить %1$d выбранных файла из КуСферы?</item>
</plurals>
<string name="cloud_selection_delete_success">Выбранные файлы удалены</string>
<string name="cloud_selection_delete_error">Часть файлов удалить не удалось. Список обновлён.</string>
<string name="cloud_file_open_error">Нет приложения, которое может открыть этот файл</string>
<string name="cloud_files_offline_title">Офлайн</string>
<string name="cloud_files_shares_title">Ссылки</string>
<string name="cloud_files_spaces_title">Пространства</string>
</resources>
@@ -70,4 +70,22 @@
<string name="cloud_files_load_error">Could not load files</string>
<string name="cloud_files_cached">Network refresh failed. Showing saved files.</string>
<string name="cloud_profile_automatic_uploads">Automatic uploads</string>
<string name="cloud_selection_selected">Selected</string>
<string name="cloud_selection_close">Close selection</string>
<string name="cloud_selection_send">Send</string>
<string name="cloud_selection_delete">Delete</string>
<string name="cloud_selection_more">More actions</string>
<string name="cloud_selection_select_all">Select all</string>
<string name="cloud_selection_preparing">Preparing files…</string>
<string name="cloud_selection_share_error">Could not prepare the selected files</string>
<plurals name="cloud_selection_delete_confirm">
<item quantity="one">Delete %1$d selected file from КуСфера?</item>
<item quantity="other">Delete %1$d selected files from КуСфера?</item>
</plurals>
<string name="cloud_selection_delete_success">Selected files deleted</string>
<string name="cloud_selection_delete_error">Some files could not be deleted. The list has been refreshed.</string>
<string name="cloud_file_open_error">No app could open this file</string>
<string name="cloud_files_offline_title">Offline</string>
<string name="cloud_files_shares_title">Shared links</string>
<string name="cloud_files_spaces_title">Spaces</string>
</resources>
@@ -64,6 +64,7 @@
<!-- special transparent_cloud action bar colors for image preview -->
<color name="qsfera_petrol_transparent">#20396676</color>
<color name="qsfera_petrol_dark_transparent">#4021434F</color>
<color name="cloud_media_selection_scrim">#4D4B7BEC</color>
<!-- Multiselect backgrounds -->
<color name="selected_item_background">#ECECEC</color>
@@ -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,
)
}