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" testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 34 versionCode = 35
versionName = "1.3.6" versionName = "1.3.7"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\"" buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\"" buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -5,11 +5,12 @@
*/ */
package eu.qsfera.android.presentation.cloud package eu.qsfera.android.presentation.cloud
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.view.View import android.view.View
import android.widget.EditText import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
@@ -18,12 +19,14 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.FileProvider
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import eu.qsfera.android.R import eu.qsfera.android.R
import eu.qsfera.android.data.ClientManager
import eu.qsfera.android.domain.files.FileRepository import eu.qsfera.android.domain.files.FileRepository
import eu.qsfera.android.domain.files.model.FileListOption import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.lib.common.QSferaAccount 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.thumbnails.ThumbnailsRequester
import eu.qsfera.android.presentation.transfers.TransfersViewModel import eu.qsfera.android.presentation.transfers.TransfersViewModel
import eu.qsfera.android.ui.activity.FileActivity 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.UploadListActivity
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
@@ -44,8 +46,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
class CloudHomeActivity : FileActivity() { class CloudHomeActivity : FileActivity() {
private val clientManager: ClientManager by inject()
private val fileRepository: FileRepository by inject() private val fileRepository: FileRepository by inject()
private val transfersViewModel: TransfersViewModel by viewModel() private val transfersViewModel: TransfersViewModel by viewModel()
@@ -55,6 +61,12 @@ class CloudHomeActivity : FileActivity() {
private lateinit var toolbarTitle: TextView private lateinit var toolbarTitle: TextView
private lateinit var toolbarSearchButton: View private lateinit var toolbarSearchButton: View
private lateinit var toolbarSearch: SearchView 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 lateinit var bottomNavigation: BottomNavigationView
private var uploadsWereRunning = false private var uploadsWereRunning = false
@@ -76,6 +88,7 @@ class CloudHomeActivity : FileActivity() {
setContentView(R.layout.activity_cloud_home) setContentView(R.layout.activity_cloud_home)
bindViews() bindViews()
setupToolbar() setupToolbar()
setupSelectionToolbar()
setupBottomNavigation() setupBottomNavigation()
if (savedInstanceState == null) { if (savedInstanceState == null) {
@@ -83,7 +96,12 @@ class CloudHomeActivity : FileActivity() {
.replace(R.id.cloud_content, CloudHubFragment.newInstance(currentSection)) .replace(R.id.cloud_content, CloudHubFragment.newInstance(currentSection))
.commitNow() .commitNow()
} }
selectSection(currentSection, updateNavigation = true) if (savedInstanceState == null) {
selectSection(currentSection, updateNavigation = true)
} else {
showRootTitle(currentSection)
bottomNavigation.menu.findItem(currentSection.menuResource)?.isChecked = true
}
loadAvatar() loadAvatar()
observeUploadCompletion() observeUploadCompletion()
@@ -92,6 +110,10 @@ class CloudHomeActivity : FileActivity() {
findViewById<View>(R.id.cloud_toolbar).updateLayoutParams { findViewById<View>(R.id.cloud_toolbar).updateLayoutParams {
height = resources.getDimensionPixelSize(R.dimen.cloud_toolbar_height) + insets.top 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 { findViewById<View>(R.id.cloud_bottom_spacer).updateLayoutParams {
height = insets.bottom height = insets.bottom
} }
@@ -108,9 +130,141 @@ class CloudHomeActivity : FileActivity() {
toolbarTitle = findViewById(R.id.cloud_toolbar_title) toolbarTitle = findViewById(R.id.cloud_toolbar_title)
toolbarSearchButton = findViewById(R.id.cloud_toolbar_search_button) toolbarSearchButton = findViewById(R.id.cloud_toolbar_search_button)
toolbarSearch = findViewById(R.id.cloud_toolbar_search) 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) 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() { private fun observeUploadCompletion() {
transfersViewModel.workInfosListLiveData.observe(this) { workInfos -> transfersViewModel.workInfosListLiveData.observe(this) { workInfos ->
val uploadsAreRunning = workInfos.isNotEmpty() val uploadsAreRunning = workInfos.isNotEmpty()
@@ -211,6 +365,10 @@ class CloudHomeActivity : FileActivity() {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onBackPressed() { override fun onBackPressed() {
if (cloudFragment()?.hasSelection() == true) {
cloudFragment()?.clearSelection()
return
}
if (this::toolbarSearch.isInitialized && toolbarSearch.visibility == View.VISIBLE) { if (this::toolbarSearch.isInitialized && toolbarSearch.visibility == View.VISIBLE) {
closeSearch() closeSearch()
return return
@@ -328,7 +486,7 @@ class CloudHomeActivity : FileActivity() {
dialog.setContentView(content) dialog.setContentView(content)
content.findViewById<View>(R.id.cloud_more_storage).setOnClickListener { content.findViewById<View>(R.id.cloud_more_storage).setOnClickListener {
dialog.dismiss() dialog.dismiss()
selectSection(CloudSection.FILES, updateNavigation = true) showFilesMode(CloudFilesMode.ALL)
} }
content.findViewById<View>(R.id.cloud_more_transfers).setOnClickListener { content.findViewById<View>(R.id.cloud_more_transfers).setOnClickListener {
dialog.dismiss() dialog.dismiss()
@@ -336,15 +494,15 @@ class CloudHomeActivity : FileActivity() {
} }
content.findViewById<View>(R.id.cloud_more_offline).setOnClickListener { content.findViewById<View>(R.id.cloud_more_offline).setOnClickListener {
dialog.dismiss() dialog.dismiss()
openFileList(FileListOption.AV_OFFLINE) showFilesMode(CloudFilesMode.OFFLINE)
} }
content.findViewById<View>(R.id.cloud_more_shares).setOnClickListener { content.findViewById<View>(R.id.cloud_more_shares).setOnClickListener {
dialog.dismiss() dialog.dismiss()
openFileList(FileListOption.SHARED_BY_LINK) showFilesMode(CloudFilesMode.SHARES)
} }
content.findViewById<View>(R.id.cloud_more_spaces).setOnClickListener { content.findViewById<View>(R.id.cloud_more_spaces).setOnClickListener {
dialog.dismiss() dialog.dismiss()
openFileList(FileListOption.SPACES_LIST) showFilesMode(CloudFilesMode.SPACES)
} }
content.findViewById<View>(R.id.cloud_more_settings).setOnClickListener { content.findViewById<View>(R.id.cloud_more_settings).setOnClickListener {
dialog.dismiss() dialog.dismiss()
@@ -399,10 +557,32 @@ class CloudHomeActivity : FileActivity() {
} }
fun openStorageItem(item: CloudStorageItem) { fun openStorageItem(item: CloudStorageItem) {
startActivity(Intent(this, FileDisplayActivity::class.java).apply { if (item.isImage || item.isVideo) {
putExtra(FileActivity.EXTRA_FILE, item.toOCFile()) startActivity(CloudMediaPreviewActivity.createIntent(this, item.toCloudMedia()))
putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, FileListOption.ALL_FILES as Parcelable) 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) { override fun onAccountSet(stateWasRecovered: Boolean) {
@@ -412,14 +592,28 @@ class CloudHomeActivity : FileActivity() {
cloudFragment()?.onAccountChanged() cloudFragment()?.onAccountChanged()
} }
override fun navigateToOption(fileListOption: FileListOption) { override fun restart() {
openFileList(fileListOption) startActivity(createIntent(this, currentSection))
finish()
} }
private fun openFileList(option: FileListOption) { override fun navigateToOption(fileListOption: FileListOption) {
startActivity(Intent(this, FileDisplayActivity::class.java).apply { showFilesMode(
putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, option as Parcelable) 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? = private fun cloudFragment(): CloudHubFragment? =
@@ -428,6 +622,9 @@ class CloudHomeActivity : FileActivity() {
companion object { companion object {
private const val EXTRA_SECTION = "cloud_section" private const val EXTRA_SECTION = "cloud_section"
private const val STATE_SECTION = "cloud_state_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 = fun createIntent(context: Context, section: CloudSection): Intent =
Intent(context, CloudHomeActivity::class.java).apply { Intent(context, CloudHomeActivity::class.java).apply {
@@ -7,6 +7,7 @@ package eu.qsfera.android.presentation.cloud
import android.accounts.Account import android.accounts.Account
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.HapticFeedbackConstants
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -24,6 +25,7 @@ import eu.qsfera.android.utils.MimetypeIconUtil
internal class CloudHubAdapter( internal class CloudHubAdapter(
private var account: Account, private var account: Account,
private val onMediaClick: (CloudMediaItem) -> Unit, private val onMediaClick: (CloudMediaItem) -> Unit,
private val onMediaLongClick: (CloudMediaItem) -> Unit,
private val onAlbumClick: (String) -> Unit, private val onAlbumClick: (String) -> Unit,
private val onStorageClick: (CloudStorageItem) -> Unit, private val onStorageClick: (CloudStorageItem) -> Unit,
private val onShortcutClick: (CloudShortcut) -> Unit, private val onShortcutClick: (CloudShortcut) -> Unit,
@@ -33,6 +35,7 @@ internal class CloudHubAdapter(
private val onFeedGroupClick: (String) -> Unit, private val onFeedGroupClick: (String) -> Unit,
) : RecyclerView.Adapter<CloudHubAdapter.Holder>() { ) : RecyclerView.Adapter<CloudHubAdapter.Holder>() {
private var rows: List<CloudHubRow> = emptyList() private var rows: List<CloudHubRow> = emptyList()
private var selectedMediaKeys: Set<String> = emptySet()
fun updateAccount(newAccount: Account) { fun updateAccount(newAccount: Account) {
if (account == newAccount) return if (account == newAccount) return
@@ -57,6 +60,17 @@ internal class CloudHubAdapter(
result.dispatchUpdatesTo(this) 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 getItemCount(): Int = rows.size
override fun getItemViewType(position: Int): Int = when (rows[position]) { override fun getItemViewType(position: Int): Int = when (rows[position]) {
@@ -189,22 +203,44 @@ internal class CloudHubAdapter(
private fun bindMedia(view: View, item: CloudMediaItem) { private fun bindMedia(view: View, item: CloudMediaItem) {
val image = view.findViewById<ImageView>(R.id.cloud_media_image) val image = view.findViewById<ImageView>(R.id.cloud_media_image)
val video = view.findViewById<ImageView>(R.id.cloud_media_video) 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 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) loadMediaImage(image, item, PREVIEW_MEDIUM)
view.contentDescription = item.name view.contentDescription = item.name
view.setOnClickListener { onMediaClick(item) } view.setOnClickListener { onMediaClick(item) }
view.setOnLongClickListener {
it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
onMediaLongClick(item)
true
}
} }
private fun loadMediaImage(image: ImageView, item: CloudMediaItem, size: Int) { private fun loadMediaImage(image: ImageView, item: CloudMediaItem, size: Int) {
if (item.isImage) { if (item.isImage) {
image.scaleType = ImageView.ScaleType.CENTER_CROP image.scaleType = ImageView.ScaleType.CENTER_CROP
image.load( val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
previewUri(item, size), val requestKey = item.selectionKey
ThumbnailsRequester.getContentAddressedImageLoader(account), image.setTag(R.id.cloud_media_image, requestKey)
) { val preview = runCatching { previewUri(item, size) }.getOrNull()
placeholder(R.drawable.cloud_media_placeholder) if (preview == null) {
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) loadOriginalMediaImage(image, item, requestKey)
crossfade(true) } 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 { } else {
image.dispose() 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) { private fun bindStorage(view: View, item: CloudStorageItem) {
view.findViewById<TextView>(R.id.cloud_storage_title).text = item.name view.findViewById<TextView>(R.id.cloud_storage_title).text = item.name
view.findViewById<ImageView>(R.id.cloud_storage_icon).setImageResource( 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 cover = view.findViewById<ImageView>(R.id.cloud_album_cover)
val coverItem = album.cover val coverItem = album.cover
if (coverItem?.isImage == true) { if (coverItem?.isImage == true) {
cover.scaleType = ImageView.ScaleType.CENTER_CROP loadMediaImage(cover, coverItem, PREVIEW_MEDIUM)
cover.load(
previewUri(coverItem, PREVIEW_MEDIUM),
ThumbnailsRequester.getContentAddressedImageLoader(account),
) {
placeholder(R.drawable.cloud_media_placeholder)
error(R.drawable.ic_qsfera_folder)
crossfade(true)
}
} else { } else {
cover.dispose() cover.dispose()
cover.scaleType = ImageView.ScaleType.CENTER_INSIDE cover.scaleType = ImageView.ScaleType.CENTER_INSIDE
@@ -262,6 +310,13 @@ internal class CloudHubAdapter(
ThumbnailsRequester.getPreviewUriForFile(item.toOCFile(account.name), account, item.etag, size, size) 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) { private fun bindAction(view: View, action: CloudHubRow.Action) {
view.findViewById<ImageView>(R.id.cloud_action_icon).setImageResource(action.icon) view.findViewById<ImageView>(R.id.cloud_action_icon).setImageResource(action.icon)
view.findViewById<TextView>(R.id.cloud_action_title).text = action.title view.findViewById<TextView>(R.id.cloud_action_title).text = action.title
@@ -6,11 +6,12 @@
package eu.qsfera.android.presentation.cloud package eu.qsfera.android.presentation.cloud
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment 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.R
import eu.qsfera.android.data.ClientManager import eu.qsfera.android.data.ClientManager
import eu.qsfera.android.data.executeRemoteOperation 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.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.TransferRepository
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.lib.common.accounts.AccountUtils as LibraryAccountUtils 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.MediaSearchRequest
import eu.qsfera.android.lib.resources.files.search.MediaSearchType import eu.qsfera.android.lib.resources.files.search.MediaSearchType
import eu.qsfera.android.presentation.authentication.AccountUtils 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.ui.activity.UploadListActivity
import eu.qsfera.android.utils.MimetypeIconUtil import eu.qsfera.android.utils.MimetypeIconUtil
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -47,6 +48,7 @@ import java.util.Locale
class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private val clientManager: ClientManager by inject() private val clientManager: ClientManager by inject()
private val fileRepository: FileRepository by inject() private val fileRepository: FileRepository by inject()
private val spacesRepository: SpacesRepository by inject()
private val transferRepository: TransferRepository by inject() private val transferRepository: TransferRepository by inject()
private lateinit var section: CloudSection private lateinit var section: CloudSection
@@ -66,6 +68,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private var reachedEnd = false private var reachedEnd = false
private var mediaLoaded = false private var mediaLoaded = false
private var filesLoaded = false private var filesLoaded = false
private var filesMode = CloudFilesMode.ALL
private var parentFilesMode: CloudFilesMode? = null
private var selection = CloudMediaSelection()
var currentFolderPath: String = ROOT_PATH var currentFolderPath: String = ROOT_PATH
private set private set
@@ -81,15 +86,28 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION)) 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) 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val account = AccountUtils.getCurrentQSferaAccount(requireContext()) val account = AccountUtils.getCurrentQSferaAccount(requireContext())
adapter = CloudHubAdapter( adapter = CloudHubAdapter(
account = account, account = account,
onMediaClick = ::openMedia, onMediaClick = ::handleMediaClick,
onMediaLongClick = ::toggleMediaSelection,
onAlbumClick = ::openAlbum, onAlbumClick = ::openAlbum,
onStorageClick = ::openStorage, onStorageClick = ::openStorage,
onShortcutClick = ::openShortcut, onShortcutClick = ::openShortcut,
@@ -124,6 +142,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
setOnClickListener { (activity as? CloudHomeActivity)?.showAddSheet() } setOnClickListener { (activity as? CloudHomeActivity)?.showAddSheet() }
} }
configureSection() configureSection()
updateSelectionUi()
reload() reload()
} }
@@ -133,11 +152,14 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
return return
} }
invalidateActiveLoad() invalidateActiveLoad()
if (currentFolderPath != ROOT_PATH) { clearSelection()
if (currentFolderPath != ROOT_PATH || newSection == CloudSection.FILES) {
filesLoaded = false filesLoaded = false
storageItems = emptyList() storageItems = emptyList()
} }
section = newSection section = newSection
if (newSection == CloudSection.FILES) filesMode = CloudFilesMode.ALL
parentFilesMode = null
activeAlbumPath = null activeAlbumPath = null
activeFeedDate = null activeFeedDate = null
currentFolderPath = ROOT_PATH currentFolderPath = ROOT_PATH
@@ -197,6 +219,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
fun onAccountChanged() { fun onAccountChanged() {
if (!isAdded || !this::adapter.isInitialized) return if (!isAdded || !this::adapter.isInitialized) return
invalidateActiveLoad() invalidateActiveLoad()
clearSelection()
adapter.updateAccount(AccountUtils.getCurrentQSferaAccount(requireContext())) adapter.updateAccount(AccountUtils.getCurrentQSferaAccount(requireContext()))
allMedia = emptyList() allMedia = emptyList()
storageItems = 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), ""))) adapter.submitRows(listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), "")))
val requestedPath = currentFolderPath val requestedPath = currentFolderPath
val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name
val requestedMode = filesMode
val requestedSpaceId = currentFolderSpaceId
val requestGeneration = ++loadGeneration val requestGeneration = ++loadGeneration
loadJob = viewLifecycleOwner.lifecycleScope.launch { loadJob = viewLifecycleOwner.lifecycleScope.launch {
val result = try { val result = try {
Result.success( Result.success(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
loadStorageFolder(requestedPath, accountName) loadFilesForMode(requestedMode, requestedPath, requestedSpaceId, accountName)
} }
) )
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
@@ -298,12 +323,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
Result.failure(throwable) Result.failure(throwable)
} }
if ( if (!isAdded || isStaleFilesRequest(requestGeneration, requestedPath, requestedMode, requestedSpaceId)) {
!isAdded || return@launch
requestGeneration != loadGeneration || }
section != CloudSection.FILES ||
requestedPath != currentFolderPath
) return@launch
isLoading = false isLoading = false
refresh.isRefreshing = false refresh.isRefreshing = false
result.onSuccess { loadedFolder -> result.onSuccess { loadedFolder ->
@@ -328,14 +350,73 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
} }
private fun loadStorageFolder(requestedPath: String, accountName: String): LoadedStorageFolder { private fun isStaleFilesRequest(
val rootFolder = fileRepository.getPersonalRootFolderForAccount(accountName) requestGeneration: Long,
val cachedFolder = storedFolder(requestedPath, accountName, rootFolder.spaceId) 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 { val refreshFailure = runCatching {
fileRepository.refreshFolder(requestedPath, accountName, rootFolder.spaceId) fileRepository.refreshFolder(requestedPath, accountName, effectiveSpaceId)
}.exceptionOrNull() }.exceptionOrNull()
val refreshedFolder = if (refreshFailure == null) { val refreshedFolder = if (refreshFailure == null) {
storedFolder(requestedPath, accountName, rootFolder.spaceId) storedFolder(requestedPath, accountName, requestedSpaceId)
} else { } else {
null null
} }
@@ -349,7 +430,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
private fun storedFolder(requestedPath: String, accountName: String, spaceId: String?) = private fun storedFolder(requestedPath: String, accountName: String, spaceId: String?) =
if (sameRemotePath(requestedPath, ROOT_PATH)) { if (sameRemotePath(requestedPath, ROOT_PATH) && spaceId == null) {
fileRepository.getPersonalRootFolderForAccount(accountName) fileRepository.getPersonalRootFolderForAccount(accountName)
} else { } else {
fileRepository.getFileByRemotePath(requestedPath, accountName, spaceId) fileRepository.getFileByRemotePath(requestedPath, accountName, spaceId)
@@ -360,19 +441,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
return fileRepository.getFolderContent(storedFolderId) return fileRepository.getFolderContent(storedFolderId)
.asSequence() .asSequence()
.filterNot { sameRemotePath(it.remotePath, requestedPath) } .filterNot { sameRemotePath(it.remotePath, requestedPath) }
.map { remote -> .map(OCFile::toCloudStorageItem)
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,
)
}
.sortedWith(compareByDescending<CloudStorageItem> { it.isFolder }.thenBy { it.name.lowercase() }) .sortedWith(compareByDescending<CloudStorageItem> { it.isFolder }.thenBy { it.name.lowercase() })
.toList() .toList()
} }
@@ -465,6 +534,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
CloudSection.MORE -> emptyList() CloudSection.MORE -> emptyList()
} }
adapter.submitRows(rows) adapter.submitRows(rows)
adapter.updateMediaSelection(selection.keys)
} }
private fun filteredMedia(): List<CloudMediaItem> = allMedia.filter { media -> 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) {
startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media)) 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) { private fun openFeedGroup(date: String) {
@@ -591,6 +756,8 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private fun openStorage(item: CloudStorageItem) { private fun openStorage(item: CloudStorageItem) {
if (item.isFolder) { if (item.isFolder) {
parentFilesMode = filesMode.takeUnless { it == CloudFilesMode.ALL }
filesMode = CloudFilesMode.ALL
currentFolderPath = item.remotePath.ensureFolderPath() currentFolderPath = item.remotePath.ensureFolderPath()
currentFolderSpaceId = item.spaceId currentFolderSpaceId = item.spaceId
filesLoaded = false filesLoaded = false
@@ -603,6 +770,10 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
fun navigateUp() { fun navigateUp() {
if (!selection.isEmpty) {
clearSelection()
return
}
when { when {
activeFeedDate != null -> { activeFeedDate != null -> {
activeFeedDate = null activeFeedDate = null
@@ -620,8 +791,10 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
currentFolderPath = parentFolder(currentFolderPath) currentFolderPath = parentFolder(currentFolderPath)
filesLoaded = false filesLoaded = false
if (currentFolderPath == ROOT_PATH) { if (currentFolderPath == ROOT_PATH) {
nestedBackCallback.isEnabled = false nestedBackCallback.isEnabled = parentFilesMode != null
(activity as? CloudHomeActivity)?.restoreSectionTitle() if (parentFilesMode == null) {
(activity as? CloudHomeActivity)?.restoreSectionTitle()
}
} else { } else {
(activity as? CloudHomeActivity)?.showNestedTitle( (activity as? CloudHomeActivity)?.showNestedTitle(
currentFolderPath.trimEnd('/').substringAfterLast('/') currentFolderPath.trimEnd('/').substringAfterLast('/')
@@ -629,14 +802,46 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
reloadFiles() 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) { private fun openShortcut(shortcut: CloudShortcut) {
when (shortcut) { when (shortcut) {
CloudShortcut.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java)) CloudShortcut.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java))
CloudShortcut.OFFLINE -> openFileList(FileListOption.AV_OFFLINE) CloudShortcut.OFFLINE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.OFFLINE)
CloudShortcut.SHARES -> openFileList(FileListOption.SHARED_BY_LINK) CloudShortcut.SHARES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SHARES)
} }
} }
@@ -644,25 +849,20 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
when (action) { when (action) {
CloudAction.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java)) CloudAction.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java))
CloudAction.SETTINGS -> startActivity(Intent(requireContext(), eu.qsfera.android.presentation.settings.SettingsActivity::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.STORAGE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.ALL)
CloudAction.OFFLINE -> openFileList(FileListOption.AV_OFFLINE) CloudAction.OFFLINE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.OFFLINE)
CloudAction.SHARES -> openFileList(FileListOption.SHARED_BY_LINK) CloudAction.SHARES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SHARES)
CloudAction.SPACES -> openFileList(FileListOption.SPACES_LIST) 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 { companion object {
private const val ARG_SECTION = "cloud_section" private const val ARG_SECTION = "cloud_section"
private const val GRID_SPANS = 6 private const val GRID_SPANS = 6
private const val MEDIA_PAGE_SIZE = 200 private const val MEDIA_PAGE_SIZE = 200
private const val LOAD_MORE_THRESHOLD = 18 private const val LOAD_MORE_THRESHOLD = 18
private const val ROOT_PATH = "/" private const val ROOT_PATH = "/"
private const val STATE_SELECTION = "cloud_media_selection"
fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply { fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply {
arguments = bundleOf(ARG_SECTION to section.wireValue) 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( private data class LoadedMediaPage(
val items: List<CloudMediaItem>, val items: List<CloudMediaItem>,
val rawResultCount: Int, val rawResultCount: Int,
@@ -61,14 +61,30 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
val account = AccountUtils.getCurrentQSferaAccount(this) val account = AccountUtils.getCurrentQSferaAccount(this)
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE } val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE }
val progress = findViewById<ProgressBar>(R.id.cloud_preview_progress) val progress = findViewById<ProgressBar>(R.id.cloud_preview_progress)
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
photo.load( photo.load(
previewUri(media, account, 2560, 2560), previewUri(media, account, 2560, 2560),
ThumbnailsRequester.getContentAddressedImageLoader(account), loader,
) { ) {
crossfade(true) crossfade(true)
listener( listener(
onSuccess = { _, _ -> progress.visibility = View.GONE }, 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 { val prepared = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val client = clientManager.getClientForCoilThumbnails(account.name) 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() contentUrl to client.credentials?.headerAuth.orEmpty()
} }
}.getOrNull() ?: run { }.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") @Suppress("DEPRECATION")
private fun mediaExtra(): CloudMediaItem? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { private fun mediaExtra(): CloudMediaItem? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_MEDIA, CloudMediaItem::class.java) intent.getParcelableExtra(EXTRA_MEDIA, CloudMediaItem::class.java)
@@ -24,6 +24,7 @@ data class CloudMediaItem(
val isVideo: Boolean get() = mimeType.startsWith("video/") val isVideo: Boolean get() = mimeType.startsWith("video/")
val isImage: Boolean get() = mimeType.startsWith("image/") val isImage: Boolean get() = mimeType.startsWith("image/")
val albumKey: String get() = "${spaceId.orEmpty()}::$parentPath" val albumKey: String get() = "${spaceId.orEmpty()}::$parentPath"
val selectionKey: String get() = "${spaceId.orEmpty()}::${webDavHref.ifBlank { remotePath }}"
fun toOCFile(owner: String): OCFile = OCFile( fun toOCFile(owner: String): OCFile = OCFile(
owner = owner, owner = owner,
@@ -47,8 +48,9 @@ data class CloudStorageItem(
val remoteId: String? = null, val remoteId: String? = null,
val permissions: String? = null, val permissions: String? = null,
val spaceId: 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 isFolder: Boolean get() = mimeType == "DIR" || mimeType == "httpd/unix-directory"
val isImage: Boolean get() = mimeType.startsWith("image/") val isImage: Boolean get() = mimeType.startsWith("image/")
val isVideo: Boolean get() = mimeType.startsWith("video/") val isVideo: Boolean get() = mimeType.startsWith("video/")
@@ -72,9 +74,48 @@ data class CloudStorageItem(
size = size, size = size,
modifiedAt = modifiedAt, modifiedAt = modifiedAt,
etag = etag, 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) { enum class CloudSection(val wireValue: String) {
FEED("feed"), FEED("feed"),
FILES("files"), FILES("files"),
@@ -112,12 +112,19 @@ object ThumbnailsRequester : KoinComponent {
width: Int = 1024, width: Int = 1024,
height: Int = 1024, height: Int = 1024,
): String { ): String {
val baseUrl = accountBaseUrls.getOrPut(account.name) { val absoluteHref = getContentUriForWebDavHref(webDavHref, account)
val accountManager = AccountManager.get(appContext) val separator = if ('?' in absoluteHref) '&' else '?'
accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1"
?.trimEnd('/') }
.orEmpty()
} /**
* 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 baseHttpUrl = baseUrl.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid account base URL")
val absoluteCandidate = webDavHref.toHttpUrlOrNull() val absoluteCandidate = webDavHref.toHttpUrlOrNull()
if (absoluteCandidate != null) { if (absoluteCandidate != null) {
@@ -125,24 +132,22 @@ object ThumbnailsRequester : KoinComponent {
absoluteCandidate.scheme == baseHttpUrl.scheme && absoluteCandidate.scheme == baseHttpUrl.scheme &&
absoluteCandidate.host == baseHttpUrl.host && absoluteCandidate.host == baseHttpUrl.host &&
absoluteCandidate.port == baseHttpUrl.port absoluteCandidate.port == baseHttpUrl.port
) { "WebDAV preview href must use the account origin" } ) { "WebDAV href must use the account origin" }
} }
val absoluteHref = absoluteCandidate?.toString() return absoluteCandidate?.toString() ?: "$baseUrl/${webDavHref.trimStart('/')}"
?: "$baseUrl/${webDavHref.trimStart('/')}" }
val separator = if ('?' in absoluteHref) '&' else '?'
return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1" 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 = fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String =
String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag) 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 { private fun getPreviewUri(remotePath: String?, etag: String?, account: Account, width: Int, height: Int): String {
val baseUrl = accountBaseUrls.getOrPut(account.name) { val baseUrl = getAccountBaseUrl(account)
val accountManager = AccountManager.get(appContext)
accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL)
?.trimEnd('/')
.orEmpty()
}
val normalizedRemotePath = remotePath.orEmpty() val normalizedRemotePath = remotePath.orEmpty()
val path = if (normalizedRemotePath.startsWith("/")) normalizedRemotePath else "/$normalizedRemotePath" val path = if (normalizedRemotePath.startsWith("/")) normalizedRemotePath else "/$normalizedRemotePath"
val encodedPath = Uri.encode(path, "/") 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()) 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 { fun getContentAddressedImageLoader(): ImageLoader {
val account = AccountUtils.getCurrentQSferaAccount(appContext) val account = AccountUtils.getCurrentQSferaAccount(appContext)
return getContentAddressedImageLoader(account) 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" /> app:queryHint="@string/actionbar_search" />
</androidx.constraintlayout.widget.ConstraintLayout> </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 <FrameLayout
android:id="@+id/cloud_content" android:id="@+id/cloud_content"
android:layout_width="0dp" android:layout_width="0dp"
@@ -12,6 +12,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:contentDescription="@null" android:contentDescription="@null"
android:importantForAccessibility="no"
android:scaleType="centerCrop" /> android:scaleType="centerCrop" />
<ImageView <ImageView
@@ -23,5 +24,25 @@
android:background="@drawable/cloud_card_background" android:background="@drawable/cloud_card_background"
android:padding="5dp" android:padding="5dp"
android:src="@drawable/ic_play_arrow" 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" /> android:visibility="gone" />
</eu.qsfera.android.presentation.security.passcode.SquareFrameLayout> </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_load_error">Не удалось загрузить файлы</string>
<string name="cloud_files_cached">Не удалось обновить данные по сети. Показаны сохранённые файлы.</string> <string name="cloud_files_cached">Не удалось обновить данные по сети. Показаны сохранённые файлы.</string>
<string name="cloud_profile_automatic_uploads">Автозагрузка</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> </resources>
@@ -70,4 +70,22 @@
<string name="cloud_files_load_error">Could not load files</string> <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_files_cached">Network refresh failed. Showing saved files.</string>
<string name="cloud_profile_automatic_uploads">Automatic uploads</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> </resources>
@@ -64,6 +64,7 @@
<!-- special transparent_cloud action bar colors for image preview --> <!-- special transparent_cloud action bar colors for image preview -->
<color name="qsfera_petrol_transparent">#20396676</color> <color name="qsfera_petrol_transparent">#20396676</color>
<color name="qsfera_petrol_dark_transparent">#4021434F</color> <color name="qsfera_petrol_dark_transparent">#4021434F</color>
<color name="cloud_media_selection_scrim">#4D4B7BEC</color>
<!-- Multiselect backgrounds --> <!-- Multiselect backgrounds -->
<color name="selected_item_background">#ECECEC</color> <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,
)
}