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