Make photo library offline-first and fix video thumbnails
Server / deployment-config (push) Successful in 59s
Android / test-and-build (push) Failing after 1m14s
Server / vulnerability-scan (push) Failing after 8m54s

This commit is contained in:
Курнат Андрей
2026-07-19 21:01:35 +03:00
parent 54556b76e9
commit 7135199f23
52 changed files with 2424 additions and 464 deletions
+2 -2
View File
@@ -137,8 +137,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner" testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 38 versionCode = 42
versionName = "1.3.10" versionName = "1.3.14"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\"" buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\"" buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -6,7 +6,6 @@
package eu.qsfera.android.presentation.cloud package eu.qsfera.android.presentation.cloud
import android.accounts.Account import android.accounts.Account
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.View import android.view.View
@@ -15,18 +14,18 @@ import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.dispose import coil.dispose
import coil.load import coil.load
import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import eu.qsfera.android.R import eu.qsfera.android.R
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.utils.MimetypeIconUtil import eu.qsfera.android.utils.MimetypeIconUtil
import java.io.File
internal class CloudHubAdapter( internal class CloudHubAdapter(
private var account: Account, private var account: Account,
private val aspectRatioCache: CloudMediaAspectRatioCache,
private val onMediaClick: (CloudMediaItem) -> Unit, private val onMediaClick: (CloudMediaItem) -> Unit,
private val onMediaLongClick: (CloudMediaItem) -> Unit, private val onMediaLongClick: (CloudMediaItem) -> Unit,
private val onAlbumClick: (String) -> Unit, private val onAlbumClick: (String) -> Unit,
@@ -41,37 +40,28 @@ internal class CloudHubAdapter(
private var selectedMediaKeys: Set<String> = emptySet() private var selectedMediaKeys: Set<String> = emptySet()
private var gridSpanCount = DEFAULT_GRID_SPANS private var gridSpanCount = DEFAULT_GRID_SPANS
private var mediaSpanSize = DEFAULT_MEDIA_SPANS private var mediaSpanSize = DEFAULT_MEDIA_SPANS
private var mediaPreviewSize = PREVIEW_MEDIUM
private val prefetchedUrls = mutableSetOf<String>()
fun updateGridGeometry(spanCount: Int, mediaSpans: Int, previewSize: Int) { init {
setHasStableIds(true)
}
fun updateGridGeometry(spanCount: Int, mediaSpans: Int) {
gridSpanCount = spanCount gridSpanCount = spanCount
mediaSpanSize = mediaSpans mediaSpanSize = mediaSpans
mediaPreviewSize = previewSize
} }
fun updateAccount(newAccount: Account) { fun updateAccount(newAccount: Account) {
if (account == newAccount) return if (account == newAccount) return
account = newAccount account = newAccount
prefetchedUrls.clear()
notifyItemRangeChanged(0, itemCount) notifyItemRangeChanged(0, itemCount)
} }
fun submitRows(newRows: List<CloudHubRow>) { fun submitRows(newRows: List<CloudHubRow>) {
val oldRows = rows
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldRows.size
override fun getNewListSize(): Int = newRows.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
stableKey(oldRows[oldItemPosition]) == stableKey(newRows[newItemPosition])
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldRows[oldItemPosition] == newRows[newItemPosition]
})
rows = newRows rows = newRows
result.dispatchUpdatesTo(this) // The photo layout can replace 15k row identities at once (smart groups versus tiles).
// A full notification is cheaper and visually faster than calculating a massive diff;
// stable IDs let RecyclerView retain and recycle the currently visible holders.
notifyDataSetChanged()
} }
fun updateMediaSelection(newSelection: Set<String>) { fun updateMediaSelection(newSelection: Set<String>) {
@@ -92,6 +82,8 @@ internal class CloudHubAdapter(
override fun getItemCount(): Int = rows.size override fun getItemCount(): Int = rows.size
override fun getItemId(position: Int): Long = stableCloudId(stableKey(rows[position]))
override fun getItemViewType(position: Int): Int = when (rows[position]) { override fun getItemViewType(position: Int): Int = when (rows[position]) {
is CloudHubRow.Header -> { TYPE_HEADER } is CloudHubRow.Header -> { TYPE_HEADER }
is CloudHubRow.FeedCard -> { TYPE_FEED_CARD } is CloudHubRow.FeedCard -> { TYPE_FEED_CARD }
@@ -143,7 +135,7 @@ internal class CloudHubAdapter(
CloudHubRow.Shortcuts -> { bindShortcuts(holder.itemView) } CloudHubRow.Shortcuts -> { bindShortcuts(holder.itemView) }
is CloudHubRow.PhotoStatus -> { bindPhotoStatus(holder.itemView, row) } is CloudHubRow.PhotoStatus -> { bindPhotoStatus(holder.itemView, row) }
is CloudHubRow.Media -> { is CloudHubRow.Media -> {
bindMedia(holder.itemView, row.item, previewSize = mediaPreviewSize) bindMedia(holder.itemView, row.item)
} }
is CloudHubRow.SmartMediaGroup -> { bindSmartMediaGroup(holder.itemView, row) } is CloudHubRow.SmartMediaGroup -> { bindSmartMediaGroup(holder.itemView, row) }
is CloudHubRow.Storage -> { bindStorage(holder.itemView, row.item) } is CloudHubRow.Storage -> { bindStorage(holder.itemView, row.item) }
@@ -170,7 +162,7 @@ internal class CloudHubAdapter(
view.findViewById<TextView>(R.id.cloud_feed_title).text = feedTitle view.findViewById<TextView>(R.id.cloud_feed_title).text = feedTitle
val mainImage = view.findViewById<ImageView>(R.id.cloud_feed_main_image) val mainImage = view.findViewById<ImageView>(R.id.cloud_feed_main_image)
items.firstOrNull()?.let { main -> items.firstOrNull()?.let { main ->
loadMediaImage(mainImage, main, PREVIEW_LARGE) loadMediaImage(mainImage, main)
mainImage.setOnClickListener { onMediaClick(main) } mainImage.setOnClickListener { onMediaClick(main) }
} }
@@ -186,7 +178,7 @@ internal class CloudHubAdapter(
val lastItem = items.getOrNull(3) val lastItem = items.getOrNull(3)
lastContainer.visibility = if (lastItem == null) View.GONE else View.VISIBLE lastContainer.visibility = if (lastItem == null) View.GONE else View.VISIBLE
lastItem?.let { media -> lastItem?.let { media ->
loadMediaImage(thirdThumbnail, media, PREVIEW_SMALL) loadMediaImage(thirdThumbnail, media)
} }
val hiddenCount = items.size - FEED_VISIBLE_MEDIA val hiddenCount = items.size - FEED_VISIBLE_MEDIA
moreCount.visibility = if (hiddenCount > 0) View.VISIBLE else View.GONE moreCount.visibility = if (hiddenCount > 0) View.VISIBLE else View.GONE
@@ -200,7 +192,7 @@ internal class CloudHubAdapter(
private fun bindOptionalThumbnail(view: ImageView, item: CloudMediaItem?) { private fun bindOptionalThumbnail(view: ImageView, item: CloudMediaItem?) {
view.visibility = if (item == null) View.GONE else View.VISIBLE view.visibility = if (item == null) View.GONE else View.VISIBLE
if (item != null) { if (item != null) {
loadMediaImage(view, item, PREVIEW_SMALL) loadMediaImage(view, item)
view.setOnClickListener { onMediaClick(item) } view.setOnClickListener { onMediaClick(item) }
} else { } else {
view.dispose() view.dispose()
@@ -225,11 +217,11 @@ internal class CloudHubAdapter(
view.context.getString(R.string.cloud_photo_status, status.photos, status.videos) view.context.getString(R.string.cloud_photo_status, status.photos, status.videos)
} }
private fun bindMedia( private fun bindMedia(view: View, item: CloudMediaItem) {
view: View, bindMedia(view, item, null)
item: CloudMediaItem, }
previewSize: Int = PREVIEW_MEDIUM,
) { private fun bindMedia(view: View, item: CloudMediaItem, onAspectRatio: ((Float) -> Unit)?) {
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 selectionScrim = view.findViewById<View>(R.id.cloud_media_selection_scrim)
@@ -239,7 +231,7 @@ internal class CloudHubAdapter(
selectionScrim.visibility = if (selected) View.VISIBLE else View.GONE selectionScrim.visibility = if (selected) View.VISIBLE else View.GONE
selectionCheck.visibility = if (selected) View.VISIBLE else View.GONE selectionCheck.visibility = if (selected) View.VISIBLE else View.GONE
view.isActivated = selected view.isActivated = selected
loadMediaImage(image, item, previewSize) loadMediaImage(image, item, onAspectRatio)
view.contentDescription = item.name view.contentDescription = item.name
view.setOnClickListener { onMediaClick(item) } view.setOnClickListener { onMediaClick(item) }
view.setOnLongClickListener { view.setOnLongClickListener {
@@ -250,9 +242,12 @@ internal class CloudHubAdapter(
} }
private fun bindSmartMediaGroup(view: View, group: CloudHubRow.SmartMediaGroup) { private fun bindSmartMediaGroup(view: View, group: CloudHubRow.SmartMediaGroup) {
view.findViewById<CloudSmartPhotoLayout>(R.id.cloud_smart_grid).apply { val grid = view.findViewById<CloudSmartPhotoLayout>(R.id.cloud_smart_grid).apply {
mediaCount = group.items.size mediaCount = group.items.size
mirrored = group.mirrored mirrored = group.mirrored
maxItemsPerRow = group.maxItemsPerRow
autoWrap = group.autoWrap
aspectRatios = group.items.map(aspectRatioCache::get)
} }
view.findViewById<TextView>(R.id.cloud_smart_date).apply { view.findViewById<TextView>(R.id.cloud_smart_date).apply {
text = group.date text = group.date
@@ -271,12 +266,9 @@ internal class CloudHubAdapter(
val item = group.items.getOrNull(index) val item = group.items.getOrNull(index)
visibility = if (item == null) View.INVISIBLE else View.VISIBLE visibility = if (item == null) View.INVISIBLE else View.VISIBLE
if (item != null) { if (item != null) {
val previewSize = if (CloudSmartPhotoLayout.isLargeSlot(group.items.size, index)) { bindMedia(this, item) { ratio ->
PREVIEW_LARGE if (aspectRatioCache.put(item, ratio)) grid.updateAspectRatio(index, ratio)
} else {
PREVIEW_MEDIUM
} }
bindMedia(this, item, previewSize = previewSize)
} else { } else {
findViewById<ImageView>(R.id.cloud_media_image).dispose() findViewById<ImageView>(R.id.cloud_media_image).dispose()
setOnClickListener(null) setOnClickListener(null)
@@ -289,55 +281,71 @@ internal class CloudHubAdapter(
fun monthLabelAt(position: Int): String? = fun monthLabelAt(position: Int): String? =
(rows.getOrNull(position) as? CloudHubRow.Media)?.label (rows.getOrNull(position) as? CloudHubRow.Media)?.label
fun prefetchRows(context: Context, rowsToPrefetch: List<CloudHubRow>) { private fun loadMediaImage(
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account) image: ImageView,
rowsToPrefetch.forEach { row -> item: CloudMediaItem,
when (row) { onAspectRatio: ((Float) -> Unit)? = null,
is CloudHubRow.Media -> prefetchMedia(context, loader, row.item, mediaPreviewSize) ) {
is CloudHubRow.SmartMediaGroup -> row.items.forEachIndexed { index, item -> if (item.isImage || item.isVideo) {
val size = if (CloudSmartPhotoLayout.isLargeSlot(row.items.size, index)) { image.scaleType = ImageView.ScaleType.CENTER_CROP
PREVIEW_LARGE val requestKey = item.selectionKey
} else { image.setTag(R.id.cloud_media_image, requestKey)
PREVIEW_MEDIUM val localPreview = item.localPreviewPath?.let(::File)?.takeIf(File::isFile)
} if (localPreview != null) {
prefetchMedia(context, loader, item, size) image.load(localPreview, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
placeholder(R.drawable.cloud_media_placeholder)
scale(Scale.FIT)
size(CloudMediaPreviewRequests.TILE_SIZE, CloudMediaPreviewRequests.TILE_SIZE)
listener(
onSuccess = { _, result ->
if (image.getTag(R.id.cloud_media_image) == requestKey) {
result.drawable.intrinsicAspectRatio()?.let { ratio -> onAspectRatio?.invoke(ratio) }
}
},
onError = { _, _ ->
if (image.getTag(R.id.cloud_media_image) == requestKey) {
loadRemoteMediaImage(image, item, requestKey, onAspectRatio)
}
},
)
} }
else -> Unit return
} }
loadRemoteMediaImage(image, item, requestKey, onAspectRatio)
} else {
image.dispose()
image.scaleType = ImageView.ScaleType.CENTER_INSIDE
image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
} }
} }
private fun prefetchMedia(context: Context, loader: coil.ImageLoader, item: CloudMediaItem, size: Int) { private fun loadRemoteMediaImage(
if (!item.isImage && !item.isVideo) return image: ImageView,
val url = runCatching { previewUri(item, size) }.getOrNull() ?: return item: CloudMediaItem,
if (!prefetchedUrls.add(url)) return requestKey: String,
loader.enqueue( onAspectRatio: ((Float) -> Unit)?,
ImageRequest.Builder(context.applicationContext) ) {
.data(url)
.scale(Scale.FILL)
.build()
)
}
private fun loadMediaImage(image: ImageView, item: CloudMediaItem, size: Int) {
if (item.isImage || item.isVideo) {
image.scaleType = ImageView.ScaleType.CENTER_CROP
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account) val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
val requestKey = item.selectionKey val preview = runCatching { CloudMediaPreviewRequests.tileUri(item, account) }.getOrNull()
image.setTag(R.id.cloud_media_image, requestKey)
val preview = runCatching { previewUri(item, size) }.getOrNull()
if (preview == null) { if (preview == null) {
loadOriginalMediaImage(image, item, requestKey) loadOriginalMediaImage(image, item, requestKey, onAspectRatio)
} else { } else {
image.load(preview, loader) { image.load(preview, loader) {
placeholder(R.drawable.cloud_media_placeholder) placeholder(R.drawable.cloud_media_placeholder)
scale(Scale.FILL) scale(Scale.FIT)
crossfade(true) size(CloudMediaPreviewRequests.TILE_SIZE, CloudMediaPreviewRequests.TILE_SIZE)
memoryCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
diskCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
listener( listener(
onSuccess = { _, result ->
if (image.getTag(R.id.cloud_media_image) == requestKey) {
result.drawable.intrinsicAspectRatio()?.let { ratio -> onAspectRatio?.invoke(ratio) }
}
},
onError = { _, _ -> onError = { _, _ ->
if (image.getTag(R.id.cloud_media_image) == requestKey) { if (image.getTag(R.id.cloud_media_image) == requestKey) {
if (item.isImage) { if (item.isImage) {
loadOriginalMediaImage(image, item, requestKey) loadOriginalMediaImage(image, item, requestKey, onAspectRatio)
} else { } else {
image.scaleType = ImageView.ScaleType.CENTER_INSIDE image.scaleType = ImageView.ScaleType.CENTER_INSIDE
image.setImageResource( image.setImageResource(
@@ -349,14 +357,14 @@ internal class CloudHubAdapter(
) )
} }
} }
} else {
image.dispose()
image.scaleType = ImageView.ScaleType.CENTER_INSIDE
image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
}
} }
private fun loadOriginalMediaImage(image: ImageView, item: CloudMediaItem, requestKey: String) { private fun loadOriginalMediaImage(
image: ImageView,
item: CloudMediaItem,
requestKey: String,
onAspectRatio: ((Float) -> Unit)?,
) {
val originalUri = runCatching { contentUri(item) }.getOrNull() val originalUri = runCatching { contentUri(item) }.getOrNull()
if (originalUri == null) { if (originalUri == null) {
image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
@@ -365,23 +373,45 @@ internal class CloudHubAdapter(
image.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) { image.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
placeholder(R.drawable.cloud_media_placeholder) placeholder(R.drawable.cloud_media_placeholder)
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
scale(Scale.FILL) scale(Scale.FIT)
memoryCacheKey("$originalUri#${item.etag}") size(CloudMediaPreviewRequests.TILE_SIZE, CloudMediaPreviewRequests.TILE_SIZE)
diskCacheKey("$originalUri#${item.etag}") memoryCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
crossfade(true) diskCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
listener( listener(
onStart = { onStart = {
if (image.getTag(R.id.cloud_media_image) != requestKey) image.dispose() if (image.getTag(R.id.cloud_media_image) != requestKey) image.dispose()
}, },
onSuccess = { _, result ->
if (image.getTag(R.id.cloud_media_image) == requestKey) {
result.drawable.intrinsicAspectRatio()?.let { ratio -> onAspectRatio?.invoke(ratio) }
}
},
) )
} }
} }
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( val icon = view.findViewById<ImageView>(R.id.cloud_storage_icon)
if (item.isFolder) R.drawable.ic_qsfera_folder else MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name) val video = view.findViewById<ImageView>(R.id.cloud_storage_video)
) when {
item.isFolder -> {
icon.dispose()
icon.scaleType = ImageView.ScaleType.CENTER_INSIDE
icon.setImageResource(R.drawable.ic_qsfera_folder)
video.visibility = View.GONE
}
item.isImage || item.isVideo -> {
loadMediaImage(icon, item.toCloudMedia())
video.visibility = if (item.isVideo) View.VISIBLE else View.GONE
}
else -> {
icon.dispose()
icon.scaleType = ImageView.ScaleType.CENTER_INSIDE
icon.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
video.visibility = View.GONE
}
}
view.contentDescription = item.name view.contentDescription = item.name
view.setOnClickListener { onStorageClick(item) } view.setOnClickListener { onStorageClick(item) }
} }
@@ -396,7 +426,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) {
loadMediaImage(cover, coverItem, PREVIEW_MEDIUM) loadMediaImage(cover, coverItem)
} else { } else {
cover.dispose() cover.dispose()
cover.scaleType = ImageView.ScaleType.CENTER_INSIDE cover.scaleType = ImageView.ScaleType.CENTER_INSIDE
@@ -405,27 +435,6 @@ internal class CloudHubAdapter(
view.setOnClickListener { onAlbumClick(album.path) } view.setOnClickListener { onAlbumClick(album.path) }
} }
private fun previewUri(item: CloudMediaItem, size: Int): String =
if (item.webDavHref.isNotBlank()) {
ThumbnailsRequester.getPreviewUriForWebDavHref(
item.webDavHref,
account,
item.etag.ifBlank { item.modifiedAt.toString() },
size,
size,
ThumbnailsRequester.PreviewProcessor.FIT,
)
} else {
ThumbnailsRequester.getPreviewUriForFile(
item.toOCFile(account.name),
account,
item.etag,
size,
size,
ThumbnailsRequester.PreviewProcessor.FIT,
)
}
private fun contentUri(item: CloudMediaItem): String = private fun contentUri(item: CloudMediaItem): String =
if (item.webDavHref.isNotBlank()) { if (item.webDavHref.isNotBlank()) {
ThumbnailsRequester.getContentUriForWebDavHref(item.webDavHref, account) ThumbnailsRequester.getContentUriForWebDavHref(item.webDavHref, account)
@@ -484,9 +493,7 @@ internal class CloudHubAdapter(
private const val DEFAULT_GRID_SPANS = 6 private const val DEFAULT_GRID_SPANS = 6
private const val DEFAULT_MEDIA_SPANS = 2 private const val DEFAULT_MEDIA_SPANS = 2
private const val DEFAULT_ALBUM_SPANS = 3 private const val DEFAULT_ALBUM_SPANS = 3
private const val PREVIEW_SMALL = 320
private const val PREVIEW_MEDIUM = 512
private const val PREVIEW_LARGE = 1024
private const val FEED_VISIBLE_MEDIA = 4 private const val FEED_VISIBLE_MEDIA = 4
} }
} }
@@ -6,11 +6,17 @@
package eu.qsfera.android.presentation.cloud package eu.qsfera.android.presentation.cloud
import android.content.Intent import android.content.Intent
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
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.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog 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
@@ -19,6 +25,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.request.SuccessResult
import com.google.android.material.floatingactionbutton.FloatingActionButton 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
@@ -29,6 +36,8 @@ import eu.qsfera.android.domain.files.FileRepository
import eu.qsfera.android.domain.files.model.OCFile import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.spaces.SpacesRepository 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.OCTransfer
import eu.qsfera.android.domain.transfers.model.TransferStatus
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
@@ -39,12 +48,16 @@ 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.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first 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
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.io.File
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -61,7 +74,11 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private lateinit var recycler: RecyclerView private lateinit var recycler: RecyclerView
private lateinit var gridLayoutManager: GridLayoutManager private lateinit var gridLayoutManager: GridLayoutManager
private lateinit var fab: FloatingActionButton private lateinit var fab: FloatingActionButton
private lateinit var mediaCatalogCache: CloudMediaCatalogCache
private lateinit var mediaAspectRatioCache: CloudMediaAspectRatioCache
private var allMedia: List<CloudMediaItem> = emptyList() private var allMedia: List<CloudMediaItem> = emptyList()
private var refreshedMedia: List<CloudMediaItem> = emptyList()
private var automaticUploadMedia: List<CloudMediaItem> = emptyList()
private var storageItems: List<CloudStorageItem> = emptyList() private var storageItems: List<CloudStorageItem> = emptyList()
private var query: String = "" private var query: String = ""
private var activeAlbumPath: String? = null private var activeAlbumPath: String? = null
@@ -79,6 +96,41 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private var selectionMode = false private var selectionMode = false
private var photoGridMode = CloudPhotoGridMode.LARGE private var photoGridMode = CloudPhotoGridMode.LARGE
private var showScreenshots = true private var showScreenshots = true
private var thumbnailQueue: Channel<CloudMediaItem>? = null
private var thumbnailWarmJob: Job? = null
private val queuedThumbnailKeys = mutableSetOf<String>()
private var networkCallbackRegistered = false
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
activity?.runOnUiThread {
if (
isAdded && view != null && mediaLoaded && !isLoading &&
section != CloudSection.FILES && section != CloudSection.MORE
) {
refreshMediaFromNetworkSilently()
}
}
}
}
private val mediaPreviewLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != android.app.Activity.RESULT_OK) return@registerForActivityResult
when (result.data?.getStringExtra(CloudMediaPreviewActivity.EXTRA_RESULT_ACTION)) {
CloudMediaPreviewActivity.RESULT_DELETED -> {
val key = result.data?.getStringExtra(CloudMediaPreviewActivity.EXTRA_RESULT_SELECTION_KEY)
?: return@registerForActivityResult
allMedia = allMedia.filterNot { it.selectionKey == key }
refreshedMedia = refreshedMedia.filterNot { it.selectionKey == key }
if (this::mediaCatalogCache.isInitialized) {
val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
mediaCatalogCache.write(accountName, allMedia.filter { it.localPreviewPath == null })
}
}
render()
}
CloudMediaPreviewActivity.RESULT_CHANGED -> reloadMedia()
}
}
var currentFolderPath: String = ROOT_PATH var currentFolderPath: String = ROOT_PATH
private set private set
@@ -93,6 +145,8 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
mediaCatalogCache = CloudMediaCatalogCache(requireContext().applicationContext)
mediaAspectRatioCache = CloudMediaAspectRatioCache(requireContext().applicationContext)
section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION)) section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION))
photoGridMode = CloudPhotoGridMode.fromWireValue( photoGridMode = CloudPhotoGridMode.fromWireValue(
preferencesProvider.getString(PREFERENCE_PHOTO_GRID_MODE, CloudPhotoGridMode.LARGE.wireValue) preferencesProvider.getString(PREFERENCE_PHOTO_GRID_MODE, CloudPhotoGridMode.LARGE.wireValue)
@@ -109,6 +163,26 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
requireActivity().onBackPressedDispatcher.addCallback(this, nestedBackCallback) requireActivity().onBackPressedDispatcher.addCallback(this, nestedBackCallback)
} }
override fun onStart() {
super.onStart()
if (networkCallbackRegistered) return
val connectivity = requireContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
runCatching { connectivity.registerNetworkCallback(request, networkCallback) }
.onSuccess { networkCallbackRegistered = true }
}
override fun onStop() {
if (networkCallbackRegistered) {
val connectivity = requireContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
runCatching { connectivity.unregisterNetworkCallback(networkCallback) }
networkCallbackRegistered = false
}
super.onStop()
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
outState.putParcelableArrayList(STATE_SELECTION, ArrayList(selection.items)) outState.putParcelableArrayList(STATE_SELECTION, ArrayList(selection.items))
outState.putBoolean(STATE_SELECTION_MODE, selectionMode) outState.putBoolean(STATE_SELECTION_MODE, selectionMode)
@@ -120,6 +194,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
val account = AccountUtils.getCurrentQSferaAccount(requireContext()) val account = AccountUtils.getCurrentQSferaAccount(requireContext())
adapter = CloudHubAdapter( adapter = CloudHubAdapter(
account = account, account = account,
aspectRatioCache = mediaAspectRatioCache,
onMediaClick = ::handleMediaClick, onMediaClick = ::handleMediaClick,
onMediaLongClick = ::toggleMediaSelection, onMediaLongClick = ::toggleMediaSelection,
onAlbumClick = ::openAlbum, onAlbumClick = ::openAlbum,
@@ -127,7 +202,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
onShortcutClick = ::openShortcut, onShortcutClick = ::openShortcut,
onActionClick = ::openAction, onActionClick = ::openAction,
onRetry = ::reload, onRetry = ::reload,
onLoadMore = { loadNextMediaPage(reset = false) }, onLoadMore = {},
onFeedGroupClick = ::openFeedGroup, onFeedGroupClick = ::openFeedGroup,
) )
gridLayoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout -> gridLayoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout ->
@@ -138,6 +213,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
recycler = view.findViewById<RecyclerView>(R.id.cloud_list).apply { recycler = view.findViewById<RecyclerView>(R.id.cloud_list).apply {
layoutManager = gridLayoutManager layoutManager = gridLayoutManager
adapter = this@CloudHubFragment.adapter adapter = this@CloudHubFragment.adapter
itemAnimator = null
addItemDecoration( addItemDecoration(
CloudPhotoMonthDecoration( CloudPhotoMonthDecoration(
labelAt = this@CloudHubFragment.adapter::monthLabelAt, labelAt = this@CloudHubFragment.adapter::monthLabelAt,
@@ -146,16 +222,6 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
}, },
) )
) )
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (section == CloudSection.FILES || section == CloudSection.MORE) return
if (dy <= 0 || isLoading || reachedEnd) return
val layout = recyclerView.layoutManager as? GridLayoutManager ?: return
if (layout.findLastVisibleItemPosition() >= this@CloudHubFragment.adapter.itemCount - LOAD_MORE_THRESHOLD) {
loadNextMediaPage(reset = false)
}
}
})
} }
refresh = view.findViewById<SwipeRefreshLayout>(R.id.cloud_refresh).apply { refresh = view.findViewById<SwipeRefreshLayout>(R.id.cloud_refresh).apply {
setColorSchemeResources(R.color.qsfera_blue) setColorSchemeResources(R.color.qsfera_blue)
@@ -164,6 +230,22 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
fab = view.findViewById<FloatingActionButton>(R.id.cloud_fab).apply { fab = view.findViewById<FloatingActionButton>(R.id.cloud_fab).apply {
setOnClickListener { (activity as? CloudHomeActivity)?.showAddSheet() } setOnClickListener { (activity as? CloudHomeActivity)?.showAddSheet() }
} }
startThumbnailPreloader(account)
viewLifecycleOwner.lifecycleScope.launch {
transferRepository.getAllTransfersAsStream().collect { transfers ->
val pending = automaticUploadMedia(transfers)
if (pending == automaticUploadMedia) return@collect
automaticUploadMedia = pending
allMedia = (pending + allMedia.filter { it.localPreviewPath == null })
.distinctBy(CloudMediaItem::contentKey)
.sortedByDescending(CloudMediaItem::modifiedAt)
if (pending.isNotEmpty()) mediaLoaded = true
if (section != CloudSection.FILES && section != CloudSection.MORE && mediaLoaded) {
render()
enqueueThumbnailWarmup(pending)
}
}
}
configureSection() configureSection()
updateSelectionUi() updateSelectionUi()
reload() reload()
@@ -217,19 +299,19 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
when (photoGridMode) { when (photoGridMode) {
CloudPhotoGridMode.SMART, CloudPhotoGridMode.SMART,
CloudPhotoGridMode.LARGE -> { CloudPhotoGridMode.LARGE -> {
GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE, PHOTO_PREVIEW_LARGE) GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE)
} }
CloudPhotoGridMode.STANDARD -> { CloudPhotoGridMode.STANDARD -> {
GridGeometry(GRID_SPANS_STANDARD, MEDIA_SPANS_STANDARD, PHOTO_PREVIEW_SMALL) GridGeometry(GRID_SPANS_STANDARD, MEDIA_SPANS_STANDARD)
} }
CloudPhotoGridMode.MONTHS -> { CloudPhotoGridMode.MONTHS -> {
GridGeometry(GRID_SPANS_MONTHS, MEDIA_SPANS_MONTHS, PHOTO_PREVIEW_SMALL) GridGeometry(GRID_SPANS_MONTHS, MEDIA_SPANS_MONTHS)
} }
} }
} else { } else {
GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE, PHOTO_PREVIEW_LARGE) GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE)
} }
adapter.updateGridGeometry(geometry.spans, geometry.mediaSpans, geometry.previewSize) adapter.updateGridGeometry(geometry.spans, geometry.mediaSpans)
gridLayoutManager.spanCount = geometry.spans gridLayoutManager.spanCount = geometry.spans
recycler.requestLayout() recycler.requestLayout()
} }
@@ -293,8 +375,11 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
if (!isAdded || !this::adapter.isInitialized) return if (!isAdded || !this::adapter.isInitialized) return
invalidateActiveLoad() invalidateActiveLoad()
clearSelection() clearSelection()
adapter.updateAccount(AccountUtils.getCurrentQSferaAccount(requireContext())) val account = AccountUtils.getCurrentQSferaAccount(requireContext())
adapter.updateAccount(account)
startThumbnailPreloader(account)
allMedia = emptyList() allMedia = emptyList()
automaticUploadMedia = emptyList()
storageItems = emptyList() storageItems = emptyList()
query = "" query = ""
activeAlbumPath = null activeAlbumPath = null
@@ -316,10 +401,48 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
invalidateActiveLoad() invalidateActiveLoad()
nextOffset = 0 nextOffset = 0
reachedEnd = false reachedEnd = false
allMedia = emptyList() refreshedMedia = emptyList()
mediaLoaded = false
refresh.isRefreshing = true refresh.isRefreshing = true
adapter.submitRows(listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), ""))) if (allMedia.isEmpty()) {
mediaLoaded = false
adapter.submitRows(listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), "")))
} else {
mediaLoaded = true
render()
}
val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name
val requestedSection = section
val cacheGeneration = ++loadGeneration
isLoading = true
loadJob = viewLifecycleOwner.lifecycleScope.launch {
val (cached, pending) = withContext(Dispatchers.IO) {
mediaCatalogCache.read(accountName) to automaticUploadMedia(transferRepository.getAllTransfers())
}
automaticUploadMedia = pending
val cachedMedia = cached
.distinctBy(CloudMediaItem::contentKey)
.sortedByDescending(CloudMediaItem::modifiedAt)
if (!isAdded || cacheGeneration != loadGeneration || section != requestedSection) return@launch
if (cachedMedia.isNotEmpty() || pending.isNotEmpty()) {
allMedia = (pending + cachedMedia)
.distinctBy(CloudMediaItem::contentKey)
.sortedByDescending(CloudMediaItem::modifiedAt)
mediaLoaded = true
refresh.isRefreshing = false
render()
enqueueThumbnailWarmup(allMedia)
}
isLoading = false
loadNextMediaPage(reset = true)
}
}
private fun refreshMediaFromNetworkSilently() {
if (isLoading || section == CloudSection.FILES || section == CloudSection.MORE) return
invalidateActiveLoad()
nextOffset = 0
reachedEnd = false
refreshedMedia = emptyList()
loadNextMediaPage(reset = true) loadNextMediaPage(reset = true)
} }
@@ -328,6 +451,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
isLoading = true isLoading = true
val requestedOffset = if (reset) 0 else nextOffset val requestedOffset = if (reset) 0 else nextOffset
val requestedSection = section val requestedSection = section
val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name
val requestGeneration = ++loadGeneration val requestGeneration = ++loadGeneration
loadJob = viewLifecycleOwner.lifecycleScope.launch { loadJob = viewLifecycleOwner.lifecycleScope.launch {
val result = try { val result = try {
@@ -344,17 +468,43 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
nextOffset = requestedOffset + page.rawResultCount nextOffset = requestedOffset + page.rawResultCount
reachedEnd = page.rawResultCount < MEDIA_PAGE_SIZE reachedEnd = page.rawResultCount < MEDIA_PAGE_SIZE
mediaLoaded = true mediaLoaded = true
allMedia = (if (reset) page.items else allMedia + page.items) val orderedPage = page.items.sortedByDescending(CloudMediaItem::modifiedAt)
refreshedMedia = (if (reset) orderedPage else refreshedMedia + orderedPage)
.distinctBy { it.webDavHref.ifBlank { "${it.spaceId.orEmpty()}:${it.remotePath}" } } .distinctBy { it.webDavHref.ifBlank { "${it.spaceId.orEmpty()}:${it.remotePath}" } }
.sortedByDescending { it.modifiedAt } allMedia = (refreshedMedia + automaticUploadMedia + allMedia)
.distinctBy(CloudMediaItem::contentKey)
.sortedByDescending(CloudMediaItem::modifiedAt)
enqueueThumbnailWarmup(orderedPage)
if (reachedEnd) {
allMedia = (refreshedMedia + automaticUploadMedia)
.distinctBy(CloudMediaItem::contentKey)
.sortedByDescending(CloudMediaItem::modifiedAt)
}
val cachedSnapshot = allMedia.filter { it.localPreviewPath == null }
val continueLoading = !reachedEnd
render() render()
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { mediaCatalogCache.write(accountName, cachedSnapshot) }
if (continueLoading && isAdded && requestGeneration == loadGeneration && section == requestedSection) {
loadNextMediaPage(reset = false)
}
}
}.onFailure { }.onFailure {
val fallback = withContext(Dispatchers.IO) { recentAutomaticUploads() } val fallback = withContext(Dispatchers.IO) {
automaticUploadMedia(transferRepository.getAllTransfers())
}
if (!isAdded || requestGeneration != loadGeneration || section != requestedSection) return@launch if (!isAdded || requestGeneration != loadGeneration || section != requestedSection) return@launch
isLoading = false isLoading = false
refresh.isRefreshing = false refresh.isRefreshing = false
if (reset && fallback.isNotEmpty()) { if (reset && fallback.isNotEmpty()) {
allMedia = fallback automaticUploadMedia = fallback
allMedia = (fallback + allMedia.filter { it.localPreviewPath == null })
.distinctBy(CloudMediaItem::contentKey)
.sortedByDescending(CloudMediaItem::modifiedAt)
mediaLoaded = true
reachedEnd = true
render()
} else if (reset && allMedia.isNotEmpty()) {
mediaLoaded = true mediaLoaded = true
reachedEnd = true reachedEnd = true
render() render()
@@ -572,27 +722,31 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
return LoadedMediaPage(items = items, rawResultCount = remoteFiles.size) return LoadedMediaPage(items = items, rawResultCount = remoteFiles.size)
} }
private fun recentAutomaticUploads(): List<CloudMediaItem> { private fun automaticUploadMedia(transfers: List<OCTransfer>): List<CloudMediaItem> {
val account = AccountUtils.getCurrentQSferaAccount(requireContext()) val account = AccountUtils.getCurrentQSferaAccount(requireContext())
return transferRepository.getFinishedTransfers() return transfers
.asSequence() .asSequence()
.filter { transfer -> .filter { transfer ->
transfer.accountName == account.name && transfer.accountName == account.name &&
transfer.createdBy != UploadEnqueuedBy.ENQUEUED_BY_USER && transfer.createdBy != UploadEnqueuedBy.ENQUEUED_BY_USER &&
transfer.transferEndTimestamp != null transfer.status != TransferStatus.TRANSFER_SUCCEEDED
} }
.map { transfer -> .mapNotNull { transfer ->
val localFile = File(transfer.localPath).takeIf(File::isFile) ?: return@mapNotNull null
CloudMediaItem( CloudMediaItem(
remotePath = transfer.remotePath, remotePath = transfer.remotePath,
mimeType = MimetypeIconUtil.getBestMimeTypeByFilename(transfer.remotePath), mimeType = MimetypeIconUtil.getBestMimeTypeByFilename(transfer.remotePath),
size = transfer.fileSize, size = transfer.fileSize,
modifiedAt = transfer.transferEndTimestamp ?: 0L, modifiedAt = localFile.lastModified().takeIf { it > 0L }
?: transfer.transferEndTimestamp
?: 0L,
spaceId = transfer.spaceId,
localPreviewPath = localFile.absolutePath,
) )
} }
.filter { it.isImage || it.isVideo } .filter { it.isImage || it.isVideo }
.distinctBy { it.remotePath } .distinctBy(CloudMediaItem::contentKey)
.sortedByDescending { it.modifiedAt } .sortedByDescending { it.modifiedAt }
.take(120)
.toList() .toList()
} }
@@ -608,13 +762,6 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
CloudSection.MORE -> emptyList() CloudSection.MORE -> emptyList()
} }
adapter.submitRows(rows) adapter.submitRows(rows)
if (section == CloudSection.PHOTOS) {
recycler.post {
if (isAdded && this::adapter.isInitialized) {
adapter.prefetchRows(requireContext(), rows)
}
}
}
adapter.updateMediaSelection(selection.keys) adapter.updateMediaSelection(selection.keys)
} }
@@ -652,16 +799,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
} }
private fun withMediaPagination(rows: List<CloudHubRow>): List<CloudHubRow> = private fun withMediaPagination(rows: List<CloudHubRow>): List<CloudHubRow> = rows
if (mediaLoaded && !reachedEnd && !isLoading) {
rows + CloudHubRow.Status(
title = getString(R.string.cloud_media_load_more),
summary = "",
loadMore = true,
)
} else {
rows
}
private fun fileRows(): List<CloudHubRow> { private fun fileRows(): List<CloudHubRow> {
val filtered = storageItems.filter { item -> val filtered = storageItems.filter { item ->
@@ -683,7 +821,6 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
private fun photoRows(media: List<CloudMediaItem>): List<CloudHubRow> = buildList { private fun photoRows(media: List<CloudMediaItem>): List<CloudHubRow> = buildList {
add(CloudHubRow.PhotoStatus(media.count(CloudMediaItem::isImage), media.count(CloudMediaItem::isVideo)))
if (media.isEmpty()) { if (media.isEmpty()) {
add( add(
CloudHubRow.Status( CloudHubRow.Status(
@@ -695,9 +832,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
addAll( addAll(
when (photoGridMode) { when (photoGridMode) {
CloudPhotoGridMode.SMART -> smartPhotoRows(media) CloudPhotoGridMode.SMART -> smartPhotoRows(media)
CloudPhotoGridMode.LARGE -> justifiedPhotoRows(media, LARGE_GROUP_SIZE)
CloudPhotoGridMode.STANDARD -> justifiedPhotoRows(media, STANDARD_GROUP_SIZE)
CloudPhotoGridMode.MONTHS -> monthPhotoRows(media) CloudPhotoGridMode.MONTHS -> monthPhotoRows(media)
CloudPhotoGridMode.LARGE,
CloudPhotoGridMode.STANDARD -> media.map(CloudHubRow::Media)
} }
) )
} }
@@ -721,6 +858,8 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
}, },
items = items, items = items,
mirrored = groupIndex % 2 == 1, mirrored = groupIndex % 2 == 1,
maxItemsPerRow = SMART_MAX_ITEMS_PER_ROW,
autoWrap = true,
) )
) )
groupIndex++ groupIndex++
@@ -729,21 +868,43 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
} }
private fun justifiedPhotoRows(media: List<CloudMediaItem>, groupSize: Int): List<CloudHubRow> =
media.chunked(groupSize).map { items ->
CloudHubRow.SmartMediaGroup(
date = "",
items = items,
mirrored = false,
maxItemsPerRow = groupSize,
autoWrap = false,
)
}
private fun monthPhotoRows(media: List<CloudMediaItem>): List<CloudHubRow> { private fun monthPhotoRows(media: List<CloudMediaItem>): List<CloudHubRow> {
val monthKeyFormat = SimpleDateFormat("yyyy-MM", Locale.ROOT) val monthKeyFormat = SimpleDateFormat("yyyy-MM", Locale.ROOT)
val monthLabelFormat = SimpleDateFormat("LLLL", Locale.getDefault()) val monthLabelFormat = SimpleDateFormat("LLLL", Locale.getDefault())
var previousMonth: String? = null return buildList {
return media.map { item -> media.groupBy { item -> monthKeyFormat.format(Date(item.modifiedAt)) }
val month = monthKeyFormat.format(Date(item.modifiedAt)) .values
val label = if (month == previousMonth) { .forEach { monthItems ->
null monthItems.chunked(MONTH_GROUP_SIZE).forEachIndexed { index, items ->
} else { val label = if (index == 0) {
previousMonth = month monthLabelFormat.format(Date(items.first().modifiedAt)).replaceFirstChar { character ->
monthLabelFormat.format(Date(item.modifiedAt)).replaceFirstChar { character -> character.titlecase(Locale.getDefault())
character.titlecase(Locale.getDefault()) }
} else {
""
}
add(
CloudHubRow.SmartMediaGroup(
date = label,
items = items,
mirrored = false,
maxItemsPerRow = MONTH_GROUP_SIZE,
autoWrap = false,
)
)
}
} }
}
CloudHubRow.Media(item, label)
} }
} }
@@ -775,7 +936,8 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private fun handleMediaClick(media: CloudMediaItem) { private fun handleMediaClick(media: CloudMediaItem) {
if (!selectionMode && selection.isEmpty) { if (!selectionMode && selection.isEmpty) {
startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media)) CloudMediaPreviewSession.update(filteredMedia())
mediaPreviewLauncher.launch(CloudMediaPreviewActivity.createIntent(requireContext(), media))
} else { } else {
toggleMediaSelection(media) toggleMediaSelection(media)
} }
@@ -856,6 +1018,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
updateSelectionUi() updateSelectionUi()
if (deletedItems.size == items.size) { if (deletedItems.size == items.size) {
render() render()
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
mediaCatalogCache.write(accountName, allMedia.filter { it.localPreviewPath == null })
}
Toast.makeText(requireContext(), R.string.cloud_selection_delete_success, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.cloud_selection_delete_success, Toast.LENGTH_SHORT).show()
} else { } else {
Toast.makeText(requireContext(), R.string.cloud_selection_delete_error, Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), R.string.cloud_selection_delete_error, Toast.LENGTH_LONG).show()
@@ -1017,11 +1182,58 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
} }
} }
private fun startThumbnailPreloader(account: android.accounts.Account) {
thumbnailQueue?.close()
thumbnailWarmJob?.cancel()
queuedThumbnailKeys.clear()
val queue = Channel<CloudMediaItem>(Channel.UNLIMITED)
thumbnailQueue = queue
val appContext = requireContext().applicationContext
val loader = eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
.getContentAddressedImageLoader(account)
thumbnailWarmJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
coroutineScope {
repeat(THUMBNAIL_WARM_CONCURRENCY) {
launch {
for (item in queue) {
runCatching {
val result = loader.execute(
CloudMediaPreviewRequests.tileRequest(appContext, item, account)
)
if (result is SuccessResult) {
result.drawable.intrinsicAspectRatio()?.let { ratio ->
mediaAspectRatioCache.put(item, ratio)
}
}
}
}
}
}
}
}
}
private fun enqueueThumbnailWarmup(items: List<CloudMediaItem>) {
val queue = thumbnailQueue ?: return
items.forEach { item ->
if ((item.isImage || item.isVideo) && queuedThumbnailKeys.add(item.selectionKey + '|' + item.cacheRevision)) {
queue.trySend(item)
}
}
}
override fun onDestroyView() {
thumbnailQueue?.close()
thumbnailQueue = null
thumbnailWarmJob?.cancel()
thumbnailWarmJob = null
super.onDestroyView()
}
companion object { companion object {
private data class GridGeometry( private data class GridGeometry(
val spans: Int, val spans: Int,
val mediaSpans: Int, val mediaSpans: Int,
val previewSize: Int,
) )
private const val ARG_SECTION = "cloud_section" private const val ARG_SECTION = "cloud_section"
@@ -1032,10 +1244,14 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private const val MEDIA_SPANS_STANDARD = 2 private const val MEDIA_SPANS_STANDARD = 2
private const val MEDIA_SPANS_MONTHS = 1 private const val MEDIA_SPANS_MONTHS = 1
private const val SMART_GROUP_SIZE = 6 private const val SMART_GROUP_SIZE = 6
private const val PHOTO_PREVIEW_SMALL = 320 private const val SMART_MAX_ITEMS_PER_ROW = 3
private const val PHOTO_PREVIEW_LARGE = 512 private const val LARGE_GROUP_SIZE = 3
private const val MEDIA_PAGE_SIZE = 200 private const val STANDARD_GROUP_SIZE = 5
private const val LOAD_MORE_THRESHOLD = 18 private const val MONTH_GROUP_SIZE = 6
private const val MEDIA_PAGE_SIZE = 1_000
// The visible grid already issues its own requests. A single background worker keeps
// filling the cache without flooding a Raspberry Pi with parallel image/video decoders.
private const val THUMBNAIL_WARM_CONCURRENCY = 1
private const val ROOT_PATH = "/" private const val ROOT_PATH = "/"
private const val STATE_SELECTION = "cloud_media_selection" private const val STATE_SELECTION = "cloud_media_selection"
private const val STATE_SELECTION_MODE = "cloud_media_selection_mode" private const val STATE_SELECTION_MODE = "cloud_media_selection_mode"
@@ -0,0 +1,133 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.content.Context
import android.graphics.drawable.Drawable
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
/**
* Compact persistent cache of decoded media aspect ratios.
*
* Every record is twelve bytes (stable 64-bit media id plus a float), so a 15,000-item
* library needs about 180 KiB. Writes are coalesced off the UI thread and duplicate records
* are compacted when the append-only file grows substantially beyond the live map.
*/
internal class CloudMediaAspectRatioCache(context: Context) {
private val file = File(context.filesDir, FILE_NAME)
private val ratios = ConcurrentHashMap<Long, Float>()
private val pending = ConcurrentHashMap<Long, Float>()
private val flushScheduled = AtomicBoolean(false)
private val writer = Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "cloud-media-aspect-cache").apply { isDaemon = true }
}
init {
readExisting()
}
fun get(item: CloudMediaItem): Float = ratios[key(item)] ?: defaultRatio(item)
fun put(item: CloudMediaItem, ratio: Float): Boolean {
if (!ratio.isFinite() || ratio <= 0f) return false
val normalized = ratio.coerceIn(MIN_RATIO, MAX_RATIO)
val key = key(item)
val old = ratios.put(key, normalized)
if (old != null && abs(old - normalized) < MIN_CHANGE) return false
pending[key] = normalized
scheduleFlush()
return true
}
private fun readExisting() {
if (!file.isFile) return
runCatching {
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
while (true) {
val key = input.readLong()
val ratio = input.readFloat()
if (ratio.isFinite() && ratio in MIN_RATIO..MAX_RATIO) ratios[key] = ratio
}
}
}.onFailure { error ->
if (error !is EOFException) file.delete()
}
}
private fun scheduleFlush() {
if (flushScheduled.compareAndSet(false, true)) {
writer.schedule(::flush, FLUSH_DELAY_MS, TimeUnit.MILLISECONDS)
}
}
private fun flush() {
val batch = HashMap<Long, Float>()
pending.entries.forEach { (key, ratio) ->
if (pending.remove(key, ratio)) batch[key] = ratio
}
runCatching {
file.parentFile?.mkdirs()
DataOutputStream(BufferedOutputStream(FileOutputStream(file, true))).use { output ->
batch.forEach { (key, ratio) ->
output.writeLong(key)
output.writeFloat(ratio)
}
}
if (file.length() > ratios.size.toLong().coerceAtLeast(1L) * RECORD_BYTES * COMPACT_FACTOR) {
compact()
}
}.onFailure {
pending.putAll(batch)
}
flushScheduled.set(false)
if (pending.isNotEmpty()) scheduleFlush()
}
private fun compact() {
val temporary = File(file.parentFile, "$FILE_NAME.tmp")
DataOutputStream(BufferedOutputStream(FileOutputStream(temporary))).use { output ->
ratios.forEach { (key, ratio) ->
output.writeLong(key)
output.writeFloat(ratio)
}
}
if (!temporary.renameTo(file)) {
temporary.copyTo(file, overwrite = true)
temporary.delete()
}
}
private fun key(item: CloudMediaItem): Long =
stableCloudId("${item.selectionKey}|${item.cacheRevision}")
private fun defaultRatio(item: CloudMediaItem): Float = if (item.isVideo) VIDEO_RATIO else 1f
companion object {
private const val FILE_NAME = "cloud-media-aspect-ratios-v1.bin"
private const val RECORD_BYTES = 12L
private const val COMPACT_FACTOR = 3L
private const val FLUSH_DELAY_MS = 750L
private const val MIN_RATIO = 0.1f
private const val MAX_RATIO = 10f
private const val MIN_CHANGE = 0.005f
private const val VIDEO_RATIO = 16f / 9f
}
}
internal fun Drawable.intrinsicAspectRatio(): Float? =
if (intrinsicWidth > 0 && intrinsicHeight > 0) intrinsicWidth.toFloat() / intrinsicHeight else null
@@ -0,0 +1,150 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.content.Context
import org.json.JSONArray
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.charset.StandardCharsets
import java.util.zip.GZIPInputStream
/** Persistent media metadata cache used to render Photos and Albums before any network request. */
internal class CloudMediaCatalogCache internal constructor(private val directory: File) {
constructor(context: Context) : this(File(context.filesDir, DIRECTORY))
init {
directory.mkdirs()
}
@Synchronized
fun read(accountName: String): List<CloudMediaItem> {
val binary = fileFor(accountName)
if (binary.isFile) {
runCatching { readBinary(binary) }
.onSuccess { return it }
binary.delete()
}
val legacy = readLegacy(legacyFileFor(accountName)) ?: return emptyList()
runCatching { write(accountName, legacy) }
.onSuccess { legacyFileFor(accountName).delete() }
return legacy
}
@Synchronized
fun write(accountName: String, items: List<CloudMediaItem>) {
directory.mkdirs()
val destination = fileFor(accountName)
val temporary = File(directory, "${destination.name}.tmp")
DataOutputStream(BufferedOutputStream(FileOutputStream(temporary))).use { output ->
output.writeInt(MAGIC)
output.writeInt(VERSION)
output.writeInt(items.size)
items.forEach { item ->
output.writeText(item.webDavHref)
output.writeText(item.remotePath)
output.writeText(item.mimeType)
output.writeLong(item.size)
output.writeLong(item.modifiedAt)
output.writeText(item.etag)
output.writeText(item.spaceId.orEmpty())
output.writeText(item.localPreviewPath.orEmpty())
}
}
if (!temporary.renameTo(destination)) {
temporary.copyTo(destination, overwrite = true)
temporary.delete()
}
}
private fun readBinary(file: File): List<CloudMediaItem> =
DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
require(input.readInt() == MAGIC) { "Invalid cloud catalog header" }
require(input.readInt() == VERSION) { "Unsupported cloud catalog version" }
val count = input.readInt()
require(count in 0..MAX_ITEMS) { "Invalid cloud catalog item count" }
buildList(count) {
repeat(count) {
add(
CloudMediaItem(
webDavHref = input.readText(),
remotePath = input.readText(),
mimeType = input.readText(),
size = input.readLong(),
modifiedAt = input.readLong(),
etag = input.readText(),
spaceId = input.readText().takeIf(String::isNotBlank),
localPreviewPath = input.readText().takeIf(String::isNotBlank),
)
)
}
}
}
private fun readLegacy(file: File): List<CloudMediaItem>? = runCatching {
if (!file.isFile) return null
val json = GZIPInputStream(BufferedInputStream(FileInputStream(file))).bufferedReader().use { it.readText() }
val array = JSONArray(json)
buildList(array.length()) {
for (index in 0 until array.length()) {
val value = array.getJSONObject(index)
add(
CloudMediaItem(
webDavHref = value.optString(KEY_HREF),
remotePath = value.getString(KEY_PATH),
mimeType = value.getString(KEY_MIME),
size = value.optLong(KEY_SIZE),
modifiedAt = value.optLong(KEY_MODIFIED),
etag = value.optString(KEY_ETAG),
spaceId = value.optString(KEY_SPACE).takeIf(String::isNotBlank),
)
)
}
}
}.getOrNull()
private fun DataOutputStream.writeText(value: String) {
val bytes = value.toByteArray(StandardCharsets.UTF_8)
require(bytes.size <= MAX_TEXT_BYTES) { "Cloud catalog value is too large" }
writeInt(bytes.size)
write(bytes)
}
private fun DataInputStream.readText(): String {
val length = readInt()
require(length in 0..MAX_TEXT_BYTES) { "Invalid cloud catalog value length" }
val bytes = ByteArray(length)
readFully(bytes)
return String(bytes, StandardCharsets.UTF_8)
}
private fun fileFor(accountName: String): File =
File(directory, "${accountName.hashCode().toUInt().toString(16)}.bin")
private fun legacyFileFor(accountName: String): File =
File(directory, "${accountName.hashCode().toUInt().toString(16)}.json.gz")
companion object {
private const val DIRECTORY = "cloud-media-catalog-v1"
private const val MAGIC = 0x51534643
private const val VERSION = 1
private const val MAX_ITEMS = 1_000_000
private const val MAX_TEXT_BYTES = 4 * 1024 * 1024
private const val KEY_HREF = "href"
private const val KEY_PATH = "path"
private const val KEY_MIME = "mime"
private const val KEY_SIZE = "size"
private const val KEY_MODIFIED = "modified"
private const val KEY_ETAG = "etag"
private const val KEY_SPACE = "space"
}
}
@@ -6,152 +6,722 @@
package eu.qsfera.android.presentation.cloud package eu.qsfera.android.presentation.cloud
import android.accounts.Account import android.accounts.Account
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.content.FileProvider
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import coil.dispose
import coil.load import coil.load
import coil.request.CachePolicy
import coil.size.Scale import coil.size.Scale
import com.github.chrisbanes.photoview.PhotoView import com.github.chrisbanes.photoview.PhotoView
import eu.qsfera.android.MainApp import eu.qsfera.android.MainApp
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.domain.availableoffline.usecases.SetFilesAsAvailableOfflineUseCase
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.presentation.authentication.AccountUtils import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.sharing.ShareActivity
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.ui.activity.FileActivity
import eu.qsfera.android.ui.activity.FolderPickerActivity
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.getActionBarSize
import com.google.android.material.bottomsheet.BottomSheetDialog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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
import java.io.File
import java.io.FileOutputStream
import java.text.DateFormat
import java.util.Date
class CloudMediaPreviewActivity : AppCompatActivity() { class CloudMediaPreviewActivity : AppCompatActivity() {
private val clientManager: ClientManager by inject() private val clientManager: ClientManager by inject()
private var player: ExoPlayer? = null private val fileRepository: FileRepository by inject()
private val spacesRepository: SpacesRepository by inject()
private val setFilesAsAvailableOffline: SetFilesAsAvailableOfflineUseCase by inject()
private lateinit var previewAdapter: PreviewAdapter
private lateinit var pager: ViewPager2
private lateinit var account: Account
private var previewItems: List<CloudMediaItem> = emptyList()
private var pendingPickerAction: PickerAction? = null
private val folderPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val target = result.data?.getParcelableExtra<OCFile>(FolderPickerActivity.EXTRA_FOLDER) ?: return@registerForActivityResult
val action = pendingPickerAction ?: return@registerForActivityResult
pendingPickerAction = null
performFolderOperation(currentMedia(), target, action)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
)
setContentView(R.layout.activity_cloud_media_preview) setContentView(R.layout.activity_cloud_media_preview)
val media = mediaExtra() ?: run { val anchor = mediaExtra() ?: run {
finish() finish()
return return
} }
val snapshot = CloudMediaPreviewSession.snapshot(anchor)
findViewById<Toolbar>(R.id.cloud_preview_toolbar).also { toolbar -> previewItems = snapshot.items
toolbar.title = media.name val toolbar = findViewById<Toolbar>(R.id.cloud_preview_toolbar).also {
setSupportActionBar(toolbar) setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
toolbar.setNavigationOnClickListener { finish() } it.setNavigationOnClickListener { finish() }
} }
account = AccountUtils.getCurrentQSferaAccount(this)
if (media.isVideo) showVideo(media) else showImage(media) previewAdapter = PreviewAdapter(snapshot.items, account)
} pager = findViewById<ViewPager2>(R.id.cloud_preview_pager).apply {
adapter = previewAdapter
private fun showImage(media: CloudMediaItem) { offscreenPageLimit = 1
val account = AccountUtils.getCurrentQSferaAccount(this) registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply { override fun onPageSelected(position: Int) {
scaleType = ImageView.ScaleType.FIT_CENTER toolbar.title = snapshot.items[position].name
visibility = View.VISIBLE previewAdapter.setActivePosition(position)
} invalidateOptionsMenu()
val progress = findViewById<ProgressBar>(R.id.cloud_preview_progress)
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
photo.load(previewUri(media, account, 2560, 2560), loader) {
scale(Scale.FIT)
crossfade(true)
listener(
onSuccess = { _, _ -> progress.visibility = View.GONE },
onError = { _, _ -> loadOriginalFallback(photo, progress, media, account) },
)
}
}
private fun loadOriginalFallback(
photo: PhotoView,
progress: ProgressBar,
media: CloudMediaItem,
account: Account,
) {
val originalUri = runCatching { contentUri(media, account) }.getOrNull()
if (originalUri == null) {
progress.visibility = View.GONE
return
}
photo.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
scale(Scale.FIT)
memoryCacheKey("$originalUri#${media.etag}")
diskCacheKey("$originalUri#${media.etag}")
crossfade(true)
listener(
onSuccess = { _, _ -> progress.visibility = View.GONE },
onError = { _, _ -> progress.visibility = View.GONE },
)
}
}
@OptIn(UnstableApi::class)
private fun showVideo(media: CloudMediaItem) {
val account = AccountUtils.getCurrentQSferaAccount(this)
val playerView = findViewById<PlayerView>(R.id.cloud_preview_player).apply { visibility = View.VISIBLE }
val progress = findViewById<ProgressBar>(R.id.cloud_preview_progress)
lifecycleScope.launch {
val prepared = runCatching {
withContext(Dispatchers.IO) {
val client = clientManager.getClientForCoilThumbnails(account.name)
val contentUrl = contentUri(media, account)
contentUrl to client.credentials?.headerAuth.orEmpty()
} }
}.getOrNull() ?: run { })
progress.visibility = View.GONE setCurrentItem(snapshot.position, false)
post {
toolbar.title = snapshot.items[currentItem].name
previewAdapter.setActivePosition(currentItem)
}
}
findViewById<View>(R.id.cloud_preview_share).setOnClickListener { shareCurrent() }
findViewById<View>(R.id.cloud_preview_info).setOnClickListener { showInformation() }
findViewById<View>(R.id.cloud_preview_edit).setOnClickListener { openCurrent(Intent.ACTION_EDIT) }
findViewById<View>(R.id.cloud_preview_delete).setOnClickListener { confirmDeleteCurrent() }
applySystemBarInsets()
}
private fun applySystemBarInsets() {
val toolbar = findViewById<View>(R.id.cloud_preview_toolbar)
val bottomActions = findViewById<View>(R.id.cloud_preview_bottom_actions)
val actionBarHeight = getActionBarSize()
val actionPanelHeight = resources.getDimensionPixelSize(R.dimen.cloud_preview_bottom_actions_height)
enableEdgeToEdgePostSetContentView { insets ->
toolbar.updatePadding(top = insets.top)
toolbar.updateLayoutParams { height = actionBarHeight + insets.top }
bottomActions.updatePadding(bottom = insets.bottom)
bottomActions.updateLayoutParams { height = actionPanelHeight + insets.bottom }
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.cloud_media_preview_menu, menu)
updateFavoriteMenu(menu.findItem(R.id.cloud_preview_favorite))
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
updateFavoriteMenu(menu.findItem(R.id.cloud_preview_favorite))
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.cloud_preview_favorite -> {
toggleFavorite()
true
}
R.id.cloud_preview_more -> {
showFileActions()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun currentMedia(): CloudMediaItem = previewItems[pager.currentItem]
private fun showFileActions() {
val dialog = BottomSheetDialog(this)
val content = layoutInflater.inflate(R.layout.sheet_cloud_preview_actions, findViewById(android.R.id.content), false)
val list = content.findViewById<ViewGroup>(R.id.cloud_preview_action_list)
fun add(title: Int, icon: Int, action: () -> Unit) {
val row = layoutInflater.inflate(R.layout.item_cloud_preview_action, list, false)
row.findViewById<ImageView>(R.id.cloud_preview_action_icon).setImageResource(icon)
row.findViewById<TextView>(R.id.cloud_preview_action_title).setText(title)
row.setOnClickListener {
dialog.dismiss()
action()
}
list.addView(row)
}
add(R.string.cloud_preview_add_album, R.drawable.ic_bottom_nav_albums) { startFolderPicker(PickerAction.COPY) }
add(R.string.cloud_preview_add_offline, R.drawable.ic_available_offline) { addCurrentOffline() }
add(R.string.cloud_preview_download, R.drawable.ic_baseline_download_grey) {
saveCurrentToDevice(Environment.DIRECTORY_DOWNLOADS)
}
add(R.string.cloud_preview_save_device, R.drawable.ic_baseline_download_grey) {
saveCurrentToDevice(Environment.DIRECTORY_PICTURES)
}
add(R.string.cloud_preview_share_link, R.drawable.ic_shared_by_link) { openShareLink() }
add(R.string.cloud_preview_open_with, R.drawable.ic_open_in_app) { openCurrent(Intent.ACTION_VIEW) }
add(R.string.cloud_preview_use_as, R.drawable.ic_cloud_edit) { openCurrent(Intent.ACTION_ATTACH_DATA) }
add(R.string.cloud_preview_move, R.drawable.ic_action_move) { startFolderPicker(PickerAction.MOVE) }
add(R.string.cloud_preview_copy, R.drawable.ic_action_copy) { startFolderPicker(PickerAction.COPY) }
add(R.string.cloud_preview_rename, R.drawable.ic_cloud_edit) { renameCurrent() }
dialog.setContentView(content)
dialog.show()
}
private fun shareCurrent() {
val media = currentMedia()
lifecycleScope.launch {
val file = runCatching { downloadToCache(media) }.getOrElse {
showActionError()
return@launch return@launch
} }
val dataSourceFactory = DefaultHttpDataSource.Factory() val uri = shareUri(file)
.setUserAgent(MainApp.userAgent) val intent = Intent(Intent.ACTION_SEND).apply {
.setDefaultRequestProperties(mapOf("Authorization" to prepared.second)) type = media.mimeType
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) putExtra(Intent.EXTRA_STREAM, uri)
.createMediaSource(MediaItem.fromUri(prepared.first)) clipData = ClipData.newUri(contentResolver, media.name, uri)
player = ExoPlayer.Builder(this@CloudMediaPreviewActivity).build().also { exoPlayer -> addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
playerView.player = exoPlayer }
exoPlayer.addListener(object : Player.Listener { startActivity(Intent.createChooser(intent, getString(R.string.cloud_preview_share)))
override fun onPlaybackStateChanged(playbackState: Int) { }
progress.visibility = if (playbackState == Player.STATE_BUFFERING) View.VISIBLE else View.GONE }
}
}) private fun openCurrent(action: String) {
exoPlayer.setMediaSource(mediaSource) val media = currentMedia()
exoPlayer.prepare() lifecycleScope.launch {
exoPlayer.playWhenReady = true val file = runCatching { downloadToCache(media) }.getOrElse {
showActionError()
return@launch
}
val uri = shareUri(file)
val intent = Intent(action).apply {
setDataAndType(uri, media.mimeType)
clipData = ClipData.newUri(contentResolver, media.name, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
try {
startActivity(Intent.createChooser(intent, getString(R.string.cloud_preview_open_with)))
} catch (_: ActivityNotFoundException) {
showActionError()
} }
} }
} }
private fun previewUri(media: CloudMediaItem, account: Account, width: Int, height: Int): String = private fun showInformation() {
val media = currentMedia()
val date = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT).format(Date(media.modifiedAt))
val message = buildString {
append(media.name)
append("\n\n")
append(Formatter.formatFileSize(this@CloudMediaPreviewActivity, media.size))
append("\n")
append(date)
append("\n\n")
append(media.parentPath)
append("\n")
append(media.mimeType)
}
AlertDialog.Builder(this)
.setTitle(R.string.cloud_preview_information)
.setMessage(message)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun confirmDeleteCurrent() {
val media = currentMedia()
AlertDialog.Builder(this)
.setMessage(getString(R.string.confirmation_remove_file_alert, media.name))
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.cloud_selection_delete) { _, _ -> deleteCurrent(media) }
.show()
}
private fun deleteCurrent(media: CloudMediaItem) {
lifecycleScope.launch {
val result = runCatching {
withContext(Dispatchers.IO) {
executeRemoteOperation {
clientManager.getFileService(account.name).removeFile(
remotePath = media.remotePath,
spaceWebDavUrl = spacesRepository.getWebDavUrlForSpace(account.name, media.spaceId),
)
}
}
}
if (result.isSuccess) finishWithResult(RESULT_DELETED, media) else showActionError()
}
}
private fun renameCurrent() {
val media = currentMedia()
val input = EditText(this).apply {
setText(media.name)
setSelection(text.length)
}
AlertDialog.Builder(this)
.setTitle(R.string.rename_dialog_title)
.setView(input)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
val name = input.text.toString().trim()
if (name.isNotBlank() && name != media.name) performRename(media, name)
}
.show()
}
private fun performRename(media: CloudMediaItem, newName: String) {
lifecycleScope.launch {
val result = runCatching {
withContext(Dispatchers.IO) {
executeRemoteOperation {
clientManager.getFileService(account.name).renameFile(
oldName = media.name,
oldRemotePath = media.remotePath,
newName = newName,
isFolder = false,
spaceWebDavUrl = spacesRepository.getWebDavUrlForSpace(account.name, media.spaceId),
)
}
}
}
if (result.isSuccess) finishWithResult(RESULT_CHANGED, media) else showActionError()
}
}
private fun startFolderPicker(action: PickerAction) {
pendingPickerAction = action
folderPicker.launch(
Intent(this, FolderPickerActivity::class.java).apply {
putParcelableArrayListExtra(FolderPickerActivity.EXTRA_FILES, arrayListOf(currentMedia().toOCFile(account.name)))
putExtra(
FolderPickerActivity.EXTRA_PICKER_MODE,
if (action == PickerAction.MOVE) FolderPickerActivity.PickerMode.MOVE else FolderPickerActivity.PickerMode.COPY,
)
}
)
}
private fun performFolderOperation(media: CloudMediaItem, target: OCFile, action: PickerAction) {
lifecycleScope.launch {
val result = runCatching {
withContext(Dispatchers.IO) {
val targetPath = target.remotePath.trimEnd('/') + "/" + media.name
val service = clientManager.getFileService(account.name)
if (action == PickerAction.COPY) {
executeRemoteOperation {
service.copyFile(
sourceRemotePath = media.remotePath,
targetRemotePath = targetPath,
sourceSpaceWebDavUrl = spacesRepository.getWebDavUrlForSpace(account.name, media.spaceId),
targetSpaceWebDavUrl = spacesRepository.getWebDavUrlForSpace(account.name, target.spaceId),
replace = false,
)
}
} else {
executeRemoteOperation {
service.moveFile(
sourceRemotePath = media.remotePath,
targetRemotePath = targetPath,
spaceWebDavUrl = spacesRepository.getWebDavUrlForSpace(account.name, media.spaceId),
replace = false,
)
}
}
}
}
if (result.isSuccess) {
if (action == PickerAction.MOVE) finishWithResult(RESULT_DELETED, media)
} else {
showActionError()
}
}
}
private fun addCurrentOffline() {
val media = currentMedia()
lifecycleScope.launch {
val result = runCatching {
withContext(Dispatchers.IO) {
val stored = fileRepository.getFileByRemotePath(media.remotePath, account.name, media.spaceId)
?: error("Media is not present in the local file catalog")
setFilesAsAvailableOffline(SetFilesAsAvailableOfflineUseCase.Params(listOf(stored)))
}
}
if (result.isSuccess) {
Toast.makeText(this@CloudMediaPreviewActivity, R.string.confirmation_set_available_offline, Toast.LENGTH_SHORT).show()
} else {
showActionError()
}
}
}
private fun openShareLink() {
val media = currentMedia()
lifecycleScope.launch {
val stored = withContext(Dispatchers.IO) {
fileRepository.getFileByRemotePath(media.remotePath, account.name, media.spaceId)
}
if (stored == null) {
showActionError()
return@launch
}
startActivity(
Intent(this@CloudMediaPreviewActivity, ShareActivity::class.java)
.putExtra(FileActivity.EXTRA_FILE, stored)
.putExtra(FileActivity.EXTRA_ACCOUNT, account)
)
}
}
private fun saveCurrentToDevice(relativeDirectory: String) {
val media = currentMedia()
lifecycleScope.launch {
val result = runCatching {
val cached = downloadToCache(media)
withContext(Dispatchers.IO) {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, media.name)
put(MediaStore.MediaColumns.MIME_TYPE, media.mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, "$relativeDirectory/КуСфера")
}
}
val collection = if (media.isVideo) {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val uri = contentResolver.insert(collection, values) ?: error("MediaStore insert failed")
contentResolver.openOutputStream(uri)?.use { output -> cached.inputStream().use { it.copyTo(output) } }
?: error("MediaStore output failed")
uri
}
}
if (result.isSuccess) {
Toast.makeText(this@CloudMediaPreviewActivity, R.string.cloud_preview_saved, Toast.LENGTH_SHORT).show()
} else {
showActionError()
}
}
}
private suspend fun downloadToCache(media: CloudMediaItem): File = withContext(Dispatchers.IO) {
val directory = File(externalCacheDir ?: cacheDir, "cloud-preview-actions").apply { mkdirs() }
val safeName = media.name.replace(Regex("[\\\\/:*?\"<>|]"), "_")
val destination = File(directory, safeName)
if (destination.isFile && destination.length() == media.size && media.size > 0) return@withContext destination
val client = clientManager.getClientForCoilThumbnails(account.name)
val request = okhttp3.Request.Builder().url(contentUri(media, account)).apply {
client.credentials?.headerAuth?.takeIf(String::isNotBlank)?.let { header("Authorization", it) }
}.build()
client.okHttpClient.newCall(request).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 shareUri(file: File): Uri = FileProvider.getUriForFile(this, getString(R.string.file_provider_authority), file)
private fun favoriteKeys(): MutableSet<String> = getSharedPreferences(FAVORITES_PREFERENCES, MODE_PRIVATE)
.getStringSet(FAVORITES_KEY, emptySet())
.orEmpty()
.toMutableSet()
private fun toggleFavorite() {
val key = currentMedia().selectionKey
val keys = favoriteKeys()
if (!keys.add(key)) keys.remove(key)
getSharedPreferences(FAVORITES_PREFERENCES, MODE_PRIVATE).edit().putStringSet(FAVORITES_KEY, keys).apply()
invalidateOptionsMenu()
}
private fun updateFavoriteMenu(item: MenuItem?) {
item ?: return
val favorite = currentMedia().selectionKey in favoriteKeys()
item.setIcon(if (favorite) R.drawable.ic_cloud_favorite_filled else R.drawable.ic_cloud_favorite_border)
item.setTitle(if (favorite) R.string.cloud_preview_remove_favorite else R.string.cloud_preview_add_favorite)
}
private fun showActionError() {
Toast.makeText(this, R.string.cloud_preview_action_error, Toast.LENGTH_LONG).show()
}
private fun finishWithResult(action: String, media: CloudMediaItem) {
setResult(
RESULT_OK,
Intent()
.putExtra(EXTRA_RESULT_ACTION, action)
.putExtra(EXTRA_RESULT_SELECTION_KEY, media.selectionKey),
)
finish()
}
override fun onDestroy() {
if (this::previewAdapter.isInitialized) previewAdapter.release()
super.onDestroy()
}
private inner class PreviewAdapter(
private val media: List<CloudMediaItem>,
private val account: Account,
) : RecyclerView.Adapter<PreviewHolder>() {
private val holders = mutableSetOf<PreviewHolder>()
private var activePosition = RecyclerView.NO_POSITION
init {
setHasStableIds(true)
}
override fun getItemCount(): Int = media.size
override fun getItemId(position: Int): Long = stableCloudId(media[position].selectionKey)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewHolder = PreviewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.item_cloud_media_preview_page, parent, false)
).also(holders::add)
override fun onBindViewHolder(holder: PreviewHolder, position: Int) {
holder.bind(media[position], position == activePosition, account)
}
override fun onViewRecycled(holder: PreviewHolder) {
holder.clear()
holders.remove(holder)
super.onViewRecycled(holder)
}
fun setActivePosition(position: Int) {
if (activePosition == position) return
val previous = activePosition
activePosition = position
if (previous != RecyclerView.NO_POSITION) notifyItemChanged(previous)
notifyItemChanged(position)
}
fun release() {
holders.toList().forEach(PreviewHolder::clear)
holders.clear()
}
}
private inner class PreviewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val photo = view.findViewById<PhotoView>(R.id.cloud_preview_photo)
private val playerView = view.findViewById<PlayerView>(R.id.cloud_preview_player)
private val progress = view.findViewById<ProgressBar>(R.id.cloud_preview_progress)
private var player: ExoPlayer? = null
private var prepareJob: Job? = null
private var boundKey: String? = null
fun bind(media: CloudMediaItem, active: Boolean, account: Account) {
clear()
boundKey = media.selectionKey
progress.visibility = View.VISIBLE
if (media.isVideo) bindVideo(media, active, account) else bindImage(media, account)
}
private fun bindImage(media: CloudMediaItem, account: Account) {
playerView.visibility = View.GONE
photo.apply {
scaleType = ImageView.ScaleType.FIT_CENTER
visibility = View.VISIBLE
}
val key = boundKey
val localPreview = media.localPreviewPath?.let(::File)?.takeIf(File::isFile)
if (localPreview != null) {
photo.load(localPreview, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
scale(Scale.FIT)
listener(
onSuccess = { _, _ -> if (boundKey == key) progress.visibility = View.GONE },
onError = { _, _ -> if (boundKey == key) bindRemoteImage(media, account) },
)
}
return
}
bindRemoteImage(media, account)
}
private fun bindRemoteImage(media: CloudMediaItem, account: Account) {
val key = boundKey
val preview = previewUri(media, account)
photo.load(preview, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
scale(Scale.FIT)
listener(
onSuccess = { _, _ -> if (boundKey == key) progress.visibility = View.GONE },
onError = { _, _ -> if (boundKey == key) loadCachedTile(media, account) },
)
}
}
private fun loadCachedTile(media: CloudMediaItem, account: Account) {
val key = boundKey
val tileUri = runCatching { CloudMediaPreviewRequests.tileUri(media, account) }.getOrNull()
if (tileUri == null) {
loadOriginal(media, account)
return
}
val cacheKey = CloudMediaPreviewRequests.tileCacheKey(media, account)
photo.load(tileUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
scale(Scale.FIT)
networkCachePolicy(CachePolicy.DISABLED)
memoryCacheKey(cacheKey)
diskCacheKey(cacheKey)
listener(
onSuccess = { _, _ -> if (boundKey == key) progress.visibility = View.GONE },
onError = { _, _ -> if (boundKey == key) loadOriginal(media, account) },
)
}
}
private fun loadOriginal(media: CloudMediaItem, account: Account) {
val key = boundKey
val original = runCatching { contentUri(media, account) }.getOrNull() ?: run {
progress.visibility = View.GONE
return
}
photo.load(original, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
scale(Scale.FIT)
memoryCacheKey("cloud-full|${account.name}|${media.selectionKey}|${media.cacheRevision}")
diskCacheKey("cloud-full|${account.name}|${media.selectionKey}|${media.cacheRevision}")
listener(
onSuccess = { _, _ -> if (boundKey == key) progress.visibility = View.GONE },
onError = { _, _ -> if (boundKey == key) progress.visibility = View.GONE },
)
}
}
@OptIn(UnstableApi::class)
private fun bindVideo(media: CloudMediaItem, active: Boolean, account: Account) {
photo.visibility = View.GONE
playerView.visibility = View.VISIBLE
val key = boundKey
val localPreview = media.localPreviewPath?.let(::File)?.takeIf(File::isFile)
if (localPreview != null) {
player = ExoPlayer.Builder(this@CloudMediaPreviewActivity).build().also { exoPlayer ->
playerView.player = exoPlayer
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY && boundKey == key) {
progress.visibility = View.GONE
}
}
})
exoPlayer.setMediaItem(MediaItem.fromUri(Uri.fromFile(localPreview)))
exoPlayer.prepare()
exoPlayer.playWhenReady = active
}
return
}
prepareJob = lifecycleScope.launch {
val prepared = runCatching {
withContext(Dispatchers.IO) {
val client = clientManager.getClientForCoilThumbnails(account.name)
contentUri(media, account) to client.credentials?.headerAuth.orEmpty()
}
}.getOrNull() ?: run {
progress.visibility = View.GONE
return@launch
}
if (boundKey != key) return@launch
val dataSource = DefaultHttpDataSource.Factory().setUserAgent(MainApp.userAgent)
if (prepared.second.isNotBlank()) {
dataSource.setDefaultRequestProperties(mapOf("Authorization" to prepared.second))
}
val source = ProgressiveMediaSource.Factory(dataSource)
.createMediaSource(MediaItem.fromUri(prepared.first))
player = ExoPlayer.Builder(this@CloudMediaPreviewActivity).build().also { exoPlayer ->
playerView.player = exoPlayer
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (boundKey == key) {
progress.visibility = if (playbackState == Player.STATE_BUFFERING) View.VISIBLE else View.GONE
}
}
override fun onPlayerError(error: PlaybackException) {
if (boundKey == key) progress.visibility = View.GONE
}
})
exoPlayer.setMediaSource(source)
exoPlayer.prepare()
exoPlayer.playWhenReady = active
}
}
}
fun clear() {
boundKey = null
prepareJob?.cancel()
prepareJob = null
photo.dispose()
playerView.player = null
player?.release()
player = null
}
}
private fun previewUri(media: CloudMediaItem, account: Account): String =
if (media.webDavHref.isNotBlank()) { if (media.webDavHref.isNotBlank()) {
ThumbnailsRequester.getPreviewUriForWebDavHref( ThumbnailsRequester.getPreviewUriForWebDavHref(
media.webDavHref, media.webDavHref,
account, account,
media.etag.ifBlank { media.modifiedAt.toString() }, media.cacheRevision,
width, FULL_PREVIEW_SIZE,
height, FULL_PREVIEW_SIZE,
ThumbnailsRequester.PreviewProcessor.FIT, ThumbnailsRequester.PreviewProcessor.FIT,
) )
} else { } else {
ThumbnailsRequester.getPreviewUriForFile( ThumbnailsRequester.getPreviewUriForFile(
media.toOCFile(account.name), media.toOCFile(account.name),
account, account,
media.etag.ifBlank { media.modifiedAt.toString() }, media.cacheRevision,
width, FULL_PREVIEW_SIZE,
height, FULL_PREVIEW_SIZE,
ThumbnailsRequester.PreviewProcessor.FIT, ThumbnailsRequester.PreviewProcessor.FIT,
) )
} }
@@ -170,16 +740,19 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
intent.getParcelableExtra(EXTRA_MEDIA) intent.getParcelableExtra(EXTRA_MEDIA)
} }
override fun onDestroy() {
player?.release()
player = null
super.onDestroy()
}
companion object { companion object {
private const val EXTRA_MEDIA = "cloud_media" private const val EXTRA_MEDIA = "cloud_media"
private const val FULL_PREVIEW_SIZE = 2560
private const val FAVORITES_PREFERENCES = "cloud_preview_favorites"
private const val FAVORITES_KEY = "items"
internal const val EXTRA_RESULT_ACTION = "cloud_media_result_action"
internal const val EXTRA_RESULT_SELECTION_KEY = "cloud_media_result_selection_key"
internal const val RESULT_DELETED = "deleted"
internal const val RESULT_CHANGED = "changed"
fun createIntent(context: Context, media: CloudMediaItem): Intent = fun createIntent(context: Context, media: CloudMediaItem): Intent =
Intent(context, CloudMediaPreviewActivity::class.java).putExtra(EXTRA_MEDIA, media) Intent(context, CloudMediaPreviewActivity::class.java).putExtra(EXTRA_MEDIA, media)
} }
private enum class PickerAction { MOVE, COPY }
} }
@@ -0,0 +1,63 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.accounts.Account
import android.content.Context
import coil.request.ImageRequest
import coil.size.Scale
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
/** Shared request identity for every place that displays a media tile. */
internal object CloudMediaPreviewRequests {
const val TILE_SIZE = 512
fun tileUri(item: CloudMediaItem, account: Account): String =
if (item.webDavHref.isNotBlank()) {
ThumbnailsRequester.getPreviewUriForWebDavHref(
item.webDavHref,
account,
item.cacheRevision,
TILE_SIZE,
TILE_SIZE,
ThumbnailsRequester.PreviewProcessor.FIT,
)
} else {
ThumbnailsRequester.getPreviewUriForFile(
item.toOCFile(account.name),
account,
item.cacheRevision,
TILE_SIZE,
TILE_SIZE,
ThumbnailsRequester.PreviewProcessor.FIT,
)
}
fun tileCacheKey(item: CloudMediaItem, account: Account): String = tileCacheKey(item, account.name)
internal fun tileCacheKey(item: CloudMediaItem, accountName: String): String = buildString {
// Layout changes must never invalidate the downloaded preview bytes.
append("cloud-tile-v3-fit|")
append(accountName)
append('|')
append(item.spaceId.orEmpty())
append('|')
append(item.remotePath)
append('|')
append(item.cacheRevision)
}
fun tileRequest(context: Context, item: CloudMediaItem, account: Account): ImageRequest {
val cacheKey = tileCacheKey(item, account)
return ImageRequest.Builder(context.applicationContext)
.data(tileUri(item, account))
.memoryCacheKey(cacheKey)
.diskCacheKey(cacheKey)
.scale(Scale.FIT)
.size(TILE_SIZE, TILE_SIZE)
.build()
}
}
@@ -0,0 +1,24 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
/** Avoids putting a potentially huge photo catalog into an Android Intent. */
internal object CloudMediaPreviewSession {
@Volatile
private var items: List<CloudMediaItem> = emptyList()
fun update(media: List<CloudMediaItem>) {
items = media.toList()
}
fun snapshot(anchor: CloudMediaItem): Snapshot {
val current = items
val position = current.indexOfFirst { it.selectionKey == anchor.selectionKey }
return if (position >= 0) Snapshot(current, position) else Snapshot(listOf(anchor), 0)
}
data class Snapshot(val items: List<CloudMediaItem>, val position: Int)
}
@@ -9,6 +9,15 @@ import android.os.Parcelable
import eu.qsfera.android.domain.files.model.OCFile import eu.qsfera.android.domain.files.model.OCFile
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
/** Deterministic 64-bit FNV-1a id; avoids RecyclerView collisions from String.hashCode(). */
internal fun stableCloudId(value: String): Long {
var hash = -0x340d631b7bdddcdbL
value.forEach { character ->
hash = (hash xor character.code.toLong()) * 0x100000001b3L
}
return hash
}
@Parcelize @Parcelize
data class CloudMediaItem( data class CloudMediaItem(
val webDavHref: String = "", val webDavHref: String = "",
@@ -18,6 +27,7 @@ data class CloudMediaItem(
val modifiedAt: Long, val modifiedAt: Long,
val etag: String = "", val etag: String = "",
val spaceId: String? = null, val spaceId: String? = null,
val localPreviewPath: String? = null,
) : Parcelable { ) : Parcelable {
val name: String get() = remotePath.trimEnd('/').substringAfterLast('/') val name: String get() = remotePath.trimEnd('/').substringAfterLast('/')
val parentPath: String get() = remotePath.substringBeforeLast('/', missingDelimiterValue = "/").ifBlank { "/" } val parentPath: String get() = remotePath.substringBeforeLast('/', missingDelimiterValue = "/").ifBlank { "/" }
@@ -25,7 +35,9 @@ data class CloudMediaItem(
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 }}" val selectionKey: String get() = "${spaceId.orEmpty()}::${webDavHref.ifBlank { remotePath }}"
val contentKey: String get() = "${spaceId.orEmpty()}::$remotePath"
val tombstoneKey: String get() = "$selectionKey::${etag.ifBlank { "no-etag" }}" val tombstoneKey: String get() = "$selectionKey::${etag.ifBlank { "no-etag" }}"
val cacheRevision: String get() = etag.ifBlank { modifiedAt.toString() }
fun toOCFile(owner: String): OCFile = OCFile( fun toOCFile(owner: String): OCFile = OCFile(
owner = owner, owner = owner,
@@ -151,6 +163,8 @@ internal sealed interface CloudHubRow {
val date: String, val date: String,
val items: List<CloudMediaItem>, val items: List<CloudMediaItem>,
val mirrored: Boolean, val mirrored: Boolean,
val maxItemsPerRow: Int,
val autoWrap: Boolean,
) : CloudHubRow ) : CloudHubRow
data class Storage(val item: CloudStorageItem) : CloudHubRow data class Storage(val item: CloudStorageItem) : CloudHubRow
data class Album(val path: String, val title: String, val count: Int, val cover: CloudMediaItem?) : CloudHubRow data class Album(val path: String, val title: String, val count: Int, val cover: CloudMediaItem?) : CloudHubRow
@@ -11,9 +11,9 @@ import android.view.ViewGroup
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Lays out up to six media tiles as a compact justified mosaic. The six-item geometry mirrors * Aspect-aware justified media rows. Tile bounds follow the decoded photo/video proportions,
* the day group visible in Yandex Disk: four tiles in a narrow column and two larger tiles in a * allowing CENTER_CROP to fill every tile without visibly cropping content or adding letterbox
* wide column. Smaller groups use dedicated geometries instead of leaving empty holes. * frames. Smart groups may wrap into several rows; the other density modes use one row.
*/ */
internal class CloudSmartPhotoLayout @JvmOverloads constructor( internal class CloudSmartPhotoLayout @JvmOverloads constructor(
context: Context, context: Context,
@@ -36,12 +36,55 @@ internal class CloudSmartPhotoLayout @JvmOverloads constructor(
} }
} }
var maxItemsPerRow: Int = MAX_MEDIA
set(value) {
val normalized = value.coerceIn(1, MAX_MEDIA)
if (field != normalized) {
field = normalized
requestLayout()
}
}
var autoWrap: Boolean = false
set(value) {
if (field != value) {
field = value
requestLayout()
}
}
var aspectRatios: List<Float> = emptyList()
set(value) {
val normalized = value.take(MAX_MEDIA).map(::normalizeRatio)
if (field != normalized) {
field = normalized
requestLayout()
}
}
private var tileBounds: List<TileBounds> = emptyList() private var tileBounds: List<TileBounds> = emptyList()
private val gap = resources.displayMetrics.density.roundToInt().coerceAtLeast(1) private val gap = resources.displayMetrics.density.roundToInt().coerceAtLeast(1)
fun updateAspectRatio(index: Int, ratio: Float) {
if (index !in 0 until mediaCount) return
val updated = MutableList(mediaCount) { position -> aspectRatios.getOrElse(position) { DEFAULT_RATIO } }
val normalized = normalizeRatio(ratio)
if (kotlin.math.abs(updated[index] - normalized) < MIN_RATIO_CHANGE) return
updated[index] = normalized
aspectRatios = updated
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec) val width = MeasureSpec.getSize(widthMeasureSpec)
tileBounds = geometry(width, mediaCount, mirrored, gap) val ratios = List(mediaCount) { index -> aspectRatios.getOrElse(index) { DEFAULT_RATIO } }
tileBounds = geometry(
width = width,
aspectRatios = ratios,
maxItemsPerRow = maxItemsPerRow,
autoWrap = autoWrap,
mirrored = mirrored,
gap = gap,
)
val desiredHeight = tileBounds.maxOfOrNull(TileBounds::bottom) ?: 0 val desiredHeight = tileBounds.maxOfOrNull(TileBounds::bottom) ?: 0
for (index in 0 until childCount) { for (index in 0 until childCount) {
@@ -59,10 +102,7 @@ internal class CloudSmartPhotoLayout @JvmOverloads constructor(
} }
} }
setMeasuredDimension( setMeasuredDimension(resolveSize(width, widthMeasureSpec), resolveSize(desiredHeight, heightMeasureSpec))
resolveSize(width, widthMeasureSpec),
resolveSize(desiredHeight, heightMeasureSpec),
)
} }
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
@@ -89,107 +129,77 @@ internal class CloudSmartPhotoLayout @JvmOverloads constructor(
companion object { companion object {
private const val MAX_MEDIA = 6 private const val MAX_MEDIA = 6
private const val DEFAULT_RATIO = 1f
private const val MIN_RATIO = 0.1f
private const val MAX_RATIO = 10f
private const val MIN_RATIO_CHANGE = 0.005f
private const val SMART_TARGET_HEIGHT_RATIO = 0.44f
internal fun isLargeSlot(count: Int, index: Int): Boolean = when (count.coerceIn(0, MAX_MEDIA)) { internal fun geometry(
1 -> index == 0 width: Int,
2 -> index <= 1 aspectRatios: List<Float>,
3 -> index == 0 maxItemsPerRow: Int,
4 -> index <= 3 autoWrap: Boolean,
5 -> index == 1 || index == 2 mirrored: Boolean,
6 -> index == 4 || index == 5 gap: Int,
else -> false ): List<TileBounds> {
} if (width <= 0 || aspectRatios.isEmpty()) return emptyList()
val ratios = aspectRatios.take(MAX_MEDIA).map(::normalizeRatio)
internal fun geometry(width: Int, count: Int, mirrored: Boolean, gap: Int): List<TileBounds> { val rowLimit = maxItemsPerRow.coerceIn(1, MAX_MEDIA)
if (width <= 0 || count <= 0) return emptyList() val rows = if (autoWrap) {
val normalizedCount = count.coerceAtMost(MAX_MEDIA) splitSmartRows(width, ratios, rowLimit)
val specs = when (normalizedCount) { } else {
1 -> GeometrySpec(rows = 1, heightRatio = 0.72f, cells = listOf(CellSpec(0, 0, 1, 1))) ratios.indices.chunked(rowLimit)
2 -> GeometrySpec(
columns = 2,
rows = 1,
heightRatio = 0.62f,
cells = listOf(CellSpec(0, 0, 1, 1), CellSpec(1, 0, 1, 1)),
)
3 -> GeometrySpec(
columns = 3,
rows = 2,
heightRatio = 0.66f,
cells = listOf(
CellSpec(0, 0, 2, 2),
CellSpec(2, 0, 1, 1),
CellSpec(2, 1, 1, 1),
),
)
4 -> GeometrySpec(
columns = 2,
rows = 2,
heightRatio = 1f,
cells = listOf(
CellSpec(0, 0, 1, 1),
CellSpec(1, 0, 1, 1),
CellSpec(0, 1, 1, 1),
CellSpec(1, 1, 1, 1),
),
)
5 -> GeometrySpec(
columns = 3,
rows = 4,
heightRatio = 1f,
cells = listOf(
CellSpec(0, 0, 1, 1),
CellSpec(0, 1, 1, 3),
CellSpec(1, 0, 2, 2),
CellSpec(1, 2, 1, 2),
CellSpec(2, 2, 1, 2),
),
)
else -> GeometrySpec(
columns = 3,
rows = 6,
heightRatio = 4f / 3f,
cells = listOf(
CellSpec(0, 0, 1, 1),
CellSpec(0, 1, 1, 2),
CellSpec(0, 3, 1, 2),
CellSpec(0, 5, 1, 1),
CellSpec(1, 0, 2, 2),
CellSpec(1, 2, 2, 4),
),
)
} }
val height = (width * specs.heightRatio).roundToInt() val result = MutableList(ratios.size) { TileBounds(0, 0, 0, 0) }
return specs.cells.map { cell -> var rowTop = 0
val rawLeft = cell.column * width / specs.columns
val rawRight = (cell.column + cell.columnSpan) * width / specs.columns rows.forEach { row ->
val rawTop = cell.row * height / specs.rows val usableWidth = (width - gap * (row.size - 1)).coerceAtLeast(row.size)
val rawBottom = (cell.row + cell.rowSpan) * height / specs.rows val ratioSum = row.sumOf { ratios[it].toDouble() }.toFloat().coerceAtLeast(MIN_RATIO)
val adjusted = TileBounds( val rowHeight = (usableWidth / ratioSum).roundToInt().coerceAtLeast(1)
left = rawLeft + if (cell.column > 0) gap else 0, var prefixRatio = 0f
top = rawTop + if (cell.row > 0) gap else 0, row.forEachIndexed { column, itemIndex ->
right = rawRight - if (cell.column + cell.columnSpan < specs.columns) gap else 0, val contentLeft = (usableWidth * prefixRatio / ratioSum).roundToInt()
bottom = rawBottom - if (cell.row + cell.rowSpan < specs.rows) gap else 0, prefixRatio += ratios[itemIndex]
) val contentRight = (usableWidth * prefixRatio / ratioSum).roundToInt()
if (mirrored) { val raw = TileBounds(
adjusted.copy(left = width - adjusted.right, right = width - adjusted.left) left = contentLeft + gap * column,
} else { top = rowTop,
adjusted right = contentRight + gap * column,
bottom = rowTop + rowHeight,
)
result[itemIndex] = if (mirrored) {
raw.copy(left = width - raw.right, right = width - raw.left)
} else {
raw
}
} }
rowTop += rowHeight + gap
} }
return result
} }
private data class GeometrySpec( private fun splitSmartRows(width: Int, ratios: List<Float>, rowLimit: Int): List<List<Int>> {
val columns: Int = 1, val targetHeight = width * SMART_TARGET_HEIGHT_RATIO
val rows: Int, val targetRatioSum = width / targetHeight
val heightRatio: Float, val rows = mutableListOf<List<Int>>()
val cells: List<CellSpec>, var index = 0
) while (index < ratios.size) {
val row = mutableListOf<Int>()
var ratioSum = 0f
while (index < ratios.size && row.size < rowLimit) {
row += index
ratioSum += ratios[index]
index++
if (row.size >= 2 && ratioSum >= targetRatioSum) break
}
rows += row
}
return rows
}
private data class CellSpec( private fun normalizeRatio(value: Float): Float =
val column: Int, if (value.isFinite() && value > 0f) value.coerceIn(MIN_RATIO, MAX_RATIO) else DEFAULT_RATIO
val row: Int,
val columnSpan: Int,
val rowSpan: Int,
)
} }
} }
@@ -50,11 +50,13 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import timber.log.Timber import timber.log.Timber
import java.io.File
import java.util.Locale import java.util.Locale
object ThumbnailsRequester : KoinComponent { object ThumbnailsRequester : KoinComponent {
enum class PreviewProcessor(val queryValue: String) { enum class PreviewProcessor(val queryValue: String) {
FIT("fit"), FIT("fit"),
THUMBNAIL("thumbnail"),
} }
private val clientManager: ClientManager by inject() private val clientManager: ClientManager by inject()
@@ -64,7 +66,12 @@ object ThumbnailsRequester : KoinComponent {
private const val SPACE_SPECIAL_PREVIEW_URI = "%s?scalingup=0&a=1&x=%d&y=%d&c=%s&preview=1" private const val SPACE_SPECIAL_PREVIEW_URI = "%s?scalingup=0&a=1&x=%d&y=%d&c=%s&preview=1"
private const val FILE_PREVIEW_URI = "%s/webdav%s?x=%d&y=%d&c=%s&preview=1" private const val FILE_PREVIEW_URI = "%s/webdav%s?x=%d&y=%d&c=%s&preview=1"
private const val THUMBNAIL_DISK_CACHE_SIZE: Long = 1024 * 1024 * 100 // 100MB // A private photo library can contain tens of thousands of media files. Keep enough
// content-addressed previews locally so changing the grid or reopening the app does not
// continuously evict and download the same thumbnails again.
private const val THUMBNAIL_DISK_CACHE_SIZE: Long = 8L * 1024 * 1024 * 1024 // 8 GiB
private const val PERSISTENT_THUMBNAIL_DIRECTORY = "cloud_media_previews_v1"
private const val LEGACY_THUMBNAIL_DIRECTORY = "thumbnails_coil_cache"
private const val AVATAR_HTTP_CACHE_SIZE: Long = 10L * 1024 * 1024 // 10MB private const val AVATAR_HTTP_CACHE_SIZE: Long = 10L * 1024 * 1024 // 10MB
private val thumbnailImageLoaders = ConcurrentHashMap<String, ImageLoader>() private val thumbnailImageLoaders = ConcurrentHashMap<String, ImageLoader>()
@@ -73,7 +80,7 @@ object ThumbnailsRequester : KoinComponent {
private val sharedDiskCache: DiskCache by lazy { private val sharedDiskCache: DiskCache by lazy {
DiskCache.Builder() DiskCache.Builder()
.directory(appContext.cacheDir.resolve("thumbnails_coil_cache")) .directory(persistentThumbnailDirectory())
.maxSizeBytes(THUMBNAIL_DISK_CACHE_SIZE) .maxSizeBytes(THUMBNAIL_DISK_CACHE_SIZE)
.build() .build()
} }
@@ -241,6 +248,23 @@ object ThumbnailsRequester : KoinComponent {
.build() .build()
} }
/**
* Media previews are user-visible offline data, not disposable HTTP scratch data. Keep them
* under filesDir so Android storage pressure does not evict the complete photo feed. Existing
* Coil entries are migrated atomically from the former cacheDir location on first use.
*/
private fun persistentThumbnailDirectory(): File {
val persistent = appContext.filesDir.resolve(PERSISTENT_THUMBNAIL_DIRECTORY)
if (persistent.isDirectory) return persistent
val legacy = appContext.cacheDir.resolve(LEGACY_THUMBNAIL_DIRECTORY)
if (legacy.isDirectory && legacy.renameTo(persistent)) return persistent
if (persistent.mkdirs() || persistent.isDirectory) return persistent
Timber.w("Could not create persistent thumbnail cache; retaining the legacy directory")
return legacy.apply { mkdirs() }
}
private fun buildAvatarImageLoader(account: Account): ImageLoader { private fun buildAvatarImageLoader(account: Account): ImageLoader {
val interceptor = CoilRequestHeaderInterceptor(clientManager, account.name) val interceptor = CoilRequestHeaderInterceptor(clientManager, account.name)
return ImageLoader(appContext).newBuilder() return ImageLoader(appContext).newBuilder()
@@ -27,6 +27,7 @@ import androidx.work.Data
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.WorkManager import androidx.work.WorkManager
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import eu.qsfera.android.domain.BaseUseCase import eu.qsfera.android.domain.BaseUseCase
@@ -38,15 +39,16 @@ import timber.log.Timber
class UploadFileFromSystemUseCase( class UploadFileFromSystemUseCase(
private val workManager: WorkManager private val workManager: WorkManager
) : BaseUseCase<Unit, UploadFileFromSystemUseCase.Params>() { ) : BaseUseCase<Operation, UploadFileFromSystemUseCase.Params>() {
override fun run(params: Params) { override fun run(params: Params): Operation {
val inputDataUploadFileFromFileSystemWorker = Data.Builder() val inputDataUploadFileFromFileSystemWorker = Data.Builder()
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName) .putString(UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName)
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR, params.behavior) .putString(UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR, params.behavior)
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH, params.localPath) .putString(UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH, params.localPath)
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH, params.uploadPath) .putString(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH, params.uploadPath)
.putLong(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID, params.uploadIdInStorageManager) .putLong(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID, params.uploadIdInStorageManager)
.putBoolean(UploadFileFromFileSystemWorker.KEY_PARAM_REMOVE_LOCAL, params.removeLocalAfterUpload)
.apply { .apply {
params.lastModifiedInSeconds?.let { params.lastModifiedInSeconds?.let {
putString(UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED, it) putString(UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED, it)
@@ -59,8 +61,10 @@ class UploadFileFromSystemUseCase(
} }
}.build() }.build()
val networkRequired = if (params.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(networkRequired)
.setRequiresCharging(params.chargingOnly)
.build() .build()
val uploadFileFromSystemWorker = OneTimeWorkRequestBuilder<UploadFileFromFileSystemWorker>() val uploadFileFromSystemWorker = OneTimeWorkRequestBuilder<UploadFileFromFileSystemWorker>()
@@ -79,7 +83,7 @@ class UploadFileFromSystemUseCase(
val uniqueWorkName = "upload_file_system_${params.uploadIdInStorageManager}" val uniqueWorkName = "upload_file_system_${params.uploadIdInStorageManager}"
val behavior = UploadBehavior.fromString(params.behavior) val behavior = UploadBehavior.fromString(params.behavior)
if (behavior == UploadBehavior.MOVE && params.sourcePath != null) { val operation = if (behavior == UploadBehavior.MOVE && params.sourcePath != null) {
val removeSourceFileWorker = OneTimeWorkRequestBuilder<RemoveSourceFileWorker>() val removeSourceFileWorker = OneTimeWorkRequestBuilder<RemoveSourceFileWorker>()
.setInputData(inputDataRemoveSourceFileWorker) .setInputData(inputDataRemoveSourceFileWorker)
.build() .build()
@@ -98,6 +102,7 @@ class UploadFileFromSystemUseCase(
} }
Timber.i("Plain upload of ${params.localPath} has been enqueued with unique work name: $uniqueWorkName") Timber.i("Plain upload of ${params.localPath} has been enqueued with unique work name: $uniqueWorkName")
return operation
} }
data class Params( data class Params(
@@ -108,5 +113,8 @@ class UploadFileFromSystemUseCase(
val uploadPath: String, val uploadPath: String,
val uploadIdInStorageManager: Long, val uploadIdInStorageManager: Long,
val sourcePath: String? = null, val sourcePath: String? = null,
val wifiOnly: Boolean = false,
val chargingOnly: Boolean = false,
val removeLocalAfterUpload: Boolean = false,
) )
} }
@@ -23,8 +23,10 @@ package eu.qsfera.android.workers
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation import androidx.work.Operation
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
@@ -50,7 +52,7 @@ import eu.qsfera.android.presentation.settings.automaticuploads.MediaStoreGenera
import eu.qsfera.android.presentation.settings.automaticuploads.PhoneMediaStore import eu.qsfera.android.presentation.settings.automaticuploads.PhoneMediaStore
import eu.qsfera.android.presentation.settings.automaticuploads.automaticUploadMediaFingerprint import eu.qsfera.android.presentation.settings.automaticuploads.automaticUploadMediaFingerprint
import eu.qsfera.android.providers.WorkManagerProvider import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromContentUriUseCase import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromSystemUseCase
import eu.qsfera.android.utils.NotificationUtils import eu.qsfera.android.utils.NotificationUtils
import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -61,6 +63,8 @@ import org.koin.core.component.inject
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -191,19 +195,37 @@ class AutomaticUploadsWorker(
// enqueued with a new upload ID — leading to duplicate uploads or 0-byte files // enqueued with a new upload ID — leading to duplicate uploads or 0-byte files
// when two workers race on the same cache path. // when two workers race on the same cache path.
val contentUri = candidate.uri.toString() val contentUri = candidate.uri.toString()
val activeTransferExists = contentUri in transferRecovery.activeSourceUris val activeTransfer = transferRecovery.activeTransfersBySourceUri[contentUri]
val activeTransferHasDurableLocalCopy = activeTransfer?.localPath
?.let(::File)
?.isFile == true
val completedTransferMatchesSource = shouldRemovePreviouslyUploadedSource( val completedTransferMatchesSource = shouldRemovePreviouslyUploadedSource(
sourceUri = contentUri, sourceUri = contentUri,
lastModified = candidate.lastModified, lastModified = candidate.lastModified,
dateAdded = candidate.dateAdded, dateAdded = candidate.dateAdded,
completedUploadTimesBySourceUri = transferRecovery.completedUploadTimesBySourceUri, completedUploadTimesBySourceUri = transferRecovery.completedUploadTimesBySourceUri,
) )
if (activeTransferExists || completedTransferMatchesSource) { if (activeTransferHasDurableLocalCopy || completedTransferMatchesSource) {
Timber.d("Skipping already-tracked file: %s", candidate.name) Timber.d("Skipping already-tracked file: %s", candidate.name)
continue continue
} }
if (activeTransfer != null) {
activeTransfer.id?.let { uploadId ->
WorkManager.getInstance(appContext).cancelUniqueWork("upload_content_uri_$uploadId")
}
}
val stagedFile = runCatching {
stageAutomaticUpload(candidate, folderBackUpConfiguration.accountName)
}.onFailure {
Timber.e(it, "Automatic upload could not be staged safely: %s", contentUri)
}.getOrNull()
if (stagedFile == null) {
allEnqueuesSuccessful = false
continue
}
val uploadId = storeOrResetUploadTransfer( val uploadId = storeOrResetUploadTransfer(
candidate = candidate, candidate = candidate,
localPath = stagedFile.absolutePath,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name), uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
accountName = folderBackUpConfiguration.accountName, accountName = folderBackUpConfiguration.accountName,
behavior = effectiveBehavior, behavior = effectiveBehavior,
@@ -212,11 +234,11 @@ class AutomaticUploadsWorker(
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
}, },
spaceId = folderBackUpConfiguration.spaceId, spaceId = folderBackUpConfiguration.spaceId,
failedTransfer = transferRecovery.failedTransfersBySourceUri[contentUri], failedTransfer = transferRecovery.failedTransfersBySourceUri[contentUri] ?: activeTransfer,
) )
val enqueueSucceeded = runCatching { val enqueueSucceeded = runCatching {
enqueueSingleUpload( enqueueSingleUpload(
contentUri = candidate.uri, localPath = stagedFile.absolutePath,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name), uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
lastModified = candidate.lastModified, lastModified = candidate.lastModified,
behavior = effectiveBehavior.toString(), behavior = effectiveBehavior.toString(),
@@ -238,6 +260,8 @@ class AutomaticUploadsWorker(
lastResult = TransferResult.SERVICE_INTERRUPTED, lastResult = TransferResult.SERVICE_INTERRUPTED,
) )
} }
} else if (effectiveBehavior == UploadBehavior.MOVE) {
if (!removeSourceNowOrSchedule(candidate.uri)) allEnqueuesSuccessful = false
} }
} }
// Save safeTimestamp (not currentTimestamp) so that files skipped by the // Save safeTimestamp (not currentTimestamp) so that files skipped by the
@@ -520,7 +544,7 @@ class AutomaticUploadsWorker(
} }
private fun enqueueSingleUpload( private fun enqueueSingleUpload(
contentUri: Uri, localPath: String,
uploadPath: String, uploadPath: String,
lastModified: Long, lastModified: Long,
behavior: String, behavior: String,
@@ -531,22 +555,91 @@ class AutomaticUploadsWorker(
): Operation { ): Operation {
val lastModifiedInSeconds = (lastModified / 1000L).toString() val lastModifiedInSeconds = (lastModified / 1000L).toString()
return UploadFileFromContentUriUseCase(WorkManager.getInstance(appContext))( return UploadFileFromSystemUseCase(WorkManager.getInstance(appContext))(
UploadFileFromContentUriUseCase.Params( UploadFileFromSystemUseCase.Params(
accountName = accountName, accountName = accountName,
contentUri = contentUri, localPath = localPath,
lastModifiedInSeconds = lastModifiedInSeconds, lastModifiedInSeconds = lastModifiedInSeconds,
behavior = behavior, behavior = behavior,
uploadPath = uploadPath, uploadPath = uploadPath,
uploadIdInStorageManager = uploadId, uploadIdInStorageManager = uploadId,
wifiOnly = wifiOnly, wifiOnly = wifiOnly,
chargingOnly = chargingOnly chargingOnly = chargingOnly,
removeLocalAfterUpload = true,
) )
) )
} }
private fun stageAutomaticUpload(candidate: AutomaticUploadCandidate, accountName: String): File {
val accountDirectory = File(
appContext.filesDir,
"$AUTOMATIC_UPLOAD_STAGING_DIRECTORY/${accountName.sha256Prefix()}",
).apply { mkdirs() }
require(accountDirectory.isDirectory) { "Automatic-upload staging directory is unavailable" }
val safeName = candidate.name.replace(UNSAFE_FILE_NAME, "_").takeLast(MAX_STAGED_FILE_NAME_LENGTH)
.ifBlank { "media" }
val identity = "${candidate.uri}|${candidate.fingerprint}".sha256Prefix()
val destination = File(accountDirectory, "$identity-$safeName")
if (isCompleteStagedMediaFile(destination, candidate.size)) {
return destination
}
val temporary = File(accountDirectory, "${destination.name}.part")
temporary.delete()
val copiedBytes = appContext.contentResolver.openInputStream(candidate.uri)?.use { input ->
FileOutputStream(temporary).use { output ->
val bytes = input.copyTo(output, STAGING_COPY_BUFFER_BYTES)
output.fd.sync()
bytes
}
} ?: error("MediaStore returned no stream for ${candidate.uri}")
require(copiedBytes > 0L) { "Refusing to stage an empty media file" }
require(candidate.size <= 0L || copiedBytes == candidate.size) {
"Staged media size differs from MediaStore size"
}
if (destination.exists()) require(destination.delete()) { "Incomplete staging file could not be replaced" }
if (!temporary.renameTo(destination)) {
temporary.inputStream().use { input ->
FileOutputStream(destination).use { output ->
input.copyTo(output, STAGING_COPY_BUFFER_BYTES)
output.fd.sync()
}
}
require(temporary.delete()) { "Temporary staging file could not be removed" }
}
if (candidate.lastModified > 0L) destination.setLastModified(candidate.lastModified)
return destination
}
private fun removeSourceNowOrSchedule(contentUri: Uri): Boolean {
val removeRequest = OneTimeWorkRequestBuilder<RemoveSourceFileWorker>()
.setInputData(
androidx.work.workDataOf(
UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI to contentUri.toString(),
)
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build()
val scheduled = runCatching {
WorkManager.getInstance(appContext).enqueueUniqueWork(
"remove_staged_source_${contentUri.toString().sha256Prefix()}",
ExistingWorkPolicy.REPLACE,
removeRequest,
).result.get(ENQUEUE_OPERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
}.onFailure {
Timber.e(it, "Source-removal work could not be persisted for %s", contentUri)
}.isSuccess
if (!scheduled) return false
runCatching { removeSourceUri(appContext, contentUri) }
.onFailure { Timber.w(it, "Staged source will be removed by worker: %s", contentUri) }
return true
}
private fun storeOrResetUploadTransfer( private fun storeOrResetUploadTransfer(
candidate: AutomaticUploadCandidate, candidate: AutomaticUploadCandidate,
localPath: String,
uploadPath: String, uploadPath: String,
accountName: String, accountName: String,
behavior: UploadBehavior, behavior: UploadBehavior,
@@ -556,7 +649,7 @@ class AutomaticUploadsWorker(
): Long { ): Long {
val ocTransfer = OCTransfer( val ocTransfer = OCTransfer(
id = failedTransfer?.id, id = failedTransfer?.id,
localPath = candidate.uri.toString(), localPath = localPath,
remotePath = uploadPath, remotePath = uploadPath,
accountName = accountName, accountName = accountName,
fileSize = candidate.size, fileSize = candidate.size,
@@ -576,7 +669,7 @@ class AutomaticUploadsWorker(
accountName: String, accountName: String,
sourceType: UploadEnqueuedBy, sourceType: UploadEnqueuedBy,
): AutomaticUploadTransferRecovery { ): AutomaticUploadTransferRecovery {
val activeSourceUris = mutableSetOf<String>() val activeTransfersBySourceUri = mutableMapOf<String, OCTransfer>()
val completedUploadTimes = mutableMapOf<String, Long>() val completedUploadTimes = mutableMapOf<String, Long>()
val retrySourceUris = mutableSetOf<String>() val retrySourceUris = mutableSetOf<String>()
val failedTransfers = mutableMapOf<String, OCTransfer>() val failedTransfers = mutableMapOf<String, OCTransfer>()
@@ -604,7 +697,10 @@ class AutomaticUploadsWorker(
TransferStatus.TRANSFER_IN_PROGRESS -> { TransferStatus.TRANSFER_IN_PROGRESS -> {
val uploadId = transfer.id val uploadId = transfer.id
if (uploadId != null && (durableWorkIds == null || uploadId in durableWorkIds)) { if (uploadId != null && (durableWorkIds == null || uploadId in durableWorkIds)) {
activeSourceUris += sourceUri val current = activeTransfersBySourceUri[sourceUri]
if (current == null || transfer.isNewerThan(current)) {
activeTransfersBySourceUri[sourceUri] = transfer
}
} else if (uploadId != null) { } else if (uploadId != null) {
val finishedAt = System.currentTimeMillis() val finishedAt = System.currentTimeMillis()
transferRepository.updateTransferWhenFinished( transferRepository.updateTransferWhenFinished(
@@ -623,9 +719,9 @@ class AutomaticUploadsWorker(
} }
} }
} }
retrySourceUris.removeAll(activeSourceUris) retrySourceUris.removeAll(activeTransfersBySourceUri.keys)
return AutomaticUploadTransferRecovery( return AutomaticUploadTransferRecovery(
activeSourceUris = activeSourceUris, activeTransfersBySourceUri = activeTransfersBySourceUri,
completedUploadTimesBySourceUri = completedUploadTimes, completedUploadTimesBySourceUri = completedUploadTimes,
retrySourceUris = retrySourceUris, retrySourceUris = retrySourceUris,
failedTransfersBySourceUri = failedTransfers, failedTransfersBySourceUri = failedTransfers,
@@ -677,6 +773,10 @@ class AutomaticUploadsWorker(
const val WRITE_SAFETY_BUFFER_MS = 10_000L const val WRITE_SAFETY_BUFFER_MS = 10_000L
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L
private const val ENQUEUE_OPERATION_TIMEOUT_SECONDS = 5L private const val ENQUEUE_OPERATION_TIMEOUT_SECONDS = 5L
private const val AUTOMATIC_UPLOAD_STAGING_DIRECTORY = "automatic-upload-staging-v1"
private const val MAX_STAGED_FILE_NAME_LENGTH = 120
private const val STAGING_COPY_BUFFER_BYTES = 128 * 1024
private val UNSAFE_FILE_NAME = Regex("[^A-Za-z0-9._() -]")
} }
} }
@@ -698,7 +798,7 @@ private data class AutomaticUploadCandidate(
) )
private data class AutomaticUploadTransferRecovery( private data class AutomaticUploadTransferRecovery(
val activeSourceUris: Set<String>, val activeTransfersBySourceUri: Map<String, OCTransfer>,
val completedUploadTimesBySourceUri: Map<String, Long>, val completedUploadTimesBySourceUri: Map<String, Long>,
val retrySourceUris: Set<String>, val retrySourceUris: Set<String>,
val failedTransfersBySourceUri: Map<String, OCTransfer>, val failedTransfersBySourceUri: Map<String, OCTransfer>,
@@ -714,6 +814,14 @@ private data class AutomaticUploadDiscovery(
private fun OCTransfer.isNewerThan(other: OCTransfer): Boolean = private fun OCTransfer.isNewerThan(other: OCTransfer): Boolean =
(transferEndTimestamp ?: id ?: Long.MIN_VALUE) > (other.transferEndTimestamp ?: other.id ?: Long.MIN_VALUE) (transferEndTimestamp ?: id ?: Long.MIN_VALUE) > (other.transferEndTimestamp ?: other.id ?: Long.MIN_VALUE)
private fun String.sha256Prefix(): String = MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.take(12)
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
internal fun isCompleteStagedMediaFile(file: File, expectedSize: Long): Boolean =
file.isFile && file.length() > 0L && (expectedSize <= 0L || file.length() == expectedSize)
internal fun isAutomaticUploadCandidateDiscovered( internal fun isAutomaticUploadCandidateDiscovered(
sourceUri: String, sourceUri: String,
lastModified: Long, lastModified: Long,
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FF5A5F" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp" android:height="28dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:strokeColor="@android:color/white" android:strokeWidth="1.8"
android:strokeLineJoin="round" android:pathData="M4,20h4l11,-11 -4,-4L4,16v4M13.5,6.5l4,4" />
</vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp" android:height="28dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:strokeColor="@android:color/white"
android:strokeWidth="1.8" android:strokeLineJoin="round"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54z" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp" android:height="28dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54z" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp" android:height="28dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/transparent" android:strokeColor="@android:color/white" android:strokeWidth="1.8"
android:pathData="M12,2A10,10 0,1 0,12 22A10,10 0,0 0,12 2M12,10L12,17M12,7L12.01,7" />
</vector>
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cloud_preview_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@android:color/black"> android:background="@android:color/black">
@@ -15,35 +16,82 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<com.github.chrisbanes.photoview.PhotoView <androidx.viewpager2.widget.ViewPager2
android:id="@+id/cloud_preview_photo" android:id="@+id/cloud_preview_pager"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:contentDescription="@null" app:layout_constraintBottom_toTopOf="@id/cloud_preview_bottom_actions"
android:scaleType="fitCenter"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" /> app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
<androidx.media3.ui.PlayerView <LinearLayout
android:id="@+id/cloud_preview_player" android:id="@+id/cloud_preview_bottom_actions"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="@dimen/cloud_preview_bottom_actions_height"
android:visibility="gone" android:background="#E6000000"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent">
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
<ProgressBar <ImageButton
android:id="@+id/cloud_preview_progress" android:id="@+id/cloud_preview_share"
android:layout_width="wrap_content" style="?attr/borderlessButtonStyle"
android:layout_height="wrap_content" android:layout_width="0dp"
android:indeterminateTint="@color/white" android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent" android:layout_weight="1"
app:layout_constraintEnd_toEndOf="parent" android:contentDescription="@string/cloud_preview_share"
app:layout_constraintStart_toStartOf="parent" android:padding="20dp"
app:layout_constraintTop_toTopOf="parent" /> android:src="@drawable/ic_cloud_share" />
<ImageButton
android:id="@+id/cloud_preview_info"
style="?attr/borderlessButtonStyle"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/cloud_preview_information"
android:padding="20dp"
android:src="@drawable/ic_cloud_info" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageButton
android:id="@+id/cloud_preview_edit"
style="?attr/borderlessButtonStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cloud_preview_edit"
android:padding="20dp"
android:src="@drawable/ic_cloud_edit" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_marginTop="5dp"
android:layout_marginEnd="8dp"
android:background="@drawable/cloud_preview_beta_badge"
android:paddingHorizontal="5dp"
android:paddingVertical="1dp"
android:text="@string/cloud_preview_beta"
android:textColor="@android:color/white"
android:textSize="10sp" />
</FrameLayout>
<ImageButton
android:id="@+id/cloud_preview_delete"
style="?attr/borderlessButtonStyle"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/cloud_selection_delete"
android:padding="20dp"
android:src="@drawable/ic_cloud_delete" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/cloud_preview_photo"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:visibility="gone" />
<androidx.media3.ui.PlayerView
android:id="@+id/cloud_preview_player"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<ProgressBar
android:id="@+id/cloud_preview_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminateTint="@color/white" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="64dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="24dp">
<ImageView
android:id="@+id/cloud_preview_action_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:contentDescription="@null" />
<TextView
android:id="@+id/cloud_preview_action_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:textColor="@color/qsfera_text_primary"
android:textSize="18sp" />
</LinearLayout>
@@ -11,13 +11,30 @@
android:paddingEnd="6dp" android:paddingEnd="6dp"
android:paddingBottom="8dp"> android:paddingBottom="8dp">
<ImageView <FrameLayout
android:id="@+id/cloud_storage_icon"
android:layout_width="96dp" android:layout_width="96dp"
android:layout_height="82dp" android:layout_height="82dp">
android:contentDescription="@null"
android:scaleType="centerInside" <ImageView
android:src="@drawable/ic_qsfera_folder" /> android:id="@+id/cloud_storage_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerInside"
android:src="@drawable/ic_qsfera_folder" />
<ImageView
android:id="@+id/cloud_storage_video"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom|end"
android:layout_margin="5dp"
android:background="@drawable/cloud_card_background"
android:contentDescription="@null"
android:padding="5dp"
android:src="@drawable/ic_play_arrow"
android:visibility="gone" />
</FrameLayout>
<TextView <TextView
android:id="@+id/cloud_storage_title" android:id="@+id/cloud_storage_title"
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="20dp"
android:paddingBottom="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:paddingBottom="12dp"
android:text="@string/cloud_preview_file_actions"
android:textColor="@color/qsfera_text_secondary"
android:textSize="18sp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true">
<LinearLayout
android:id="@+id/cloud_preview_action_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</ScrollView>
</LinearLayout>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/cloud_preview_favorite"
android:icon="@drawable/ic_cloud_favorite_border"
android:title="@string/cloud_preview_add_favorite"
app:showAsAction="always" />
<item
android:id="@+id/cloud_preview_more"
android:icon="@drawable/ic_cloud_more_vert"
android:title="@string/content_description_menu"
app:showAsAction="always" />
</menu>
@@ -16,6 +16,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
--> -->
<resources> <resources>
<dimen name="cloud_preview_bottom_actions_height">72dp</dimen>
<!-- STANDARD --> <!-- STANDARD -->
<dimen name="standard_padding">16dp</dimen> <dimen name="standard_padding">16dp</dimen>
<dimen name="standard_half_padding">8dp</dimen> <dimen name="standard_half_padding">8dp</dimen>
@@ -917,6 +917,25 @@
<string name="pattern_label">Pattern</string> <string name="pattern_label">Pattern</string>
<string name="receive_external_files_label">Receive external files</string> <string name="receive_external_files_label">Receive external files</string>
<string name="video_preview_label">Video preview</string> <string name="video_preview_label">Video preview</string>
<string name="cloud_preview_share">Поделиться</string>
<string name="cloud_preview_information">Информация</string>
<string name="cloud_preview_edit">Редактировать</string>
<string name="cloud_preview_beta">бета</string>
<string name="cloud_preview_add_favorite">Добавить в Избранное</string>
<string name="cloud_preview_remove_favorite">Удалить из Избранного</string>
<string name="cloud_preview_file_actions">Действия с файлом</string>
<string name="cloud_preview_add_album">Добавить в альбом</string>
<string name="cloud_preview_add_offline">Добавить в Офлайн</string>
<string name="cloud_preview_download">Скачать</string>
<string name="cloud_preview_save_device">Сохранить на устройство</string>
<string name="cloud_preview_share_link">Поделиться ссылкой</string>
<string name="cloud_preview_open_with">Открыть с помощью…</string>
<string name="cloud_preview_use_as">Использовать как</string>
<string name="cloud_preview_move">Переместить</string>
<string name="cloud_preview_copy">Копировать</string>
<string name="cloud_preview_rename">Переименовать</string>
<string name="cloud_preview_saved">Файл сохранён на устройстве</string>
<string name="cloud_preview_action_error">Не удалось выполнить действие</string>
<string name="release_notes_label">Release notes</string> <string name="release_notes_label">Release notes</string>
<string name="image_preview_label">Image preview</string> <string name="image_preview_label">Image preview</string>
<string name="login_label">Login</string> <string name="login_label">Login</string>
@@ -0,0 +1,66 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.File
class CloudMediaCatalogCacheTest {
@Test
fun `binary catalog restores a complete offline feed`() {
withCache { cache ->
val expected = List(15_000) { index ->
CloudMediaItem(
webDavHref = "/remote.php/dav/files/user/Camera/$index.jpg",
remotePath = "/CameraUpload/$index.jpg",
mimeType = "image/jpeg",
size = 1_000L + index,
modifiedAt = 10_000L + index,
etag = "etag-$index",
spaceId = "space",
)
}
cache.write(ACCOUNT, expected)
assertEquals(expected, cache.read(ACCOUNT))
}
}
@Test
fun `pending local preview metadata survives process restart`() {
withCache { cache ->
val expected = listOf(
CloudMediaItem(
remotePath = "/CameraUpload/offline.jpg",
mimeType = "image/jpeg",
size = 42,
modifiedAt = 123,
localPreviewPath = "/data/user/0/eu.qsfera.android/files/staged/offline.jpg",
)
)
cache.write(ACCOUNT, expected)
assertEquals(expected, cache.read(ACCOUNT))
}
}
private fun withCache(block: (CloudMediaCatalogCache) -> Unit) {
val directory = File(System.getProperty("java.io.tmpdir"), "qsfera-catalog-${System.nanoTime()}")
try {
block(CloudMediaCatalogCache(directory))
} finally {
directory.deleteRecursively()
}
}
companion object {
private const val ACCOUNT = "offline@example.test"
}
}
@@ -0,0 +1,66 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class CloudMediaPreviewCacheTest {
@Test
fun `tile cache identity does not depend on grid geometry or href spelling`() {
val first = media(href = "/remote.php/dav/files/user/Camera/photo.jpg")
val second = media(href = "https://cloud.example/remote.php/dav/files/user/Camera/photo.jpg")
assertEquals(
CloudMediaPreviewRequests.tileCacheKey(first, "account"),
CloudMediaPreviewRequests.tileCacheKey(second, "account"),
)
}
@Test
fun `changed revision invalidates cached thumbnail`() {
assertNotEquals(
CloudMediaPreviewRequests.tileCacheKey(media(etag = "one"), "account"),
CloudMediaPreviewRequests.tileCacheKey(media(etag = "two"), "account"),
)
}
@Test
fun `preview session opens anchor and preserves swipe order`() {
val newest = media(path = "/Camera/newest.jpg", modifiedAt = 3)
val middle = media(path = "/Camera/middle.jpg", modifiedAt = 2)
val oldest = media(path = "/Camera/oldest.jpg", modifiedAt = 1)
CloudMediaPreviewSession.update(listOf(newest, middle, oldest))
val snapshot = CloudMediaPreviewSession.snapshot(middle)
assertEquals(1, snapshot.position)
assertEquals(listOf(newest, middle, oldest), snapshot.items)
}
@Test
fun `stable ids do not inherit known String hash collision`() {
assertEquals("Aa".hashCode(), "BB".hashCode())
assertNotEquals(stableCloudId("Aa"), stableCloudId("BB"))
}
private fun media(
path: String = "/Camera/photo.jpg",
href: String = "",
etag: String = "revision",
modifiedAt: Long = 1,
) = CloudMediaItem(
webDavHref = href,
remotePath = path,
mimeType = "image/jpeg",
size = 1,
modifiedAt = modifiedAt,
etag = etag,
spaceId = "space",
)
}
@@ -27,7 +27,14 @@ class CloudPhotoGridModeTest {
@Test @Test
fun `smart tile geometries fill every supported group without empty slots`() { fun `smart tile geometries fill every supported group without empty slots`() {
for (count in 1..6) { for (count in 1..6) {
val bounds = CloudSmartPhotoLayout.geometry(width = 1200, count = count, mirrored = false, gap = 2) val bounds = CloudSmartPhotoLayout.geometry(
width = 1200,
aspectRatios = List(count) { 1f },
maxItemsPerRow = 3,
autoWrap = true,
mirrored = false,
gap = 2,
)
assertEquals(count, bounds.size) assertEquals(count, bounds.size)
assertTrue(bounds.all { it.width > 0 && it.height > 0 }) assertTrue(bounds.all { it.width > 0 && it.height > 0 })
@@ -37,8 +44,9 @@ class CloudPhotoGridModeTest {
@Test @Test
fun `mirrored smart geometry preserves tile sizes`() { fun `mirrored smart geometry preserves tile sizes`() {
val regular = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = false, gap = 2) val ratios = listOf(0.75f, 1.33f, 1.77f, 0.56f, 1f, 2f)
val mirrored = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = true, gap = 2) val regular = CloudSmartPhotoLayout.geometry(1200, ratios, 3, true, false, 2)
val mirrored = CloudSmartPhotoLayout.geometry(1200, ratios, 3, true, true, 2)
regular.zip(mirrored).forEach { (left, right) -> regular.zip(mirrored).forEach { (left, right) ->
assertEquals(left.width, right.width) assertEquals(left.width, right.width)
@@ -46,4 +54,23 @@ class CloudPhotoGridModeTest {
assertEquals(1200 - left.right, right.left) assertEquals(1200 - left.right, right.left)
} }
} }
@Test
fun `justified geometry follows media aspect ratios without letterboxing`() {
val ratios = listOf(0.75f, 4f / 3f, 16f / 9f)
val bounds = CloudSmartPhotoLayout.geometry(
width = 1200,
aspectRatios = ratios,
maxItemsPerRow = ratios.size,
autoWrap = false,
mirrored = false,
gap = 2,
)
bounds.zip(ratios).forEach { (tile, sourceRatio) ->
val tileRatio = tile.width.toFloat() / tile.height
assertEquals(sourceRatio, tileRatio, 0.01f)
}
assertEquals(1200, bounds.maxOf(CloudSmartPhotoLayout.TileBounds::right))
}
} }
@@ -30,6 +30,29 @@ class AutomaticUploadsWorkerTest {
private val sourceUri = "content://camera/photo.jpg" private val sourceUri = "content://camera/photo.jpg"
@Test
fun `staged media is accepted only after the complete file is durable`() {
val staged = File.createTempFile("qsfera-staged", ".jpg").apply {
writeBytes(byteArrayOf(1, 2, 3, 4))
}
try {
assertTrue(isCompleteStagedMediaFile(staged, expectedSize = 4))
assertFalse(isCompleteStagedMediaFile(staged, expectedSize = 5))
} finally {
staged.delete()
}
}
@Test
fun `empty staging file is never accepted`() {
val staged = File.createTempFile("qsfera-empty-staged", ".jpg")
try {
assertFalse(isCompleteStagedMediaFile(staged, expectedSize = 0))
} finally {
staged.delete()
}
}
@Test @Test
fun `completed upload identifies original source`() { fun `completed upload identifies original source`() {
assertTrue( assertTrue(
+1 -1
View File
@@ -36,7 +36,7 @@ RUN make release-linux-docker-${TARGETARCH} ENABLE_VIPS=true DIST=/dist
FROM alpine:3.23 FROM alpine:3.23
ARG TARGETARCH=arm64 ARG TARGETARCH=arm64
RUN apk add --no-cache attr ca-certificates curl mailcap tree vips && \ RUN apk add --no-cache attr ca-certificates curl ffmpeg mailcap tree vips && \
echo 'hosts: files dns' >| /etc/nsswitch.conf echo 'hosts: files dns' >| /etc/nsswitch.conf
LABEL maintainer="QSfera" \ LABEL maintainer="QSfera" \
@@ -3,7 +3,7 @@ FROM amd64/alpine:edge
ARG VERSION="" ARG VERSION=""
ARG REVISION="" ARG REVISION=""
RUN apk add --no-cache attr bash ca-certificates curl delve inotify-tools libc6-compat mailcap tree vips patch && \ RUN apk add --no-cache attr bash ca-certificates curl delve ffmpeg inotify-tools libc6-compat mailcap tree vips patch && \
echo 'hosts: files dns' >| /etc/nsswitch.conf echo 'hosts: files dns' >| /etc/nsswitch.conf
LABEL maintainer="QSfera" \ LABEL maintainer="QSfera" \
@@ -3,7 +3,7 @@ FROM arm64v8/alpine:edge
ARG VERSION="" ARG VERSION=""
ARG REVISION="" ARG REVISION=""
RUN apk add --no-cache attr bash ca-certificates curl delve inotify-tools libc6-compat mailcap tree vips patch && \ RUN apk add --no-cache attr bash ca-certificates curl delve ffmpeg inotify-tools libc6-compat mailcap tree vips patch && \
echo 'hosts: files dns' >| /etc/nsswitch.conf echo 'hosts: files dns' >| /etc/nsswitch.conf
LABEL maintainer="QSfera" \ LABEL maintainer="QSfera" \
+1 -1
View File
@@ -20,7 +20,7 @@ ARG REVISION
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
RUN apk add --no-cache attr bash ca-certificates curl imagemagick \ RUN apk add --no-cache attr bash ca-certificates curl ffmpeg imagemagick \
inotify-tools libc6-compat mailcap tree vips \ inotify-tools libc6-compat mailcap tree vips \
vips-magick patch && \ vips-magick patch && \
echo 'hosts: files dns' >| /etc/nsswitch.conf echo 'hosts: files dns' >| /etc/nsswitch.conf
@@ -78,6 +78,10 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
bleveReq := bleve.NewSearchRequest(q) bleveReq := bleve.NewSearchRequest(q)
bleveReq.Highlight = bleve.NewHighlight() bleveReq.Highlight = bleve.NewHighlight()
// Keep relevance as the primary order, but make equal-score media searches stable and
// newest-first. Without explicit tie breakers, increasing PageSize can reshuffle results
// that all have the same mediatype score and makes paged photo grids jump.
bleveReq.SortBy([]string{"-_score", "-Mtime", "_id"})
switch { switch {
case sir.PageSize == -1: case sir.PageSize == -1:
@@ -111,6 +111,11 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
}, },
boolQuery, boolQuery,
osu.SearchBodyParams{ osu.SearchBodyParams{
Sort: []any{
map[string]any{"_score": map[string]string{"order": "desc"}},
map[string]any{"Mtime": map[string]string{"order": "desc"}},
map[string]any{"_id": map[string]string{"order": "asc"}},
},
Highlight: &osu.BodyParamHighlight{ Highlight: &osu.BodyParamHighlight{
HighlightOptions: osu.HighlightOptions{ HighlightOptions: osu.HighlightOptions{
NumberOfFragments: 2, NumberOfFragments: 2,
@@ -95,6 +95,7 @@ func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyPa
type SearchBodyParams struct { type SearchBodyParams struct {
Highlight *BodyParamHighlight `json:"highlight,omitempty"` Highlight *BodyParamHighlight `json:"highlight,omitempty"`
Sort []any `json:"sort,omitempty"`
} }
//----------------------------------------------------------------------------// //----------------------------------------------------------------------------//
+12 -1
View File
@@ -96,7 +96,18 @@ func (ma matchArray) Swap(i, j int) {
ma[i], ma[j] = ma[j], ma[i] ma[i], ma[j] = ma[j], ma[i]
} }
func (ma matchArray) Less(i, j int) bool { func (ma matchArray) Less(i, j int) bool {
return ma[i].GetScore() > ma[j].GetScore() if ma[i].GetScore() != ma[j].GetScore() {
return ma[i].GetScore() > ma[j].GetScore()
}
leftTime := ma[i].GetEntity().GetLastModifiedTime()
rightTime := ma[j].GetEntity().GetLastModifiedTime()
if leftTime.GetSeconds() != rightTime.GetSeconds() {
return leftTime.GetSeconds() > rightTime.GetSeconds()
}
if leftTime.GetNanos() != rightTime.GetNanos() {
return leftTime.GetNanos() > rightTime.GetNanos()
}
return ma[i].GetEntity().GetId().GetOpaqueId() < ma[j].GetEntity().GetId().GetOpaqueId()
} }
func logDocCount(engine Engine, logger log.Logger) { func logDocCount(engine Engine, logger log.Logger) {
@@ -46,4 +46,5 @@ type Thumbnail struct {
MaxInputWidth int `yaml:"max_input_width" env:"THUMBNAILS_MAX_INPUT_WIDTH" desc:"The maximum width of an input image which is being processed." introductionVersion:"1.0.0"` MaxInputWidth int `yaml:"max_input_width" env:"THUMBNAILS_MAX_INPUT_WIDTH" desc:"The maximum width of an input image which is being processed." introductionVersion:"1.0.0"`
MaxInputHeight int `yaml:"max_input_height" env:"THUMBNAILS_MAX_INPUT_HEIGHT" desc:"The maximum height of an input image which is being processed." introductionVersion:"1.0.0"` MaxInputHeight int `yaml:"max_input_height" env:"THUMBNAILS_MAX_INPUT_HEIGHT" desc:"The maximum height of an input image which is being processed." introductionVersion:"1.0.0"`
MaxInputImageFileSize string `yaml:"max_input_image_file_size" env:"THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE" desc:"The maximum file size of an input image which is being processed. Usable common abbreviations: [KB, KiB, MB, MiB, GB, GiB, TB, TiB, PB, PiB, EB, EiB], example: 2GB." introductionVersion:"1.0.0"` MaxInputImageFileSize string `yaml:"max_input_image_file_size" env:"THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE" desc:"The maximum file size of an input image which is being processed. Usable common abbreviations: [KB, KiB, MB, MiB, GB, GiB, TB, TiB, PB, PiB, EB, EiB], example: 2GB." introductionVersion:"1.0.0"`
MaxInputVideoFileSize string `yaml:"max_input_video_file_size" env:"THUMBNAILS_MAX_INPUT_VIDEO_FILE_SIZE" desc:"The maximum file size of an input video used to generate a preview frame." introductionVersion:"1.0.0"`
} }
@@ -30,7 +30,7 @@ func DefaultConfig() *config.Config {
GRPC: config.GRPCConfig{ GRPC: config.GRPCConfig{
Addr: "127.0.0.1:9185", Addr: "127.0.0.1:9185",
Namespace: "qsfera.api", Namespace: "qsfera.api",
MaxConcurrentRequests: 0, MaxConcurrentRequests: 2,
}, },
HTTP: config.HTTP{ HTTP: config.HTTP{
Addr: "127.0.0.1:9186", Addr: "127.0.0.1:9186",
@@ -47,7 +47,7 @@ func DefaultConfig() *config.Config {
Name: "thumbnails", Name: "thumbnails",
}, },
Thumbnail: config.Thumbnail{ Thumbnail: config.Thumbnail{
Resolutions: []string{"16x16", "32x32", "64x64", "128x128", "1080x1920", "1920x1080", "2160x3840", "3840x2160", "4320x7680", "7680x4320"}, Resolutions: []string{"16x16", "32x32", "64x64", "128x128", "256x256", "512x512", "1024x1024", "1080x1920", "1920x1080", "2160x3840", "3840x2160", "4320x7680", "7680x4320"},
FileSystemStorage: config.FileSystemStorage{ FileSystemStorage: config.FileSystemStorage{
RootDirectory: path.Join(defaults.BaseDataPath(), "thumbnails"), RootDirectory: path.Join(defaults.BaseDataPath(), "thumbnails"),
}, },
@@ -58,6 +58,7 @@ func DefaultConfig() *config.Config {
MaxInputWidth: 7680, MaxInputWidth: 7680,
MaxInputHeight: 7680, MaxInputHeight: 7680,
MaxInputImageFileSize: "50MB", MaxInputImageFileSize: "50MB",
MaxInputVideoFileSize: "2GB",
}, },
} }
} }
@@ -4,15 +4,20 @@ import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"image" "image"
"image/draw" "image/draw"
"image/gif" "image/gif"
_ "image/jpeg"
"io" "io"
"math" "math"
"mime" "mime"
"os"
"os/exec"
"strings" "strings"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/image/font" "golang.org/x/image/font"
@@ -41,6 +46,59 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) {
return img, nil return img, nil
} }
// VideoDecoder extracts a bounded preview frame with ffmpeg. The input is first persisted to a
// temporary file so ffmpeg can seek to MP4/MOV metadata stored at the end of large camera files.
// The file lives on disk and is never retained in process memory.
type VideoDecoder struct{}
func (VideoDecoder) Convert(r io.Reader) (any, error) {
input, err := os.CreateTemp("", "qsfera-video-*")
if err != nil {
return nil, err
}
inputPath := input.Name()
defer os.Remove(inputPath)
if _, err = io.Copy(input, r); err != nil {
input.Close()
return nil, errors.Wrap(err, "could not persist video for preview extraction")
}
if err = input.Sync(); err != nil {
input.Close()
return nil, err
}
if err = input.Close(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), videoFrameExtractionTimeout)
defer cancel()
frame, err := extractVideoFrame(ctx, inputPath, nil)
if err != nil {
return nil, errors.Wrap(err, "could not extract video preview frame")
}
decoded, _, err := image.Decode(bytes.NewReader(frame))
if err != nil {
return nil, errors.Wrap(err, "could not decode video preview frame")
}
return decoded, nil
}
const videoFrameExtractionTimeout = 90 * time.Second
func extractVideoFrame(ctx context.Context, input string, stdin io.Reader) ([]byte, error) {
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-hide_banner", "-loglevel", "error", "-i", input,
"-map", "0:v:0", "-an", "-sn", "-frames:v", "1",
"-vf", "thumbnail=30,scale=1920:-2:force_original_aspect_ratio=decrease",
"-f", "image2pipe", "-vcodec", "png", "pipe:1",
)
cmd.Stdin = stdin
return cmd.Output()
}
// GgsDecoder is a converter for the geogebra slides file // GgsDecoder is a converter for the geogebra slides file
type GgsDecoder struct{ thumbnailpath string } type GgsDecoder struct{ thumbnailpath string }
@@ -303,6 +361,9 @@ func ForType(mimeType string, opts map[string]any) FileConverter {
// return the service call. So we should only get here when the mimeType parses fine. // return the service call. So we should only get here when the mimeType parses fine.
mimeType, _, _ = mime.ParseMediaType(mimeType) mimeType, _, _ = mime.ParseMediaType(mimeType)
switch mimeType { switch mimeType {
case "video/mp4", "video/quicktime", "video/webm", "video/x-matroska", "video/x-msvideo", "video/mpeg", "video/3gpp",
"video/x-m4v", "video/mp2t", "video/ogg", "video/x-ms-wmv", "video/x-flv", "video/hevc":
return VideoDecoder{}
case "text/plain": case "text/plain":
fontFileMap := "" fontFileMap := ""
fontFaceOpts := &opentype.FaceOptions{ fontFaceOpts := &opentype.FaceOptions{
@@ -173,6 +173,13 @@ var _ = Describe("ImageDecoder", func() {
Expect(decoder).To(BeAssignableToTypeOf(GifDecoder{})) Expect(decoder).To(BeAssignableToTypeOf(GifDecoder{}))
}) })
It("should return a VideoDecoder for supported video types", func() {
for _, mimeType := range []string{"video/mp4", "video/quicktime", "video/webm", "video/x-matroska"} {
decoder := ForType(mimeType, nil)
Expect(decoder).To(BeAssignableToTypeOf(VideoDecoder{}))
}
})
It("should return an GgsDecoder for ggs types", func() { It("should return an GgsDecoder for ggs types", func() {
decoder := ForType("application/vnd.geogebra.ggs", nil) decoder := ForType("application/vnd.geogebra.ggs", nil)
// This will not return the expected ggsDecoder, but an ImageDecoder since ggs contains an embedded png. // This will not return the expected ggsDecoder, but an ImageDecoder since ggs contains an embedded png.
@@ -1,6 +1,8 @@
package grpc package grpc
import ( import (
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/qsfera/server/pkg/registry" "github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/service/grpc" "github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/service/grpc/handler/ratelimiter" "github.com/qsfera/server/pkg/service/grpc/handler/ratelimiter"
@@ -10,8 +12,6 @@ import (
"github.com/qsfera/server/services/thumbnails/pkg/service/grpc/v0/decorators" "github.com/qsfera/server/services/thumbnails/pkg/service/grpc/v0/decorators"
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail/imgsource" "github.com/qsfera/server/services/thumbnails/pkg/thumbnail/imgsource"
"github.com/qsfera/server/services/thumbnails/pkg/thumbnail/storage" "github.com/qsfera/server/services/thumbnails/pkg/thumbnail/storage"
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
) )
// NewService initializes the grpc service and server. // NewService initializes the grpc service and server.
@@ -62,20 +62,25 @@ func NewService(opts ...Option) grpc.Service {
options.Logger.Error().Err(err).Msg("could not parse MaxInputImageFileSize") options.Logger.Error().Err(err).Msg("could not parse MaxInputImageFileSize")
return grpc.Service{} return grpc.Service{}
} }
videoLimit, err := bytesize.Parse(tconf.MaxInputVideoFileSize)
if err != nil {
options.Logger.Error().Err(err).Msg("could not parse MaxInputVideoFileSize")
return grpc.Service{}
}
var thumbnail decorators.DecoratedService var thumbnail decorators.DecoratedService
{ {
thumbnail = svc.NewService( thumbnail = svc.NewService(
svc.Config(options.Config), svc.Config(options.Config),
svc.Logger(options.Logger), svc.Logger(options.Logger),
svc.ThumbnailSource(imgsource.NewWebDavSource(tconf, b)), svc.ThumbnailSource(imgsource.NewWebDavSource(tconf, b, videoLimit)),
svc.ThumbnailStorage( svc.ThumbnailStorage(
storage.NewFileSystemStorage( storage.NewFileSystemStorage(
tconf.FileSystemStorage, tconf.FileSystemStorage,
options.Logger, options.Logger,
), ),
), ),
svc.CS3Source(imgsource.NewCS3Source(tconf, gatewaySelector, b)), svc.CS3Source(imgsource.NewCS3Source(tconf, gatewaySelector, b, videoLimit)),
svc.GatewaySelector(gatewaySelector), svc.GatewaySelector(gatewaySelector),
) )
thumbnail = decorators.NewInstrument(thumbnail, options.Metrics) thumbnail = decorators.NewInstrument(thumbnail, options.Metrics)
@@ -156,6 +156,7 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *thumbnailssvc.GetTh
} }
ctx = imgsource.ContextSetAuthorization(ctx, src.GetAuthorization()) ctx = imgsource.ContextSetAuthorization(ctx, src.GetAuthorization())
ctx = imgsource.ContextSetVideoSource(ctx, strings.HasPrefix(sRes.GetInfo().GetMimeType(), "video/"))
r, err := g.cs3Source.Get(ctx, src.GetPath()) r, err := g.cs3Source.Get(ctx, src.GetPath())
switch { switch {
case errors.Is(err, terrors.ErrImageTooLarge): case errors.Is(err, terrors.ErrImageTooLarge):
@@ -245,6 +246,7 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge
if src.GetWebdavAuthorization() != "" { if src.GetWebdavAuthorization() != "" {
ctx = imgsource.ContextSetAuthorization(ctx, src.GetWebdavAuthorization()) ctx = imgsource.ContextSetAuthorization(ctx, src.GetWebdavAuthorization())
} }
ctx = imgsource.ContextSetVideoSource(ctx, strings.HasPrefix(sRes.GetInfo().GetMimeType(), "video/"))
// add signature and expiration to webdav url // add signature and expiration to webdav url
signature, expiration := imgURL.Query().Get("signature"), imgURL.Query().Get("expiration") signature, expiration := imgURL.Query().Get("signature"), imgURL.Query().Get("expiration")
@@ -5,11 +5,11 @@ package thumbnail
import ( import (
"bytes" "bytes"
"image" "image"
"image/png"
"strings" "strings"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"github.com/qsfera/server/services/thumbnails/pkg/errors" "github.com/qsfera/server/services/thumbnails/pkg/errors"
"golang.org/x/image/bmp"
) )
// SimpleGenerator is the default image generator and is used for all image types expect gif. // SimpleGenerator is the default image generator and is used for all image types expect gif.
@@ -41,11 +41,13 @@ func (g SimpleGenerator) ProcessorID() string {
func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}) (interface{}, error) { func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}) (interface{}, error) {
var m *vips.ImageRef var m *vips.ImageRef
var err error var err error
switch img.(type) { switch typed := img.(type) {
case *image.RGBA: case image.Image:
// This comes from the txt preprocessor // Preprocessors for text and video return standard-library image types. Convert
// them to a lossless stream before handing them to libvips; decoded PNG video
// frames are usually *image.NRGBA and were previously rejected as invalid.
var buf bytes.Buffer var buf bytes.Buffer
if err = bmp.Encode(&buf, img.(*image.RGBA)); err != nil { if err = png.Encode(&buf, typed); err != nil {
return nil, err return nil, err
} }
m, err = vips.NewImageFromReader(&buf) m, err = vips.NewImageFromReader(&buf)
@@ -71,13 +73,11 @@ func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}) (interf
} }
func (g SimpleGenerator) Dimensions(img interface{}) (image.Rectangle, error) { func (g SimpleGenerator) Dimensions(img interface{}) (image.Rectangle, error) {
switch img.(type) { switch typed := img.(type) {
case *image.RGBA: case image.Image:
m := img.(*image.RGBA) return typed.Bounds(), nil
return m.Bounds(), nil
case *vips.ImageRef: case *vips.ImageRef:
m := img.(*vips.ImageRef) return image.Rect(0, 0, typed.Width(), typed.Height()), nil
return image.Rect(0, 0, m.Width(), m.Height()), nil
default: default:
return image.Rectangle{}, errors.ErrInvalidType return image.Rectangle{}, errors.ErrInvalidType
} }
@@ -0,0 +1,24 @@
//go:build enable_vips
package thumbnail
import (
"image"
"testing"
)
func TestVipsGeneratorAcceptsDecodedVideoFrameDimensions(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 1920, 1080))
generator, err := NewSimpleGenerator(typePng, "fit")
if err != nil {
t.Fatal(err)
}
dimensions, err := generator.Dimensions(frame)
if err != nil {
t.Fatalf("decoded video frame must be accepted: %v", err)
}
if dimensions.Dx() != 1920 || dimensions.Dy() != 1080 {
t.Fatalf("unexpected dimensions: %v", dimensions)
}
}
@@ -6,17 +6,18 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/services/thumbnails/pkg/config"
"github.com/qsfera/server/services/thumbnails/pkg/errors"
"github.com/opencloud-eu/reva/v2/pkg/bytesize" "github.com/opencloud-eu/reva/v2/pkg/bytesize"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/rhttp" "github.com/opencloud-eu/reva/v2/pkg/rhttp"
"github.com/opencloud-eu/reva/v2/pkg/storagespace" "github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/qsfera/server/services/thumbnails/pkg/config"
"github.com/qsfera/server/services/thumbnails/pkg/errors"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
) )
@@ -31,14 +32,20 @@ type CS3 struct {
gatewaySelector pool.Selectable[gateway.GatewayAPIClient] gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
insecure bool insecure bool
maxImageFileSize uint64 maxImageFileSize uint64
maxVideoFileSize uint64
} }
// NewCS3Source configures a new CS3 image source // NewCS3Source configures a new CS3 image source
func NewCS3Source(cfg config.Thumbnail, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], b bytesize.ByteSize) CS3 { func NewCS3Source(
cfg config.Thumbnail,
gatewaySelector pool.Selectable[gateway.GatewayAPIClient],
imageLimit, videoLimit bytesize.ByteSize,
) CS3 {
return CS3{ return CS3{
gatewaySelector: gatewaySelector, gatewaySelector: gatewaySelector,
insecure: cfg.CS3AllowInsecure, insecure: cfg.CS3AllowInsecure,
maxImageFileSize: b.Bytes(), maxImageFileSize: imageLimit.Bytes(),
maxVideoFileSize: videoLimit.Bytes(),
} }
} }
@@ -58,7 +65,10 @@ func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) {
} }
} }
ctx = metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, auth) // Preserve source metadata already attached to the request context. In particular, the
// video marker selects the separate video size limit; replacing the context here made
// every video larger than the image limit fail before ffmpeg was reached.
ctx = withCS3Authorization(ctx, auth)
err = s.checkImageFileSize(ctx, ref) err = s.checkImageFileSize(ctx, ref)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -88,31 +98,65 @@ func (s CS3) Get(ctx context.Context, path string) (io.ReadCloser, error) {
ep, tk = rsp.GetProtocols()[0].GetDownloadEndpoint(), rsp.GetProtocols()[0].GetToken() ep, tk = rsp.GetProtocols()[0].GetDownloadEndpoint(), rsp.GetProtocols()[0].GetToken()
} }
httpReq, err := rhttp.NewRequest(ctx, "GET", ep, nil) downloadCtx := ctx
cancelDownload := func() {}
if contextIsVideoSource(ctx) {
downloadCtx, cancelDownload = detachedVideoDownloadContext(ctx)
}
httpReq, err := rhttp.NewRequest(downloadCtx, "GET", ep, nil)
if err != nil { if err != nil {
cancelDownload()
return nil, err return nil, err
} }
httpReq.Header.Set(revactx.TokenHeader, auth) httpReq.Header.Set(revactx.TokenHeader, auth)
httpReq.Header.Set(TokenTransportHeader, tk) httpReq.Header.Set(TokenTransportHeader, tk)
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{ transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
InsecureSkipVerify: s.insecure, //nolint:gosec InsecureSkipVerify: s.insecure, //nolint:gosec
} }
client := &http.Client{} client := &http.Client{Transport: transport}
resp, err := client.Do(httpReq) resp, err := client.Do(httpReq)
if err != nil { if err != nil {
cancelDownload()
return nil, err return nil, err
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
resp.Body.Close()
cancelDownload()
return nil, fmt.Errorf("could not get the image \"%s\". Request returned with statuscode %d ", path, resp.StatusCode) return nil, fmt.Errorf("could not get the image \"%s\". Request returned with statuscode %d ", path, resp.StatusCode)
} }
if contextIsVideoSource(ctx) {
return cancelOnCloseReadCloser{ReadCloser: resp.Body, cancel: cancelDownload}, nil
}
return resp.Body, nil return resp.Body, nil
} }
type cancelOnCloseReadCloser struct {
io.ReadCloser
cancel context.CancelFunc
}
func (r cancelOnCloseReadCloser) Close() error {
err := r.ReadCloser.Close()
r.cancel()
return err
}
func detachedVideoDownloadContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), videoDownloadTimeout)
}
const videoDownloadTimeout = 15 * time.Minute
func withCS3Authorization(ctx context.Context, auth string) context.Context {
return metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, auth)
}
func (s CS3) checkImageFileSize(ctx context.Context, ref provider.Reference) error { func (s CS3) checkImageFileSize(ctx context.Context, ref provider.Reference) error {
gwc, err := s.gatewaySelector.Next() gwc, err := s.gatewaySelector.Next()
if err != nil { if err != nil {
@@ -125,7 +169,11 @@ func (s CS3) checkImageFileSize(ctx context.Context, ref provider.Reference) err
if stat.GetStatus().GetCode() != rpc.Code_CODE_OK { if stat.GetStatus().GetCode() != rpc.Code_CODE_OK {
return fmt.Errorf("could not stat image: %s", stat.GetStatus().GetMessage()) return fmt.Errorf("could not stat image: %s", stat.GetStatus().GetMessage())
} }
if stat.GetInfo().GetSize() > s.maxImageFileSize { limit := s.maxImageFileSize
if contextIsVideoSource(ctx) {
limit = s.maxVideoFileSize
}
if stat.GetInfo().GetSize() > limit {
return errors.ErrImageTooLarge return errors.ErrImageTooLarge
} }
return nil return nil
@@ -0,0 +1,29 @@
package imgsource
import (
"context"
"testing"
)
func TestCS3AuthorizationPreservesVideoSourceMarker(t *testing.T) {
ctx := ContextSetVideoSource(context.Background(), true)
ctx = withCS3Authorization(ctx, "token")
if !contextIsVideoSource(ctx) {
t.Fatal("CS3 authorization must preserve the video source marker")
}
}
func TestDetachedVideoDownloadSurvivesCallerCancellation(t *testing.T) {
parent, cancelParent := context.WithCancel(context.Background())
download, cancelDownload := detachedVideoDownloadContext(parent)
defer cancelDownload()
cancelParent()
select {
case <-download.Done():
t.Fatal("video cache warming must continue after the requesting client disconnects")
default:
}
}
@@ -9,6 +9,7 @@ type key int
const ( const (
auth key = iota auth key = iota
video
) )
// Source defines the interface for image sources // Source defines the interface for image sources
@@ -16,6 +17,16 @@ type Source interface {
Get(ctx context.Context, path string) (io.ReadCloser, error) Get(ctx context.Context, path string) (io.ReadCloser, error)
} }
// ContextSetVideoSource selects the separately bounded video input limit.
func ContextSetVideoSource(parent context.Context, isVideo bool) context.Context {
return context.WithValue(parent, video, isVideo)
}
func contextIsVideoSource(ctx context.Context) bool {
value, _ := ctx.Value(video).(bool)
return value
}
// ContextSetAuthorization puts the authorization in the context. // ContextSetAuthorization puts the authorization in the context.
func ContextSetAuthorization(parent context.Context, authorization string) context.Context { func ContextSetAuthorization(parent context.Context, authorization string) context.Context {
return context.WithValue(parent, auth, authorization) return context.WithValue(parent, auth, authorization)
@@ -11,17 +11,18 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/qsfera/server/services/thumbnails/pkg/config"
thumbnailerErrors "github.com/qsfera/server/services/thumbnails/pkg/errors"
"github.com/opencloud-eu/reva/v2/pkg/bytesize" "github.com/opencloud-eu/reva/v2/pkg/bytesize"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/qsfera/server/services/thumbnails/pkg/config"
thumbnailerErrors "github.com/qsfera/server/services/thumbnails/pkg/errors"
) )
// NewWebDavSource creates a new webdav instance. // NewWebDavSource creates a new webdav instance.
func NewWebDavSource(cfg config.Thumbnail, b bytesize.ByteSize) WebDav { func NewWebDavSource(cfg config.Thumbnail, imageLimit, videoLimit bytesize.ByteSize) WebDav {
return WebDav{ return WebDav{
insecure: cfg.WebdavAllowInsecure, insecure: cfg.WebdavAllowInsecure,
maxImageFileSize: b.Bytes(), maxImageFileSize: imageLimit.Bytes(),
maxVideoFileSize: videoLimit.Bytes(),
} }
} }
@@ -29,6 +30,7 @@ func NewWebDavSource(cfg config.Thumbnail, b bytesize.ByteSize) WebDav {
type WebDav struct { type WebDav struct {
insecure bool insecure bool
maxImageFileSize uint64 maxImageFileSize uint64
maxVideoFileSize uint64
} }
// Get downloads the file from a webdav service // Get downloads the file from a webdav service
@@ -67,7 +69,11 @@ func (s WebDav) Get(ctx context.Context, url string) (io.ReadCloser, error) {
if err != nil { if err != nil {
return nil, errors.Wrapf(err, `could not parse content length of webdav response "%s"`, url) return nil, errors.Wrapf(err, `could not parse content length of webdav response "%s"`, url)
} }
if c > s.maxImageFileSize { limit := s.maxImageFileSize
if contextIsVideoSource(ctx) {
limit = s.maxVideoFileSize
}
if c > limit {
return nil, thumbnailerErrors.ErrImageTooLarge return nil, thumbnailerErrors.ErrImageTooLarge
} }
@@ -18,5 +18,18 @@ var (
"audio/ogg": {}, "audio/ogg": {},
"application/vnd.geogebra.slides": {}, "application/vnd.geogebra.slides": {},
"application/vnd.geogebra.pinboard": {}, "application/vnd.geogebra.pinboard": {},
"video/mp4": {},
"video/quicktime": {},
"video/webm": {},
"video/x-matroska": {},
"video/x-msvideo": {},
"video/mpeg": {},
"video/3gpp": {},
"video/x-m4v": {},
"video/mp2t": {},
"video/ogg": {},
"video/x-ms-wmv": {},
"video/x-flv": {},
"video/hevc": {},
} }
) )
@@ -27,5 +27,18 @@ var (
"application/vnd.geogebra.slides": {}, "application/vnd.geogebra.slides": {},
"application/vnd.geogebra.pinboard": {}, "application/vnd.geogebra.pinboard": {},
"image/webp": {}, "image/webp": {},
"video/mp4": {},
"video/quicktime": {},
"video/webm": {},
"video/x-matroska": {},
"video/x-msvideo": {},
"video/mpeg": {},
"video/3gpp": {},
"video/x-m4v": {},
"video/mp2t": {},
"video/ogg": {},
"video/x-ms-wmv": {},
"video/x-flv": {},
"video/hevc": {},
} }
) )