Match Yandex photo grid and preserve photo framing
Android / test-and-build (push) Failing after 56s

This commit is contained in:
Курнат Андрей
2026-07-18 15:44:52 +03:00
parent 7eaccd88bd
commit e53f62b464
21 changed files with 1162 additions and 56 deletions
+2 -2
View File
@@ -137,8 +137,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 35
versionName = "1.3.7"
versionCode = 37
versionName = "1.3.9"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -12,8 +12,10 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.RadioButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -25,6 +27,7 @@ import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.switchmaterial.SwitchMaterial
import eu.qsfera.android.R
import eu.qsfera.android.data.ClientManager
import eu.qsfera.android.domain.files.FileRepository
@@ -60,6 +63,7 @@ class CloudHomeActivity : FileActivity() {
private lateinit var toolbarBack: View
private lateinit var toolbarTitle: TextView
private lateinit var toolbarSearchButton: View
private lateinit var toolbarMoreButton: View
private lateinit var toolbarSearch: SearchView
private lateinit var selectionToolbar: View
private lateinit var selectionCount: TextView
@@ -129,6 +133,7 @@ class CloudHomeActivity : FileActivity() {
toolbarBack = findViewById(R.id.cloud_toolbar_back)
toolbarTitle = findViewById(R.id.cloud_toolbar_title)
toolbarSearchButton = findViewById(R.id.cloud_toolbar_search_button)
toolbarMoreButton = findViewById(R.id.cloud_toolbar_more_button)
toolbarSearch = findViewById(R.id.cloud_toolbar_search)
selectionToolbar = findViewById(R.id.cloud_selection_toolbar)
selectionCount = findViewById(R.id.cloud_selection_count)
@@ -146,9 +151,9 @@ class CloudHomeActivity : FileActivity() {
selectionMore.setOnClickListener { showMediaActionsSheet() }
}
fun showMediaSelection(count: Int) {
selectionToolbar.visibility = if (count > 0) View.VISIBLE else View.GONE
if (count > 0) {
fun showMediaSelection(count: Int, active: Boolean = count > 0) {
selectionToolbar.visibility = if (active) View.VISIBLE else View.GONE
if (active) {
selectionCount.text = resources.getQuantityString(R.plurals.items_selected_count, count, count)
} else {
setSelectionBusy(false)
@@ -278,9 +283,11 @@ class CloudHomeActivity : FileActivity() {
private fun setupToolbar() {
toolbarAvatar.setOnClickListener { showProfileSheet() }
toolbarBack.setOnClickListener { cloudFragment()?.navigateUp() }
toolbarMoreButton.setOnClickListener { showPhotoViewSheet() }
toolbarSearchButton.setOnClickListener {
toolbarTitle.visibility = View.GONE
toolbarSearchButton.visibility = View.GONE
toolbarMoreButton.visibility = View.GONE
toolbarSearch.visibility = View.VISIBLE
toolbarSearch.requestFocus()
}
@@ -302,6 +309,49 @@ class CloudHomeActivity : FileActivity() {
}
}
private fun showPhotoViewSheet() {
val fragment = cloudFragment() ?: return
if (currentSection != CloudSection.PHOTOS) return
val dialog = BottomSheetDialog(this)
val content = layoutInflater.inflate(
R.layout.sheet_cloud_photo_view,
findViewById<ViewGroup>(android.R.id.content),
false,
)
dialog.setContentView(content)
content.findViewById<View>(R.id.cloud_photo_select_files).setOnClickListener {
dialog.dismiss()
fragment.startMediaSelection()
}
val screenshotsSwitch = content.findViewById<SwitchMaterial>(R.id.cloud_photo_show_screenshots).apply {
isChecked = fragment.areScreenshotsShown()
setOnCheckedChangeListener { _, checked -> fragment.setScreenshotsShown(checked) }
}
content.findViewById<View>(R.id.cloud_photo_show_screenshots_row).setOnClickListener {
screenshotsSwitch.isChecked = !screenshotsSwitch.isChecked
}
val modeRows = linkedMapOf(
CloudPhotoGridMode.SMART to Pair(R.id.cloud_photo_view_smart, R.id.cloud_photo_view_smart_radio),
CloudPhotoGridMode.LARGE to Pair(R.id.cloud_photo_view_large, R.id.cloud_photo_view_large_radio),
CloudPhotoGridMode.STANDARD to Pair(R.id.cloud_photo_view_standard, R.id.cloud_photo_view_standard_radio),
CloudPhotoGridMode.MONTHS to Pair(R.id.cloud_photo_view_months, R.id.cloud_photo_view_months_radio),
)
fun selectMode(mode: CloudPhotoGridMode) {
modeRows.forEach { (candidate, ids) ->
content.findViewById<RadioButton>(ids.second).isChecked = candidate == mode
}
fragment.setPhotoGridMode(mode)
}
selectMode(fragment.photoGridMode())
modeRows.forEach { (mode, ids) ->
content.findViewById<View>(ids.first).setOnClickListener { selectMode(mode) }
}
dialog.show()
}
@Suppress("DEPRECATION")
private fun setupBottomNavigation() {
bottomNavigation.setOnNavigationItemSelectedListener { item ->
@@ -336,12 +386,14 @@ class CloudHomeActivity : FileActivity() {
toolbarBack.visibility = View.GONE
toolbarAvatar.visibility = View.VISIBLE
toolbarTitle.text = getString(section.titleResource)
toolbarMoreButton.visibility = if (section == CloudSection.PHOTOS) View.VISIBLE else View.GONE
}
fun showNestedTitle(title: String) {
toolbarAvatar.visibility = View.GONE
toolbarBack.visibility = View.VISIBLE
toolbarTitle.text = title
toolbarMoreButton.visibility = View.GONE
}
fun restoreSectionTitle() {
@@ -355,6 +407,7 @@ class CloudHomeActivity : FileActivity() {
toolbarSearch.visibility = View.GONE
toolbarTitle.visibility = View.VISIBLE
toolbarSearchButton.visibility = View.VISIBLE
toolbarMoreButton.visibility = if (currentSection == CloudSection.PHOTOS) View.VISIBLE else View.GONE
cloudFragment()?.filter("")
}
@@ -6,6 +6,7 @@
package eu.qsfera.android.presentation.cloud
import android.accounts.Account
import android.content.Context
import android.view.LayoutInflater
import android.view.HapticFeedbackConstants
import android.view.View
@@ -18,6 +19,8 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.dispose
import coil.load
import coil.request.ImageRequest
import coil.size.Scale
import eu.qsfera.android.R
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.utils.MimetypeIconUtil
@@ -36,10 +39,21 @@ internal class CloudHubAdapter(
) : RecyclerView.Adapter<CloudHubAdapter.Holder>() {
private var rows: List<CloudHubRow> = emptyList()
private var selectedMediaKeys: Set<String> = emptySet()
private var gridSpanCount = DEFAULT_GRID_SPANS
private var mediaSpanSize = DEFAULT_MEDIA_SPANS
private var mediaPreviewSize = PREVIEW_MEDIUM
private val prefetchedUrls = mutableSetOf<String>()
fun updateGridGeometry(spanCount: Int, mediaSpans: Int, previewSize: Int) {
gridSpanCount = spanCount
mediaSpanSize = mediaSpans
mediaPreviewSize = previewSize
}
fun updateAccount(newAccount: Account) {
if (account == newAccount) return
account = newAccount
prefetchedUrls.clear()
notifyItemRangeChanged(0, itemCount)
}
@@ -65,7 +79,12 @@ internal class CloudHubAdapter(
val changedKeys = (selectedMediaKeys - newSelection) + (newSelection - selectedMediaKeys)
selectedMediaKeys = newSelection
rows.forEachIndexed { index, row ->
if (row is CloudHubRow.Media && row.item.selectionKey in changedKeys) {
val changed = when (row) {
is CloudHubRow.Media -> row.item.selectionKey in changedKeys
is CloudHubRow.SmartMediaGroup -> row.items.any { it.selectionKey in changedKeys }
else -> false
}
if (changed) {
notifyItemChanged(index)
}
}
@@ -79,6 +98,7 @@ internal class CloudHubAdapter(
CloudHubRow.Shortcuts -> { TYPE_SHORTCUTS }
is CloudHubRow.PhotoStatus -> { TYPE_PHOTO_STATUS }
is CloudHubRow.Media -> { TYPE_MEDIA }
is CloudHubRow.SmartMediaGroup -> { TYPE_SMART_MEDIA_GROUP }
is CloudHubRow.Storage -> { TYPE_STORAGE }
is CloudHubRow.Album -> { TYPE_ALBUM }
is CloudHubRow.Action -> { TYPE_ACTION }
@@ -86,15 +106,16 @@ internal class CloudHubAdapter(
}
fun spanSize(position: Int): Int = when (rows[position]) {
is CloudHubRow.Media,
is CloudHubRow.Storage -> { 2 }
is CloudHubRow.Album -> { 3 }
is CloudHubRow.Media -> mediaSpanSize
is CloudHubRow.Storage -> DEFAULT_MEDIA_SPANS
is CloudHubRow.Album -> DEFAULT_ALBUM_SPANS
is CloudHubRow.Header,
is CloudHubRow.FeedCard,
is CloudHubRow.SmartMediaGroup,
CloudHubRow.Shortcuts,
is CloudHubRow.PhotoStatus,
is CloudHubRow.Action,
is CloudHubRow.Status -> { 6 }
is CloudHubRow.Status -> gridSpanCount
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
@@ -104,6 +125,7 @@ internal class CloudHubAdapter(
TYPE_SHORTCUTS -> { R.layout.item_cloud_shortcuts }
TYPE_PHOTO_STATUS -> { R.layout.item_cloud_photo_status }
TYPE_MEDIA -> { R.layout.item_cloud_media }
TYPE_SMART_MEDIA_GROUP -> { R.layout.item_cloud_photo_smart_group }
TYPE_STORAGE -> { R.layout.item_cloud_storage }
TYPE_ALBUM -> { R.layout.item_cloud_album }
TYPE_ACTION -> { R.layout.item_cloud_action }
@@ -120,7 +142,10 @@ internal class CloudHubAdapter(
is CloudHubRow.FeedCard -> { bindFeedCard(holder.itemView, row) }
CloudHubRow.Shortcuts -> { bindShortcuts(holder.itemView) }
is CloudHubRow.PhotoStatus -> { bindPhotoStatus(holder.itemView, row) }
is CloudHubRow.Media -> { bindMedia(holder.itemView, row.item) }
is CloudHubRow.Media -> {
bindMedia(holder.itemView, row.item, previewSize = mediaPreviewSize)
}
is CloudHubRow.SmartMediaGroup -> { bindSmartMediaGroup(holder.itemView, row) }
is CloudHubRow.Storage -> { bindStorage(holder.itemView, row.item) }
is CloudHubRow.Album -> { bindAlbum(holder.itemView, row) }
is CloudHubRow.Action -> { bindAction(holder.itemView, row) }
@@ -200,7 +225,11 @@ internal class CloudHubAdapter(
view.context.getString(R.string.cloud_photo_status, status.photos, status.videos)
}
private fun bindMedia(view: View, item: CloudMediaItem) {
private fun bindMedia(
view: View,
item: CloudMediaItem,
previewSize: Int = PREVIEW_MEDIUM,
) {
val image = view.findViewById<ImageView>(R.id.cloud_media_image)
val video = view.findViewById<ImageView>(R.id.cloud_media_video)
val selectionScrim = view.findViewById<View>(R.id.cloud_media_selection_scrim)
@@ -210,7 +239,7 @@ internal class CloudHubAdapter(
selectionScrim.visibility = if (selected) View.VISIBLE else View.GONE
selectionCheck.visibility = if (selected) View.VISIBLE else View.GONE
view.isActivated = selected
loadMediaImage(image, item, PREVIEW_MEDIUM)
loadMediaImage(image, item, previewSize)
view.contentDescription = item.name
view.setOnClickListener { onMediaClick(item) }
view.setOnLongClickListener {
@@ -220,8 +249,78 @@ internal class CloudHubAdapter(
}
}
private fun bindSmartMediaGroup(view: View, group: CloudHubRow.SmartMediaGroup) {
view.findViewById<CloudSmartPhotoLayout>(R.id.cloud_smart_grid).apply {
mediaCount = group.items.size
mirrored = group.mirrored
}
view.findViewById<TextView>(R.id.cloud_smart_date).apply {
text = group.date
visibility = if (group.date.isBlank()) View.GONE else View.VISIBLE
}
val slots = listOf(
R.id.cloud_smart_media_first,
R.id.cloud_smart_media_second,
R.id.cloud_smart_media_third,
R.id.cloud_smart_media_fourth,
R.id.cloud_smart_media_fifth,
R.id.cloud_smart_media_sixth,
)
slots.forEachIndexed { index, slotId ->
view.findViewById<View>(slotId).apply {
val item = group.items.getOrNull(index)
visibility = if (item == null) View.INVISIBLE else View.VISIBLE
if (item != null) {
val previewSize = if (CloudSmartPhotoLayout.isLargeSlot(group.items.size, index)) {
PREVIEW_LARGE
} else {
PREVIEW_MEDIUM
}
bindMedia(this, item, previewSize = previewSize)
} else {
findViewById<ImageView>(R.id.cloud_media_image).dispose()
setOnClickListener(null)
setOnLongClickListener(null)
}
}
}
}
fun monthLabelAt(position: Int): String? =
(rows.getOrNull(position) as? CloudHubRow.Media)?.label
fun prefetchRows(context: Context, rowsToPrefetch: List<CloudHubRow>) {
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
rowsToPrefetch.forEach { row ->
when (row) {
is CloudHubRow.Media -> prefetchMedia(context, loader, row.item, mediaPreviewSize)
is CloudHubRow.SmartMediaGroup -> row.items.forEachIndexed { index, item ->
val size = if (CloudSmartPhotoLayout.isLargeSlot(row.items.size, index)) {
PREVIEW_LARGE
} else {
PREVIEW_MEDIUM
}
prefetchMedia(context, loader, item, size)
}
else -> Unit
}
}
}
private fun prefetchMedia(context: Context, loader: coil.ImageLoader, item: CloudMediaItem, size: Int) {
if (!item.isImage && !item.isVideo) return
val url = runCatching { previewUri(item, size) }.getOrNull() ?: return
if (!prefetchedUrls.add(url)) return
loader.enqueue(
ImageRequest.Builder(context.applicationContext)
.data(url)
.scale(Scale.FILL)
.build()
)
}
private fun loadMediaImage(image: ImageView, item: CloudMediaItem, size: Int) {
if (item.isImage) {
if (item.isImage || item.isVideo) {
image.scaleType = ImageView.ScaleType.CENTER_CROP
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
val requestKey = item.selectionKey
@@ -232,11 +331,19 @@ internal class CloudHubAdapter(
} else {
image.load(preview, loader) {
placeholder(R.drawable.cloud_media_placeholder)
scale(Scale.FILL)
crossfade(true)
listener(
onError = { _, _ ->
if (image.getTag(R.id.cloud_media_image) == requestKey) {
loadOriginalMediaImage(image, item, requestKey)
if (item.isImage) {
loadOriginalMediaImage(image, item, requestKey)
} else {
image.scaleType = ImageView.ScaleType.CENTER_INSIDE
image.setImageResource(
MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)
)
}
}
}
)
@@ -258,13 +365,14 @@ internal class CloudHubAdapter(
image.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) {
placeholder(R.drawable.cloud_media_placeholder)
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
scale(Scale.FILL)
memoryCacheKey("$originalUri#${item.etag}")
diskCacheKey("$originalUri#${item.etag}")
crossfade(true)
listener(
onStart = {
if (image.getTag(R.id.cloud_media_image) != requestKey) image.dispose()
}
},
)
}
}
@@ -305,9 +413,17 @@ internal class CloudHubAdapter(
item.etag.ifBlank { item.modifiedAt.toString() },
size,
size,
ThumbnailsRequester.PreviewProcessor.FIT,
)
} else {
ThumbnailsRequester.getPreviewUriForFile(item.toOCFile(account.name), account, item.etag, size, size)
ThumbnailsRequester.getPreviewUriForFile(
item.toOCFile(account.name),
account,
item.etag,
size,
size,
ThumbnailsRequester.PreviewProcessor.FIT,
)
}
private fun contentUri(item: CloudMediaItem): String =
@@ -343,6 +459,9 @@ internal class CloudHubAdapter(
CloudHubRow.Shortcuts -> { "shortcuts" }
is CloudHubRow.PhotoStatus -> { "photo-status" }
is CloudHubRow.Media -> { "media:${row.item.spaceId.orEmpty()}:${row.item.remotePath}" }
is CloudHubRow.SmartMediaGroup -> {
"smart:${row.date}:${row.items.firstOrNull()?.selectionKey.orEmpty()}:${row.mirrored}"
}
is CloudHubRow.Storage -> { "storage:${row.item.remotePath}" }
is CloudHubRow.Album -> { "album:${row.path}" }
is CloudHubRow.Action -> { "action:${row.id}" }
@@ -361,6 +480,10 @@ internal class CloudHubAdapter(
private const val TYPE_SHORTCUTS = 6
private const val TYPE_PHOTO_STATUS = 7
private const val TYPE_STORAGE = 8
private const val TYPE_SMART_MEDIA_GROUP = 9
private const val DEFAULT_GRID_SPANS = 6
private const val DEFAULT_MEDIA_SPANS = 2
private const val DEFAULT_ALBUM_SPANS = 3
private const val PREVIEW_SMALL = 320
private const val PREVIEW_MEDIUM = 512
private const val PREVIEW_LARGE = 1024
@@ -23,6 +23,7 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import eu.qsfera.android.R
import eu.qsfera.android.data.ClientManager
import eu.qsfera.android.data.executeRemoteOperation
import eu.qsfera.android.data.providers.SharedPreferencesProvider
import eu.qsfera.android.domain.files.FileRepository
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.spaces.SpacesRepository
@@ -42,6 +43,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -50,11 +52,13 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private val fileRepository: FileRepository by inject()
private val spacesRepository: SpacesRepository by inject()
private val transferRepository: TransferRepository by inject()
private val preferencesProvider: SharedPreferencesProvider by inject()
private lateinit var section: CloudSection
private lateinit var adapter: CloudHubAdapter
private lateinit var refresh: SwipeRefreshLayout
private lateinit var recycler: RecyclerView
private lateinit var gridLayoutManager: GridLayoutManager
private lateinit var fab: FloatingActionButton
private var allMedia: List<CloudMediaItem> = emptyList()
private var storageItems: List<CloudStorageItem> = emptyList()
@@ -71,6 +75,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private var filesMode = CloudFilesMode.ALL
private var parentFilesMode: CloudFilesMode? = null
private var selection = CloudMediaSelection()
private var selectionMode = false
private var photoGridMode = CloudPhotoGridMode.LARGE
private var showScreenshots = true
var currentFolderPath: String = ROOT_PATH
private set
@@ -86,6 +93,10 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION))
photoGridMode = CloudPhotoGridMode.fromWireValue(
preferencesProvider.getString(PREFERENCE_PHOTO_GRID_MODE, CloudPhotoGridMode.LARGE.wireValue)
)
showScreenshots = preferencesProvider.getBoolean(PREFERENCE_SHOW_SCREENSHOTS, true)
val restoredSelection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
savedInstanceState?.getParcelableArrayList(STATE_SELECTION, CloudMediaItem::class.java)
} else {
@@ -93,11 +104,13 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
savedInstanceState?.getParcelableArrayList(STATE_SELECTION)
}
selection = CloudMediaSelection(restoredSelection.orEmpty())
selectionMode = savedInstanceState?.getBoolean(STATE_SELECTION_MODE, false) == true || !selection.isEmpty
requireActivity().onBackPressedDispatcher.addCallback(this, nestedBackCallback)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putParcelableArrayList(STATE_SELECTION, ArrayList(selection.items))
outState.putBoolean(STATE_SELECTION_MODE, selectionMode)
super.onSaveInstanceState(outState)
}
@@ -116,13 +129,22 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
onLoadMore = { loadNextMediaPage(reset = false) },
onFeedGroupClick = ::openFeedGroup,
)
recycler = view.findViewById<RecyclerView>(R.id.cloud_list).apply {
layoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout ->
layout.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
gridLayoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout ->
layout.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int = this@CloudHubFragment.adapter.spanSize(position)
}
}
}
recycler = view.findViewById<RecyclerView>(R.id.cloud_list).apply {
layoutManager = gridLayoutManager
adapter = this@CloudHubFragment.adapter
addItemDecoration(
CloudPhotoMonthDecoration(
labelAt = this@CloudHubFragment.adapter::monthLabelAt,
isEnabled = {
section == CloudSection.PHOTOS && photoGridMode == CloudPhotoGridMode.MONTHS
},
)
)
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (section == CloudSection.FILES || section == CloudSection.MORE) return
@@ -185,6 +207,56 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
recycler.setPadding(sidePadding, 0, sidePadding, resources.getDimensionPixelSize(R.dimen.cloud_list_bottom_padding))
fab.isVisible = section == CloudSection.FILES || section == CloudSection.PHOTOS
refresh.isEnabled = section != CloudSection.MORE
applyGridGeometry()
}
private fun applyGridGeometry() {
if (!this::gridLayoutManager.isInitialized || !this::adapter.isInitialized) return
val geometry = if (section == CloudSection.PHOTOS) {
when (photoGridMode) {
CloudPhotoGridMode.SMART,
CloudPhotoGridMode.LARGE -> {
GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE, PHOTO_PREVIEW_LARGE)
}
CloudPhotoGridMode.STANDARD -> {
GridGeometry(GRID_SPANS_STANDARD, MEDIA_SPANS_STANDARD, PHOTO_PREVIEW_SMALL)
}
CloudPhotoGridMode.MONTHS -> {
GridGeometry(GRID_SPANS_MONTHS, MEDIA_SPANS_MONTHS, PHOTO_PREVIEW_SMALL)
}
}
} else {
GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE, PHOTO_PREVIEW_LARGE)
}
adapter.updateGridGeometry(geometry.spans, geometry.mediaSpans, geometry.previewSize)
gridLayoutManager.spanCount = geometry.spans
recycler.requestLayout()
}
internal fun photoGridMode(): CloudPhotoGridMode = photoGridMode
internal fun setPhotoGridMode(mode: CloudPhotoGridMode) {
if (photoGridMode == mode) return
photoGridMode = mode
preferencesProvider.putString(PREFERENCE_PHOTO_GRID_MODE, mode.wireValue)
applyGridGeometry()
render()
recycler.scrollToPosition(0)
}
fun areScreenshotsShown(): Boolean = showScreenshots
fun setScreenshotsShown(show: Boolean) {
if (showScreenshots == show) return
showScreenshots = show
preferencesProvider.putBoolean(PREFERENCE_SHOW_SCREENSHOTS, show)
render()
}
fun startMediaSelection() {
if (section != CloudSection.PHOTOS) return
selectionMode = true
updateSelectionUi()
}
fun reload() {
@@ -534,12 +606,21 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
CloudSection.MORE -> emptyList()
}
adapter.submitRows(rows)
if (section == CloudSection.PHOTOS) {
recycler.post {
if (isAdded && this::adapter.isInitialized) {
adapter.prefetchRows(requireContext(), rows)
}
}
}
adapter.updateMediaSelection(selection.keys)
}
private fun filteredMedia(): List<CloudMediaItem> = allMedia.filter { media ->
query.isBlank() || media.name.contains(query, ignoreCase = true) ||
val matchesQuery = query.isBlank() || media.name.contains(query, ignoreCase = true) ||
media.parentPath.contains(query, ignoreCase = true)
val includedByScreenshotSetting = section != CloudSection.PHOTOS || showScreenshots || !media.isScreenshot()
matchesQuery && includedByScreenshotSetting
}
private fun feedRows(media: List<CloudMediaItem>): List<CloudHubRow> {
@@ -609,7 +690,58 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
)
)
} else {
addAll(media.map(CloudHubRow::Media))
addAll(
when (photoGridMode) {
CloudPhotoGridMode.SMART -> smartPhotoRows(media)
CloudPhotoGridMode.MONTHS -> monthPhotoRows(media)
CloudPhotoGridMode.LARGE,
CloudPhotoGridMode.STANDARD -> media.map(CloudHubRow::Media)
}
)
}
}
private fun smartPhotoRows(media: List<CloudMediaItem>): List<CloudHubRow> {
val dayKeyFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
val dayLabelFormat = SimpleDateFormat("d MMMM", Locale.getDefault())
var groupIndex = 0
return buildList {
media.groupBy { dayKeyFormat.format(Date(it.modifiedAt)) }
.values
.forEach { dayItems ->
dayItems.chunked(SMART_GROUP_SIZE).forEachIndexed { index, items ->
add(
CloudHubRow.SmartMediaGroup(
date = if (index == 0) {
dayLabelFormat.format(Date(items.first().modifiedAt))
} else {
""
},
items = items,
mirrored = groupIndex % 2 == 1,
)
)
groupIndex++
}
}
}
}
private fun monthPhotoRows(media: List<CloudMediaItem>): List<CloudHubRow> {
val monthKeyFormat = SimpleDateFormat("yyyy-MM", Locale.ROOT)
val monthLabelFormat = SimpleDateFormat("LLLL", Locale.getDefault())
var previousMonth: String? = null
return media.map { item ->
val month = monthKeyFormat.format(Date(item.modifiedAt))
val label = if (month == previousMonth) {
null
} else {
previousMonth = month
monthLabelFormat.format(Date(item.modifiedAt)).replaceFirstChar { character ->
character.titlecase(Locale.getDefault())
}
}
CloudHubRow.Media(item, label)
}
}
@@ -640,7 +772,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
}
private fun handleMediaClick(media: CloudMediaItem) {
if (selection.isEmpty) {
if (!selectionMode && selection.isEmpty) {
startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media))
} else {
toggleMediaSelection(media)
@@ -648,17 +780,19 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
}
private fun toggleMediaSelection(media: CloudMediaItem) {
selectionMode = true
selection.toggle(media)
updateSelectionUi()
}
fun clearSelection() {
if (selection.isEmpty) return
if (selection.isEmpty && !selectionMode) return
selection.clear()
selectionMode = false
updateSelectionUi()
}
fun hasSelection(): Boolean = !selection.isEmpty
fun hasSelection(): Boolean = selectionMode || !selection.isEmpty
fun selectAllVisibleMedia() {
selection.selectAll(selectableMedia())
@@ -735,7 +869,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private fun updateSelectionUi() {
if (this::adapter.isInitialized) adapter.updateMediaSelection(selection.keys)
(activity as? CloudHomeActivity)?.showMediaSelection(selection.size)
(activity as? CloudHomeActivity)?.showMediaSelection(selection.size, selectionMode)
}
private fun openFeedGroup(date: String) {
@@ -857,12 +991,29 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
}
companion object {
private data class GridGeometry(
val spans: Int,
val mediaSpans: Int,
val previewSize: Int,
)
private const val ARG_SECTION = "cloud_section"
private const val GRID_SPANS = 6
private const val GRID_SPANS_STANDARD = 10
private const val GRID_SPANS_MONTHS = 8
private const val MEDIA_SPANS_LARGE = 2
private const val MEDIA_SPANS_STANDARD = 2
private const val MEDIA_SPANS_MONTHS = 1
private const val SMART_GROUP_SIZE = 6
private const val PHOTO_PREVIEW_SMALL = 320
private const val PHOTO_PREVIEW_LARGE = 512
private const val MEDIA_PAGE_SIZE = 200
private const val LOAD_MORE_THRESHOLD = 18
private const val ROOT_PATH = "/"
private const val STATE_SELECTION = "cloud_media_selection"
private const val STATE_SELECTION_MODE = "cloud_media_selection_mode"
private const val PREFERENCE_PHOTO_GRID_MODE = "cloud_photo_grid_mode"
private const val PREFERENCE_SHOW_SCREENSHOTS = "cloud_photo_show_screenshots"
fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply {
arguments = bundleOf(ARG_SECTION to section.wireValue)
@@ -902,3 +1053,17 @@ private data class LoadedStorageFolder(
val spaceId: String?,
val usedCacheAfterRefreshFailure: Boolean,
)
private fun CloudMediaItem.isScreenshot(): Boolean {
val searchable = "$parentPath/$name".lowercase(Locale.ROOT)
return SCREENSHOT_MARKERS.any(searchable::contains)
}
private val SCREENSHOT_MARKERS = listOf(
"/screenshots/",
"/screenshot_",
"/screenshot-",
"/скриншоты/",
"/скриншот_",
"/снимки экрана/",
)
@@ -11,6 +11,7 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
@@ -24,6 +25,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import coil.load
import coil.size.Scale
import com.github.chrisbanes.photoview.PhotoView
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
@@ -59,32 +61,41 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
private fun showImage(media: CloudMediaItem) {
val account = AccountUtils.getCurrentQSferaAccount(this)
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE }
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply {
scaleType = ImageView.ScaleType.FIT_CENTER
visibility = View.VISIBLE
}
val progress = findViewById<ProgressBar>(R.id.cloud_preview_progress)
val loader = ThumbnailsRequester.getContentAddressedImageLoader(account)
photo.load(
previewUri(media, account, 2560, 2560),
loader,
) {
photo.load(previewUri(media, account, 2560, 2560), loader) {
scale(Scale.FIT)
crossfade(true)
listener(
onSuccess = { _, _ -> progress.visibility = View.GONE },
onError = { _, _ ->
val originalUri = runCatching { contentUri(media, account) }.getOrNull()
if (originalUri == null) {
progress.visibility = View.GONE
} else {
photo.load(originalUri, loader) {
memoryCacheKey("$originalUri#${media.etag}")
diskCacheKey("$originalUri#${media.etag}")
crossfade(true)
listener(
onSuccess = { _, _ -> progress.visibility = View.GONE },
onError = { _, _ -> 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 },
)
}
}
@@ -132,6 +143,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
media.etag.ifBlank { media.modifiedAt.toString() },
width,
height,
ThumbnailsRequester.PreviewProcessor.FIT,
)
} else {
ThumbnailsRequester.getPreviewUriForFile(
@@ -140,6 +152,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() {
media.etag.ifBlank { media.modifiedAt.toString() },
width,
height,
ThumbnailsRequester.PreviewProcessor.FIT,
)
}
@@ -116,6 +116,18 @@ internal enum class CloudFilesMode {
SPACES,
}
internal enum class CloudPhotoGridMode(val wireValue: String) {
SMART("smart"),
LARGE("large"),
STANDARD("standard"),
MONTHS("months");
companion object {
fun fromWireValue(value: String?): CloudPhotoGridMode =
entries.firstOrNull { it.wireValue == value } ?: LARGE
}
}
enum class CloudSection(val wireValue: String) {
FEED("feed"),
FILES("files"),
@@ -133,7 +145,12 @@ internal sealed interface CloudHubRow {
data class FeedCard(val date: String, val items: List<CloudMediaItem>) : CloudHubRow
data object Shortcuts : CloudHubRow
data class PhotoStatus(val photos: Int, val videos: Int) : CloudHubRow
data class Media(val item: CloudMediaItem) : CloudHubRow
data class Media(val item: CloudMediaItem, val label: String? = null) : CloudHubRow
data class SmartMediaGroup(
val date: String,
val items: List<CloudMediaItem>,
val mirrored: Boolean,
) : CloudHubRow
data class Storage(val item: CloudStorageItem) : CloudHubRow
data class Album(val path: String, val title: String, val count: Int, val cover: CloudMediaItem?) : CloudHubRow
data class Action(val id: CloudAction, val title: String, val summary: String, val icon: Int) : CloudHubRow
@@ -0,0 +1,66 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import androidx.recyclerview.widget.RecyclerView
/** Draws month labels above the photo cells so a label is never clipped to one narrow column. */
internal class CloudPhotoMonthDecoration(
private val labelAt: (Int) -> String?,
private val isEnabled: () -> Boolean,
) : RecyclerView.ItemDecoration() {
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xF2FFFFFF.toInt()
style = Paint.Style.FILL
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private val bounds = RectF()
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (!isEnabled()) return
val density = parent.resources.displayMetrics.density
val scaledDensity = parent.resources.displayMetrics.scaledDensity
val horizontalPadding = 8f * density
val verticalPadding = 5f * density
val offset = 8f * density
val radius = 7f * density
textPaint.textSize = 16f * scaledDensity
for (index in 0 until parent.childCount) {
val child = parent.getChildAt(index)
val position = parent.getChildAdapterPosition(child)
if (position == RecyclerView.NO_POSITION) continue
val label = labelAt(position)?.takeIf(String::isNotBlank) ?: continue
val textWidth = textPaint.measureText(label)
val fontMetrics = textPaint.fontMetrics
val textHeight = fontMetrics.descent - fontMetrics.ascent
val left = child.left + offset
val top = child.top + offset
bounds.set(
left,
top,
left + textWidth + horizontalPadding * 2,
top + textHeight + verticalPadding * 2,
)
canvas.drawRoundRect(bounds, radius, radius, backgroundPaint)
canvas.drawText(
label,
left + horizontalPadding,
top + verticalPadding - fontMetrics.ascent,
textPaint,
)
}
}
}
@@ -0,0 +1,195 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import kotlin.math.roundToInt
/**
* Lays out up to six media tiles as a compact justified mosaic. The six-item geometry mirrors
* the day group visible in Yandex Disk: four tiles in a narrow column and two larger tiles in a
* wide column. Smaller groups use dedicated geometries instead of leaving empty holes.
*/
internal class CloudSmartPhotoLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : ViewGroup(context, attrs) {
var mediaCount: Int = 0
set(value) {
val normalized = value.coerceIn(0, MAX_MEDIA)
if (field != normalized) {
field = normalized
requestLayout()
}
}
var mirrored: Boolean = false
set(value) {
if (field != value) {
field = value
requestLayout()
}
}
private var tileBounds: List<TileBounds> = emptyList()
private val gap = resources.displayMetrics.density.roundToInt().coerceAtLeast(1)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
tileBounds = geometry(width, mediaCount, mirrored, gap)
val desiredHeight = tileBounds.maxOfOrNull(TileBounds::bottom) ?: 0
for (index in 0 until childCount) {
val bounds = tileBounds.getOrNull(index)
if (bounds == null) {
getChildAt(index).measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY),
)
} else {
getChildAt(index).measure(
MeasureSpec.makeMeasureSpec(bounds.width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(bounds.height, MeasureSpec.EXACTLY),
)
}
}
setMeasuredDimension(
resolveSize(width, widthMeasureSpec),
resolveSize(desiredHeight, heightMeasureSpec),
)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
for (index in 0 until childCount) {
val child = getChildAt(index)
val bounds = tileBounds.getOrNull(index)
if (bounds == null) {
child.layout(0, 0, 0, 0)
} else {
child.layout(bounds.left, bounds.top, bounds.right, bounds.bottom)
}
}
}
internal data class TileBounds(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) {
val width: Int get() = (right - left).coerceAtLeast(0)
val height: Int get() = (bottom - top).coerceAtLeast(0)
}
companion object {
private const val MAX_MEDIA = 6
internal fun isLargeSlot(count: Int, index: Int): Boolean = when (count.coerceIn(0, MAX_MEDIA)) {
1 -> index == 0
2 -> index <= 1
3 -> index == 0
4 -> index <= 3
5 -> index == 1 || index == 2
6 -> index == 4 || index == 5
else -> false
}
internal fun geometry(width: Int, count: Int, mirrored: Boolean, gap: Int): List<TileBounds> {
if (width <= 0 || count <= 0) return emptyList()
val normalizedCount = count.coerceAtMost(MAX_MEDIA)
val specs = when (normalizedCount) {
1 -> GeometrySpec(rows = 1, heightRatio = 0.72f, cells = listOf(CellSpec(0, 0, 1, 1)))
2 -> GeometrySpec(
columns = 2,
rows = 1,
heightRatio = 0.62f,
cells = listOf(CellSpec(0, 0, 1, 1), CellSpec(1, 0, 1, 1)),
)
3 -> GeometrySpec(
columns = 3,
rows = 2,
heightRatio = 0.66f,
cells = listOf(
CellSpec(0, 0, 2, 2),
CellSpec(2, 0, 1, 1),
CellSpec(2, 1, 1, 1),
),
)
4 -> GeometrySpec(
columns = 2,
rows = 2,
heightRatio = 1f,
cells = listOf(
CellSpec(0, 0, 1, 1),
CellSpec(1, 0, 1, 1),
CellSpec(0, 1, 1, 1),
CellSpec(1, 1, 1, 1),
),
)
5 -> GeometrySpec(
columns = 3,
rows = 4,
heightRatio = 1f,
cells = listOf(
CellSpec(0, 0, 1, 1),
CellSpec(0, 1, 1, 3),
CellSpec(1, 0, 2, 2),
CellSpec(1, 2, 1, 2),
CellSpec(2, 2, 1, 2),
),
)
else -> GeometrySpec(
columns = 3,
rows = 6,
heightRatio = 4f / 3f,
cells = listOf(
CellSpec(0, 0, 1, 1),
CellSpec(0, 1, 1, 2),
CellSpec(0, 3, 1, 2),
CellSpec(0, 5, 1, 1),
CellSpec(1, 0, 2, 2),
CellSpec(1, 2, 2, 4),
),
)
}
val height = (width * specs.heightRatio).roundToInt()
return specs.cells.map { cell ->
val rawLeft = cell.column * width / specs.columns
val rawRight = (cell.column + cell.columnSpan) * width / specs.columns
val rawTop = cell.row * height / specs.rows
val rawBottom = (cell.row + cell.rowSpan) * height / specs.rows
val adjusted = TileBounds(
left = rawLeft + if (cell.column > 0) gap else 0,
top = rawTop + if (cell.row > 0) gap else 0,
right = rawRight - if (cell.column + cell.columnSpan < specs.columns) gap else 0,
bottom = rawBottom - if (cell.row + cell.rowSpan < specs.rows) gap else 0,
)
if (mirrored) {
adjusted.copy(left = width - adjusted.right, right = width - adjusted.left)
} else {
adjusted
}
}
}
private data class GeometrySpec(
val columns: Int = 1,
val rows: Int,
val heightRatio: Float,
val cells: List<CellSpec>,
)
private data class CellSpec(
val column: Int,
val row: Int,
val columnSpan: Int,
val rowSpan: Int,
)
}
}
@@ -53,6 +53,10 @@ import timber.log.Timber
import java.util.Locale
object ThumbnailsRequester : KoinComponent {
enum class PreviewProcessor(val queryValue: String) {
FIT("fit"),
}
private val clientManager: ClientManager by inject()
private val preferencesProvider: SharedPreferencesProvider by inject()
@@ -99,8 +103,15 @@ object ThumbnailsRequester : KoinComponent {
return "$baseUrl/graph/v1.0/me/photo/\$value?u=${account.name.hashCode().toString(16)}"
}
fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null, width: Int = 1024, height: Int = 1024): String =
getPreviewUri(file.remotePath, etag ?: file.remoteEtag, account, width, height)
@JvmOverloads
fun getPreviewUriForFile(
file: OCFile,
account: Account,
etag: String? = null,
width: Int = 1024,
height: Int = 1024,
processor: PreviewProcessor? = null,
): String = getPreviewUri(file.remotePath, etag ?: file.remoteEtag, account, width, height, processor)
fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String =
getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height)
@@ -111,10 +122,14 @@ object ThumbnailsRequester : KoinComponent {
etag: String? = null,
width: Int = 1024,
height: Int = 1024,
processor: PreviewProcessor? = null,
): String {
val absoluteHref = getContentUriForWebDavHref(webDavHref, account)
val separator = if ('?' in absoluteHref) '&' else '?'
return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1"
return withProcessor(
"$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1",
processor,
)
}
/**
@@ -146,15 +161,28 @@ object ThumbnailsRequester : KoinComponent {
fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String =
String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag)
private fun getPreviewUri(remotePath: String?, etag: String?, account: Account, width: Int, height: Int): String {
private fun getPreviewUri(
remotePath: String?,
etag: String?,
account: Account,
width: Int,
height: Int,
processor: PreviewProcessor?,
): String {
val baseUrl = getAccountBaseUrl(account)
val normalizedRemotePath = remotePath.orEmpty()
val path = if (normalizedRemotePath.startsWith("/")) normalizedRemotePath else "/$normalizedRemotePath"
val encodedPath = Uri.encode(path, "/")
return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag.orEmpty())
return withProcessor(
String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag.orEmpty()),
processor,
)
}
private fun withProcessor(uri: String, processor: PreviewProcessor?): String =
processor?.let { "$uri&processor=${it.queryValue}" } ?: uri
private fun getAccountBaseUrl(account: Account): String = accountBaseUrls.getOrPut(account.name) {
val accountManager = AccountManager.get(appContext)
accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL)
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/qsfera_text_primary"
android:pathData="M3,3h8v8H3zM13,3h8v8h-8zM3,13h8v8H3zM13,13h8v8h-8z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/qsfera_text_primary"
android:pathData="M4,3h16c0.55,0 1,0.45 1,1v16c0,0.55 -0.45,1 -1,1H4c-0.55,0 -1,-0.45 -1,-1V4c0,-0.55 0.45,-1 1,-1zM5,8v5h6V8zM13,8v5h6V8zM5,15v4h6v-4zM13,15v4h6v-4z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/qsfera_text_primary"
android:pathData="M3,3h11v18H3zM16,3h5v8h-5zM16,13h5v8h-5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/qsfera_text_primary"
android:pathData="M3,3h5v5H3zM9.5,3h5v5h-5zM16,3h5v5h-5zM3,9.5h5v5H3zM9.5,9.5h5v5h-5zM16,9.5h5v5h-5zM3,16h5v5H3zM9.5,16h5v5h-5zM16,16h5v5h-5z" />
</vector>
@@ -65,9 +65,23 @@
android:padding="11dp"
android:src="@drawable/ic_cloud_search"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/cloud_toolbar_more_button"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/cloud_toolbar_more_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cloud_photo_view_options"
android:padding="11dp"
android:src="@drawable/ic_cloud_more_vert"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/qsfera_text_primary" />
<androidx.appcompat.widget.SearchView
android:id="@+id/cloud_toolbar_search"
android:layout_width="0dp"
@@ -20,6 +20,7 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@@ -4,7 +4,6 @@
android:layout_height="wrap_content"
android:layout_margin="1dp"
android:background="@drawable/cloud_media_placeholder"
android:clipToOutline="true"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
@@ -0,0 +1,53 @@
<?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="wrap_content">
<eu.qsfera.android.presentation.cloud.CloudSmartPhotoLayout
android:id="@+id/cloud_smart_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include
android:id="@+id/cloud_smart_media_first"
layout="@layout/view_cloud_smart_media" />
<include
android:id="@+id/cloud_smart_media_second"
layout="@layout/view_cloud_smart_media" />
<include
android:id="@+id/cloud_smart_media_third"
layout="@layout/view_cloud_smart_media" />
<include
android:id="@+id/cloud_smart_media_fourth"
layout="@layout/view_cloud_smart_media" />
<include
android:id="@+id/cloud_smart_media_fifth"
layout="@layout/view_cloud_smart_media" />
<include
android:id="@+id/cloud_smart_media_sixth"
layout="@layout/view_cloud_smart_media" />
</eu.qsfera.android.presentation.cloud.CloudSmartPhotoLayout>
<TextView
android:id="@+id/cloud_smart_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_margin="12dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="4dp"
android:paddingVertical="2dp"
android:shadowColor="#99000000"
android:shadowDx="0"
android:shadowDy="1"
android:shadowRadius="3"
android:textColor="@android:color/white"
android:textSize="22sp"
android:textStyle="bold" />
</FrameLayout>
@@ -0,0 +1,234 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/cloud_sheet_background"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="12dp"
android:paddingEnd="18dp"
android:paddingBottom="24dp">
<View
android:layout_width="40dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="14dp"
android:background="@drawable/cloud_sheet_handle" />
<TextView
android:id="@+id/cloud_photo_select_files"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:text="@string/cloud_photo_select_files"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp"
app:drawableStartCompat="@drawable/ic_select_all"
android:drawablePadding="18dp" />
<LinearLayout
android:id="@+id/cloud_photo_show_screenshots_row"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="4dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_select_inverse" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_weight="1"
android:text="@string/cloud_photo_show_screenshots"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/cloud_photo_show_screenshots"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/cloud_photo_show_screenshots" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="14dp"
android:background="@color/qsfera_divider" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingBottom="8dp"
android:text="@string/cloud_photo_view_title"
android:textColor="@color/qsfera_text_secondary"
android:textSize="14sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/cloud_photo_view_smart"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_cloud_photo_view_smart" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_weight="1"
android:text="@string/cloud_photo_view_smart"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp" />
<RadioButton
android:id="@+id/cloud_photo_view_smart_radio"
android:layout_width="48dp"
android:layout_height="48dp"
android:buttonTint="@color/qsfera_blue"
android:clickable="false"
android:focusable="false"
android:gravity="center" />
</LinearLayout>
<LinearLayout
android:id="@+id/cloud_photo_view_large"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_cloud_photo_view_large" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_weight="1"
android:text="@string/cloud_photo_view_large"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp" />
<RadioButton
android:id="@+id/cloud_photo_view_large_radio"
android:layout_width="48dp"
android:layout_height="48dp"
android:buttonTint="@color/qsfera_blue"
android:clickable="false"
android:focusable="false"
android:gravity="center" />
</LinearLayout>
<LinearLayout
android:id="@+id/cloud_photo_view_standard"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_cloud_photo_view_standard" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_weight="1"
android:text="@string/cloud_photo_view_standard"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp" />
<RadioButton
android:id="@+id/cloud_photo_view_standard_radio"
android:layout_width="48dp"
android:layout_height="48dp"
android:buttonTint="@color/qsfera_blue"
android:clickable="false"
android:focusable="false"
android:gravity="center" />
</LinearLayout>
<LinearLayout
android:id="@+id/cloud_photo_view_months"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_cloud_photo_view_months" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_weight="1"
android:text="@string/cloud_photo_view_months"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp" />
<RadioButton
android:id="@+id/cloud_photo_view_months_radio"
android:layout_width="48dp"
android:layout_height="48dp"
android:buttonTint="@color/qsfera_blue"
android:clickable="false"
android:focusable="false"
android:gravity="center" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,46 @@
<?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="@drawable/cloud_media_placeholder"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/cloud_media_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/cloud_media_video"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom|end"
android:layout_margin="8dp"
android:background="@drawable/cloud_card_background"
android:importantForAccessibility="no"
android:padding="5dp"
android:src="@drawable/ic_play_arrow"
android:visibility="gone" />
<View
android:id="@+id/cloud_media_selection_scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/cloud_media_selection_scrim"
android:visibility="gone" />
<ImageView
android:id="@+id/cloud_media_selection_check"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:background="@drawable/cloud_media_selection_badge"
android:contentDescription="@string/cloud_selection_selected"
android:padding="5dp"
android:src="@drawable/ic_cloud_check"
android:visibility="gone" />
</FrameLayout>
@@ -899,6 +899,16 @@
<string name="feedback_dialog_get_in_contact_description"><![CDATA[ Discuss in our <a href=\"%1$s\"><b>GitHub repo</b></a>]]></string>
<!-- Cloud photo view options -->
<string name="cloud_photo_view_options">Настройки отображения фото</string>
<string name="cloud_photo_select_files">Выбрать файлы</string>
<string name="cloud_photo_show_screenshots">Показывать скриншоты</string>
<string name="cloud_photo_view_title">Вид</string>
<string name="cloud_photo_view_smart">Умная плитка</string>
<string name="cloud_photo_view_large">Крупная плитка</string>
<string name="cloud_photo_view_standard">Стандартная плитка</string>
<string name="cloud_photo_view_months">По месяцам</string>
<string name="link_role_accessibility">Link</string>
<string name="button_role_accessibility">Button</string>
@@ -0,0 +1,49 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CloudPhotoGridModeTest {
@Test
fun `all persisted grid mode values can be restored`() {
CloudPhotoGridMode.entries.forEach { mode ->
assertEquals(mode, CloudPhotoGridMode.fromWireValue(mode.wireValue))
}
}
@Test
fun `unknown persisted grid mode falls back to large tiles`() {
assertEquals(CloudPhotoGridMode.LARGE, CloudPhotoGridMode.fromWireValue("unknown"))
assertEquals(CloudPhotoGridMode.LARGE, CloudPhotoGridMode.fromWireValue(null))
}
@Test
fun `smart tile geometries fill every supported group without empty slots`() {
for (count in 1..6) {
val bounds = CloudSmartPhotoLayout.geometry(width = 1200, count = count, mirrored = false, gap = 2)
assertEquals(count, bounds.size)
assertTrue(bounds.all { it.width > 0 && it.height > 0 })
assertTrue(bounds.all { it.left >= 0 && it.top >= 0 && it.right <= 1200 })
}
}
@Test
fun `mirrored smart geometry preserves tile sizes`() {
val regular = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = false, gap = 2)
val mirrored = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = true, gap = 2)
regular.zip(mirrored).forEach { (left, right) ->
assertEquals(left.width, right.width)
assertEquals(left.height, right.height)
assertEquals(1200 - left.right, right.left)
}
}
}