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"
|
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() + "\""
|
||||||
|
|||||||
+214
-17
@@ -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 {
|
||||||
|
|||||||
+71
-16
@@ -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
|
||||||
|
|||||||
+260
-48
@@ -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,
|
||||||
|
|||||||
+26
-3
@@ -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)
|
||||||
|
|||||||
+42
-1
@@ -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"),
|
||||||
|
|||||||
+29
-17
@@ -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>
|
||||||
|
|||||||
+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