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