diff --git a/Android/qsferaApp/build.gradle b/Android/qsferaApp/build.gradle index 274aafee3..3b34a614d 100644 --- a/Android/qsferaApp/build.gradle +++ b/Android/qsferaApp/build.gradle @@ -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() + "\"" diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt index 5b5f95d42..450229221 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt @@ -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 = emptySet() private var gridSpanCount = DEFAULT_GRID_SPANS private var mediaSpanSize = DEFAULT_MEDIA_SPANS - private var mediaPreviewSize = PREVIEW_MEDIUM - private val prefetchedUrls = mutableSetOf() - 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) { - 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) { @@ -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(R.id.cloud_feed_title).text = feedTitle val mainImage = view.findViewById(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(R.id.cloud_media_image) val video = view.findViewById(R.id.cloud_media_video) val selectionScrim = view.findViewById(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(R.id.cloud_smart_grid).apply { + val grid = view.findViewById(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(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(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) { - 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(R.id.cloud_storage_title).text = item.name - view.findViewById(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(R.id.cloud_storage_icon) + val video = view.findViewById(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(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 + } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt index ca0ed4280..8a7e4b4b8 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt @@ -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 = emptyList() + private var refreshedMedia: List = emptyList() + private var automaticUploadMedia: List = emptyList() private var storageItems: List = 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? = null + private var thumbnailWarmJob: Job? = null + private val queuedThumbnailKeys = mutableSetOf() + 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(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(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(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 { + private fun automaticUploadMedia(transfers: List): List { 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): List = - 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): List = rows private fun fileRows(): List { val filtered = storageItems.filter { item -> @@ -683,7 +821,6 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } private fun photoRows(media: List): List = 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, groupSize: Int): List = + media.chunked(groupSize).map { items -> + CloudHubRow.SmartMediaGroup( + date = "", + items = items, + mirrored = false, + maxItemsPerRow = groupSize, + autoWrap = false, + ) + } + private fun monthPhotoRows(media: List): List { 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(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) { + 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" diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaAspectRatioCache.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaAspectRatioCache.kt new file mode 100644 index 000000000..ca320ea76 --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaAspectRatioCache.kt @@ -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() + private val pending = ConcurrentHashMap() + 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() + 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 diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaCatalogCache.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaCatalogCache.kt new file mode 100644 index 000000000..e959be160 --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaCatalogCache.kt @@ -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 { + 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) { + 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 = + 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? = 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" + } +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt index fe980c38a..0c9b3a4d6 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt @@ -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 = emptyList() + private var pendingPickerAction: PickerAction? = null + + private val folderPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val target = result.data?.getParcelableExtra(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(R.id.cloud_preview_toolbar).also { toolbar -> - toolbar.title = media.name - setSupportActionBar(toolbar) + val snapshot = CloudMediaPreviewSession.snapshot(anchor) + previewItems = snapshot.items + val toolbar = findViewById(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(R.id.cloud_preview_photo).apply { - scaleType = ImageView.ScaleType.FIT_CENTER - visibility = View.VISIBLE - } - val progress = findViewById(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(R.id.cloud_preview_player).apply { visibility = View.VISIBLE } - val progress = findViewById(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(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(R.id.cloud_preview_share).setOnClickListener { shareCurrent() } + findViewById(R.id.cloud_preview_info).setOnClickListener { showInformation() } + findViewById(R.id.cloud_preview_edit).setOnClickListener { openCurrent(Intent.ACTION_EDIT) } + findViewById(R.id.cloud_preview_delete).setOnClickListener { confirmDeleteCurrent() } + applySystemBarInsets() + } + + private fun applySystemBarInsets() { + val toolbar = findViewById(R.id.cloud_preview_toolbar) + val bottomActions = findViewById(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(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(R.id.cloud_preview_action_icon).setImageResource(icon) + row.findViewById(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 = 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, + private val account: Account, + ) : RecyclerView.Adapter() { + private val holders = mutableSetOf() + 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(R.id.cloud_preview_photo) + private val playerView = view.findViewById(R.id.cloud_preview_player) + private val progress = view.findViewById(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 } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewRequests.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewRequests.kt new file mode 100644 index 000000000..1fd339d67 --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewRequests.kt @@ -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() + } +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewSession.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewSession.kt new file mode 100644 index 000000000..591cfdb9b --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewSession.kt @@ -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 = emptyList() + + fun update(media: List) { + 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, val position: Int) +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt index c5c9274ac..052c7a6e4 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt @@ -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, 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 diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudSmartPhotoLayout.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudSmartPhotoLayout.kt index 0060f6b11..60bd982a9 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudSmartPhotoLayout.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudSmartPhotoLayout.kt @@ -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 = emptyList() + set(value) { + val normalized = value.take(MAX_MEDIA).map(::normalizeRatio) + if (field != normalized) { + field = normalized + requestLayout() + } + } + private var tileBounds: List = 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 { - 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, + maxItemsPerRow: Int, + autoWrap: Boolean, + mirrored: Boolean, + gap: Int, + ): List { + 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, - ) + private fun splitSmartRows(width: Int, ratios: List, rowLimit: Int): List> { + val targetHeight = width * SMART_TARGET_HEIGHT_RATIO + val targetRatioSum = width / targetHeight + val rows = mutableListOf>() + var index = 0 + while (index < ratios.size) { + val row = mutableListOf() + 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 } } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt index 402ae08b9..6115e19a0 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -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() @@ -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() diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt index 0ef1a0a18..eadbb7e88 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt @@ -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() { +) : BaseUseCase() { - 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() @@ -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() .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, ) } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt index 3703e1fc3..2cc7b02f4 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/workers/AutomaticUploadsWorker.kt @@ -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() + .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() + val activeTransfersBySourceUri = mutableMapOf() val completedUploadTimes = mutableMapOf() val retrySourceUris = mutableSetOf() val failedTransfers = mutableMapOf() @@ -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, + val activeTransfersBySourceUri: Map, val completedUploadTimesBySourceUri: Map, val retrySourceUris: Set, val failedTransfersBySourceUri: Map, @@ -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, diff --git a/Android/qsferaApp/src/main/res/drawable/cloud_preview_beta_badge.xml b/Android/qsferaApp/src/main/res/drawable/cloud_preview_beta_badge.xml new file mode 100644 index 000000000..0dce7790c --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/cloud_preview_beta_badge.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_edit.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_edit.xml new file mode 100644 index 000000000..c4c0cbf29 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_edit.xml @@ -0,0 +1,5 @@ + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_favorite_border.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_favorite_border.xml new file mode 100644 index 000000000..681df577b --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_favorite_border.xml @@ -0,0 +1,6 @@ + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_favorite_filled.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_favorite_filled.xml new file mode 100644 index 000000000..f4bdbb2e6 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_favorite_filled.xml @@ -0,0 +1,5 @@ + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_info.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_info.xml new file mode 100644 index 000000000..e4573074d --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_info.xml @@ -0,0 +1,5 @@ + + + diff --git a/Android/qsferaApp/src/main/res/layout/activity_cloud_media_preview.xml b/Android/qsferaApp/src/main/res/layout/activity_cloud_media_preview.xml index 776822447..df042ec6d 100644 --- a/Android/qsferaApp/src/main/res/layout/activity_cloud_media_preview.xml +++ b/Android/qsferaApp/src/main/res/layout/activity_cloud_media_preview.xml @@ -1,6 +1,7 @@ @@ -15,35 +16,82 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - - + app:layout_constraintStart_toStartOf="parent"> - + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_media_preview_page.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_media_preview_page.xml new file mode 100644 index 000000000..d2b5bdc2b --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_media_preview_page.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_preview_action.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_preview_action.xml new file mode 100644 index 000000000..b4086832d --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_preview_action.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/item_cloud_storage.xml b/Android/qsferaApp/src/main/res/layout/item_cloud_storage.xml index 9adcead8a..7ceba8f43 100644 --- a/Android/qsferaApp/src/main/res/layout/item_cloud_storage.xml +++ b/Android/qsferaApp/src/main/res/layout/item_cloud_storage.xml @@ -11,13 +11,30 @@ android:paddingEnd="6dp" android:paddingBottom="8dp"> - + android:layout_height="82dp"> + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/menu/cloud_media_preview_menu.xml b/Android/qsferaApp/src/main/res/menu/cloud_media_preview_menu.xml new file mode 100644 index 000000000..311044f4e --- /dev/null +++ b/Android/qsferaApp/src/main/res/menu/cloud_media_preview_menu.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/Android/qsferaApp/src/main/res/values/dims.xml b/Android/qsferaApp/src/main/res/values/dims.xml index 171203b64..b7e81b18c 100644 --- a/Android/qsferaApp/src/main/res/values/dims.xml +++ b/Android/qsferaApp/src/main/res/values/dims.xml @@ -16,6 +16,7 @@ along with this program. If not, see . --> + 72dp 16dp 8dp diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index 0ab8ed788..01fe720cf 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -917,6 +917,25 @@ Pattern Receive external files Video preview + Поделиться + Информация + Редактировать + бета + Добавить в Избранное + Удалить из Избранного + Действия с файлом + Добавить в альбом + Добавить в Офлайн + Скачать + Сохранить на устройство + Поделиться ссылкой + Открыть с помощью… + Использовать как + Переместить + Копировать + Переименовать + Файл сохранён на устройстве + Не удалось выполнить действие Release notes Image preview Login diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaCatalogCacheTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaCatalogCacheTest.kt new file mode 100644 index 000000000..fe7d6d72b --- /dev/null +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaCatalogCacheTest.kt @@ -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" + } +} diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewCacheTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewCacheTest.kt new file mode 100644 index 000000000..351461004 --- /dev/null +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewCacheTest.kt @@ -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", + ) +} diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudPhotoGridModeTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudPhotoGridModeTest.kt index 666b2e22e..1194d5dd3 100644 --- a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudPhotoGridModeTest.kt +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudPhotoGridModeTest.kt @@ -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)) + } } diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt index de163d55a..66c44ee84 100644 --- a/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/workers/AutomaticUploadsWorkerTest.kt @@ -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( diff --git a/Server/Dockerfile b/Server/Dockerfile index 561b7a5b6..155a38451 100644 --- a/Server/Dockerfile +++ b/Server/Dockerfile @@ -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" \ diff --git a/Server/qsfera/docker/Dockerfile.linux.debug.amd64 b/Server/qsfera/docker/Dockerfile.linux.debug.amd64 index 7fdee2636..d321cd7a5 100644 --- a/Server/qsfera/docker/Dockerfile.linux.debug.amd64 +++ b/Server/qsfera/docker/Dockerfile.linux.debug.amd64 @@ -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" \ diff --git a/Server/qsfera/docker/Dockerfile.linux.debug.arm64 b/Server/qsfera/docker/Dockerfile.linux.debug.arm64 index 5c97db39e..fec4b7bc4 100644 --- a/Server/qsfera/docker/Dockerfile.linux.debug.arm64 +++ b/Server/qsfera/docker/Dockerfile.linux.debug.arm64 @@ -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" \ diff --git a/Server/qsfera/docker/Dockerfile.multiarch b/Server/qsfera/docker/Dockerfile.multiarch index c44ee167a..5e0df763d 100644 --- a/Server/qsfera/docker/Dockerfile.multiarch +++ b/Server/qsfera/docker/Dockerfile.multiarch @@ -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 diff --git a/Server/services/search/pkg/bleve/backend.go b/Server/services/search/pkg/bleve/backend.go index d1386db8f..80d4aefb1 100644 --- a/Server/services/search/pkg/bleve/backend.go +++ b/Server/services/search/pkg/bleve/backend.go @@ -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: diff --git a/Server/services/search/pkg/opensearch/backend.go b/Server/services/search/pkg/opensearch/backend.go index 1f931ff9a..449a0897b 100644 --- a/Server/services/search/pkg/opensearch/backend.go +++ b/Server/services/search/pkg/opensearch/backend.go @@ -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, diff --git a/Server/services/search/pkg/opensearch/internal/osu/request.go b/Server/services/search/pkg/opensearch/internal/osu/request.go index 927da7ac5..9ed0a0983 100644 --- a/Server/services/search/pkg/opensearch/internal/osu/request.go +++ b/Server/services/search/pkg/opensearch/internal/osu/request.go @@ -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"` } //----------------------------------------------------------------------------// diff --git a/Server/services/search/pkg/search/search.go b/Server/services/search/pkg/search/search.go index 277021a2d..aa56a4ea5 100644 --- a/Server/services/search/pkg/search/search.go +++ b/Server/services/search/pkg/search/search.go @@ -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) { diff --git a/Server/services/thumbnails/pkg/config/config.go b/Server/services/thumbnails/pkg/config/config.go index 621144e16..b9621aa31 100644 --- a/Server/services/thumbnails/pkg/config/config.go +++ b/Server/services/thumbnails/pkg/config/config.go @@ -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"` } diff --git a/Server/services/thumbnails/pkg/config/defaults/defaultconfig.go b/Server/services/thumbnails/pkg/config/defaults/defaultconfig.go index 1d34d05ce..b848a0191 100644 --- a/Server/services/thumbnails/pkg/config/defaults/defaultconfig.go +++ b/Server/services/thumbnails/pkg/config/defaults/defaultconfig.go @@ -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", }, } } diff --git a/Server/services/thumbnails/pkg/preprocessor/preprocessor.go b/Server/services/thumbnails/pkg/preprocessor/preprocessor.go index 7515160f6..c622e5d47 100644 --- a/Server/services/thumbnails/pkg/preprocessor/preprocessor.go +++ b/Server/services/thumbnails/pkg/preprocessor/preprocessor.go @@ -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{ diff --git a/Server/services/thumbnails/pkg/preprocessor/preprocessor_test.go b/Server/services/thumbnails/pkg/preprocessor/preprocessor_test.go index a76a0512c..cb816f8e7 100644 --- a/Server/services/thumbnails/pkg/preprocessor/preprocessor_test.go +++ b/Server/services/thumbnails/pkg/preprocessor/preprocessor_test.go @@ -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. diff --git a/Server/services/thumbnails/pkg/server/grpc/server.go b/Server/services/thumbnails/pkg/server/grpc/server.go index a1c3feea6..040e2cea7 100644 --- a/Server/services/thumbnails/pkg/server/grpc/server.go +++ b/Server/services/thumbnails/pkg/server/grpc/server.go @@ -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) diff --git a/Server/services/thumbnails/pkg/service/grpc/v0/service.go b/Server/services/thumbnails/pkg/service/grpc/v0/service.go index 29ecb09d6..26d8e6252 100644 --- a/Server/services/thumbnails/pkg/service/grpc/v0/service.go +++ b/Server/services/thumbnails/pkg/service/grpc/v0/service.go @@ -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") diff --git a/Server/services/thumbnails/pkg/thumbnail/generator_vips.go b/Server/services/thumbnails/pkg/thumbnail/generator_vips.go index f29660d44..6a4237a77 100644 --- a/Server/services/thumbnails/pkg/thumbnail/generator_vips.go +++ b/Server/services/thumbnails/pkg/thumbnail/generator_vips.go @@ -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 } diff --git a/Server/services/thumbnails/pkg/thumbnail/generator_vips_test.go b/Server/services/thumbnails/pkg/thumbnail/generator_vips_test.go new file mode 100644 index 000000000..70c6857d9 --- /dev/null +++ b/Server/services/thumbnails/pkg/thumbnail/generator_vips_test.go @@ -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) + } +} diff --git a/Server/services/thumbnails/pkg/thumbnail/imgsource/cs3.go b/Server/services/thumbnails/pkg/thumbnail/imgsource/cs3.go index b135565d5..0c861a4e8 100644 --- a/Server/services/thumbnails/pkg/thumbnail/imgsource/cs3.go +++ b/Server/services/thumbnails/pkg/thumbnail/imgsource/cs3.go @@ -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 diff --git a/Server/services/thumbnails/pkg/thumbnail/imgsource/cs3_test.go b/Server/services/thumbnails/pkg/thumbnail/imgsource/cs3_test.go new file mode 100644 index 000000000..0522fdd0e --- /dev/null +++ b/Server/services/thumbnails/pkg/thumbnail/imgsource/cs3_test.go @@ -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: + } +} diff --git a/Server/services/thumbnails/pkg/thumbnail/imgsource/imgsource.go b/Server/services/thumbnails/pkg/thumbnail/imgsource/imgsource.go index 77c274bf2..a40261211 100644 --- a/Server/services/thumbnails/pkg/thumbnail/imgsource/imgsource.go +++ b/Server/services/thumbnails/pkg/thumbnail/imgsource/imgsource.go @@ -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) diff --git a/Server/services/thumbnails/pkg/thumbnail/imgsource/webdav.go b/Server/services/thumbnails/pkg/thumbnail/imgsource/webdav.go index 3d8b23632..5abf1460b 100644 --- a/Server/services/thumbnails/pkg/thumbnail/imgsource/webdav.go +++ b/Server/services/thumbnails/pkg/thumbnail/imgsource/webdav.go @@ -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 } diff --git a/Server/services/thumbnails/pkg/thumbnail/mimetypes.go b/Server/services/thumbnails/pkg/thumbnail/mimetypes.go index 02a91e22f..c77e9e2f5 100644 --- a/Server/services/thumbnails/pkg/thumbnail/mimetypes.go +++ b/Server/services/thumbnails/pkg/thumbnail/mimetypes.go @@ -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": {}, } ) diff --git a/Server/services/thumbnails/pkg/thumbnail/mimetypes_vips.go b/Server/services/thumbnails/pkg/thumbnail/mimetypes_vips.go index 597d2e742..4a94b7062 100644 --- a/Server/services/thumbnails/pkg/thumbnail/mimetypes_vips.go +++ b/Server/services/thumbnails/pkg/thumbnail/mimetypes_vips.go @@ -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": {}, } )