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"
versionCode = 38
versionName = "1.3.10"
versionCode = 42
versionName = "1.3.14"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -6,7 +6,6 @@
package eu.qsfera.android.presentation.cloud
import android.accounts.Account
import android.content.Context
import android.view.LayoutInflater
import android.view.HapticFeedbackConstants
import android.view.View
@@ -15,18 +14,18 @@ import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.dispose
import coil.load
import coil.request.ImageRequest
import coil.size.Scale
import eu.qsfera.android.R
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.utils.MimetypeIconUtil
import java.io.File
internal class CloudHubAdapter(
private var account: Account,
private val aspectRatioCache: CloudMediaAspectRatioCache,
private val onMediaClick: (CloudMediaItem) -> Unit,
private val onMediaLongClick: (CloudMediaItem) -> Unit,
private val onAlbumClick: (String) -> Unit,
@@ -41,37 +40,28 @@ internal class CloudHubAdapter(
private var selectedMediaKeys: Set<String> = emptySet()
private var gridSpanCount = DEFAULT_GRID_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
mediaSpanSize = mediaSpans
mediaPreviewSize = previewSize
}
fun updateAccount(newAccount: Account) {
if (account == newAccount) return
account = newAccount
prefetchedUrls.clear()
notifyItemRangeChanged(0, itemCount)
}
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
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>) {
@@ -92,6 +82,8 @@ internal class CloudHubAdapter(
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]) {
is CloudHubRow.Header -> { TYPE_HEADER }
is CloudHubRow.FeedCard -> { TYPE_FEED_CARD }
@@ -143,7 +135,7 @@ internal class CloudHubAdapter(
CloudHubRow.Shortcuts -> { bindShortcuts(holder.itemView) }
is CloudHubRow.PhotoStatus -> { bindPhotoStatus(holder.itemView, row) }
is CloudHubRow.Media -> {
bindMedia(holder.itemView, row.item, previewSize = mediaPreviewSize)
bindMedia(holder.itemView, row.item)
}
is CloudHubRow.SmartMediaGroup -> { bindSmartMediaGroup(holder.itemView, row) }
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
val mainImage = view.findViewById<ImageView>(R.id.cloud_feed_main_image)
items.firstOrNull()?.let { main ->
loadMediaImage(mainImage, main, PREVIEW_LARGE)
loadMediaImage(mainImage, main)
mainImage.setOnClickListener { onMediaClick(main) }
}
@@ -186,7 +178,7 @@ internal class CloudHubAdapter(
val lastItem = items.getOrNull(3)
lastContainer.visibility = if (lastItem == null) View.GONE else View.VISIBLE
lastItem?.let { media ->
loadMediaImage(thirdThumbnail, media, PREVIEW_SMALL)
loadMediaImage(thirdThumbnail, media)
}
val hiddenCount = items.size - FEED_VISIBLE_MEDIA
moreCount.visibility = if (hiddenCount > 0) View.VISIBLE else View.GONE
@@ -200,7 +192,7 @@ internal class CloudHubAdapter(
private fun bindOptionalThumbnail(view: ImageView, item: CloudMediaItem?) {
view.visibility = if (item == null) View.GONE else View.VISIBLE
if (item != null) {
loadMediaImage(view, item, PREVIEW_SMALL)
loadMediaImage(view, item)
view.setOnClickListener { onMediaClick(item) }
} else {
view.dispose()
@@ -225,11 +217,11 @@ internal class CloudHubAdapter(
view.context.getString(R.string.cloud_photo_status, status.photos, status.videos)
}
private fun bindMedia(
view: View,
item: CloudMediaItem,
previewSize: Int = PREVIEW_MEDIUM,
) {
private fun bindMedia(view: View, item: CloudMediaItem) {
bindMedia(view, item, null)
}
private fun bindMedia(view: View, item: CloudMediaItem, onAspectRatio: ((Float) -> Unit)?) {
val image = view.findViewById<ImageView>(R.id.cloud_media_image)
val video = view.findViewById<ImageView>(R.id.cloud_media_video)
val selectionScrim = view.findViewById<View>(R.id.cloud_media_selection_scrim)
@@ -239,7 +231,7 @@ internal class CloudHubAdapter(
selectionScrim.visibility = if (selected) View.VISIBLE else View.GONE
selectionCheck.visibility = if (selected) View.VISIBLE else View.GONE
view.isActivated = selected
loadMediaImage(image, item, previewSize)
loadMediaImage(image, item, onAspectRatio)
view.contentDescription = item.name
view.setOnClickListener { onMediaClick(item) }
view.setOnLongClickListener {
@@ -250,9 +242,12 @@ internal class CloudHubAdapter(
}
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
mirrored = group.mirrored
maxItemsPerRow = group.maxItemsPerRow
autoWrap = group.autoWrap
aspectRatios = group.items.map(aspectRatioCache::get)
}
view.findViewById<TextView>(R.id.cloud_smart_date).apply {
text = group.date
@@ -271,12 +266,9 @@ internal class CloudHubAdapter(
val item = group.items.getOrNull(index)
visibility = if (item == null) View.INVISIBLE else View.VISIBLE
if (item != null) {
val previewSize = if (CloudSmartPhotoLayout.isLargeSlot(group.items.size, index)) {
PREVIEW_LARGE
} else {
PREVIEW_MEDIUM
bindMedia(this, item) { ratio ->
if (aspectRatioCache.put(item, ratio)) grid.updateAspectRatio(index, ratio)
}
bindMedia(this, item, previewSize = previewSize)
} else {
findViewById<ImageView>(R.id.cloud_media_image).dispose()
setOnClickListener(null)
@@ -289,55 +281,71 @@ internal class CloudHubAdapter(
fun monthLabelAt(position: Int): String? =
(rows.getOrNull(position) as? CloudHubRow.Media)?.label
fun prefetchRows(context: Context, rowsToPrefetch: List<CloudHubRow>) {
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
rowsToPrefetch.forEach { row ->
when (row) {
is CloudHubRow.Media -> prefetchMedia(context, loader, row.item, mediaPreviewSize)
is CloudHubRow.SmartMediaGroup -> row.items.forEachIndexed { index, item ->
val size = if (CloudSmartPhotoLayout.isLargeSlot(row.items.size, index)) {
PREVIEW_LARGE
} else {
PREVIEW_MEDIUM
}
prefetchMedia(context, loader, item, size)
private fun loadMediaImage(
image: ImageView,
item: CloudMediaItem,
onAspectRatio: ((Float) -> Unit)? = null,
) {
if (item.isImage || item.isVideo) {
image.scaleType = ImageView.ScaleType.CENTER_CROP
val requestKey = item.selectionKey
image.setTag(R.id.cloud_media_image, requestKey)
val localPreview = item.localPreviewPath?.let(::File)?.takeIf(File::isFile)
if (localPreview != null) {
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) {
if (!item.isImage && !item.isVideo) return
val url = runCatching { previewUri(item, size) }.getOrNull() ?: return
if (!prefetchedUrls.add(url)) return
loader.enqueue(
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
private fun loadRemoteMediaImage(
image: ImageView,
item: CloudMediaItem,
requestKey: String,
onAspectRatio: ((Float) -> Unit)?,
) {
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
val requestKey = item.selectionKey
image.setTag(R.id.cloud_media_image, requestKey)
val preview = runCatching { previewUri(item, size) }.getOrNull()
val preview = runCatching { CloudMediaPreviewRequests.tileUri(item, account) }.getOrNull()
if (preview == null) {
loadOriginalMediaImage(image, item, requestKey)
loadOriginalMediaImage(image, item, requestKey, onAspectRatio)
} else {
image.load(preview, loader) {
placeholder(R.drawable.cloud_media_placeholder)
scale(Scale.FILL)
crossfade(true)
scale(Scale.FIT)
size(CloudMediaPreviewRequests.TILE_SIZE, CloudMediaPreviewRequests.TILE_SIZE)
memoryCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
diskCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
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) {
if (item.isImage) {
loadOriginalMediaImage(image, item, requestKey)
loadOriginalMediaImage(image, item, requestKey, onAspectRatio)
} else {
image.scaleType = ImageView.ScaleType.CENTER_INSIDE
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()
if (originalUri == null) {
image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
@@ -365,23 +373,45 @@ internal class CloudHubAdapter(
image.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
placeholder(R.drawable.cloud_media_placeholder)
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
scale(Scale.FILL)
memoryCacheKey("$originalUri#${item.etag}")
diskCacheKey("$originalUri#${item.etag}")
crossfade(true)
scale(Scale.FIT)
size(CloudMediaPreviewRequests.TILE_SIZE, CloudMediaPreviewRequests.TILE_SIZE)
memoryCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
diskCacheKey(CloudMediaPreviewRequests.tileCacheKey(item, account))
listener(
onStart = {
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) {
view.findViewById<TextView>(R.id.cloud_storage_title).text = item.name
view.findViewById<ImageView>(R.id.cloud_storage_icon).setImageResource(
if (item.isFolder) R.drawable.ic_qsfera_folder else MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)
)
val icon = view.findViewById<ImageView>(R.id.cloud_storage_icon)
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.setOnClickListener { onStorageClick(item) }
}
@@ -396,7 +426,7 @@ internal class CloudHubAdapter(
val cover = view.findViewById<ImageView>(R.id.cloud_album_cover)
val coverItem = album.cover
if (coverItem?.isImage == true) {
loadMediaImage(cover, coverItem, PREVIEW_MEDIUM)
loadMediaImage(cover, coverItem)
} else {
cover.dispose()
cover.scaleType = ImageView.ScaleType.CENTER_INSIDE
@@ -405,27 +435,6 @@ internal class CloudHubAdapter(
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 =
if (item.webDavHref.isNotBlank()) {
ThumbnailsRequester.getContentUriForWebDavHref(item.webDavHref, account)
@@ -484,9 +493,7 @@ internal class CloudHubAdapter(
private const val DEFAULT_GRID_SPANS = 6
private const val DEFAULT_MEDIA_SPANS = 2
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
}
}
@@ -6,11 +6,17 @@
package eu.qsfera.android.presentation.cloud
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.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
@@ -19,6 +25,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.request.SuccessResult
import com.google.android.material.floatingactionbutton.FloatingActionButton
import eu.qsfera.android.R
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.spaces.SpacesRepository
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.lib.common.accounts.AccountUtils as LibraryAccountUtils
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.Dispatchers
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.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.io.File
import java.util.Date
import java.util.Locale
@@ -61,7 +74,11 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private lateinit var recycler: RecyclerView
private lateinit var gridLayoutManager: GridLayoutManager
private lateinit var fab: FloatingActionButton
private lateinit var mediaCatalogCache: CloudMediaCatalogCache
private lateinit var mediaAspectRatioCache: CloudMediaAspectRatioCache
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 query: String = ""
private var activeAlbumPath: String? = null
@@ -79,6 +96,41 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private var selectionMode = false
private var photoGridMode = CloudPhotoGridMode.LARGE
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
private set
@@ -93,6 +145,8 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mediaCatalogCache = CloudMediaCatalogCache(requireContext().applicationContext)
mediaAspectRatioCache = CloudMediaAspectRatioCache(requireContext().applicationContext)
section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION))
photoGridMode = CloudPhotoGridMode.fromWireValue(
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)
}
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) {
outState.putParcelableArrayList(STATE_SELECTION, ArrayList(selection.items))
outState.putBoolean(STATE_SELECTION_MODE, selectionMode)
@@ -120,6 +194,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
adapter = CloudHubAdapter(
account = account,
aspectRatioCache = mediaAspectRatioCache,
onMediaClick = ::handleMediaClick,
onMediaLongClick = ::toggleMediaSelection,
onAlbumClick = ::openAlbum,
@@ -127,7 +202,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
onShortcutClick = ::openShortcut,
onActionClick = ::openAction,
onRetry = ::reload,
onLoadMore = { loadNextMediaPage(reset = false) },
onLoadMore = {},
onFeedGroupClick = ::openFeedGroup,
)
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 {
layoutManager = gridLayoutManager
adapter = this@CloudHubFragment.adapter
itemAnimator = null
addItemDecoration(
CloudPhotoMonthDecoration(
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 {
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 {
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()
updateSelectionUi()
reload()
@@ -217,19 +299,19 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
when (photoGridMode) {
CloudPhotoGridMode.SMART,
CloudPhotoGridMode.LARGE -> {
GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE, PHOTO_PREVIEW_LARGE)
GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE)
}
CloudPhotoGridMode.STANDARD -> {
GridGeometry(GRID_SPANS_STANDARD, MEDIA_SPANS_STANDARD, PHOTO_PREVIEW_SMALL)
GridGeometry(GRID_SPANS_STANDARD, MEDIA_SPANS_STANDARD)
}
CloudPhotoGridMode.MONTHS -> {
GridGeometry(GRID_SPANS_MONTHS, MEDIA_SPANS_MONTHS, PHOTO_PREVIEW_SMALL)
GridGeometry(GRID_SPANS_MONTHS, MEDIA_SPANS_MONTHS)
}
}
} 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
recycler.requestLayout()
}
@@ -293,8 +375,11 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
if (!isAdded || !this::adapter.isInitialized) return
invalidateActiveLoad()
clearSelection()
adapter.updateAccount(AccountUtils.getCurrentQSferaAccount(requireContext()))
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
adapter.updateAccount(account)
startThumbnailPreloader(account)
allMedia = emptyList()
automaticUploadMedia = emptyList()
storageItems = emptyList()
query = ""
activeAlbumPath = null
@@ -316,10 +401,48 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
invalidateActiveLoad()
nextOffset = 0
reachedEnd = false
allMedia = emptyList()
mediaLoaded = false
refreshedMedia = emptyList()
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)
}
@@ -328,6 +451,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
isLoading = true
val requestedOffset = if (reset) 0 else nextOffset
val requestedSection = section
val accountName = AccountUtils.getCurrentQSferaAccount(requireContext()).name
val requestGeneration = ++loadGeneration
loadJob = viewLifecycleOwner.lifecycleScope.launch {
val result = try {
@@ -344,17 +468,43 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
nextOffset = requestedOffset + page.rawResultCount
reachedEnd = page.rawResultCount < MEDIA_PAGE_SIZE
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}" } }
.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()
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { mediaCatalogCache.write(accountName, cachedSnapshot) }
if (continueLoading && isAdded && requestGeneration == loadGeneration && section == requestedSection) {
loadNextMediaPage(reset = false)
}
}
}.onFailure {
val fallback = withContext(Dispatchers.IO) { recentAutomaticUploads() }
val fallback = withContext(Dispatchers.IO) {
automaticUploadMedia(transferRepository.getAllTransfers())
}
if (!isAdded || requestGeneration != loadGeneration || section != requestedSection) return@launch
isLoading = false
refresh.isRefreshing = false
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
reachedEnd = true
render()
@@ -572,27 +722,31 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
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())
return transferRepository.getFinishedTransfers()
return transfers
.asSequence()
.filter { transfer ->
transfer.accountName == account.name &&
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(
remotePath = transfer.remotePath,
mimeType = MimetypeIconUtil.getBestMimeTypeByFilename(transfer.remotePath),
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 }
.distinctBy { it.remotePath }
.distinctBy(CloudMediaItem::contentKey)
.sortedByDescending { it.modifiedAt }
.take(120)
.toList()
}
@@ -608,13 +762,6 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
CloudSection.MORE -> emptyList()
}
adapter.submitRows(rows)
if (section == CloudSection.PHOTOS) {
recycler.post {
if (isAdded && this::adapter.isInitialized) {
adapter.prefetchRows(requireContext(), rows)
}
}
}
adapter.updateMediaSelection(selection.keys)
}
@@ -652,16 +799,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
}
}
private fun withMediaPagination(rows: List<CloudHubRow>): List<CloudHubRow> =
if (mediaLoaded && !reachedEnd && !isLoading) {
rows + CloudHubRow.Status(
title = getString(R.string.cloud_media_load_more),
summary = "",
loadMore = true,
)
} else {
rows
}
private fun withMediaPagination(rows: List<CloudHubRow>): List<CloudHubRow> = rows
private fun fileRows(): List<CloudHubRow> {
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 {
add(CloudHubRow.PhotoStatus(media.count(CloudMediaItem::isImage), media.count(CloudMediaItem::isVideo)))
if (media.isEmpty()) {
add(
CloudHubRow.Status(
@@ -695,9 +832,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
addAll(
when (photoGridMode) {
CloudPhotoGridMode.SMART -> smartPhotoRows(media)
CloudPhotoGridMode.LARGE -> justifiedPhotoRows(media, LARGE_GROUP_SIZE)
CloudPhotoGridMode.STANDARD -> justifiedPhotoRows(media, STANDARD_GROUP_SIZE)
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,
mirrored = groupIndex % 2 == 1,
maxItemsPerRow = SMART_MAX_ITEMS_PER_ROW,
autoWrap = true,
)
)
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> {
val monthKeyFormat = SimpleDateFormat("yyyy-MM", Locale.ROOT)
val monthLabelFormat = SimpleDateFormat("LLLL", Locale.getDefault())
var previousMonth: String? = null
return media.map { item ->
val month = monthKeyFormat.format(Date(item.modifiedAt))
val label = if (month == previousMonth) {
null
} else {
previousMonth = month
monthLabelFormat.format(Date(item.modifiedAt)).replaceFirstChar { character ->
character.titlecase(Locale.getDefault())
return buildList {
media.groupBy { item -> monthKeyFormat.format(Date(item.modifiedAt)) }
.values
.forEach { monthItems ->
monthItems.chunked(MONTH_GROUP_SIZE).forEachIndexed { index, items ->
val label = if (index == 0) {
monthLabelFormat.format(Date(items.first().modifiedAt)).replaceFirstChar { character ->
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) {
if (!selectionMode && selection.isEmpty) {
startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media))
CloudMediaPreviewSession.update(filteredMedia())
mediaPreviewLauncher.launch(CloudMediaPreviewActivity.createIntent(requireContext(), media))
} else {
toggleMediaSelection(media)
}
@@ -856,6 +1018,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
updateSelectionUi()
if (deletedItems.size == items.size) {
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()
} else {
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 {
private data class GridGeometry(
val spans: Int,
val mediaSpans: Int,
val previewSize: Int,
)
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_MONTHS = 1
private const val SMART_GROUP_SIZE = 6
private const val PHOTO_PREVIEW_SMALL = 320
private const val PHOTO_PREVIEW_LARGE = 512
private const val MEDIA_PAGE_SIZE = 200
private const val LOAD_MORE_THRESHOLD = 18
private const val SMART_MAX_ITEMS_PER_ROW = 3
private const val LARGE_GROUP_SIZE = 3
private const val STANDARD_GROUP_SIZE = 5
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 STATE_SELECTION = "cloud_media_selection"
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
import android.accounts.Account
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
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.ViewGroup
import android.widget.EditText
import android.widget.ImageView
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.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
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.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import coil.dispose
import coil.load
import coil.request.CachePolicy
import coil.size.Scale
import com.github.chrisbanes.photoview.PhotoView
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
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.sharing.ShareActivity
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.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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() {
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?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
)
setContentView(R.layout.activity_cloud_media_preview)
val media = mediaExtra() ?: run {
val anchor = mediaExtra() ?: run {
finish()
return
}
findViewById<Toolbar>(R.id.cloud_preview_toolbar).also { toolbar ->
toolbar.title = media.name
setSupportActionBar(toolbar)
val snapshot = CloudMediaPreviewSession.snapshot(anchor)
previewItems = snapshot.items
val toolbar = findViewById<Toolbar>(R.id.cloud_preview_toolbar).also {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
toolbar.setNavigationOnClickListener { finish() }
it.setNavigationOnClickListener { finish() }
}
if (media.isVideo) showVideo(media) else showImage(media)
}
private fun showImage(media: CloudMediaItem) {
val account = AccountUtils.getCurrentQSferaAccount(this)
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply {
scaleType = ImageView.ScaleType.FIT_CENTER
visibility = View.VISIBLE
}
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()
account = AccountUtils.getCurrentQSferaAccount(this)
previewAdapter = PreviewAdapter(snapshot.items, account)
pager = findViewById<ViewPager2>(R.id.cloud_preview_pager).apply {
adapter = previewAdapter
offscreenPageLimit = 1
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
toolbar.title = snapshot.items[position].name
previewAdapter.setActivePosition(position)
invalidateOptionsMenu()
}
}.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
}
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(MainApp.userAgent)
.setDefaultRequestProperties(mapOf("Authorization" to prepared.second))
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.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) {
progress.visibility = if (playbackState == Player.STATE_BUFFERING) View.VISIBLE else View.GONE
}
})
exoPlayer.setMediaSource(mediaSource)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
val uri = shareUri(file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = media.mimeType
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newUri(contentResolver, media.name, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, getString(R.string.cloud_preview_share)))
}
}
private fun openCurrent(action: String) {
val media = currentMedia()
lifecycleScope.launch {
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()) {
ThumbnailsRequester.getPreviewUriForWebDavHref(
media.webDavHref,
account,
media.etag.ifBlank { media.modifiedAt.toString() },
width,
height,
media.cacheRevision,
FULL_PREVIEW_SIZE,
FULL_PREVIEW_SIZE,
ThumbnailsRequester.PreviewProcessor.FIT,
)
} else {
ThumbnailsRequester.getPreviewUriForFile(
media.toOCFile(account.name),
account,
media.etag.ifBlank { media.modifiedAt.toString() },
width,
height,
media.cacheRevision,
FULL_PREVIEW_SIZE,
FULL_PREVIEW_SIZE,
ThumbnailsRequester.PreviewProcessor.FIT,
)
}
@@ -170,16 +740,19 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
intent.getParcelableExtra(EXTRA_MEDIA)
}
override fun onDestroy() {
player?.release()
player = null
super.onDestroy()
}
companion object {
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 =
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 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
data class CloudMediaItem(
val webDavHref: String = "",
@@ -18,6 +27,7 @@ data class CloudMediaItem(
val modifiedAt: Long,
val etag: String = "",
val spaceId: String? = null,
val localPreviewPath: String? = null,
) : Parcelable {
val name: String get() = remotePath.trimEnd('/').substringAfterLast('/')
val parentPath: String get() = remotePath.substringBeforeLast('/', missingDelimiterValue = "/").ifBlank { "/" }
@@ -25,7 +35,9 @@ data class CloudMediaItem(
val isImage: Boolean get() = mimeType.startsWith("image/")
val albumKey: String get() = "${spaceId.orEmpty()}::$parentPath"
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 cacheRevision: String get() = etag.ifBlank { modifiedAt.toString() }
fun toOCFile(owner: String): OCFile = OCFile(
owner = owner,
@@ -151,6 +163,8 @@ internal sealed interface CloudHubRow {
val date: String,
val items: List<CloudMediaItem>,
val mirrored: Boolean,
val maxItemsPerRow: Int,
val autoWrap: Boolean,
) : CloudHubRow
data class Storage(val item: CloudStorageItem) : 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
/**
* Lays out up to six media tiles as a compact justified mosaic. The six-item geometry mirrors
* the day group visible in Yandex Disk: four tiles in a narrow column and two larger tiles in a
* wide column. Smaller groups use dedicated geometries instead of leaving empty holes.
* Aspect-aware justified media rows. Tile bounds follow the decoded photo/video proportions,
* allowing CENTER_CROP to fill every tile without visibly cropping content or adding letterbox
* frames. Smart groups may wrap into several rows; the other density modes use one row.
*/
internal class CloudSmartPhotoLayout @JvmOverloads constructor(
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 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) {
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
for (index in 0 until childCount) {
@@ -59,10 +102,7 @@ internal class CloudSmartPhotoLayout @JvmOverloads constructor(
}
}
setMeasuredDimension(
resolveSize(width, widthMeasureSpec),
resolveSize(desiredHeight, heightMeasureSpec),
)
setMeasuredDimension(resolveSize(width, widthMeasureSpec), resolveSize(desiredHeight, heightMeasureSpec))
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
@@ -89,107 +129,77 @@ internal class CloudSmartPhotoLayout @JvmOverloads constructor(
companion object {
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)) {
1 -> index == 0
2 -> index <= 1
3 -> index == 0
4 -> index <= 3
5 -> index == 1 || index == 2
6 -> index == 4 || index == 5
else -> false
}
internal fun geometry(width: Int, count: Int, mirrored: Boolean, gap: Int): List<TileBounds> {
if (width <= 0 || count <= 0) return emptyList()
val normalizedCount = count.coerceAtMost(MAX_MEDIA)
val specs = when (normalizedCount) {
1 -> GeometrySpec(rows = 1, heightRatio = 0.72f, cells = listOf(CellSpec(0, 0, 1, 1)))
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),
),
)
internal fun geometry(
width: Int,
aspectRatios: List<Float>,
maxItemsPerRow: Int,
autoWrap: Boolean,
mirrored: Boolean,
gap: Int,
): List<TileBounds> {
if (width <= 0 || aspectRatios.isEmpty()) return emptyList()
val ratios = aspectRatios.take(MAX_MEDIA).map(::normalizeRatio)
val rowLimit = maxItemsPerRow.coerceIn(1, MAX_MEDIA)
val rows = if (autoWrap) {
splitSmartRows(width, ratios, rowLimit)
} else {
ratios.indices.chunked(rowLimit)
}
val height = (width * specs.heightRatio).roundToInt()
return specs.cells.map { cell ->
val rawLeft = cell.column * width / specs.columns
val rawRight = (cell.column + cell.columnSpan) * width / specs.columns
val rawTop = cell.row * height / specs.rows
val rawBottom = (cell.row + cell.rowSpan) * height / specs.rows
val adjusted = TileBounds(
left = rawLeft + if (cell.column > 0) gap else 0,
top = rawTop + if (cell.row > 0) gap else 0,
right = rawRight - if (cell.column + cell.columnSpan < specs.columns) gap else 0,
bottom = rawBottom - if (cell.row + cell.rowSpan < specs.rows) gap else 0,
)
if (mirrored) {
adjusted.copy(left = width - adjusted.right, right = width - adjusted.left)
} else {
adjusted
val result = MutableList(ratios.size) { TileBounds(0, 0, 0, 0) }
var rowTop = 0
rows.forEach { row ->
val usableWidth = (width - gap * (row.size - 1)).coerceAtLeast(row.size)
val ratioSum = row.sumOf { ratios[it].toDouble() }.toFloat().coerceAtLeast(MIN_RATIO)
val rowHeight = (usableWidth / ratioSum).roundToInt().coerceAtLeast(1)
var prefixRatio = 0f
row.forEachIndexed { column, itemIndex ->
val contentLeft = (usableWidth * prefixRatio / ratioSum).roundToInt()
prefixRatio += ratios[itemIndex]
val contentRight = (usableWidth * prefixRatio / ratioSum).roundToInt()
val raw = TileBounds(
left = contentLeft + gap * column,
top = rowTop,
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(
val columns: Int = 1,
val rows: Int,
val heightRatio: Float,
val cells: List<CellSpec>,
)
private fun splitSmartRows(width: Int, ratios: List<Float>, rowLimit: Int): List<List<Int>> {
val targetHeight = width * SMART_TARGET_HEIGHT_RATIO
val targetRatioSum = width / targetHeight
val rows = mutableListOf<List<Int>>()
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(
val column: Int,
val row: Int,
val columnSpan: Int,
val rowSpan: Int,
)
private fun normalizeRatio(value: Float): Float =
if (value.isFinite() && value > 0f) value.coerceIn(MIN_RATIO, MAX_RATIO) else DEFAULT_RATIO
}
}
@@ -50,11 +50,13 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
import java.io.File
import java.util.Locale
object ThumbnailsRequester : KoinComponent {
enum class PreviewProcessor(val queryValue: String) {
FIT("fit"),
THUMBNAIL("thumbnail"),
}
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 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 val thumbnailImageLoaders = ConcurrentHashMap<String, ImageLoader>()
@@ -73,7 +80,7 @@ object ThumbnailsRequester : KoinComponent {
private val sharedDiskCache: DiskCache by lazy {
DiskCache.Builder()
.directory(appContext.cacheDir.resolve("thumbnails_coil_cache"))
.directory(persistentThumbnailDirectory())
.maxSizeBytes(THUMBNAIL_DISK_CACHE_SIZE)
.build()
}
@@ -241,6 +248,23 @@ object ThumbnailsRequester : KoinComponent {
.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 {
val interceptor = CoilRequestHeaderInterceptor(clientManager, account.name)
return ImageLoader(appContext).newBuilder()
@@ -27,6 +27,7 @@ import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
import eu.qsfera.android.domain.BaseUseCase
@@ -38,15 +39,16 @@ import timber.log.Timber
class UploadFileFromSystemUseCase(
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()
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName)
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR, params.behavior)
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH, params.localPath)
.putString(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH, params.uploadPath)
.putLong(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID, params.uploadIdInStorageManager)
.putBoolean(UploadFileFromFileSystemWorker.KEY_PARAM_REMOVE_LOCAL, params.removeLocalAfterUpload)
.apply {
params.lastModifiedInSeconds?.let {
putString(UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED, it)
@@ -59,8 +61,10 @@ class UploadFileFromSystemUseCase(
}
}.build()
val networkRequired = if (params.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiredNetworkType(networkRequired)
.setRequiresCharging(params.chargingOnly)
.build()
val uploadFileFromSystemWorker = OneTimeWorkRequestBuilder<UploadFileFromFileSystemWorker>()
@@ -79,7 +83,7 @@ class UploadFileFromSystemUseCase(
val uniqueWorkName = "upload_file_system_${params.uploadIdInStorageManager}"
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>()
.setInputData(inputDataRemoveSourceFileWorker)
.build()
@@ -98,6 +102,7 @@ class UploadFileFromSystemUseCase(
}
Timber.i("Plain upload of ${params.localPath} has been enqueued with unique work name: $uniqueWorkName")
return operation
}
data class Params(
@@ -108,5 +113,8 @@ class UploadFileFromSystemUseCase(
val uploadPath: String,
val uploadIdInStorageManager: Long,
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.net.Uri
import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.WorkInfo
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.automaticUploadMediaFingerprint
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.UPLOAD_NOTIFICATION_CHANNEL_ID
import kotlinx.coroutines.CancellationException
@@ -61,6 +63,8 @@ import org.koin.core.component.inject
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Date
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
// when two workers race on the same cache path.
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(
sourceUri = contentUri,
lastModified = candidate.lastModified,
dateAdded = candidate.dateAdded,
completedUploadTimesBySourceUri = transferRecovery.completedUploadTimesBySourceUri,
)
if (activeTransferExists || completedTransferMatchesSource) {
if (activeTransferHasDurableLocalCopy || completedTransferMatchesSource) {
Timber.d("Skipping already-tracked file: %s", candidate.name)
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(
candidate = candidate,
localPath = stagedFile.absolutePath,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
accountName = folderBackUpConfiguration.accountName,
behavior = effectiveBehavior,
@@ -212,11 +234,11 @@ class AutomaticUploadsWorker(
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
},
spaceId = folderBackUpConfiguration.spaceId,
failedTransfer = transferRecovery.failedTransfersBySourceUri[contentUri],
failedTransfer = transferRecovery.failedTransfersBySourceUri[contentUri] ?: activeTransfer,
)
val enqueueSucceeded = runCatching {
enqueueSingleUpload(
contentUri = candidate.uri,
localPath = stagedFile.absolutePath,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
lastModified = candidate.lastModified,
behavior = effectiveBehavior.toString(),
@@ -238,6 +260,8 @@ class AutomaticUploadsWorker(
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
@@ -520,7 +544,7 @@ class AutomaticUploadsWorker(
}
private fun enqueueSingleUpload(
contentUri: Uri,
localPath: String,
uploadPath: String,
lastModified: Long,
behavior: String,
@@ -531,22 +555,91 @@ class AutomaticUploadsWorker(
): Operation {
val lastModifiedInSeconds = (lastModified / 1000L).toString()
return UploadFileFromContentUriUseCase(WorkManager.getInstance(appContext))(
UploadFileFromContentUriUseCase.Params(
return UploadFileFromSystemUseCase(WorkManager.getInstance(appContext))(
UploadFileFromSystemUseCase.Params(
accountName = accountName,
contentUri = contentUri,
localPath = localPath,
lastModifiedInSeconds = lastModifiedInSeconds,
behavior = behavior,
uploadPath = uploadPath,
uploadIdInStorageManager = uploadId,
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(
candidate: AutomaticUploadCandidate,
localPath: String,
uploadPath: String,
accountName: String,
behavior: UploadBehavior,
@@ -556,7 +649,7 @@ class AutomaticUploadsWorker(
): Long {
val ocTransfer = OCTransfer(
id = failedTransfer?.id,
localPath = candidate.uri.toString(),
localPath = localPath,
remotePath = uploadPath,
accountName = accountName,
fileSize = candidate.size,
@@ -576,7 +669,7 @@ class AutomaticUploadsWorker(
accountName: String,
sourceType: UploadEnqueuedBy,
): AutomaticUploadTransferRecovery {
val activeSourceUris = mutableSetOf<String>()
val activeTransfersBySourceUri = mutableMapOf<String, OCTransfer>()
val completedUploadTimes = mutableMapOf<String, Long>()
val retrySourceUris = mutableSetOf<String>()
val failedTransfers = mutableMapOf<String, OCTransfer>()
@@ -604,7 +697,10 @@ class AutomaticUploadsWorker(
TransferStatus.TRANSFER_IN_PROGRESS -> {
val uploadId = transfer.id
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) {
val finishedAt = System.currentTimeMillis()
transferRepository.updateTransferWhenFinished(
@@ -623,9 +719,9 @@ class AutomaticUploadsWorker(
}
}
}
retrySourceUris.removeAll(activeSourceUris)
retrySourceUris.removeAll(activeTransfersBySourceUri.keys)
return AutomaticUploadTransferRecovery(
activeSourceUris = activeSourceUris,
activeTransfersBySourceUri = activeTransfersBySourceUri,
completedUploadTimesBySourceUri = completedUploadTimes,
retrySourceUris = retrySourceUris,
failedTransfersBySourceUri = failedTransfers,
@@ -677,6 +773,10 @@ class AutomaticUploadsWorker(
const val WRITE_SAFETY_BUFFER_MS = 10_000L
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L
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(
val activeSourceUris: Set<String>,
val activeTransfersBySourceUri: Map<String, OCTransfer>,
val completedUploadTimesBySourceUri: Map<String, Long>,
val retrySourceUris: Set<String>,
val failedTransfersBySourceUri: Map<String, OCTransfer>,
@@ -714,6 +814,14 @@ private data class AutomaticUploadDiscovery(
private fun OCTransfer.isNewerThan(other: OCTransfer): Boolean =
(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(
sourceUri: String,
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"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cloud_preview_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
@@ -15,35 +16,82 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/cloud_preview_photo"
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/cloud_preview_pager"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@id/cloud_preview_bottom_actions"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
<androidx.media3.ui.PlayerView
android:id="@+id/cloud_preview_player"
<LinearLayout
android:id="@+id/cloud_preview_bottom_actions"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
android:layout_height="@dimen/cloud_preview_bottom_actions_height"
android:background="#E6000000"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
app:layout_constraintStart_toStartOf="parent">
<ProgressBar
android:id="@+id/cloud_preview_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminateTint="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/cloud_preview_share"
style="?attr/borderlessButtonStyle"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/cloud_preview_share"
android:padding="20dp"
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>
@@ -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:paddingBottom="8dp">
<ImageView
android:id="@+id/cloud_storage_icon"
<FrameLayout
android:layout_width="96dp"
android:layout_height="82dp"
android:contentDescription="@null"
android:scaleType="centerInside"
android:src="@drawable/ic_qsfera_folder" />
android:layout_height="82dp">
<ImageView
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
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/>.
-->
<resources>
<dimen name="cloud_preview_bottom_actions_height">72dp</dimen>
<!-- STANDARD -->
<dimen name="standard_padding">16dp</dimen>
<dimen name="standard_half_padding">8dp</dimen>
@@ -917,6 +917,25 @@
<string name="pattern_label">Pattern</string>
<string name="receive_external_files_label">Receive external files</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="image_preview_label">Image preview</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
fun `smart tile geometries fill every supported group without empty slots`() {
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)
assertTrue(bounds.all { it.width > 0 && it.height > 0 })
@@ -37,8 +44,9 @@ class CloudPhotoGridModeTest {
@Test
fun `mirrored smart geometry preserves tile sizes`() {
val regular = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = false, gap = 2)
val mirrored = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = true, gap = 2)
val ratios = listOf(0.75f, 1.33f, 1.77f, 0.56f, 1f, 2f)
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) ->
assertEquals(left.width, right.width)
@@ -46,4 +54,23 @@ class CloudPhotoGridModeTest {
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"
@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
fun `completed upload identifies original source`() {
assertTrue(
+1 -1
View File
@@ -36,7 +36,7 @@ RUN make release-linux-docker-${TARGETARCH} ENABLE_VIPS=true DIST=/dist
FROM alpine:3.23
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
LABEL maintainer="QSfera" \
@@ -3,7 +3,7 @@ FROM amd64/alpine:edge
ARG VERSION=""
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
LABEL maintainer="QSfera" \
@@ -3,7 +3,7 @@ FROM arm64v8/alpine:edge
ARG VERSION=""
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
LABEL maintainer="QSfera" \
+1 -1
View File
@@ -20,7 +20,7 @@ ARG REVISION
ARG TARGETOS
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 \
vips-magick patch && \
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.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 {
case sir.PageSize == -1:
@@ -111,6 +111,11 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
},
boolQuery,
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{
HighlightOptions: osu.HighlightOptions{
NumberOfFragments: 2,
@@ -95,6 +95,7 @@ func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyPa
type SearchBodyParams struct {
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]
}
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) {
@@ -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"`
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"`
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{
Addr: "127.0.0.1:9185",
Namespace: "qsfera.api",
MaxConcurrentRequests: 0,
MaxConcurrentRequests: 2,
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9186",
@@ -47,7 +47,7 @@ func DefaultConfig() *config.Config {
Name: "thumbnails",
},
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{
RootDirectory: path.Join(defaults.BaseDataPath(), "thumbnails"),
},
@@ -58,6 +58,7 @@ func DefaultConfig() *config.Config {
MaxInputWidth: 7680,
MaxInputHeight: 7680,
MaxInputImageFileSize: "50MB",
MaxInputVideoFileSize: "2GB",
},
}
}
@@ -4,15 +4,20 @@ import (
"archive/zip"
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"image"
"image/draw"
"image/gif"
_ "image/jpeg"
"io"
"math"
"mime"
"os"
"os/exec"
"strings"
"time"
"github.com/pkg/errors"
"golang.org/x/image/font"
@@ -41,6 +46,59 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) {
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
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.
mimeType, _, _ = mime.ParseMediaType(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":
fontFileMap := ""
fontFaceOpts := &opentype.FaceOptions{
@@ -173,6 +173,13 @@ var _ = Describe("ImageDecoder", func() {
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() {
decoder := ForType("application/vnd.geogebra.ggs", nil)
// This will not return the expected ggsDecoder, but an ImageDecoder since ggs contains an embedded png.
@@ -1,6 +1,8 @@
package grpc
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/service/grpc"
"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/thumbnail/imgsource"
"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.
@@ -62,20 +62,25 @@ func NewService(opts ...Option) grpc.Service {
options.Logger.Error().Err(err).Msg("could not parse MaxInputImageFileSize")
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
{
thumbnail = svc.NewService(
svc.Config(options.Config),
svc.Logger(options.Logger),
svc.ThumbnailSource(imgsource.NewWebDavSource(tconf, b)),
svc.ThumbnailSource(imgsource.NewWebDavSource(tconf, b, videoLimit)),
svc.ThumbnailStorage(
storage.NewFileSystemStorage(
tconf.FileSystemStorage,
options.Logger,
),
),
svc.CS3Source(imgsource.NewCS3Source(tconf, gatewaySelector, b)),
svc.CS3Source(imgsource.NewCS3Source(tconf, gatewaySelector, b, videoLimit)),
svc.GatewaySelector(gatewaySelector),
)
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.ContextSetVideoSource(ctx, strings.HasPrefix(sRes.GetInfo().GetMimeType(), "video/"))
r, err := g.cs3Source.Get(ctx, src.GetPath())
switch {
case errors.Is(err, terrors.ErrImageTooLarge):
@@ -245,6 +246,7 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *thumbnailssvc.Ge
if 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
signature, expiration := imgURL.Query().Get("signature"), imgURL.Query().Get("expiration")
@@ -5,11 +5,11 @@ package thumbnail
import (
"bytes"
"image"
"image/png"
"strings"
"github.com/davidbyttow/govips/v2/vips"
"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.
@@ -41,11 +41,13 @@ func (g SimpleGenerator) ProcessorID() string {
func (g SimpleGenerator) Generate(size image.Rectangle, img interface{}) (interface{}, error) {
var m *vips.ImageRef
var err error
switch img.(type) {
case *image.RGBA:
// This comes from the txt preprocessor
switch typed := img.(type) {
case image.Image:
// 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
if err = bmp.Encode(&buf, img.(*image.RGBA)); err != nil {
if err = png.Encode(&buf, typed); err != nil {
return nil, err
}
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) {
switch img.(type) {
case *image.RGBA:
m := img.(*image.RGBA)
return m.Bounds(), nil
switch typed := img.(type) {
case image.Image:
return typed.Bounds(), nil
case *vips.ImageRef:
m := img.(*vips.ImageRef)
return image.Rect(0, 0, m.Width(), m.Height()), nil
return image.Rect(0, 0, typed.Width(), typed.Height()), nil
default:
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"
"io"
"net/http"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/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"
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/rhttp"
"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"
)
@@ -31,14 +32,20 @@ type CS3 struct {
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
insecure bool
maxImageFileSize uint64
maxVideoFileSize uint64
}
// 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{
gatewaySelector: gatewaySelector,
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)
if err != nil {
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()
}
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 {
cancelDownload()
return nil, err
}
httpReq.Header.Set(revactx.TokenHeader, auth)
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,
InsecureSkipVerify: s.insecure, //nolint:gosec
}
client := &http.Client{}
client := &http.Client{Transport: transport}
resp, err := client.Do(httpReq)
if err != nil {
cancelDownload()
return nil, err
}
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)
}
if contextIsVideoSource(ctx) {
return cancelOnCloseReadCloser{ReadCloser: resp.Body, cancel: cancelDownload}, 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 {
gwc, err := s.gatewaySelector.Next()
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 {
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 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 (
auth key = iota
video
)
// Source defines the interface for image sources
@@ -16,6 +17,16 @@ type Source interface {
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.
func ContextSetAuthorization(parent context.Context, authorization string) context.Context {
return context.WithValue(parent, auth, authorization)
@@ -11,17 +11,18 @@ import (
"net/http"
"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/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.
func NewWebDavSource(cfg config.Thumbnail, b bytesize.ByteSize) WebDav {
func NewWebDavSource(cfg config.Thumbnail, imageLimit, videoLimit bytesize.ByteSize) WebDav {
return WebDav{
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 {
insecure bool
maxImageFileSize uint64
maxVideoFileSize uint64
}
// 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 {
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
}
@@ -18,5 +18,18 @@ var (
"audio/ogg": {},
"application/vnd.geogebra.slides": {},
"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.pinboard": {},
"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": {},
}
)