Fix cloud photo rendering and selection actions
Android / test-and-build (push) Successful in 19m51s
Android / test-and-build (push) Successful in 19m51s
This commit is contained in:
@@ -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() + "\""
|
||||
|
||||
+214
-17
@@ -5,11 +5,12 @@
|
||||
*/
|
||||
package eu.qsfera.android.presentation.cloud
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
@@ -18,12 +19,14 @@ import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import eu.qsfera.android.R
|
||||
import eu.qsfera.android.data.ClientManager
|
||||
import eu.qsfera.android.domain.files.FileRepository
|
||||
import eu.qsfera.android.domain.files.model.FileListOption
|
||||
import eu.qsfera.android.lib.common.QSferaAccount
|
||||
@@ -35,7 +38,6 @@ import eu.qsfera.android.presentation.settings.SettingsActivity
|
||||
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
|
||||
import eu.qsfera.android.presentation.transfers.TransfersViewModel
|
||||
import eu.qsfera.android.ui.activity.FileActivity
|
||||
import eu.qsfera.android.ui.activity.FileDisplayActivity
|
||||
import eu.qsfera.android.ui.activity.UploadListActivity
|
||||
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
|
||||
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
|
||||
@@ -44,8 +46,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
class CloudHomeActivity : FileActivity() {
|
||||
private val clientManager: ClientManager by inject()
|
||||
private val fileRepository: FileRepository by inject()
|
||||
private val transfersViewModel: TransfersViewModel by viewModel()
|
||||
|
||||
@@ -55,6 +61,12 @@ class CloudHomeActivity : FileActivity() {
|
||||
private lateinit var toolbarTitle: TextView
|
||||
private lateinit var toolbarSearchButton: View
|
||||
private lateinit var toolbarSearch: SearchView
|
||||
private lateinit var selectionToolbar: View
|
||||
private lateinit var selectionCount: TextView
|
||||
private lateinit var selectionProgress: View
|
||||
private lateinit var selectionSend: View
|
||||
private lateinit var selectionDelete: View
|
||||
private lateinit var selectionMore: View
|
||||
private lateinit var bottomNavigation: BottomNavigationView
|
||||
private var uploadsWereRunning = false
|
||||
|
||||
@@ -76,6 +88,7 @@ class CloudHomeActivity : FileActivity() {
|
||||
setContentView(R.layout.activity_cloud_home)
|
||||
bindViews()
|
||||
setupToolbar()
|
||||
setupSelectionToolbar()
|
||||
setupBottomNavigation()
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
@@ -83,7 +96,12 @@ class CloudHomeActivity : FileActivity() {
|
||||
.replace(R.id.cloud_content, CloudHubFragment.newInstance(currentSection))
|
||||
.commitNow()
|
||||
}
|
||||
selectSection(currentSection, updateNavigation = true)
|
||||
if (savedInstanceState == null) {
|
||||
selectSection(currentSection, updateNavigation = true)
|
||||
} else {
|
||||
showRootTitle(currentSection)
|
||||
bottomNavigation.menu.findItem(currentSection.menuResource)?.isChecked = true
|
||||
}
|
||||
loadAvatar()
|
||||
observeUploadCompletion()
|
||||
|
||||
@@ -92,6 +110,10 @@ class CloudHomeActivity : FileActivity() {
|
||||
findViewById<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 {
|
||||
|
||||
+71
-16
@@ -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),
|
||||
) {
|
||||
placeholder(R.drawable.cloud_media_placeholder)
|
||||
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
|
||||
crossfade(true)
|
||||
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
|
||||
val requestKey = item.selectionKey
|
||||
image.setTag(R.id.cloud_media_image, requestKey)
|
||||
val preview = runCatching { previewUri(item, size) }.getOrNull()
|
||||
if (preview == null) {
|
||||
loadOriginalMediaImage(image, item, requestKey)
|
||||
} else {
|
||||
image.load(preview, loader) {
|
||||
placeholder(R.drawable.cloud_media_placeholder)
|
||||
crossfade(true)
|
||||
listener(
|
||||
onError = { _, _ ->
|
||||
if (image.getTag(R.id.cloud_media_image) == requestKey) {
|
||||
loadOriginalMediaImage(image, item, requestKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
image.dispose()
|
||||
@@ -213,6 +249,26 @@ internal class CloudHubAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOriginalMediaImage(image: ImageView, item: CloudMediaItem, requestKey: String) {
|
||||
val originalUri = runCatching { contentUri(item) }.getOrNull()
|
||||
if (originalUri == null) {
|
||||
image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
|
||||
return
|
||||
}
|
||||
image.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
|
||||
placeholder(R.drawable.cloud_media_placeholder)
|
||||
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
|
||||
memoryCacheKey("$originalUri#${item.etag}")
|
||||
diskCacheKey("$originalUri#${item.etag}")
|
||||
crossfade(true)
|
||||
listener(
|
||||
onStart = {
|
||||
if (image.getTag(R.id.cloud_media_image) != requestKey) image.dispose()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindStorage(view: View, item: CloudStorageItem) {
|
||||
view.findViewById<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
|
||||
|
||||
+260
-48
@@ -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) {
|
||||
startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media))
|
||||
private fun handleMediaClick(media: CloudMediaItem) {
|
||||
if (selection.isEmpty) {
|
||||
startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media))
|
||||
} else {
|
||||
toggleMediaSelection(media)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleMediaSelection(media: CloudMediaItem) {
|
||||
selection.toggle(media)
|
||||
updateSelectionUi()
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
if (selection.isEmpty) return
|
||||
selection.clear()
|
||||
updateSelectionUi()
|
||||
}
|
||||
|
||||
fun hasSelection(): Boolean = !selection.isEmpty
|
||||
|
||||
fun selectAllVisibleMedia() {
|
||||
selection.selectAll(selectableMedia())
|
||||
updateSelectionUi()
|
||||
}
|
||||
|
||||
fun shareSelectedMedia() {
|
||||
val selected = selection.items
|
||||
if (selected.isNotEmpty()) {
|
||||
(activity as? CloudHomeActivity)?.shareMedia(selected)
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmDeleteSelectedMedia() {
|
||||
val selected = selection.items
|
||||
if (selected.isEmpty()) return
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setMessage(resources.getQuantityString(R.plurals.cloud_selection_delete_confirm, selected.size, selected.size))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.cloud_selection_delete) { _, _ -> deleteMedia(selected) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteMedia(items: List<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
|
||||
(activity as? CloudHomeActivity)?.restoreSectionTitle()
|
||||
nestedBackCallback.isEnabled = parentFilesMode != null
|
||||
if (parentFilesMode == null) {
|
||||
(activity as? CloudHomeActivity)?.restoreSectionTitle()
|
||||
}
|
||||
} else {
|
||||
(activity as? CloudHomeActivity)?.showNestedTitle(
|
||||
currentFolderPath.trimEnd('/').substringAfterLast('/')
|
||||
@@ -629,14 +802,46 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
|
||||
}
|
||||
reloadFiles()
|
||||
}
|
||||
section == CloudSection.FILES && parentFilesMode != null -> {
|
||||
showFilesMode(parentFilesMode ?: CloudFilesMode.ALL)
|
||||
}
|
||||
section == CloudSection.FILES && filesMode != CloudFilesMode.ALL -> {
|
||||
showFilesMode(CloudFilesMode.ALL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun showFilesMode(mode: CloudFilesMode) {
|
||||
if (!this::adapter.isInitialized) return
|
||||
invalidateActiveLoad()
|
||||
clearSelection()
|
||||
section = CloudSection.FILES
|
||||
filesMode = mode
|
||||
parentFilesMode = null
|
||||
currentFolderPath = ROOT_PATH
|
||||
currentFolderSpaceId = null
|
||||
filesLoaded = false
|
||||
nestedBackCallback.isEnabled = mode != CloudFilesMode.ALL
|
||||
val title = when (mode) {
|
||||
CloudFilesMode.ALL -> R.string.cloud_files_title
|
||||
CloudFilesMode.OFFLINE -> R.string.cloud_files_offline_title
|
||||
CloudFilesMode.SHARES -> R.string.cloud_files_shares_title
|
||||
CloudFilesMode.SPACES -> R.string.cloud_files_spaces_title
|
||||
}
|
||||
if (mode == CloudFilesMode.ALL) {
|
||||
(activity as? CloudHomeActivity)?.restoreSectionTitle()
|
||||
} else {
|
||||
(activity as? CloudHomeActivity)?.showNestedTitle(getString(title))
|
||||
}
|
||||
configureSection()
|
||||
reloadFiles()
|
||||
}
|
||||
|
||||
private fun openShortcut(shortcut: CloudShortcut) {
|
||||
when (shortcut) {
|
||||
CloudShortcut.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java))
|
||||
CloudShortcut.OFFLINE -> openFileList(FileListOption.AV_OFFLINE)
|
||||
CloudShortcut.SHARES -> openFileList(FileListOption.SHARED_BY_LINK)
|
||||
CloudShortcut.OFFLINE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.OFFLINE)
|
||||
CloudShortcut.SHARES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SHARES)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,25 +849,20 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
|
||||
when (action) {
|
||||
CloudAction.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java))
|
||||
CloudAction.SETTINGS -> startActivity(Intent(requireContext(), eu.qsfera.android.presentation.settings.SettingsActivity::class.java))
|
||||
CloudAction.STORAGE -> (activity as? CloudHomeActivity)?.navigateToOption(FileListOption.ALL_FILES)
|
||||
CloudAction.OFFLINE -> openFileList(FileListOption.AV_OFFLINE)
|
||||
CloudAction.SHARES -> openFileList(FileListOption.SHARED_BY_LINK)
|
||||
CloudAction.SPACES -> openFileList(FileListOption.SPACES_LIST)
|
||||
CloudAction.STORAGE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.ALL)
|
||||
CloudAction.OFFLINE -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.OFFLINE)
|
||||
CloudAction.SHARES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SHARES)
|
||||
CloudAction.SPACES -> (activity as? CloudHomeActivity)?.showFilesMode(CloudFilesMode.SPACES)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFileList(option: FileListOption) {
|
||||
startActivity(Intent(requireContext(), FileDisplayActivity::class.java).apply {
|
||||
putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, option as Parcelable)
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_SECTION = "cloud_section"
|
||||
private const val GRID_SPANS = 6
|
||||
private const val MEDIA_PAGE_SIZE = 200
|
||||
private const val LOAD_MORE_THRESHOLD = 18
|
||||
private const val ROOT_PATH = "/"
|
||||
private const val STATE_SELECTION = "cloud_media_selection"
|
||||
|
||||
fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply {
|
||||
arguments = bundleOf(ARG_SECTION to section.wireValue)
|
||||
@@ -680,6 +880,18 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun OCFile.toCloudStorageItem(): CloudStorageItem = CloudStorageItem(
|
||||
remotePath = remotePath,
|
||||
mimeType = mimeType,
|
||||
size = length,
|
||||
modifiedAt = modificationTimestamp,
|
||||
owner = owner,
|
||||
etag = remoteEtag.orEmpty().ifBlank { etag.orEmpty() },
|
||||
remoteId = remoteId,
|
||||
permissions = permissions,
|
||||
spaceId = spaceId,
|
||||
)
|
||||
|
||||
private data class LoadedMediaPage(
|
||||
val items: List<CloudMediaItem>,
|
||||
val rawResultCount: Int,
|
||||
|
||||
+26
-3
@@ -61,14 +61,30 @@ 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 = { _, _ -> progress.visibility = View.GONE },
|
||||
onError = { _, _ ->
|
||||
val originalUri = runCatching { contentUri(media, account) }.getOrNull()
|
||||
if (originalUri == null) {
|
||||
progress.visibility = View.GONE
|
||||
} else {
|
||||
photo.load(originalUri, loader) {
|
||||
memoryCacheKey("$originalUri#${media.etag}")
|
||||
diskCacheKey("$originalUri#${media.etag}")
|
||||
crossfade(true)
|
||||
listener(
|
||||
onSuccess = { _, _ -> progress.visibility = View.GONE },
|
||||
onError = { _, _ -> progress.visibility = View.GONE },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,7 +98,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
|
||||
val prepared = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = clientManager.getClientForCoilThumbnails(account.name)
|
||||
val contentUrl = previewUri(media, account, 1, 1).substringBefore('?')
|
||||
val contentUrl = contentUri(media, account)
|
||||
contentUrl to client.credentials?.headerAuth.orEmpty()
|
||||
}
|
||||
}.getOrNull() ?: run {
|
||||
@@ -127,6 +143,13 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun contentUri(media: CloudMediaItem, account: Account): String =
|
||||
if (media.webDavHref.isNotBlank()) {
|
||||
ThumbnailsRequester.getContentUriForWebDavHref(media.webDavHref, account)
|
||||
} else {
|
||||
ThumbnailsRequester.getContentUriForFile(media.toOCFile(account.name), account)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun mediaExtra(): CloudMediaItem? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(EXTRA_MEDIA, CloudMediaItem::class.java)
|
||||
|
||||
+42
-1
@@ -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"),
|
||||
|
||||
+29
-17
@@ -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>
|
||||
|
||||
+54
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user