Implement cloud media UI and reliable automatic uploads
Android / test-and-build (push) Canceled after 0s
Server / deployment-config (push) Successful in 4m56s
Server / vulnerability-scan (push) Failing after 11m7s

This commit is contained in:
Курнат Андрей
2026-07-16 01:47:36 +03:00
parent e48e1e36a5
commit dd21285700
77 changed files with 3585 additions and 387 deletions
+2 -2
View File
@@ -137,8 +137,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 32
versionName = "1.3.4"
versionCode = 33
versionName = "1.3.5"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
+25 -1
View File
@@ -22,7 +22,19 @@
WRITE_EXTERNAL_STORAGE may be enabled or disabled by the user after installation in
API >= 23; the app needs to handle this
-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
<!--
Notifications are off by default since API 33;
See note in https://developer.android.com/develop/ui/views/notifications/notification-permission
@@ -78,6 +90,9 @@
android:name=".presentation.settings.privacypolicy.PrivacyPolicyActivity"
android:label="@string/actionbar_privacy_policy" />
<activity android:name=".presentation.settings.SettingsActivity" />
<activity
android:name=".presentation.settings.automaticuploads.AutomaticUploadFoldersActivity"
android:exported="false" />
<activity android:name=".presentation.migration.StorageMigrationActivity"
android:theme="@style/Theme.qsfera.Toolbar.Fill" />
<activity
@@ -95,6 +110,15 @@
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.qsfera.Toolbar.Drawer"
android:windowSoftInputMode="adjustPan" />
<activity
android:name=".presentation.cloud.CloudHomeActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.qsfera.Toolbar.Drawer"
android:windowSoftInputMode="adjustPan" />
<activity
android:name=".presentation.cloud.CloudMediaPreviewActivity"
android:exported="false"
android:theme="@style/Theme.qsfera.Toolbar.Drawer" />
<activity
android:name=".ui.activity.ReceiveExternalFilesActivity"
android:configChanges="orientation|screenSize"
@@ -128,6 +128,14 @@ class MainApp : Application() {
val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase by inject()
var startedActivities = 0
// Some Android vendors can start this process for a content-triggered JobService and
// then cancel the service bind while the cold process is still initializing. The
// MediaStore change has already been consumed at that point, so recover it with one
// delayed scan. WorkManagerProvider suppresses this when another scan is already running.
CoroutineScope(Dispatchers.IO).launch {
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
}
// register global protection with pass code, pattern lock and biometric lock
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
@@ -47,7 +47,8 @@ import eu.qsfera.android.extensions.showErrorInSnackbar
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.ui.activity.FileActivity
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
import eu.qsfera.android.presentation.cloud.CloudSection
import eu.qsfera.android.ui.activity.ToolbarActivity
import eu.qsfera.android.utils.PreferenceUtils
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -197,10 +198,7 @@ class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.Acc
parentActivity.account = account
// Refresh dependencies to be used in selected account
MainApp.initDependencyInjection()
val i = Intent(
parentActivity.applicationContext,
FileDisplayActivity::class.java
)
val i = CloudHomeActivity.createIntent(parentActivity.applicationContext, CloudSection.FEED)
i.putExtra(FileActivity.EXTRA_ACCOUNT, account)
i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
parentActivity.startActivity(i)
@@ -83,6 +83,8 @@ import eu.qsfera.android.presentation.settings.SettingsActivity
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
import eu.qsfera.android.presentation.cloud.CloudSection
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
import eu.qsfera.android.ui.dialog.SslUntrustedCertDialog
@@ -322,9 +324,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
return
}
val newIntent = Intent(this, FileDisplayActivity::class.java)
if (authenticationViewModel.launchedFromDeepLink) {
newIntent.data = intent.data
val newIntent = if (authenticationViewModel.launchedFromDeepLink) {
Intent(this, FileDisplayActivity::class.java).apply { data = intent.data }
} else {
CloudHomeActivity.createIntent(this, CloudSection.FEED)
}
newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(newIntent)
@@ -0,0 +1,127 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import eu.qsfera.android.R
import eu.qsfera.android.databinding.ActivityMainBinding
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.ui.activity.FileActivity
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
class CloudHomeActivity : FileActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var section: CloudSection
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
section = CloudSection.fromWireValue(intent.getStringExtra(EXTRA_SECTION))
enableEdgeToEdgePreSetContentView(false)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupRootToolbar(
title = getString(section.titleResource),
isSearchEnabled = section != CloudSection.MORE,
isAvatarRequested = true,
)
setupDrawer()
setupNavigationBottomBar(section.menuResource)
if (section != CloudSection.MORE) {
findViewById<SearchView>(R.id.root_toolbar_search_view).setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
cloudFragment()?.filter(query.orEmpty())
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
cloudFragment()?.filter(newText.orEmpty())
return true
}
}
)
}
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.left_fragment_container, CloudHubFragment.newInstance(section))
.commit()
}
enableEdgeToEdgePostSetContentView { insets ->
binding.navCoordinatorLayout.appBarLayout.updatePadding(
top = insets.top,
left = insets.left,
right = insets.right,
)
binding.navCoordinatorLayout.bottomNavViewSpacer.updateLayoutParams {
height = insets.bottom
}
findViewById<View>(R.id.nav_view_container).updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom
}
}
}
override fun onAccountSet(stateWasRecovered: Boolean) {
super.onAccountSet(stateWasRecovered)
setAccountInDrawer(account)
cloudFragment()?.reload()
}
private fun cloudFragment(): CloudHubFragment? =
supportFragmentManager.findFragmentById(R.id.left_fragment_container) as? CloudHubFragment
override fun navigateToOption(fileListOption: FileListOption) {
if (fileListOption == FileListOption.ALL_FILES) {
startActivity(Intent(this, FileDisplayActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, fileListOption as Parcelable)
})
} else {
super.navigateToOption(fileListOption)
}
}
companion object {
private const val EXTRA_SECTION = "cloud_section"
fun createIntent(context: Context, section: CloudSection): Intent =
Intent(context, CloudHomeActivity::class.java).apply {
putExtra(EXTRA_SECTION, section.wireValue)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
}
}
private val CloudSection.titleResource: Int
get() = when (this) {
CloudSection.FEED -> R.string.cloud_feed_title
CloudSection.PHOTOS -> R.string.cloud_photos_title
CloudSection.ALBUMS -> R.string.cloud_albums_title
CloudSection.MORE -> R.string.cloud_more_title
}
private val CloudSection.menuResource: Int
get() = when (this) {
CloudSection.FEED -> R.id.nav_feed
CloudSection.PHOTOS -> R.id.nav_photos
CloudSection.ALBUMS -> R.id.nav_albums
CloudSection.MORE -> R.id.nav_more
}
@@ -0,0 +1,153 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.accounts.Account
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import coil.dispose
import coil.load
import eu.qsfera.android.R
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import eu.qsfera.android.utils.MimetypeIconUtil
internal class CloudHubAdapter(
private val account: Account,
private val onMediaClick: (CloudMediaItem) -> Unit,
private val onAlbumClick: (String) -> Unit,
private val onActionClick: (CloudAction) -> Unit,
private val onRetry: () -> Unit,
) : RecyclerView.Adapter<CloudHubAdapter.Holder>() {
private var rows: List<CloudHubRow> = emptyList()
fun submitRows(newRows: List<CloudHubRow>) {
rows = newRows
notifyDataSetChanged()
}
override fun getItemCount(): Int = rows.size
override fun getItemViewType(position: Int): Int = when (rows[position]) {
is CloudHubRow.Header -> TYPE_HEADER
is CloudHubRow.Media -> TYPE_MEDIA
is CloudHubRow.Album -> TYPE_ALBUM
is CloudHubRow.Action -> TYPE_ACTION
is CloudHubRow.Status -> TYPE_STATUS
}
fun spanSize(position: Int): Int = when (rows[position]) {
is CloudHubRow.Media -> 2
is CloudHubRow.Album -> 3
is CloudHubRow.Header,
is CloudHubRow.Action,
is CloudHubRow.Status -> 6
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val layout = when (viewType) {
TYPE_HEADER -> R.layout.item_cloud_header
TYPE_MEDIA -> R.layout.item_cloud_media
TYPE_ALBUM -> R.layout.item_cloud_album
TYPE_ACTION -> R.layout.item_cloud_action
else -> R.layout.item_cloud_status
}
return Holder(LayoutInflater.from(parent.context).inflate(layout, parent, false))
}
override fun onBindViewHolder(holder: Holder, position: Int) {
when (val row = rows[position]) {
is CloudHubRow.Header -> holder.itemView.findViewById<TextView>(R.id.cloud_header_text).text = row.title
is CloudHubRow.Media -> bindMedia(holder.itemView, row.item)
is CloudHubRow.Album -> bindAlbum(holder.itemView, row)
is CloudHubRow.Action -> bindAction(holder.itemView, row)
is CloudHubRow.Status -> bindStatus(holder.itemView, row)
}
}
private fun bindMedia(view: View, item: CloudMediaItem) {
val image = view.findViewById<ImageView>(R.id.cloud_media_image)
val video = view.findViewById<ImageView>(R.id.cloud_media_video)
video.visibility = if (item.isVideo) View.VISIBLE else View.GONE
if (item.isImage) {
image.load(
previewUri(item, 512),
ThumbnailsRequester.getContentAddressedImageLoader(account),
) {
placeholder(R.drawable.cloud_media_placeholder)
error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
crossfade(true)
}
} else {
image.dispose()
image.setImageResource(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name))
}
view.contentDescription = item.name
view.setOnClickListener { onMediaClick(item) }
}
private fun bindAlbum(view: View, album: CloudHubRow.Album) {
view.findViewById<TextView>(R.id.cloud_album_title).text = album.title
view.findViewById<TextView>(R.id.cloud_album_count).text =
view.context.getString(R.string.cloud_album_items, album.count)
val cover = view.findViewById<ImageView>(R.id.cloud_album_cover)
val coverItem = album.cover
if (coverItem?.isImage == true) {
cover.load(
previewUri(coverItem, 320),
ThumbnailsRequester.getContentAddressedImageLoader(account),
) {
placeholder(R.drawable.ic_qsfera_folder)
error(R.drawable.ic_qsfera_folder)
crossfade(true)
}
} else {
cover.dispose()
cover.setImageResource(R.drawable.ic_qsfera_folder)
}
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,
)
} else {
ThumbnailsRequester.getPreviewUriForFile(item.toOCFile(account.name), account, item.etag, size, size)
}
private fun bindAction(view: View, action: CloudHubRow.Action) {
view.findViewById<ImageView>(R.id.cloud_action_icon).setImageResource(action.icon)
view.findViewById<TextView>(R.id.cloud_action_title).text = action.title
view.findViewById<TextView>(R.id.cloud_action_summary).text = action.summary
view.setOnClickListener { onActionClick(action.id) }
}
private fun bindStatus(view: View, status: CloudHubRow.Status) {
view.findViewById<TextView>(R.id.cloud_status_title).text = status.title
view.findViewById<TextView>(R.id.cloud_status_summary).text = status.summary
view.isClickable = status.retry
view.setOnClickListener(if (status.retry) View.OnClickListener { onRetry() } else null)
}
internal class Holder(itemView: View) : RecyclerView.ViewHolder(itemView)
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_MEDIA = 1
private const val TYPE_ALBUM = 2
private const val TYPE_ACTION = 3
private const val TYPE_STATUS = 4
}
}
@@ -0,0 +1,393 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import eu.qsfera.android.R
import eu.qsfera.android.data.ClientManager
import eu.qsfera.android.data.executeRemoteOperation
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.domain.transfers.TransferRepository
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.lib.common.accounts.AccountUtils as LibraryAccountUtils
import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest
import eu.qsfera.android.lib.resources.files.search.MediaSearchType
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.settings.SettingsActivity
import eu.qsfera.android.ui.activity.FileActivity
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.ui.activity.UploadListActivity
import eu.qsfera.android.utils.MimetypeIconUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import java.text.DateFormat
import java.util.Date
import java.util.Locale
class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) {
private val clientManager: ClientManager by inject()
private val transferRepository: TransferRepository by inject()
private lateinit var section: CloudSection
private lateinit var adapter: CloudHubAdapter
private lateinit var refresh: SwipeRefreshLayout
private var allMedia: List<CloudMediaItem> = emptyList()
private var query: String = ""
private var activeAlbumPath: String? = null
private var loadJob: Job? = null
private var nextOffset = 0
private var isLoading = false
private var reachedEnd = false
private val albumBackCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
activeAlbumPath = null
isEnabled = false
render()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION))
requireActivity().onBackPressedDispatcher.addCallback(this, albumBackCallback)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
adapter = CloudHubAdapter(
account = account,
onMediaClick = ::openMedia,
onAlbumClick = { albumPath ->
activeAlbumPath = albumPath
albumBackCallback.isEnabled = true
render()
},
onActionClick = ::openAction,
onRetry = ::reload,
)
view.findViewById<RecyclerView>(R.id.cloud_list).apply {
layoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout ->
layout.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int = this@CloudHubFragment.adapter.spanSize(position)
}
}
adapter = this@CloudHubFragment.adapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading || reachedEnd) return
val layout = recyclerView.layoutManager as? GridLayoutManager ?: return
if (layout.findLastVisibleItemPosition() >= this@CloudHubFragment.adapter.itemCount - LOAD_MORE_THRESHOLD) {
loadNextPage(reset = false)
}
}
})
}
refresh = view.findViewById<SwipeRefreshLayout>(R.id.cloud_refresh).apply {
setColorSchemeResources(R.color.qsfera_blue)
setOnRefreshListener(::reload)
isEnabled = section != CloudSection.MORE
}
reload()
}
fun reload() {
if (!isAdded || !this::adapter.isInitialized) return
if (section == CloudSection.MORE) {
allMedia = emptyList()
render()
return
}
loadJob?.cancel()
isLoading = false
nextOffset = 0
reachedEnd = false
allMedia = emptyList()
refresh.isRefreshing = true
adapter.submitRows(
listOf(CloudHubRow.Status(getString(R.string.cloud_media_loading), ""))
)
loadNextPage(reset = true)
}
private fun loadNextPage(reset: Boolean) {
if (isLoading || reachedEnd || section == CloudSection.MORE) return
isLoading = true
val requestedOffset = if (reset) 0 else nextOffset
loadJob = viewLifecycleOwner.lifecycleScope.launch {
val result = runCatching { withContext(Dispatchers.IO) { loadMediaPage(requestedOffset) } }
if (!isAdded) return@launch
isLoading = false
refresh.isRefreshing = false
result.onSuccess { page ->
nextOffset = requestedOffset + page.rawResultCount
reachedEnd = page.rawResultCount < MEDIA_PAGE_SIZE
allMedia = (if (reset) page.items else allMedia + page.items)
.distinctBy { it.webDavHref.ifBlank { "${it.spaceId.orEmpty()}:${it.remotePath}" } }
.sortedByDescending { it.modifiedAt }
render()
}.onFailure {
val fallback = withContext(Dispatchers.IO) { recentAutomaticUploads() }
if (reset && section == CloudSection.FEED && fallback.isNotEmpty()) {
allMedia = fallback
reachedEnd = true
render()
} else if (reset) {
adapter.submitRows(
listOf(
CloudHubRow.Status(
getString(R.string.cloud_media_load_error),
getString(R.string.cloud_media_retry),
retry = true,
)
)
)
}
}
}
}
fun filter(newQuery: String) {
query = newQuery.trim()
render()
}
private fun loadMediaPage(offset: Int): LoadedMediaPage {
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
val userId = LibraryAccountUtils.getUserId(account, requireContext())
val remoteFiles = executeRemoteOperation {
clientManager.getMediaSearchService(account.name).searchMedia(
MediaSearchRequest(
mediaTypes = setOf(MediaSearchType.IMAGE, MediaSearchType.VIDEO),
limit = MEDIA_PAGE_SIZE,
offset = offset,
)
)
}
val davPrefix = "/remote.php/dav/files/$userId"
val items = remoteFiles.mapNotNull { remote ->
val mimeType = remote.mimeType ?: return@mapNotNull null
if (!mimeType.startsWith("image/") && !mimeType.startsWith("video/")) return@mapNotNull null
val spacesPrefix = "/remote.php/dav/spaces/"
val spaceReference = remote.path.substringAfter(spacesPrefix, missingDelimiterValue = "")
.substringBefore('/', missingDelimiterValue = "")
.takeIf { it.isNotBlank() }
val pathWithoutDavPrefix = if (spaceReference != null) {
remote.path.removePrefix(spacesPrefix).substringAfter('/', missingDelimiterValue = "")
} else {
remote.path.removePrefix(davPrefix)
}
val remotePath = pathWithoutDavPrefix.let { path ->
if (path.startsWith('/')) path else "/$path"
}
CloudMediaItem(
webDavHref = remote.href,
remotePath = remotePath,
mimeType = mimeType,
size = remote.size ?: 0L,
modifiedAt = remote.modifiedTimestamp ?: 0L,
etag = remote.etag.orEmpty(),
spaceId = spaceReference,
)
}
return LoadedMediaPage(items = items, rawResultCount = remoteFiles.size)
}
private fun recentAutomaticUploads(): List<CloudMediaItem> {
val account = AccountUtils.getCurrentQSferaAccount(requireContext())
return transferRepository.getFinishedTransfers()
.asSequence()
.filter { transfer ->
transfer.accountName == account.name &&
transfer.createdBy != UploadEnqueuedBy.ENQUEUED_BY_USER &&
transfer.transferEndTimestamp != null
}
.map { transfer ->
val mimeType = MimetypeIconUtil.getBestMimeTypeByFilename(transfer.remotePath)
CloudMediaItem(
remotePath = transfer.remotePath,
mimeType = mimeType,
size = transfer.fileSize,
modifiedAt = transfer.transferEndTimestamp ?: 0L,
)
}
.filter { it.isImage || it.isVideo }
.distinctBy { it.remotePath }
.sortedByDescending { it.modifiedAt }
.take(120)
.toList()
}
private fun render() {
if (!this::adapter.isInitialized) return
if (section == CloudSection.MORE) {
adapter.submitRows(moreRows())
return
}
val filtered = allMedia.filter { media ->
query.isBlank() || media.name.contains(query, ignoreCase = true) ||
media.parentPath.contains(query, ignoreCase = true)
}
val rows = when (section) {
CloudSection.FEED -> feedRows(filtered)
CloudSection.PHOTOS -> photoRows(filtered)
CloudSection.ALBUMS -> albumRows(filtered)
CloudSection.MORE -> moreRows()
}
adapter.submitRows(rows)
}
private fun feedRows(media: List<CloudMediaItem>): List<CloudHubRow> {
if (media.isEmpty()) return listOf(
CloudHubRow.Status(
getString(R.string.cloud_feed_empty_title),
getString(R.string.cloud_feed_empty_summary),
)
)
val dateFormat = DateFormat.getDateInstance(DateFormat.LONG, Locale.getDefault())
return buildList {
media.groupBy { dateFormat.format(Date(it.modifiedAt)) }.forEach { (date, items) ->
add(CloudHubRow.Header(date))
addAll(items.map { CloudHubRow.Media(it) })
}
}
}
private fun photoRows(media: List<CloudMediaItem>): List<CloudHubRow> {
if (media.isEmpty()) return listOf(
CloudHubRow.Status(
getString(R.string.cloud_photos_empty_title),
getString(R.string.cloud_photos_empty_summary),
)
)
return buildList {
add(CloudHubRow.Header(getString(R.string.cloud_all_photos)))
addAll(media.map { CloudHubRow.Media(it) })
}
}
private fun albumRows(media: List<CloudMediaItem>): List<CloudHubRow> {
val selectedAlbumKey = activeAlbumPath
if (selectedAlbumKey != null) {
val albumMedia = media.filter { it.albumKey == selectedAlbumKey }
val displayPath = albumMedia.firstOrNull()?.parentPath.orEmpty()
return buildList {
add(CloudHubRow.Header(displayPath.substringAfterLast('/').ifBlank { getString(R.string.cloud_albums_title) }))
addAll(albumMedia.map { CloudHubRow.Media(it) })
}
}
if (media.isEmpty()) return listOf(
CloudHubRow.Status(
getString(R.string.cloud_albums_empty_title),
getString(R.string.cloud_albums_empty_summary),
)
)
return media.groupBy(CloudMediaItem::albumKey)
.entries
.sortedWith(compareByDescending<Map.Entry<String, List<CloudMediaItem>>> { it.value.size }.thenBy { it.key })
.map { (albumKey, items) ->
val path = items.first().parentPath
CloudHubRow.Album(
path = albumKey,
title = path.substringAfterLast('/').ifBlank { getString(R.string.cloud_albums_title) },
count = items.size,
cover = items.firstOrNull(CloudMediaItem::isImage),
)
}
}
private fun moreRows(): List<CloudHubRow> = listOf(
CloudHubRow.Action(
CloudAction.STORAGE,
getString(R.string.cloud_more_storage),
getString(R.string.cloud_more_storage_summary),
R.drawable.ic_folder,
),
CloudHubRow.Action(
CloudAction.TRANSFERS,
getString(R.string.cloud_more_transfers),
getString(R.string.cloud_more_transfers_summary),
R.drawable.ic_uploads,
),
CloudHubRow.Action(
CloudAction.OFFLINE,
getString(R.string.cloud_more_offline),
getString(R.string.cloud_more_offline_summary),
R.drawable.ic_available_offline,
),
CloudHubRow.Action(
CloudAction.SHARES,
getString(R.string.cloud_more_shares),
getString(R.string.cloud_more_shares_summary),
R.drawable.ic_shared_by_link,
),
CloudHubRow.Action(
CloudAction.SPACES,
getString(R.string.cloud_more_spaces),
getString(R.string.cloud_more_spaces_summary),
R.drawable.ic_spaces,
),
CloudHubRow.Action(
CloudAction.SETTINGS,
getString(R.string.cloud_more_settings),
getString(R.string.cloud_more_settings_summary),
R.drawable.ic_settings,
),
)
private fun openMedia(media: CloudMediaItem) {
startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media))
}
private fun openAction(action: CloudAction) {
when (action) {
CloudAction.TRANSFERS -> startActivity(Intent(requireContext(), UploadListActivity::class.java))
CloudAction.SETTINGS -> startActivity(Intent(requireContext(), SettingsActivity::class.java))
CloudAction.STORAGE -> openFileList(FileListOption.ALL_FILES)
CloudAction.OFFLINE -> openFileList(FileListOption.AV_OFFLINE)
CloudAction.SHARES -> openFileList(FileListOption.SHARED_BY_LINK)
CloudAction.SPACES -> openFileList(FileListOption.SPACES_LIST)
}
}
private fun openFileList(option: FileListOption) {
startActivity(Intent(requireContext(), FileDisplayActivity::class.java).apply {
putExtra(FileActivity.EXTRA_FILE_LIST_OPTION, option as Parcelable)
})
}
companion object {
private const val ARG_SECTION = "cloud_section"
private const val GRID_SPANS = 6
private const val MEDIA_PAGE_SIZE = 200
private const val LOAD_MORE_THRESHOLD = 18
fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply {
arguments = bundleOf(ARG_SECTION to section.wireValue)
}
}
}
private data class LoadedMediaPage(
val items: List<CloudMediaItem>,
val rawResultCount: Int,
)
@@ -0,0 +1,141 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.ProgressBar
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import coil.load
import com.github.chrisbanes.photoview.PhotoView
import eu.qsfera.android.MainApp
import eu.qsfera.android.R
import eu.qsfera.android.data.ClientManager
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
class CloudMediaPreviewActivity : AppCompatActivity() {
private val clientManager: ClientManager by inject()
private var player: ExoPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_cloud_media_preview)
val media = mediaExtra() ?: run {
finish()
return
}
findViewById<Toolbar>(R.id.cloud_preview_toolbar).also { toolbar ->
toolbar.title = media.name
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
toolbar.setNavigationOnClickListener { finish() }
}
if (media.isVideo) showVideo(media) else showImage(media)
}
private fun showImage(media: CloudMediaItem) {
val account = AccountUtils.getCurrentQSferaAccount(this)
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE }
val progress = findViewById<ProgressBar>(R.id.cloud_preview_progress)
photo.load(
ThumbnailsRequester.getPreviewUriForWebDavHref(
media.webDavHref,
account,
media.etag.ifBlank { media.modifiedAt.toString() },
2560,
2560,
),
ThumbnailsRequester.getContentAddressedImageLoader(account),
) {
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 = ThumbnailsRequester.getPreviewUriForWebDavHref(
media.webDavHref,
account,
media.modifiedAt.toString(),
1,
1,
).substringBefore('?')
contentUrl to client.credentials?.headerAuth.orEmpty()
}
}.getOrNull() ?: run {
progress.visibility = View.GONE
return@launch
}
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(MainApp.userAgent)
.setDefaultRequestProperties(mapOf("Authorization" to prepared.second))
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(prepared.first))
player = ExoPlayer.Builder(this@CloudMediaPreviewActivity).build().also { exoPlayer ->
playerView.player = exoPlayer
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
progress.visibility = if (playbackState == Player.STATE_BUFFERING) View.VISIBLE else View.GONE
}
})
exoPlayer.setMediaSource(mediaSource)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
}
@Suppress("DEPRECATION")
private fun mediaExtra(): CloudMediaItem? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_MEDIA, CloudMediaItem::class.java)
} else {
intent.getParcelableExtra(EXTRA_MEDIA)
}
override fun onDestroy() {
player?.release()
player = null
super.onDestroy()
}
companion object {
private const val EXTRA_MEDIA = "cloud_media"
fun createIntent(context: Context, media: CloudMediaItem): Intent =
Intent(context, CloudMediaPreviewActivity::class.java).putExtra(EXTRA_MEDIA, media)
}
}
@@ -0,0 +1,66 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.cloud
import android.os.Parcelable
import eu.qsfera.android.domain.files.model.OCFile
import kotlinx.parcelize.Parcelize
@Parcelize
data class CloudMediaItem(
val webDavHref: String = "",
val remotePath: String,
val mimeType: String,
val size: Long,
val modifiedAt: Long,
val etag: String = "",
val spaceId: String? = null,
) : Parcelable {
val name: String get() = remotePath.trimEnd('/').substringAfterLast('/')
val parentPath: String get() = remotePath.substringBeforeLast('/', missingDelimiterValue = "/").ifBlank { "/" }
val isVideo: Boolean get() = mimeType.startsWith("video/")
val isImage: Boolean get() = mimeType.startsWith("image/")
val albumKey: String get() = "${spaceId.orEmpty()}::$parentPath"
fun toOCFile(owner: String): OCFile = OCFile(
owner = owner,
length = size,
modificationTimestamp = modifiedAt,
remotePath = remotePath,
mimeType = mimeType,
etag = etag,
remoteEtag = etag,
spaceId = spaceId,
)
}
enum class CloudSection(val wireValue: String) {
FEED("feed"),
PHOTOS("photos"),
ALBUMS("albums"),
MORE("more");
companion object {
fun fromWireValue(value: String?): CloudSection = entries.firstOrNull { it.wireValue == value } ?: FEED
}
}
internal sealed interface CloudHubRow {
data class Header(val title: String) : CloudHubRow
data class Media(val item: CloudMediaItem) : 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
data class Status(val title: String, val summary: String, val retry: Boolean = false) : CloudHubRow
}
internal enum class CloudAction {
STORAGE,
TRANSFERS,
OFFLINE,
SHARES,
SPACES,
SETTINGS,
}
@@ -38,7 +38,8 @@ import eu.qsfera.android.presentation.settings.automaticuploads.SettingsVideoUpl
import eu.qsfera.android.presentation.settings.logging.SettingsLogsFragment
import eu.qsfera.android.presentation.settings.more.SettingsMoreFragment
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
import eu.qsfera.android.presentation.cloud.CloudSection
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
@@ -55,9 +56,7 @@ class SettingsActivity : AppCompatActivity() {
val toolbar = findViewById<Toolbar>(R.id.standard_toolbar).apply {
isVisible = true
}
findViewById<ConstraintLayout>(R.id.root_toolbar).apply {
isVisible = false
}
findViewById<ConstraintLayout>(R.id.root_toolbar).isVisible = false
setSupportActionBar(toolbar)
updateToolbarTitle()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
@@ -95,9 +94,7 @@ class SettingsActivity : AppCompatActivity() {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
} else {
intent = Intent(this, FileDisplayActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
intent = CloudHomeActivity.createIntent(this, CloudSection.MORE)
startActivity(intent)
}
}
@@ -0,0 +1,240 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import eu.qsfera.android.R
import eu.qsfera.android.ui.activity.enableEdgeToEdgePostSetContentView
import eu.qsfera.android.ui.activity.enableEdgeToEdgePreSetContentView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AutomaticUploadFoldersActivity : AppCompatActivity() {
private lateinit var mediaKind: AutomaticUploadMediaKind
private val selectedSources = linkedSetOf<String>()
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
currentFragment()?.reload()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdgePreSetContentView(false)
setContentView(R.layout.activity_settings)
mediaKind = intent.getStringExtra(EXTRA_MEDIA_KIND)
?.let(AutomaticUploadMediaKind::fromWireValue)
?: AutomaticUploadMediaKind.IMAGE
selectedSources += (savedInstanceState?.getStringArrayList(STATE_SELECTED_SOURCES)
?: intent.getStringArrayListExtra(EXTRA_SELECTED_SOURCES).orEmpty())
.mapNotNull(AutomaticUploadMediaSource::parse)
.filter { it.kind == mediaKind && !it.isCamera }
.map(AutomaticUploadMediaSource::encodedValue)
findViewById<Toolbar>(R.id.standard_toolbar).apply { isVisible = true }.also {
setSupportActionBar(it)
}
findViewById<ConstraintLayout>(R.id.root_toolbar).isVisible = false
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
setTitle(R.string.automatic_upload_folders_title)
}
enableEdgeToEdgePostSetContentView { insets ->
findViewById<View>(R.id.toolbar).setPadding(0, insets.top, 0, 0)
findViewById<View>(android.R.id.content).setPadding(0, 0, 0, insets.bottom)
}
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(
R.id.settings_container,
AutomaticUploadFoldersFragment().apply {
arguments = bundleOf(ARG_MEDIA_KIND to mediaKind.wireValue)
},
)
.commit()
}
}
override fun onResume() {
super.onResume()
currentFragment()?.reload()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putStringArrayList(STATE_SELECTED_SOURCES, ArrayList(selectedSources))
super.onSaveInstanceState(outState)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return super.onOptionsItemSelected(item)
}
override fun finish() {
setResult(
Activity.RESULT_OK,
Intent().putStringArrayListExtra(EXTRA_SELECTED_SOURCES, ArrayList(selectedSources)),
)
super.finish()
}
internal fun requestReadPermission() {
permissionLauncher.launch(AutomaticUploadsPermissions.readPermissions(mediaKind))
}
internal fun selectedRelativePaths(): Set<String> = selectedSources.mapNotNull { encodedSource ->
AutomaticUploadMediaSource.parse(encodedSource)
?.takeIf { it.kind == mediaKind }
?.relativePath
}.toSet()
internal fun setFolderSelected(source: AutomaticUploadMediaSource, selected: Boolean) {
selectedSources.removeAll { existing ->
AutomaticUploadMediaSource.parse(existing)?.let {
it.kind == source.kind && it.relativePath.equals(source.relativePath, ignoreCase = true)
} == true
}
if (selected) selectedSources += source.encodedValue
}
private fun currentFragment(): AutomaticUploadFoldersFragment? =
supportFragmentManager.findFragmentById(R.id.settings_container) as? AutomaticUploadFoldersFragment
companion object {
const val EXTRA_SELECTED_SOURCES = "selected_sources"
private const val EXTRA_MEDIA_KIND = "media_kind"
private const val STATE_SELECTED_SOURCES = "selected_sources_state"
internal const val ARG_MEDIA_KIND = "media_kind"
fun createIntent(
context: Context,
kind: AutomaticUploadMediaKind,
selectedSources: Collection<String>,
): Intent = Intent(context, AutomaticUploadFoldersActivity::class.java).apply {
putExtra(EXTRA_MEDIA_KIND, kind.wireValue)
putStringArrayListExtra(EXTRA_SELECTED_SOURCES, ArrayList(selectedSources))
}
}
}
class AutomaticUploadFoldersFragment : PreferenceFragmentCompat() {
private lateinit var mediaKind: AutomaticUploadMediaKind
private var reloadGeneration = 0
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
mediaKind = arguments?.getString(AutomaticUploadFoldersActivity.ARG_MEDIA_KIND)
?.let(AutomaticUploadMediaKind::fromWireValue)
?: AutomaticUploadMediaKind.IMAGE
preferenceScreen = preferenceManager.createPreferenceScreen(requireContext())
reload()
}
fun reload() {
if (!isAdded || preferenceScreen == null) return
val generation = ++reloadGeneration
val host = requireActivity() as AutomaticUploadFoldersActivity
preferenceScreen.removeAll()
preferenceScreen.addPreference(Preference(requireContext()).apply {
isSelectable = false
summary = getString(R.string.automatic_upload_folders_camera_description)
})
if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), mediaKind)) {
preferenceScreen.addPreference(Preference(requireContext()).apply {
title = getString(R.string.automatic_upload_permission_title)
summary = getString(R.string.automatic_upload_permission_read_missing)
setOnPreferenceClickListener {
host.requestReadPermission()
true
}
})
return
}
preferenceScreen.addPreference(Preference(requireContext()).apply {
isSelectable = false
summary = getString(R.string.automatic_upload_folders_loading)
})
lifecycleScope.launch {
val foldersResult = runCatching {
withContext(Dispatchers.IO) { PhoneMediaStore(requireContext()).getFolders(mediaKind) }
}
if (!isAdded || generation != reloadGeneration) return@launch
preferenceScreen.removeAll()
preferenceScreen.addPreference(Preference(requireContext()).apply {
isSelectable = false
summary = getString(R.string.automatic_upload_folders_camera_description)
})
foldersResult.onFailure {
preferenceScreen.addPreference(Preference(requireContext()).apply {
isSelectable = false
summary = getString(R.string.automatic_upload_folders_error)
})
}.onSuccess { folders ->
val otherFolders = folders.filterNot {
AutomaticUploadMediaSource.isCameraPath(it.relativePath)
}
if (otherFolders.isEmpty()) {
preferenceScreen.addPreference(Preference(requireContext()).apply {
isSelectable = false
summary = getString(R.string.automatic_upload_folders_empty)
})
return@onSuccess
}
preferenceScreen.addPreference(PreferenceCategory(requireContext()).apply {
title = getString(R.string.automatic_upload_folders_other)
otherFolders.forEach { folder ->
val source = AutomaticUploadMediaSource.create(mediaKind, folder.relativePath)
addPreference(SwitchPreferenceCompat(requireContext()).apply {
key = source.encodedValue
title = folder.displayName
summary = getString(R.string.automatic_upload_folders_item_count, folder.itemCount)
isChecked = host.selectedRelativePaths().any {
it.equals(source.relativePath, ignoreCase = true)
}
setOnPreferenceChangeListener { _, newValue ->
val isSelected = newValue as? Boolean
?: return@setOnPreferenceChangeListener false
host.setFolderSelected(source, isSelected)
true
}
})
}
})
}
}
}
}
@@ -0,0 +1,75 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import java.nio.charset.StandardCharsets
import java.util.Base64
enum class AutomaticUploadMediaKind(val wireValue: String) {
IMAGE("image"),
VIDEO("video");
companion object {
fun fromWireValue(value: String): AutomaticUploadMediaKind? = entries.firstOrNull { it.wireValue == value }
}
}
data class AutomaticUploadMediaSource(
val kind: AutomaticUploadMediaKind,
val relativePath: String,
) {
val encodedValue: String
get() {
val encodedPath = Base64.getUrlEncoder().withoutPadding()
.encodeToString(relativePath.toByteArray(StandardCharsets.UTF_8))
return "$PREFIX${kind.wireValue}:$encodedPath"
}
val displayPath: String
get() = relativePath.trimEnd('/').substringAfterLast('/')
val isCamera: Boolean
get() = isCameraPath(relativePath)
companion object {
const val CAMERA_RELATIVE_PATH = "DCIM/Camera/"
private const val PREFIX = "mediastore:"
fun create(kind: AutomaticUploadMediaKind, relativePath: String): AutomaticUploadMediaSource =
AutomaticUploadMediaSource(kind, normalizeRelativePath(relativePath))
fun camera(kind: AutomaticUploadMediaKind): AutomaticUploadMediaSource = create(kind, CAMERA_RELATIVE_PATH)
fun parse(value: String): AutomaticUploadMediaSource? {
if (!value.startsWith(PREFIX)) return null
val payload = value.removePrefix(PREFIX)
val separatorIndex = payload.indexOf(':')
if (separatorIndex <= 0 || separatorIndex == payload.lastIndex) return null
val kind = AutomaticUploadMediaKind.fromWireValue(payload.substring(0, separatorIndex)) ?: return null
val decodedPath = runCatching {
String(
Base64.getUrlDecoder().decode(payload.substring(separatorIndex + 1)),
StandardCharsets.UTF_8,
)
}.getOrNull() ?: return null
return decodedPath.takeIf { it.isNotBlank() }?.let { create(kind, it) }
}
fun normalizeRelativePath(path: String): String =
path.replace('\\', '/').trim().trim('/').let { normalized ->
if (normalized.isEmpty()) "" else "$normalized/"
}
fun isCameraPath(path: String): Boolean =
normalizeRelativePath(path).equals(CAMERA_RELATIVE_PATH, ignoreCase = true)
}
}
@@ -0,0 +1,55 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.provider.MediaStore
import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
object AutomaticUploadsPermissions {
fun readPermission(kind: AutomaticUploadMediaKind): String = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && kind == AutomaticUploadMediaKind.IMAGE ->
Manifest.permission.READ_MEDIA_IMAGES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.READ_MEDIA_VIDEO
else -> Manifest.permission.READ_EXTERNAL_STORAGE
}
fun hasReadPermission(context: Context, kind: AutomaticUploadMediaKind): Boolean =
ContextCompat.checkSelfPermission(context, readPermission(kind)) == PackageManager.PERMISSION_GRANTED
fun readPermissions(kind: AutomaticUploadMediaKind): Array<String> = buildList {
add(readPermission(kind))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
add(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
}
}.toTypedArray()
fun hasDeletePermission(context: Context): Boolean = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.R -> true
!Environment.isExternalStorageManager() -> false
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> true
else -> MediaStore.canManageMedia(context)
}
fun deletePermissionIntent(context: Context): Intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:${context.packageName}"),
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !MediaStore.canManageMedia(context)) {
Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}"))
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
}
}
@@ -0,0 +1,183 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import java.io.File
data class PhoneMediaFolder(
val displayName: String,
val relativePath: String,
val itemCount: Int,
)
data class PhoneMediaItem(
val uri: Uri,
val displayName: String,
val mimeType: String,
val size: Long,
val lastModified: Long,
)
class PhoneMediaStore(context: Context) {
private val contentResolver = context.applicationContext.contentResolver
fun getFolders(kind: AutomaticUploadMediaKind): List<PhoneMediaFolder> {
val folders = linkedMapOf<String, MutableFolder>()
query(kind) { row ->
if (row.relativePath.isEmpty()) return@query
val folder = folders.getOrPut(row.relativePath.lowercase()) {
MutableFolder(
displayName = row.bucketDisplayName.ifBlank {
row.relativePath.trimEnd('/').substringAfterLast('/')
},
relativePath = row.relativePath,
)
}
folder.itemCount += 1
}
return folders.values
.map { PhoneMediaFolder(it.displayName, it.relativePath, it.itemCount) }
.sortedWith(
compareBy<PhoneMediaFolder> { it.displayName.lowercase() }
.thenBy { it.relativePath.lowercase() }
)
}
fun getItems(source: AutomaticUploadMediaSource): List<PhoneMediaItem> = getItems(listOf(source))
fun getItems(sources: Collection<AutomaticUploadMediaSource>): List<PhoneMediaItem> {
if (sources.isEmpty()) return emptyList()
val kind = sources.first().kind
require(sources.all { it.kind == kind }) { "All media sources must have the same kind" }
val selectedPaths = sources.map { it.relativePath.lowercase() }.toSet()
val items = mutableListOf<PhoneMediaItem>()
query(kind) { row ->
if (row.relativePath.lowercase() !in selectedPaths) return@query
if (row.displayName.isBlank() || row.mimeType.isBlank()) return@query
items += PhoneMediaItem(
uri = ContentUris.withAppendedId(collectionFor(kind), row.id),
displayName = row.displayName,
mimeType = row.mimeType,
size = row.size,
lastModified = row.lastModified,
)
}
return items.sortedBy { it.lastModified }
}
private fun query(kind: AutomaticUploadMediaKind, consume: (MediaRow) -> Unit) {
val projection = buildList {
add(MediaStore.MediaColumns._ID)
add(MediaStore.MediaColumns.DISPLAY_NAME)
add(MediaStore.MediaColumns.MIME_TYPE)
add(MediaStore.MediaColumns.SIZE)
add(MediaStore.MediaColumns.DATE_MODIFIED)
add(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
add(MediaStore.MediaColumns.RELATIVE_PATH)
} else {
@Suppress("DEPRECATION")
add(MediaStore.MediaColumns.DATA)
}
}.toTypedArray()
val selection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"${MediaStore.MediaColumns.IS_PENDING}=0"
} else {
null
}
val cursor = contentResolver.query(
collectionFor(kind),
projection,
selection,
null,
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
) ?: throw IllegalStateException("MediaStore returned no cursor for $kind")
cursor.use {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
val modifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val bucketColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
val pathColumn = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)
} else {
@Suppress("DEPRECATION")
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
}
while (cursor.moveToNext()) {
val rawPath = cursor.getString(pathColumn).orEmpty()
val relativePath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
AutomaticUploadMediaSource.normalizeRelativePath(rawPath)
} else {
relativePathFromLegacyData(rawPath)
}
consume(
MediaRow(
id = cursor.getLong(idColumn),
displayName = cursor.getString(displayNameColumn).orEmpty(),
mimeType = cursor.getString(mimeTypeColumn).orEmpty(),
size = cursor.getLong(sizeColumn),
lastModified = cursor.getLong(modifiedColumn) * 1_000L,
bucketDisplayName = cursor.getString(bucketColumn).orEmpty(),
relativePath = relativePath,
)
)
}
}
}
private fun collectionFor(kind: AutomaticUploadMediaKind): Uri = when (kind) {
AutomaticUploadMediaKind.IMAGE -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
AutomaticUploadMediaKind.VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
private fun relativePathFromLegacyData(dataPath: String): String {
val parentPath = File(dataPath).parent.orEmpty().replace('\\', '/')
val externalRoot = Environment.getExternalStorageDirectory().absolutePath.replace('\\', '/').trimEnd('/')
val relativePath = if (parentPath.startsWith(externalRoot, ignoreCase = true)) {
parentPath.removePrefix(externalRoot)
} else {
parentPath.substringAfter("/storage/emulated/0/", parentPath)
}
return AutomaticUploadMediaSource.normalizeRelativePath(relativePath)
}
private data class MutableFolder(
val displayName: String,
val relativePath: String,
var itemCount: Int = 0,
)
private data class MediaRow(
val id: Long,
val displayName: String,
val mimeType: String,
val size: Long,
val lastModified: Long,
val bucketDisplayName: String,
val relativePath: String,
)
}
@@ -25,12 +25,10 @@ package eu.qsfera.android.presentation.settings.automaticuploads
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.Settings
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -65,14 +63,17 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private var prefEnablePictureUploads: SwitchPreferenceCompat? = null
private var prefPictureUploadsPath: Preference? = null
private var prefPictureUploadsOnWifi: CheckBoxPreference? = null
private var prefPictureUploadsOnWifi: SwitchPreferenceCompat? = null
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
private var prefPictureUploadsSourcePath: Preference? = null
private var prefPictureUploadsClearSourcePaths: Preference? = null
private var prefPictureUploadsPermissions: Preference? = null
private var prefPictureUploadsAccount: ListPreference? = null
private var prefPictureUploadsLastSync: Preference? = null
private var spaceId: String? = null
private lateinit var selectedAccount: String
private var pendingEnable = false
private var pendingOpenFolders = false
private var requestedMediaManagement = false
private val selectPictureUploadsPathLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -83,12 +84,36 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private val selectPictureUploadsSourcePathLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
// here we ask the content resolver to persist the permission for us
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
val contentUriForTree = result.data!!.data!!
val sourcePaths = result.data
?.getStringArrayListExtra(AutomaticUploadFoldersActivity.EXTRA_SELECTED_SOURCES)
.orEmpty()
picturesViewModel.replacePictureUploadsSourcePaths(sourcePaths)
}
requireContext().contentResolver.takePersistableUriPermission(contentUriForTree, takeFlags)
picturesViewModel.handleSelectPictureUploadsSourcePath(contentUriForTree)
private val readMediaPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
if (grants[AutomaticUploadsPermissions.readPermission(AutomaticUploadMediaKind.IMAGE)] == true) {
continuePendingPermissionAction()
} else {
pendingEnable = false
pendingOpenFolders = false
updatePermissionSummary()
}
}
private val deletePermissionLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
requestedMediaManagement = false
continuePendingPermissionAction()
} else if (!requestedMediaManagement) {
requestRequiredPermissions()
} else {
requestedMediaManagement = false
pendingEnable = false
pendingOpenFolders = false
updatePermissionSummary()
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@@ -99,14 +124,12 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY)
prefPictureUploadsOnCharging = findPreference(PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY)
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
prefPictureUploadsPermissions = findPreference(PREF_PICTURE_UPLOADS_PERMISSIONS)
prefPictureUploadsLastSync = findPreference(PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC)
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
prefPictureUploadsSourcePath?.title = String.format(prefPictureUploadsSourcePath?.title.toString(), comment)
initPreferenceListeners()
updatePermissionSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -130,7 +153,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let {
if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName
} else {
@@ -145,8 +168,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsOnWifi?.isChecked = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
spaceId = it.spaceId
@@ -163,12 +185,9 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
val value = newValue as Boolean
if (value) {
picturesViewModel.enablePictureUploads(selectedAccount)
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.proper_pics_folder_warning_camera_upload)
)
true
pendingEnable = true
requestRequiredPermissions()
false
} else {
showAlertDialog(
title = getString(R.string.confirmation_disable_camera_uploads_title),
@@ -198,52 +217,34 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
prefPictureUploadsSourcePath?.setOnPreferenceClickListener {
val sourcePath = picturesViewModel.getPictureUploadsSourcePaths().lastOrNull()?.let { currentSourcePath ->
currentSourcePath.takeUnless { it.endsWith(File.separator) } ?: currentSourcePath.plus(File.separator)
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, sourcePath)
}
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
}
selectPictureUploadsSourcePathLauncher.launch(intent)
pendingOpenFolders = true
requestRequiredPermissions(requireDeletePermission = false)
true
}
prefPictureUploadsClearSourcePaths?.setOnPreferenceClickListener {
showAlertDialog(
title = getString(R.string.confirmation_clear_camera_upload_sources_title),
message = getString(R.string.confirmation_clear_camera_upload_sources_message),
positiveButtonText = getString(R.string.common_yes),
positiveButtonListener = { _: DialogInterface?, _: Int ->
picturesViewModel.clearPictureUploadsSourcePaths()
},
negativeButtonText = getString(R.string.common_no)
)
prefPictureUploadsPermissions?.setOnPreferenceClickListener {
requestRequiredPermissions()
true
}
prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
picturesViewModel.useWifiOnly(newValue)
newValue
val useMobileData = newValue as? Boolean
?: return@setOnPreferenceChangeListener false
picturesViewModel.useWifiOnly(!useMobileData)
true
}
prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
picturesViewModel.useChargingOnly(newValue)
newValue
val chargingOnly = newValue as? Boolean
?: return@setOnPreferenceChangeListener false
picturesViewModel.useChargingOnly(chargingOnly)
true
}
prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
picturesViewModel.handleSelectAccount(newValue)
val accountName = newValue as? String
?: return@setOnPreferenceChangeListener false
picturesViewModel.handleSelectAccount(accountName)
true
}
@@ -254,6 +255,11 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
super.onDestroy()
}
override fun onResume() {
super.onResume()
updatePermissionSummary()
}
private fun enablePictureUploads(value: Boolean, isLightUser: Boolean) {
prefEnablePictureUploads?.isChecked = value
if (isLightUser) {
@@ -263,7 +269,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsOnWifi?.isEnabled = value
prefPictureUploadsOnCharging?.isEnabled = value
prefPictureUploadsSourcePath?.isEnabled = value
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
prefPictureUploadsAccount?.isEnabled = value
prefPictureUploadsLastSync?.isEnabled = value
}
@@ -272,22 +277,88 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsAccount?.value = null
prefPictureUploadsPath?.summary = null
prefPictureUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
prefPictureUploadsClearSourcePaths?.isEnabled = false
prefPictureUploadsOnWifi?.isChecked = false
prefPictureUploadsOnWifi?.isChecked = true
prefPictureUploadsOnCharging?.isChecked = false
prefPictureUploadsLastSync?.summary = null
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
if (sourcePaths.isEmpty()) {
getString(R.string.prefs_camera_upload_source_paths_empty)
private fun getSourcePathsSummary(sourcePaths: List<String>): String {
val selectedCount = sourcePaths.count { sourcePath ->
AutomaticUploadMediaSource.parse(sourcePath)?.isCamera != true
}
val suffix = if (selectedCount == 0) {
""
} else {
sourcePaths.joinToString(separator = "\n") { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
getString(R.string.automatic_upload_selected_folders_suffix, selectedCount)
}
return getString(R.string.automatic_upload_camera_and_folders_summary, suffix)
}
private fun requestRequiredPermissions(requireDeletePermission: Boolean = true) {
if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.IMAGE)) {
readMediaPermissionLauncher.launch(AutomaticUploadsPermissions.readPermissions(AutomaticUploadMediaKind.IMAGE))
updatePermissionSummary()
return
}
if (requireDeletePermission && !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
val permissionIntent = AutomaticUploadsPermissions.deletePermissionIntent(requireContext())
requestedMediaManagement = permissionIntent.action == Settings.ACTION_REQUEST_MANAGE_MEDIA
runCatching { deletePermissionLauncher.launch(permissionIntent) }
.onFailure {
startActivity(permissionIntent)
}
updatePermissionSummary()
return
}
continuePendingPermissionAction()
}
private fun continuePendingPermissionAction() {
updatePermissionSummary()
if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.IMAGE)) return
if (pendingEnable && !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
requestRequiredPermissions()
return
}
if (pendingEnable) {
pendingEnable = false
picturesViewModel.enablePictureUploads(selectedAccount)
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.automatic_upload_enable_message),
)
}
if (pendingOpenFolders) {
pendingOpenFolders = false
selectPictureUploadsSourcePathLauncher.launch(
AutomaticUploadFoldersActivity.createIntent(
requireContext(),
AutomaticUploadMediaKind.IMAGE,
picturesViewModel.getPictureUploadsSourcePaths(),
)
)
}
}
private fun updatePermissionSummary() {
val hasRead = AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.IMAGE)
val hasDelete = AutomaticUploadsPermissions.hasDeletePermission(requireContext())
prefPictureUploadsPermissions?.summary = getString(
when {
hasRead && hasDelete -> R.string.automatic_upload_permission_ready
!hasRead && !hasDelete -> R.string.automatic_upload_permission_both_missing
!hasRead -> R.string.automatic_upload_permission_read_missing
else -> R.string.automatic_upload_permission_delete_missing
}
)
}
companion object {
private const val PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS = "picture_uploads_clear_source_paths"
private const val PREF_PICTURE_UPLOADS_PERMISSIONS = "automatic_picture_uploads_permissions"
}
}
@@ -23,7 +23,6 @@
package eu.qsfera.android.presentation.settings.automaticuploads
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.ExistingWorkPolicy
@@ -164,10 +163,9 @@ class SettingsPictureUploadsViewModel(
}
}
fun handleSelectPictureUploadsSourcePath(contentUriForTree: Uri) {
fun replacePictureUploadsSourcePaths(sourcePaths: List<String>) {
val previousSourcePaths = getPictureUploadsSourcePaths()
val newSourcePath = contentUriForTree.toString()
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
val updatedSourcePaths = FolderBackUpConfiguration.parseSourcePaths(encodeSourcePaths(sourcePaths))
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
@@ -218,8 +216,8 @@ class SettingsPictureUploadsViewModel(
behavior = UploadBehavior.MOVE,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
chargingOnly = chargingOnly ?: false,
wifiOnly = wifiOnly == true,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
name = _pictureUploads.value?.name ?: pictureUploadsName,
spaceId = spaceId,
@@ -237,6 +235,7 @@ class SettingsPictureUploadsViewModel(
fun getUploadPathString(): String {
val spaceName = handleSpaceName(pictureUploadsSpace?.name)
.orEmpty()
val uploadPath = pictureUploads.value?.uploadPath
val spaceId = pictureUploads.value?.spaceId
@@ -25,12 +25,10 @@ package eu.qsfera.android.presentation.settings.automaticuploads
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.Settings
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -67,15 +65,19 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private var prefEnableVideoUploads: SwitchPreferenceCompat? = null
private var prefVideoUploadsPath: Preference? = null
private var prefVideoUploadsOnWifi: CheckBoxPreference? = null
private var prefVideoUploadsOnWifi: SwitchPreferenceCompat? = null
private var prefVideoUploadsOnCharging: CheckBoxPreference? = null
private var prefVideoUploadsSourcePath: Preference? = null
private var prefVideoUploadsClearSourcePaths: Preference? = null
private var prefVideoUploadsPermissions: Preference? = null
private var prefVideoUploadsBehaviour: ListPreference? = null
private var prefVideoUploadsAccount: ListPreference? = null
private var prefVideoUploadsLastSync: Preference? = null
private var spaceId: String? = null
private lateinit var selectedAccount: String
private var pendingEnable = false
private var pendingOpenFolders = false
private var pendingMoveBehavior = false
private var requestedMediaManagement = false
private val selectVideoUploadsPathLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -86,12 +88,37 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private val selectVideoUploadsSourcePathLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
// here we ask the content resolver to persist the permission for us
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
val contentUriForTree = result.data!!.data!!
val sourcePaths = result.data
?.getStringArrayListExtra(AutomaticUploadFoldersActivity.EXTRA_SELECTED_SOURCES)
.orEmpty()
videosViewModel.replaceVideoUploadsSourcePaths(sourcePaths)
}
requireContext().contentResolver.takePersistableUriPermission(contentUriForTree, takeFlags)
videosViewModel.handleSelectVideoUploadsSourcePath(contentUriForTree)
private val readMediaPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
if (grants[AutomaticUploadsPermissions.readPermission(AutomaticUploadMediaKind.VIDEO)] == true) {
continuePendingPermissionAction()
} else {
pendingEnable = false
pendingOpenFolders = false
updatePermissionSummary()
}
}
private val deletePermissionLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (AutomaticUploadsPermissions.hasDeletePermission(requireContext()) && pendingMoveBehavior) {
requestedMediaManagement = false
pendingMoveBehavior = false
videosViewModel.handleSelectBehaviour(UploadBehavior.MOVE.name)
prefVideoUploadsBehaviour?.value = UploadBehavior.MOVE.name
} else if (pendingMoveBehavior && !requestedMediaManagement) {
requestDeletePermissionForMove()
} else {
requestedMediaManagement = false
pendingMoveBehavior = false
}
updatePermissionSummary()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
@@ -102,7 +129,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY)
prefVideoUploadsOnCharging = findPreference(PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY)
prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE)
prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS)
prefVideoUploadsPermissions = findPreference(PREF_VIDEO_UPLOADS_PERMISSIONS)
prefVideoUploadsLastSync = findPreference(PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_LAST_SYNC)
prefVideoUploadsBehaviour = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR)?.apply {
entries = listOf(getString(R.string.pref_behaviour_entries_keep_file),
@@ -111,10 +138,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
prefVideoUploadsSourcePath?.title = String.format(prefVideoUploadsSourcePath?.title.toString(), comment)
initPreferenceListeners()
updatePermissionSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -138,7 +163,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let {
if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName
} else {
@@ -153,8 +178,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsOnWifi?.isChecked = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
@@ -172,12 +196,9 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
val value = newValue as Boolean
if (value) {
videosViewModel.enableVideoUploads(selectedAccount)
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.proper_videos_folder_warning_camera_upload)
)
true
pendingEnable = true
requestReadPermissionIfNeeded()
false
} else {
showAlertDialog(
title = getString(R.string.confirmation_disable_camera_uploads_title),
@@ -207,67 +228,62 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
prefVideoUploadsSourcePath?.setOnPreferenceClickListener {
val sourcePath = videosViewModel.getVideoUploadsSourcePaths().lastOrNull()?.let { currentSourcePath ->
currentSourcePath.takeUnless { it.endsWith(File.separator) } ?: currentSourcePath.plus(File.separator)
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, sourcePath)
}
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
}
selectVideoUploadsSourcePathLauncher.launch(intent)
pendingOpenFolders = true
requestReadPermissionIfNeeded()
true
}
prefVideoUploadsClearSourcePaths?.setOnPreferenceClickListener {
showAlertDialog(
title = getString(R.string.confirmation_clear_camera_upload_sources_title),
message = getString(R.string.confirmation_clear_camera_upload_sources_message),
positiveButtonText = getString(R.string.common_yes),
positiveButtonListener = { _: DialogInterface?, _: Int ->
videosViewModel.clearVideoUploadsSourcePaths()
},
negativeButtonText = getString(R.string.common_no)
)
prefVideoUploadsPermissions?.setOnPreferenceClickListener {
requestReadPermissionIfNeeded()
true
}
prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
videosViewModel.useWifiOnly(newValue)
newValue
val useMobileData = newValue as? Boolean
?: return@setOnPreferenceChangeListener false
videosViewModel.useWifiOnly(!useMobileData)
true
}
prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
videosViewModel.useChargingOnly(newValue)
newValue
val chargingOnly = newValue as? Boolean
?: return@setOnPreferenceChangeListener false
videosViewModel.useChargingOnly(chargingOnly)
true
}
prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
videosViewModel.handleSelectAccount(newValue)
val accountName = newValue as? String
?: return@setOnPreferenceChangeListener false
videosViewModel.handleSelectAccount(accountName)
true
}
prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
videosViewModel.handleSelectBehaviour(newValue)
val behaviorValue = newValue as? String
?: return@setOnPreferenceChangeListener false
val behavior = UploadBehavior.fromString(behaviorValue)
if (behavior == UploadBehavior.MOVE && !AutomaticUploadsPermissions.hasDeletePermission(requireContext())) {
pendingMoveBehavior = true
requestDeletePermissionForMove()
false
} else {
videosViewModel.handleSelectBehaviour(behaviorValue)
true
}
}
}
override fun onDestroy() {
videosViewModel.scheduleVideoUploads()
super.onDestroy()
}
override fun onResume() {
super.onResume()
updatePermissionSummary()
}
private fun enableVideoUploads(value: Boolean, isLightUser: Boolean) {
prefEnableVideoUploads?.isChecked = value
if (isLightUser) {
@@ -277,7 +293,6 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsOnWifi?.isEnabled = value
prefVideoUploadsOnCharging?.isEnabled = value
prefVideoUploadsSourcePath?.isEnabled = value
prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty()
prefVideoUploadsBehaviour?.isEnabled = value
prefVideoUploadsAccount?.isEnabled = value
prefVideoUploadsLastSync?.isEnabled = value
@@ -287,23 +302,72 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsAccount?.value = null
prefVideoUploadsPath?.summary = null
prefVideoUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
prefVideoUploadsClearSourcePaths?.isEnabled = false
prefVideoUploadsOnWifi?.isChecked = false
prefVideoUploadsOnWifi?.isChecked = true
prefVideoUploadsOnCharging?.isChecked = false
prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name
prefVideoUploadsLastSync?.summary = null
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
if (sourcePaths.isEmpty()) {
getString(R.string.prefs_camera_upload_source_paths_empty)
private fun getSourcePathsSummary(sourcePaths: List<String>): String {
val selectedCount = sourcePaths.count { sourcePath ->
AutomaticUploadMediaSource.parse(sourcePath)?.isCamera != true
}
val suffix = if (selectedCount == 0) {
""
} else {
sourcePaths.joinToString(separator = "\n") { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
getString(R.string.automatic_upload_selected_folders_suffix, selectedCount)
}
return getString(R.string.automatic_upload_camera_and_folders_summary, suffix)
}
private fun requestReadPermissionIfNeeded() {
if (AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.VIDEO)) {
continuePendingPermissionAction()
} else {
readMediaPermissionLauncher.launch(AutomaticUploadsPermissions.readPermissions(AutomaticUploadMediaKind.VIDEO))
}
updatePermissionSummary()
}
private fun requestDeletePermissionForMove() {
val permissionIntent = AutomaticUploadsPermissions.deletePermissionIntent(requireContext())
requestedMediaManagement = permissionIntent.action == Settings.ACTION_REQUEST_MANAGE_MEDIA
deletePermissionLauncher.launch(permissionIntent)
}
private fun continuePendingPermissionAction() {
updatePermissionSummary()
if (!AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.VIDEO)) return
if (pendingEnable) {
pendingEnable = false
videosViewModel.enableVideoUploads(selectedAccount)
showAlertDialog(
title = getString(R.string.common_important),
message = getString(R.string.automatic_upload_enable_message),
)
}
if (pendingOpenFolders) {
pendingOpenFolders = false
selectVideoUploadsSourcePathLauncher.launch(
AutomaticUploadFoldersActivity.createIntent(
requireContext(),
AutomaticUploadMediaKind.VIDEO,
videosViewModel.getVideoUploadsSourcePaths(),
)
)
}
}
private fun updatePermissionSummary() {
val hasRead = AutomaticUploadsPermissions.hasReadPermission(requireContext(), AutomaticUploadMediaKind.VIDEO)
prefVideoUploadsPermissions?.summary = getString(
if (hasRead) R.string.automatic_upload_permission_media_ready else R.string.automatic_upload_permission_read_missing
)
}
companion object {
private const val PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS = "video_uploads_clear_source_paths"
private const val PREF_VIDEO_UPLOADS_PERMISSIONS = "automatic_video_uploads_permissions"
}
}
@@ -23,7 +23,6 @@
package eu.qsfera.android.presentation.settings.automaticuploads
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.ExistingWorkPolicy
@@ -174,10 +173,9 @@ class SettingsVideoUploadsViewModel(
}
}
fun handleSelectVideoUploadsSourcePath(contentUriForTree: Uri) {
fun replaceVideoUploadsSourcePaths(sourcePaths: List<String>) {
val previousSourcePaths = getVideoUploadsSourcePaths()
val newSourcePath = contentUriForTree.toString()
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
val updatedSourcePaths = FolderBackUpConfiguration.parseSourcePaths(encodeSourcePaths(sourcePaths))
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
@@ -230,8 +228,8 @@ class SettingsVideoUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
chargingOnly = chargingOnly ?: false,
wifiOnly = wifiOnly == true,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
name = _videoUploads.value?.name ?: videoUploadsName,
spaceId = spaceId,
@@ -249,6 +247,7 @@ class SettingsVideoUploadsViewModel(
fun getUploadPathString(): String {
val spaceName = handleSpaceName(videoUploadsSpace?.name)
.orEmpty()
val uploadPath = videoUploads.value?.uploadPath
val spaceId = videoUploads.value?.spaceId
@@ -46,6 +46,7 @@ import okhttp3.Cache
import okhttp3.Headers.Companion.toHeaders
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
@@ -104,6 +105,34 @@ object ThumbnailsRequester : KoinComponent {
fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String =
getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height)
fun getPreviewUriForWebDavHref(
webDavHref: String,
account: Account,
etag: String? = null,
width: Int = 1024,
height: Int = 1024,
): String {
val baseUrl = accountBaseUrls.getOrPut(account.name) {
val accountManager = AccountManager.get(appContext)
accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL)
?.trimEnd('/')
.orEmpty()
}
val baseHttpUrl = baseUrl.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid account base URL")
val absoluteCandidate = webDavHref.toHttpUrlOrNull()
if (absoluteCandidate != null) {
require(
absoluteCandidate.scheme == baseHttpUrl.scheme &&
absoluteCandidate.host == baseHttpUrl.host &&
absoluteCandidate.port == baseHttpUrl.port
) { "WebDAV preview href must use the account origin" }
}
val absoluteHref = absoluteCandidate?.toString()
?: "$baseUrl/${webDavHref.trimStart('/')}"
val separator = if ('?' in absoluteHref) '&' else '?'
return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1"
}
fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String =
String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag)
@@ -114,7 +143,8 @@ object ThumbnailsRequester : KoinComponent {
?.trimEnd('/')
.orEmpty()
}
val path = if (remotePath?.startsWith("/") == true) remotePath else "/$remotePath"
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())
@@ -236,7 +266,7 @@ object ThumbnailsRequester : KoinComponent {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
var builder = response.newBuilder()
val builder = response.newBuilder()
var changed = false
// The server sends no-cache (or no Cache-Control) for avatar responses.
@@ -125,8 +125,10 @@ class WorkManagerProvider(
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val mediaTriggerRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.MEDIA_STORE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
if (periodicRunning || immediateRunning) {
if (periodicRunning || immediateRunning || mediaTriggerRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return
}
@@ -16,7 +16,7 @@ fun FragmentActivity.enableEdgeToEdgePreSetContentView(
isNavigationBackgroundPrimary: Boolean
) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle =
if (isNavigationBackgroundPrimary)
SystemBarStyle.dark(Color.TRANSPARENT)
@@ -44,38 +44,33 @@ import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.GravityCompat
import androidx.core.view.get
import androidx.core.view.isVisible
import androidx.drawerlayout.widget.DrawerLayout
import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.navigation.NavigationView
import eu.qsfera.android.R
import eu.qsfera.android.domain.capabilities.model.OCCapability
import eu.qsfera.android.domain.files.model.FileListOption
import eu.qsfera.android.domain.user.model.UserQuota
import eu.qsfera.android.domain.user.model.UserQuotaState
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
import eu.qsfera.android.extensions.goToUrl
import eu.qsfera.android.extensions.openPrivacyPolicy
import eu.qsfera.android.extensions.sendEmailOrOpenFeedbackDialogAction
import eu.qsfera.android.extensions.setAccessibilityRole
import eu.qsfera.android.lib.common.QSferaAccount
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.avatar.AvatarUtils
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
import eu.qsfera.android.presentation.cloud.CloudSection
import eu.qsfera.android.presentation.common.DrawerViewModel
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.settings.SettingsActivity
import eu.qsfera.android.utils.DisplayUtils
import eu.qsfera.android.utils.PreferenceUtils
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import timber.log.Timber
import androidx.lifecycle.lifecycleScope
@@ -91,12 +86,6 @@ import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester
abstract class DrawerActivity : ToolbarActivity() {
private val drawerViewModel by viewModel<DrawerViewModel>()
private val capabilitiesViewModel by viewModel<CapabilityViewModel> {
parametersOf(
account?.name
)
}
private var currentAccountAvatarRadiusDimension = 0f
private var drawerToggle: ActionBarDrawerToggle? = null
@@ -218,46 +207,25 @@ abstract class DrawerActivity : ToolbarActivity() {
open fun setupNavigationBottomBar(menuItemId: Int) {
// Allow or disallow touches with other visible windows
getBottomNavigationView()?.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(this)
if (account != null) {
capabilitiesViewModel.capabilities.observe(this) { event: Event<UIResult<OCCapability>> ->
setSpacesVisibilityBottomBar(event.peekContent())
}
}
setCheckedItemAtBottomBar(menuItemId)
getBottomNavigationView()?.setOnNavigationItemSelectedListener { menuItem: MenuItem ->
bottomBarNavigationTo(menuItem.itemId, getBottomNavigationView()?.selectedItemId == menuItem.itemId)
bottomBarNavigationTo(menuItem.itemId)
true
}
}
private fun setSpacesVisibilityBottomBar(uiResult: UIResult<OCCapability>) {
if (uiResult is UIResult.Success) {
val capabilities = uiResult.data
if (AccountUtils.isSpacesFeatureAllowedForAccount(baseContext, account, capabilities)) {
getBottomNavigationView()?.menu?.get(0)?.title = getString(R.string.bottom_nav_personal)
getBottomNavigationView()?.menu?.get(1)?.title = getString(R.string.bottom_nav_shares)
getBottomNavigationView()?.menu?.get(1)?.icon = AppCompatResources.getDrawable(this, R.drawable.ic_server_shares)
getBottomNavigationView()?.menu?.get(2)?.isVisible = capabilities?.isSpacesProjectsAllowed() == true
} else {
getBottomNavigationView()?.menu?.get(0)?.title = getString(R.string.bottom_nav_files)
getBottomNavigationView()?.menu?.get(2)?.isVisible = false
}
}
}
private fun bottomBarNavigationTo(menuItemId: Int, isCurrentOptionActive: Boolean) {
private fun bottomBarNavigationTo(menuItemId: Int) {
when (menuItemId) {
R.id.nav_feed -> openCloudSection(CloudSection.FEED)
R.id.nav_all_files -> navigateToOption(FileListOption.ALL_FILES)
R.id.nav_spaces -> navigateToOption(FileListOption.SPACES_LIST)
R.id.nav_uploads -> if (!isCurrentOptionActive) {
val uploadListIntent = Intent(applicationContext, UploadListActivity::class.java)
uploadListIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(uploadListIntent)
R.id.nav_photos -> openCloudSection(CloudSection.PHOTOS)
R.id.nav_albums -> openCloudSection(CloudSection.ALBUMS)
R.id.nav_more -> openCloudSection(CloudSection.MORE)
}
}
R.id.nav_available_offline_files -> navigateToOption(FileListOption.AV_OFFLINE)
R.id.nav_shared_by_link_files -> navigateToOption(FileListOption.SHARED_BY_LINK)
}
private fun openCloudSection(section: CloudSection) {
startActivity(CloudHomeActivity.createIntent(this, section))
}
private fun openHelp() {
@@ -1839,12 +1839,8 @@ class FileDisplayActivity : FileActivity(),
navigateTo(fileListOption)
}
private fun getMenuItemForFileListOption(fileListOption: FileListOption?): Int = when (fileListOption) {
FileListOption.SPACES_LIST -> R.id.nav_spaces
FileListOption.SHARED_BY_LINK -> R.id.nav_shared_by_link_files
FileListOption.AV_OFFLINE -> R.id.nav_available_offline_files
else -> R.id.nav_all_files
}
private fun getMenuItemForFileListOption(fileListOption: FileListOption?): Int =
if (fileListOption == FileListOption.ALL_FILES) R.id.nav_all_files else R.id.nav_more
override fun optionLockSelected(type: LockType) {
manageOptionLockSelected(type)
@@ -20,7 +20,6 @@
package eu.qsfera.android.ui.activity
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import eu.qsfera.android.BuildConfig
@@ -30,6 +29,8 @@ import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvid
import eu.qsfera.android.presentation.security.LockTimeout
import eu.qsfera.android.presentation.security.PREFERENCE_LOCK_TIMEOUT
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.presentation.cloud.CloudHomeActivity
import eu.qsfera.android.presentation.cloud.CloudSection
import eu.qsfera.android.utils.CONFIGURATION_ALLOW_SCREENSHOTS
import eu.qsfera.android.utils.CONFIGURATION_DEVICE_PROTECTION
import eu.qsfera.android.utils.CONFIGURATION_LOCK_DELAY_TIME
@@ -63,7 +64,7 @@ class SplashActivity : AppCompatActivity() {
checkLockDelayEnforced(mdmProvider)
startActivity(Intent(this, FileDisplayActivity::class.java))
startActivity(CloudHomeActivity.createIntent(this, CloudSection.FEED))
finish()
}
@@ -112,7 +112,7 @@ abstract class ToolbarActivity : BaseActivity() {
val textSearchView = findViewById<EditText>(androidx.appcompat.R.id.search_src_text)
val closeButton = findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
textSearchView.setHintTextColor(ContextCompat.getColor(applicationContext, R.color.search_view_hint_text))
closeButton.setColorFilter(ContextCompat.getColor(applicationContext, R.color.white))
closeButton.setColorFilter(ContextCompat.getColor(applicationContext, R.color.qsfera_text_primary))
}
AccountUtils.getCurrentQSferaAccount(baseContext) ?: return
@@ -171,7 +171,7 @@ abstract class ToolbarActivity : BaseActivity() {
searchButton.setBackgroundColor(getColor(R.color.actionbar_start_color))
searchText.setHintTextColor(getColor(R.color.search_view_hint_text))
closeButton.setColorFilter(getColor(R.color.white))
closeButton.setColorFilter(getColor(R.color.qsfera_text_primary))
background = getDrawable(R.drawable.rounded_search_view)
isFocusable = false
}
@@ -89,7 +89,7 @@ public class UploadListActivity extends FileActivity {
setupDrawer();
// setup navigation bottom bar
setupNavigationBottomBar(R.id.nav_uploads);
setupNavigationBottomBar(R.id.nav_more);
// Add fragment with a transaction for setting a tag
if (savedInstanceState == null) {
@@ -23,8 +23,8 @@ package eu.qsfera.android.workers
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
@@ -41,9 +41,11 @@ import eu.qsfera.android.domain.transfers.model.OCTransfer
import eu.qsfera.android.domain.transfers.model.TransferStatus
import eu.qsfera.android.presentation.settings.SettingsActivity
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadMediaKind
import eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadMediaSource
import eu.qsfera.android.presentation.settings.automaticuploads.PhoneMediaStore
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.usecases.transfers.uploads.UploadFileFromContentUriUseCase
import eu.qsfera.android.utils.MimetypeIconUtil
import eu.qsfera.android.utils.NotificationUtils
import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
import org.koin.core.component.KoinComponent
@@ -90,20 +92,24 @@ class AutomaticUploadsWorker(
configuredSourcePaths = cameraUploadsConfiguration.sourcePaths
cameraUploadsConfiguration.pictureUploadsConfiguration?.let { pictureUploadsConfiguration ->
try {
checkSourcePathsAreValidUrisOrThrowException(pictureUploadsConfiguration.sourcePaths)
syncFolder(pictureUploadsConfiguration)
} catch (illegalArgumentException: IllegalArgumentException) {
Timber.e(illegalArgumentException, "Source path for picture uploads is not valid")
showNotificationToUpdateUri(SyncType.PICTURE_UPLOADS)
} catch (securityException: SecurityException) {
Timber.e(securityException, "Picture uploads cannot read phone media")
showNotificationToUpdateUri(SyncType.PICTURE_UPLOADS)
}
}
cameraUploadsConfiguration.videoUploadsConfiguration?.let { videoUploadsConfiguration ->
try {
checkSourcePathsAreValidUrisOrThrowException(videoUploadsConfiguration.sourcePaths)
syncFolder(videoUploadsConfiguration)
} catch (illegalArgumentException: IllegalArgumentException) {
Timber.e(illegalArgumentException, "Source path for video uploads is not valid")
showNotificationToUpdateUri(SyncType.VIDEO_UPLOADS)
} catch (securityException: SecurityException) {
Timber.e(securityException, "Video uploads cannot read phone media")
showNotificationToUpdateUri(SyncType.VIDEO_UPLOADS)
}
}
}
@@ -130,28 +136,21 @@ class AutomaticUploadsWorker(
)
}
@Throws(IllegalArgumentException::class)
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
sourcePaths.forEach { sourcePath ->
val sourceUri: Uri = sourcePath.toUri()
DocumentFile.fromTreeUri(applicationContext, sourceUri)
?: throw IllegalArgumentException("Source path is not a valid tree URI: $sourcePath")
}
}
private fun cancelWorker() {
WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER)
}
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration?) {
if (folderBackUpConfiguration == null) return
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration) {
val syncType = when {
folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS
folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS
// Else should not happen for the moment. Maybe in upcoming features..
else -> SyncType.PICTURE_UPLOADS
}
val mediaKind = when (syncType) {
SyncType.PICTURE_UPLOADS -> AutomaticUploadMediaKind.IMAGE
SyncType.VIDEO_UPLOADS -> AutomaticUploadMediaKind.VIDEO
}
val effectiveBehavior = folderBackUpConfiguration.effectiveBehavior
val currentTimestamp = System.currentTimeMillis()
@@ -159,50 +158,72 @@ class AutomaticUploadsWorker(
SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
}
val completedUploadTimesBySourceUri = if (effectiveBehavior == UploadBehavior.MOVE) {
transferRepository.getFinishedTransfers()
.asSequence()
.filter {
it.createdBy == automaticUploadSourceType &&
it.accountName == folderBackUpConfiguration.accountName
}
.mapNotNull { transfer ->
val sourcePath = transfer.sourcePath
val completedAt = transfer.transferEndTimestamp
if (sourcePath != null && completedAt != null) sourcePath to completedAt else null
}
.groupBy(keySelector = { it.first }, valueTransform = { it.second })
.mapValues { (_, completionTimes) -> completionTimes.maxOrNull()!! }
} else {
emptyMap()
}
val completedUploadTimesBySourceUri = completedUploadTimesBySourceUri(
folderBackUpConfiguration,
effectiveBehavior,
automaticUploadSourceType,
)
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
getFilesReadyToUpload(
val effectiveSourcePaths = buildList {
add(AutomaticUploadMediaSource.camera(mediaKind).encodedValue)
folderBackUpConfiguration.sourcePaths.forEach { configuredPath ->
val mediaSource = AutomaticUploadMediaSource.parse(configuredPath)
when {
mediaSource?.kind == mediaKind -> add(mediaSource.encodedValue)
mediaSource != null -> Unit
else -> migrateLegacyTreeSource(configuredPath, mediaKind)?.let(::add)
}
}
}.distinct()
val discoveryStartTimestamp = if (effectiveBehavior == UploadBehavior.MOVE) {
0L
} else {
folderBackUpConfiguration.lastSyncTimestamp
}
var allScansSuccessful = true
val uploadCandidates = mutableListOf<AutomaticUploadCandidate>()
val mediaSources = effectiveSourcePaths.mapNotNull(AutomaticUploadMediaSource::parse)
if (mediaSources.isNotEmpty()) {
runCatching {
val allMediaFiles = PhoneMediaStore(applicationContext).getItems(mediaSources).map { mediaItem ->
AutomaticUploadCandidate(
uri = mediaItem.uri,
name = mediaItem.displayName,
mimeType = mediaItem.mimeType,
size = mediaItem.size,
lastModified = mediaItem.lastModified,
)
}
filterFilesReadyToUpload(
syncType = syncType,
sourcePath = sourcePath,
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
sourceLabel = mediaSources.joinToString { it.relativePath },
allFiles = allMediaFiles,
lastSyncTimestamp = discoveryStartTimestamp,
currentTimestamp = currentTimestamp,
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
)
}.onSuccess(uploadCandidates::addAll).onFailure {
allScansSuccessful = false
Timber.e(it, "MediaStore scan failed for %s", syncType)
}
}
showNotification(syncType, localPicturesDocumentFiles.size)
showNotification(syncType, uploadCandidates.size)
for (documentFile in localPicturesDocumentFiles) {
for (candidate in uploadCandidates) {
// Dedup: if this content URI already has a queued, in-progress, or succeeded transfer,
// skip it. Without this, a worker killed mid-loop (before updateTimestamp) or a
// file whose lastModified changed (e.g. media scanner) would be re-discovered and
// enqueued with a new upload ID — leading to duplicate uploads or 0-byte files
// when two workers race on the same cache path.
val contentUri = documentFile.uri.toString()
val contentUri = candidate.uri.toString()
if (transferRepository.existsNonFailedTransferForUri(contentUri)) {
Timber.d("Skipping already-tracked file: %s", documentFile.name)
Timber.d("Skipping already-tracked file: %s", candidate.name)
continue
}
val uploadId = storeInUploadsDatabase(
documentFile = documentFile,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
candidate = candidate,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
accountName = folderBackUpConfiguration.accountName,
behavior = effectiveBehavior,
createdByWorker = when (syncType) {
@@ -212,9 +233,9 @@ class AutomaticUploadsWorker(
spaceId = folderBackUpConfiguration.spaceId
)
enqueueSingleUpload(
contentUri = documentFile.uri,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
lastModified = documentFile.lastModified(),
contentUri = candidate.uri,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(candidate.name),
lastModified = candidate.lastModified,
behavior = effectiveBehavior.toString(),
accountName = folderBackUpConfiguration.accountName,
uploadId = uploadId,
@@ -224,9 +245,30 @@ class AutomaticUploadsWorker(
}
// Save safeTimestamp (not currentTimestamp) so that files skipped by the
// write-safety buffer are re-evaluated on the next run instead of being lost.
if (allScansSuccessful) {
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp)
}
}
private fun completedUploadTimesBySourceUri(
configuration: FolderBackUpConfiguration,
behavior: UploadBehavior,
sourceType: UploadEnqueuedBy,
): Map<String, Long> {
if (behavior != UploadBehavior.MOVE) return emptyMap()
return transferRepository.getFinishedTransfers()
.asSequence()
.filter { it.createdBy == sourceType && it.accountName == configuration.accountName }
.mapNotNull { transfer ->
val sourcePath = transfer.sourcePath
val completedAt = transfer.transferEndTimestamp
if (sourcePath != null && completedAt != null) sourcePath to completedAt else null
}
.groupBy(keySelector = { it.first }, valueTransform = { it.second })
.mapValues { (_, completionTimes) -> completionTimes.maxOrNull()!! }
}
private fun showNotification(
syncType: SyncType,
@@ -296,48 +338,48 @@ class AutomaticUploadsWorker(
}
}
private fun getFilesReadyToUpload(
private fun filterFilesReadyToUpload(
syncType: SyncType,
sourcePath: String,
sourceLabel: String,
allFiles: List<AutomaticUploadCandidate>,
lastSyncTimestamp: Long,
currentTimestamp: Long,
completedUploadTimesBySourceUri: Map<String, Long>,
): List<DocumentFile> {
val sourceUri: Uri = sourcePath.toUri()
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
val arrayOfLocalFiles = documentTree?.listFiles() ?: arrayOf()
): List<AutomaticUploadCandidate> {
// Exclude files modified within the last few seconds. Camera apps may still be
// writing the file (not all apps use atomic rename), so picking it up too early
// can result in uploading a truncated or 0-byte JPEG.
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
val mediaFiles = arrayOfLocalFiles
.sortedBy { it.lastModified() }
.filter { MimetypeIconUtil.getBestMimeTypeByFilename(it.name).startsWith(syncType.prefixForType) }
val mediaFiles = allFiles
.sortedBy { it.lastModified }
.filter { it.mimeType.startsWith(syncType.prefixForType) }
val previouslyUploadedFiles = mediaFiles.filter { documentFile ->
val previouslyUploadedFiles = mediaFiles.filter { candidate ->
shouldRemovePreviouslyUploadedSource(
sourceUri = documentFile.uri.toString(),
lastModified = documentFile.lastModified(),
sourceUri = candidate.uri.toString(),
lastModified = candidate.lastModified,
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
)
}
previouslyUploadedFiles.forEach { documentFile ->
if (!removeSourceDocument(documentFile)) {
Timber.w("Uploaded source file could not be removed yet: %s", documentFile.uri)
previouslyUploadedFiles.forEach { candidate ->
val removed = runCatching { removeSourceUri(applicationContext, candidate.uri) }
.onFailure { Timber.w(it, "Uploaded source file could not be removed yet: %s", candidate.uri) }
.getOrDefault(false)
if (!removed) {
Timber.w("Uploaded source file could not be removed yet: %s", candidate.uri)
}
}
val previouslyUploadedUris = previouslyUploadedFiles.mapTo(mutableSetOf()) { it.uri }
val filteredList: List<DocumentFile> = mediaFiles
val filteredList: List<AutomaticUploadCandidate> = mediaFiles
.filterNot { it.uri in previouslyUploadedUris }
.filter { it.lastModified() >= lastSyncTimestamp }
.filter { it.lastModified() < safeTimestamp }
.filter { it.lastModified >= lastSyncTimestamp }
.filter { it.lastModified < safeTimestamp }
Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path}")
Timber.i("${allFiles.size} files found in folder: $sourceLabel")
Timber.i("${filteredList.size} files are ${syncType.name} and were taken after last sync")
return filteredList
@@ -370,7 +412,7 @@ class AutomaticUploadsWorker(
}
private fun storeInUploadsDatabase(
documentFile: DocumentFile,
candidate: AutomaticUploadCandidate,
uploadPath: String,
accountName: String,
behavior: UploadBehavior,
@@ -378,21 +420,36 @@ class AutomaticUploadsWorker(
spaceId: String?,
): Long {
val ocTransfer = OCTransfer(
localPath = documentFile.uri.toString(),
localPath = candidate.uri.toString(),
remotePath = uploadPath,
accountName = accountName,
fileSize = documentFile.length(),
fileSize = candidate.size,
status = TransferStatus.TRANSFER_QUEUED,
localBehaviour = behavior,
forceOverwrite = false,
createdBy = createdByWorker,
spaceId = spaceId,
sourcePath = documentFile.uri.toString(),
sourcePath = candidate.uri.toString(),
)
return transferRepository.saveTransfer(ocTransfer)
}
private fun migrateLegacyTreeSource(
sourcePath: String,
mediaKind: AutomaticUploadMediaKind,
): String? {
val sourceUri = runCatching { sourcePath.toUri() }.getOrNull() ?: return null
if (sourceUri.scheme != "content" || !DocumentsContract.isTreeUri(sourceUri)) return null
val documentId = runCatching { DocumentsContract.getTreeDocumentId(sourceUri) }.getOrNull() ?: return null
val volume = documentId.substringBefore(':', missingDelimiterValue = "")
val relativePath = documentId.substringAfter(':', missingDelimiterValue = "")
if (!volume.equals("primary", ignoreCase = true) || relativePath.isBlank()) return null
return AutomaticUploadMediaSource.create(mediaKind, relativePath).encodedValue
}
companion object {
const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER"
const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER"
@@ -407,6 +464,14 @@ class AutomaticUploadsWorker(
}
}
private data class AutomaticUploadCandidate(
val uri: Uri,
val name: String,
val mimeType: String,
val size: Long,
val lastModified: Long,
)
internal fun shouldRemovePreviouslyUploadedSource(
sourceUri: String,
lastModified: Long,
@@ -22,6 +22,8 @@ package eu.qsfera.android.workers
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.os.Environment
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
@@ -43,16 +45,18 @@ class RemoveSourceFileWorker(
override suspend fun doWork(): Result {
if (!areParametersValid()) return Result.failure()
return try {
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
if (removeSourceDocument(documentFile)) {
if (removeSourceUri(appContext, contentUri)) {
Result.success()
} else {
Timber.w("Source file could not be removed yet: %s", contentUri)
Result.retry()
}
} catch (securityException: SecurityException) {
Timber.e(securityException, "Media-management access is required to remove %s", contentUri)
Result.failure()
} catch (throwable: Throwable) {
Timber.e(throwable)
Result.retry()
if (runAttemptCount >= MAX_RETRY_ATTEMPTS) Result.failure() else Result.retry()
}
}
@@ -63,6 +67,53 @@ class RemoveSourceFileWorker(
return true
}
companion object {
private const val MAX_RETRY_ATTEMPTS = 4
}
}
internal fun removeSourceUri(context: Context, uri: Uri): Boolean {
if (uri.scheme == "content" && uri.authority == MediaStore.AUTHORITY) {
val removedRows = try {
context.contentResolver.delete(uri, null, null)
} catch (securityException: SecurityException) {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R || !Environment.isExternalStorageManager()) {
throw securityException
}
if (!removeMediaStoreFileByPath(context, uri)) throw securityException
1
}
if (removedRows > 0) return true
return context.contentResolver.query(
uri,
arrayOf(MediaStore.MediaColumns._ID),
null,
null,
null,
)?.use { cursor -> !cursor.moveToFirst() } != false
}
return removeSourceDocument(DocumentFile.fromSingleUri(context, uri))
}
@Suppress("DEPRECATION")
private fun removeMediaStoreFileByPath(context: Context, uri: Uri): Boolean {
val path = context.contentResolver.query(
uri,
arrayOf(MediaStore.MediaColumns.DATA),
null,
null,
null,
)?.use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0)
} ?: return true
val file = java.io.File(path)
val removed = !file.exists() || file.delete()
if (removed) context.contentResolver.notifyChange(uri, null)
return removed
}
internal fun removeSourceDocument(documentFile: DocumentFile?): Boolean {
@@ -27,6 +27,8 @@ import android.app.Notification
import android.content.Context
import android.content.pm.ServiceInfo
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
@@ -95,6 +97,10 @@ class UploadFileFromContentUriWorker(
private lateinit var ocTransfer: OCTransfer
private var spaceWebDavUrl: String? = null
private val transferRepository: TransferRepository by inject()
private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject()
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
private lateinit var uploadFileOperation: UploadFileFromFileSystemOperation
private val tusUploadHelper by lazy { TusUploadHelper(transferRepository) }
@@ -104,10 +110,6 @@ class UploadFileFromContentUriWorker(
private var currentForegroundProgress = -1
private val foregroundScope = CoroutineScope(Dispatchers.IO)
private val transferRepository: TransferRepository by inject()
private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject()
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
override suspend fun doWork(): Result = try {
prepareFile()
startForeground()
@@ -119,6 +121,10 @@ class UploadFileFromContentUriWorker(
Result.success()
}catch (throwable: Throwable) {
Timber.e(throwable)
Log.w(
"QSferaUpload",
"Content upload failed: ${throwable.javaClass.simpleName}; root=${throwable.rootCauseClassName()}",
)
if (shouldRetry(throwable)) {
Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager)
@@ -190,6 +196,18 @@ class UploadFileFromContentUriWorker(
}
private fun checkDocumentFileExists() {
if (contentUri.authority == MediaStore.AUTHORITY) {
val exists = appContext.contentResolver.query(
contentUri,
arrayOf(MediaStore.MediaColumns._ID),
null,
null,
null,
)?.use { cursor -> cursor.moveToFirst() } == true
if (!exists) throw LocalFileNotFoundException()
return
}
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
if (documentFile?.exists() != true && documentFile?.isFile != true) {
// File does not exists anymore. Throw an exception to tell the user
@@ -198,6 +216,14 @@ class UploadFileFromContentUriWorker(
}
private fun checkPermissionsToReadDocumentAreGranted() {
if (contentUri.authority == MediaStore.AUTHORITY) {
val canRead = runCatching {
appContext.contentResolver.openFileDescriptor(contentUri, "r")?.use { true } == true
}.getOrDefault(false)
if (!canRead) throw LocalFileNotFoundException()
return
}
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
if (documentFile?.canRead() != true) {
// Permissions not granted. Throw an exception to ask for them.
@@ -261,7 +287,7 @@ class UploadFileFromContentUriWorker(
)
private fun checkParentFolderExistence(client: QSferaClient) {
var pathToGrant: String = File(uploadPath).parent ?: ""
var pathToGrant: String = File(uploadPath).parent.orEmpty()
pathToGrant = if (pathToGrant.endsWith(File.separator)) pathToGrant else pathToGrant + File.separator
val checkPathExistenceOperation =
@@ -579,3 +605,9 @@ class UploadFileFromContentUriWorker(
const val KEY_PARAM_UPLOAD_ID = "KEY_PARAM_UPLOAD_ID"
}
}
private fun Throwable.rootCauseClassName(): String {
var root = this
while (root.cause != null && root.cause !== root) root = root.cause!!
return root.javaClass.simpleName
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#246BFD" android:state_checked="true" />
<item android:color="#73777F" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#1F246BFD" android:state_pressed="true" />
<item android:color="#14246BFD" android:state_focused="true" />
<item android:color="#00000000" />
</selector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:top="1dp">
<shape android:shape="rectangle">
<solid android:color="@color/white" />
</shape>
</item>
<item android:height="1dp" android:gravity="top">
<shape android:shape="rectangle">
<solid android:color="#E6E8EC" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/qsfera_surface_muted" />
<corners android:radius="20dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="315"
android:endColor="#E7ECF8"
android:startColor="#F4F6FB" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,17 @@
<?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="@android:color/transparent"
android:pathData="M6,5L18,5A2,2 0,0 1,20 7L20,17A2,2 0,0 1,18 19L6,19A2,2 0,0 1,4 17L4,7A2,2 0,0 1,6 5ZM7,2.75L17,2.75M7,21.25L17,21.25M5,16L8.5,12.5A1.4,1.4 0,0 1,10.5 12.5L12.5,14.5L14.2,12.8A1.4,1.4 0,0 1,16.2 12.8L20,16.6"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8" />
<path
android:fillColor="#FF000000"
android:pathData="M8,8A1.25,1.25 0,1 0,8 10.5A1.25,1.25 0,1 0,8 8Z" />
</vector>
@@ -0,0 +1,17 @@
<?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="@android:color/transparent"
android:pathData="M5,4.5L19,4.5A1.5,1.5 0,0 1,20.5 6L20.5,9A1.5,1.5 0,0 1,19 10.5L5,10.5A1.5,1.5 0,0 1,3.5 9L3.5,6A1.5,1.5 0,0 1,5 4.5ZM5,13.5L19,13.5A1.5,1.5 0,0 1,20.5 15L20.5,18A1.5,1.5 0,0 1,19 19.5L5,19.5A1.5,1.5 0,0 1,3.5 18L3.5,15A1.5,1.5 0,0 1,5 13.5Z"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8" />
<path
android:fillColor="#FF000000"
android:pathData="M7,6.5A1,1 0,1 0,7 8.5A1,1 0,1 0,7 6.5ZM7,15.5A1,1 0,1 0,7 17.5A1,1 0,1 0,7 15.5Z" />
</vector>
@@ -0,0 +1,14 @@
<?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="@android:color/transparent"
android:pathData="M3.5,7.5L3.5,18A2,2 0,0 0,5.5 20L18.5,20A2,2 0,0 0,20.5 18L20.5,8.5A2,2 0,0 0,18.5 6.5L12,6.5L10.2,4.5L5.5,4.5A2,2 0,0 0,3.5 6.5Z"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8" />
</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="#FF000000"
android:pathData="M5,10.25A1.75,1.75 0,1 0,5 13.75A1.75,1.75 0,1 0,5 10.25ZM12,10.25A1.75,1.75 0,1 0,12 13.75A1.75,1.75 0,1 0,12 10.25ZM19,10.25A1.75,1.75 0,1 0,19 13.75A1.75,1.75 0,1 0,19 10.25Z" />
</vector>
@@ -0,0 +1,17 @@
<?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="@android:color/transparent"
android:pathData="M5,4L19,4A2,2 0,0 1,21 6L21,18A2,2 0,0 1,19 20L5,20A2,2 0,0 1,3 18L3,6A2,2 0,0 1,5 4ZM4,17L8.4,12.6A1.5,1.5 0,0 1,10.5 12.6L12.8,14.9L15.2,12.5A1.5,1.5 0,0 1,17.3 12.5L21,16.2"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8" />
<path
android:fillColor="#FF000000"
android:pathData="M8,7A1.5,1.5 0,1 0,8 10A1.5,1.5 0,1 0,8 7Z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/qsfera_folder"
android:pathData="M3,5.25C3,4.56 3.56,4 4.25,4h5.1c0.4,0 0.77,0.19 1,0.5L11.5,6H19.75C20.44,6 21,6.56 21,7.25v10.5C21,18.44 20.44,19 19.75,19H4.25C3.56,19 3,18.44 3,17.75z" />
</vector>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<androidx.appcompat.widget.Toolbar
android:id="@+id/cloud_preview_toolbar"
android:layout_width="0dp"
android:layout_height="?android:actionBarSize"
android:background="#CC000000"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/cloud_preview_photo"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@null"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
<androidx.media3.ui.PlayerView
android:id="@+id/cloud_preview_player"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cloud_preview_toolbar" />
<ProgressBar
android:id="@+id/cloud_preview_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminateTint="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/cloud_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/qsfera_surface">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/cloud_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="12dp"
android:paddingBottom="28dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
@@ -0,0 +1,50 @@
<?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:layout_marginHorizontal="4dp"
android:layout_marginVertical="4dp"
android:background="@drawable/cloud_card_background"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="76dp"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<ImageView
android:id="@+id/cloud_action_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:contentDescription="@null" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/cloud_action_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/cloud_action_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:textColor="@color/qsfera_text_secondary"
android:textSize="14sp" />
</LinearLayout>
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
android:contentDescription="@null"
android:src="@drawable/ic_arrow_forward" />
</LinearLayout>
@@ -0,0 +1,36 @@
<?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:layout_margin="6dp"
android:background="@drawable/cloud_card_background"
android:foreground="?attr/selectableItemBackground"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/cloud_album_cover"
android:layout_width="64dp"
android:layout_height="64dp"
android:contentDescription="@null"
android:src="@drawable/ic_qsfera_folder" />
<TextView
android:id="@+id/cloud_album_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/qsfera_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/cloud_album_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/qsfera_text_secondary"
android:textSize="14sp" />
</LinearLayout>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/cloud_header_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="4dp"
android:paddingTop="20dp"
android:paddingEnd="4dp"
android:paddingBottom="10dp"
android:textColor="@color/qsfera_text_primary"
android:textSize="20sp"
android:textStyle="bold" />
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.qsfera.android.presentation.security.passcode.SquareFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="@drawable/cloud_media_placeholder"
android:clipToOutline="true"
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: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:padding="5dp"
android:src="@drawable/ic_play_arrow"
android:visibility="gone" />
</eu.qsfera.android.presentation.security.passcode.SquareFrameLayout>
@@ -0,0 +1,35 @@
<?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:gravity="center"
android:minHeight="260dp"
android:orientation="vertical"
android:padding="32dp">
<ImageView
android:id="@+id/cloud_status_icon"
android:layout_width="72dp"
android:layout_height="72dp"
android:contentDescription="@null"
android:src="@drawable/ic_qsfera_folder" />
<TextView
android:id="@+id/cloud_status_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:textColor="@color/qsfera_text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/cloud_status_summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textColor="@color/qsfera_text_secondary"
android:textSize="15sp" />
</LinearLayout>
@@ -46,22 +46,24 @@
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_navigation_bar_height"
android:layout_gravity="bottom"
android:background="@color/actionbar_start_color"
android:background="@drawable/bg_bottom_navigation"
android:visibility="visible"
app:itemIconTint="@color/primary_button_text_color"
app:itemTextColor="@color/primary_button_text_color"
app:itemIconSize="24dp"
app:itemIconTint="@color/bottom_navigation_item_tint"
app:itemRippleColor="@color/bottom_navigation_ripple"
app:itemTextColor="@color/bottom_navigation_item_tint"
app:labelVisibilityMode="labeled"
app:layout_constraintBottom_toTopOf="@id/bottom_nav_view_spacer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:elevation="0dp"
app:elevation="8dp"
app:menu="@menu/bottom_navbar_menu" />
<FrameLayout
android:id="@+id/bottom_nav_view_spacer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/actionbar_start_color"
android:background="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
@@ -18,6 +18,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/qsfera.Appbar"
android:background="@color/qsfera_surface"
android:focusableInTouchMode="true">
<androidx.constraintlayout.widget.ConstraintLayout
@@ -33,6 +34,7 @@
android:padding="@dimen/standard_half_padding"
android:src="@drawable/ic_drawer_icon"
android:contentDescription="@string/content_description_menu"
app:tint="@color/qsfera_text_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -62,11 +64,12 @@
android:gravity="center_vertical"
android:maxLines="1"
android:paddingHorizontal="@dimen/standard_half_padding"
android:textColor="@color/white"
android:textColor="@color/qsfera_text_primary"
android:textStyle="bold"
android:textSize="@dimen/toolbar_title_text_size"
android:contentDescription="@string/content_description_search"
app:drawableEndCompat="@drawable/ic_search"
app:drawableTint="@color/white"
app:drawableTint="@color/qsfera_text_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/root_toolbar_avatar"
app:layout_constraintStart_toEndOf="@id/root_toolbar_left_icon"
@@ -90,7 +93,7 @@
android:id="@+id/standard_toolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorPrimary"
android:background="@color/qsfera_surface"
android:theme="@style/qsfera.Appbar"
android:visibility="gone"
android:layout_marginEnd="@dimen/standard_margin"
@@ -17,26 +17,24 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_feed"
android:icon="@drawable/ic_bottom_nav_feed"
android:title="@string/bottom_nav_feed" />
<item
android:id="@+id/nav_all_files"
android:icon="@drawable/ic_folder"
android:icon="@drawable/ic_bottom_nav_files"
android:title="@string/bottom_nav_files" />
<item
android:id="@+id/nav_shared_by_link_files"
android:icon="@drawable/ic_shared_by_link"
android:title="@string/bottom_nav_links" />
android:id="@+id/nav_photos"
android:icon="@drawable/ic_bottom_nav_photos"
android:title="@string/bottom_nav_photos" />
<item
android:id="@+id/nav_spaces"
android:icon="@drawable/ic_spaces"
android:title="@string/bottom_nav_spaces"
android:visible="false"
/>
android:id="@+id/nav_albums"
android:icon="@drawable/ic_bottom_nav_albums"
android:title="@string/bottom_nav_albums" />
<item
android:id="@+id/nav_uploads"
android:icon="@drawable/ic_uploads"
android:title="@string/bottom_nav_uploads" />
<item
android:id="@+id/nav_available_offline_files"
android:icon="@drawable/ic_available_offline"
android:title="@string/bottom_nav_offline" />
android:id="@+id/nav_more"
android:icon="@drawable/ic_bottom_nav_more"
android:title="@string/bottom_nav_more" />
</menu>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cloud_feed_title">Лента</string>
<string name="cloud_photos_title">Фото</string>
<string name="cloud_albums_title">Альбомы</string>
<string name="cloud_more_title">Ещё</string>
<string name="cloud_feed_empty_title">Лента готова</string>
<string name="cloud_feed_empty_summary">Новые автозагрузки появятся здесь после сохранения в QSfera.</string>
<string name="cloud_photos_empty_title">Фотографий пока нет</string>
<string name="cloud_photos_empty_summary">Загрузите фото или включите автозагрузку в настройках.</string>
<string name="cloud_albums_empty_title">Альбомов пока нет</string>
<string name="cloud_albums_empty_summary">Альбомы создаются из папок, в которых есть фото и видео.</string>
<string name="cloud_media_loading">Загружаем ваше облако…</string>
<string name="cloud_media_load_error">Не удалось загрузить медиафайлы</string>
<string name="cloud_media_retry">Нажмите, чтобы повторить</string>
<string name="cloud_recent_uploads">Недавние загрузки</string>
<string name="cloud_all_photos">Все фото</string>
<string name="cloud_album_items">Объектов: %1$d</string>
<string name="cloud_more_transfers">Загрузки</string>
<string name="cloud_more_transfers_summary">Текущие и завершённые передачи</string>
<string name="cloud_more_offline">Офлайн</string>
<string name="cloud_more_offline_summary">Файлы, доступные без сети</string>
<string name="cloud_more_shares">Общие ссылки</string>
<string name="cloud_more_shares_summary">Файлы, которыми вы поделились</string>
<string name="cloud_more_spaces">Пространства</string>
<string name="cloud_more_spaces_summary">Личное и проектное хранилище</string>
<string name="cloud_more_settings">Настройки</string>
<string name="cloud_more_settings_summary">Автозагрузка, учётная запись и безопасность</string>
<string name="cloud_more_storage">Хранилище</string>
<string name="cloud_more_storage_summary">Управление файлами QSfera</string>
</resources>
@@ -139,7 +139,11 @@
<string name="pattern_configure_your_pattern_explanation">Графический ключ будет запрашиваться каждый раз при запуске приложения</string>
<string name="document_provider_locked">Заблокировано</string>
<string name="illegal_argument_exception_message">Допустимое ДЕЙСТВИЕ требуется для передачи в намерениях</string>
<string name="bottom_nav_feed">Лента</string>
<string name="bottom_nav_files">Файлы</string>
<string name="bottom_nav_photos">Фото</string>
<string name="bottom_nav_albums">Альбомы</string>
<string name="bottom_nav_more">Ещё</string>
<string name="bottom_nav_personal">Личный</string>
<string name="bottom_nav_uploads">Загрузки</string>
<string name="bottom_nav_offline">Оффлайн</string>
@@ -555,10 +559,31 @@
<string name="file_list__footer__files_and_folders">%1$d файлов, %2$d каталогов</string>
<string name="prefs_picture_upload_account">Учётная запись для закачки изображений</string>
<string name="prefs_video_upload_account">Учётная запись для закачки видео</string>
<string name="prefs_camera_upload_source_path_title">Каталог камеры (%1$s)</string>
<string name="prefs_camera_upload_source_path_title_required">обязательно</string>
<string name="prefs_camera_upload_source_path_title">Папки</string>
<string name="prefs_camera_upload_source_path_title_required">камера включена автоматически</string>
<string name="prefs_camera_upload_source_paths_clear_title">Очистить выбранные папки</string>
<string name="prefs_camera_upload_source_paths_empty">Папки не выбраны</string>
<string name="prefs_automatic_uploads_source_permission_error">Выберите папку с разрешением на чтение и удаление файлов</string>
<string name="automatic_upload_folders_title">Папки на телефоне</string>
<string name="automatic_upload_folders_camera_description">Фото и видео из папки «Камера» загружаются автоматически. Загрузку из остальных папок можно настроить ниже.</string>
<string name="automatic_upload_folders_other">Остальные папки</string>
<string name="automatic_upload_folders_loading">Ищем папки с медиафайлами…</string>
<string name="automatic_upload_folders_empty">Других папок с медиафайлами не найдено.</string>
<string name="automatic_upload_folders_error">Не удалось прочитать папки. Проверьте доступ к фото и видео.</string>
<string name="automatic_upload_folders_item_count">Медиафайлов: %1$d</string>
<string name="automatic_upload_permission_title">Доступ к фото и файлам</string>
<string name="automatic_upload_permission_ready">Доступ разрешён. Автозагрузка камеры работает в фоне, а оригинал удаляется после успешной загрузки.</string>
<string name="automatic_upload_permission_media_ready">Доступ к медиафайлам разрешён.</string>
<string name="automatic_upload_permission_read_missing">Разрешите доступ к фото и видео, чтобы QSfera обнаруживала новые файлы.</string>
<string name="automatic_upload_permission_delete_missing">Разрешите полный доступ к файлам, чтобы QSfera удаляла оригинал только после успешной загрузки.</string>
<string name="automatic_upload_permission_both_missing">Нужны два разрешения: читать фото и удалять успешно загруженный оригинал.</string>
<string name="automatic_upload_camera_and_folders_summary">Камера — автоматически%1$s</string>
<string name="automatic_upload_selected_folders_suffix">; выбрано других папок: %1$d</string>
<string name="automatic_upload_enable_message">Папка «Камера» отслеживается автоматически. Другие папки выбираются в разделе «Папки». Оригинал удаляется только после подтверждённой сервером успешной загрузки.</string>
<string name="automatic_upload_mobile_data">Использовать мобильный интернет</string>
<string name="automatic_upload_mobile_data_summary">Автозагрузка будет работать и без Wi-Fi</string>
<string name="automatic_upload_destination_category">Куда загружать</string>
<string name="automatic_upload_options_category">Параметры загрузки</string>
<string name="confirmation_clear_camera_upload_sources_title">Очистить выбранные папки</string>
<string name="confirmation_clear_camera_upload_sources_message">Автозагрузка перестанет проверять папки телефона, пока вы снова не добавите папку.</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл будет</string>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cloud_feed_title">Feed</string>
<string name="cloud_photos_title">Photos</string>
<string name="cloud_albums_title">Albums</string>
<string name="cloud_more_title">More</string>
<string name="cloud_feed_empty_title">Your feed is ready</string>
<string name="cloud_feed_empty_summary">New automatic uploads will appear here after they reach QSfera.</string>
<string name="cloud_photos_empty_title">No photos found</string>
<string name="cloud_photos_empty_summary">Upload a photo or enable automatic uploads in Settings.</string>
<string name="cloud_albums_empty_title">No albums yet</string>
<string name="cloud_albums_empty_summary">Albums are created from folders that contain photos and videos.</string>
<string name="cloud_media_loading">Loading your cloud…</string>
<string name="cloud_media_load_error">Could not load cloud media</string>
<string name="cloud_media_retry">Tap to retry</string>
<string name="cloud_recent_uploads">Recent uploads</string>
<string name="cloud_all_photos">All photos</string>
<string name="cloud_album_items">%1$d items</string>
<string name="cloud_more_transfers">Transfers</string>
<string name="cloud_more_transfers_summary">Current and completed uploads</string>
<string name="cloud_more_offline">Offline</string>
<string name="cloud_more_offline_summary">Files available without a network</string>
<string name="cloud_more_shares">Shared links</string>
<string name="cloud_more_shares_summary">Files shared by link</string>
<string name="cloud_more_spaces">Spaces</string>
<string name="cloud_more_spaces_summary">Personal and project storage</string>
<string name="cloud_more_settings">Settings</string>
<string name="cloud_more_settings_summary">Automatic uploads, account and security</string>
<string name="cloud_more_storage">Storage</string>
<string name="cloud_more_storage_summary">Manage files in QSfera</string>
</resources>
@@ -20,14 +20,22 @@
<resources>
<!-- standard material color definitions -->
<color name="primary">@color/qsfera_petrol</color>
<color name="primary_dark">#19353F</color>
<color name="color_accent">@color/qsfera_petrol_accent</color>
<color name="primary">@color/qsfera_blue</color>
<color name="primary_dark">#2F61C7</color>
<color name="color_accent">@color/qsfera_blue</color>
<!-- Colors -->
<color name="qsfera_petrol">#21434F</color>
<color name="qsfera_petrol_accent">#396676</color>
<color name="qsfera_blue_bright">#00ddff</color>
<color name="qsfera_blue">#4B7BEC</color>
<color name="qsfera_blue_pressed">#3566D1</color>
<color name="qsfera_surface">#FFFFFF</color>
<color name="qsfera_surface_muted">#F3F4F6</color>
<color name="qsfera_text_primary">#17181A</color>
<color name="qsfera_text_secondary">#6D7178</color>
<color name="qsfera_divider">#E8E9EC</color>
<color name="qsfera_folder">#F4C95D</color>
<color name="warning_grey_text">#525757</color>
<color name="list_item_lastmod_and_filesize_text">#707575</color>
<color name="search_view_hint_text">#BDBDBD</color>
@@ -38,15 +38,15 @@
<color name="login_credentials_text_color">@color/white</color>
<color name="login_button_background_color">@color/white</color>
<color name="login_button_text_color">@color/color_accent</color>
<color name="background_color">#FFFFFF</color>
<color name="actionbar_start_color">@color/primary</color>
<color name="background_color">@color/qsfera_surface</color>
<color name="actionbar_start_color">@color/qsfera_surface</color>
<color name="primary_button_background_color">@color/color_accent</color>
<color name="primary_button_text_color">@color/white</color>
<color name="secondary_button_background_color">#D6D7D7</color>
<color name="secondary_button_text_color">@color/black</color>
<color name="drawer_header_color">@color/qsfera_petrol_accent</color>
<color name="spaces_card_background_color">#edf3fa</color>
<color name="search_view_background_color">@color/actionbar_start_color</color>
<color name="search_view_background_color">@color/qsfera_surface_muted</color>
<!-- Splash Screen Background -->
<color name="splash_background">@color/qsfera_petrol</color>
@@ -142,7 +142,11 @@
<string name="illegal_argument_exception_message">A valid ACTION is needed in the intent passed to</string>
<!-- Bottom navigation bar -->
<string name="bottom_nav_feed">Feed</string>
<string name="bottom_nav_files">Files</string>
<string name="bottom_nav_photos">Photos</string>
<string name="bottom_nav_albums">Albums</string>
<string name="bottom_nav_more">More</string>
<string name="bottom_nav_personal">Personal</string>
<string name="bottom_nav_uploads">Uploads</string>
<string name="bottom_nav_offline">Offline</string>
@@ -593,10 +597,31 @@
<string name="file_list__footer__files_and_folders">%1$d files, %2$d folders</string>
<string name="prefs_picture_upload_account">Account to upload pictures</string>
<string name="prefs_video_upload_account">Account to upload videos</string>
<string name="prefs_camera_upload_source_path_title">Camera folder (%1$s)</string>
<string name="prefs_camera_upload_source_path_title_required">required</string>
<string name="prefs_camera_upload_source_path_title">Folders</string>
<string name="prefs_camera_upload_source_path_title_required">Camera is included automatically</string>
<string name="prefs_camera_upload_source_paths_clear_title">Clear selected folders</string>
<string name="prefs_camera_upload_source_paths_empty">No folders selected</string>
<string name="prefs_automatic_uploads_source_permission_error">Select a folder that grants both read and write access</string>
<string name="automatic_upload_folders_title">Folders on phone</string>
<string name="automatic_upload_folders_camera_description">Photos and videos from the Camera folder are uploaded automatically. Uploads from other folders can be enabled below.</string>
<string name="automatic_upload_folders_other">Other folders</string>
<string name="automatic_upload_folders_loading">Looking for media folders…</string>
<string name="automatic_upload_folders_empty">No other media folders were found.</string>
<string name="automatic_upload_folders_error">Folders could not be read. Check photo and video access.</string>
<string name="automatic_upload_folders_item_count">%1$d media files</string>
<string name="automatic_upload_permission_title">Photo and file access</string>
<string name="automatic_upload_permission_ready">Access granted. Camera uploads can run in the background and remove originals after a successful upload.</string>
<string name="automatic_upload_permission_media_ready">Media access is allowed.</string>
<string name="automatic_upload_permission_read_missing">Allow access to photos and videos so QSfera can detect new media.</string>
<string name="automatic_upload_permission_delete_missing">Allow full file access so QSfera can remove an original only after its upload succeeds.</string>
<string name="automatic_upload_permission_both_missing">Two permissions are required: read photos and remove a successfully uploaded original.</string>
<string name="automatic_upload_camera_and_folders_summary">Camera automatically%1$s</string>
<string name="automatic_upload_selected_folders_suffix">; %1$d other folders selected</string>
<string name="automatic_upload_enable_message">The Camera folder is monitored automatically. Other folders can be selected under “Folders”. An original is removed only after the server confirms a successful upload.</string>
<string name="automatic_upload_mobile_data">Use mobile data</string>
<string name="automatic_upload_mobile_data_summary">Automatic uploads will also work when Wi-Fi is unavailable</string>
<string name="automatic_upload_destination_category">Upload destination</string>
<string name="automatic_upload_options_category">Upload options</string>
<string name="confirmation_clear_camera_upload_sources_title">Clear selected folders</string>
<string name="confirmation_clear_camera_upload_sources_message">Automatic uploads will stop scanning phone folders until you add a folder again.</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Original file will be</string>
@@ -107,13 +107,13 @@
overrides colorPrimary to allow customization of the app bar color,
independent of Material Design guidelines, if desired
-->
<style name="qsfera.Appbar" parent="ThemeOverlay.AppCompat.Dark.ActionBar">
<style name="qsfera.Appbar" parent="ThemeOverlay.AppCompat.Light">
<item name="colorPrimary">@color/actionbar_start_color</item>
<item name="toolbarNavigationButtonStyle">@style/Toolbar.Button.Navigation.Tinted</item>
</style>
<style name="Toolbar.Button.Navigation.Tinted" parent="Widget.AppCompat.Toolbar.Button.Navigation">
<item name="tint">@color/white</item>
<item name="tint">@color/qsfera_text_primary</item>
</style>
<!-- Transparent style for the app bar -->
@@ -25,6 +25,23 @@
app:key="enable_picture_uploads"
app:summary="@string/prefs_camera_picture_upload_summary"
app:title="@string/prefs_camera_picture_upload" />
<Preference
app:iconSpaceReserved="false"
app:key="automatic_picture_uploads_permissions"
app:title="@string/automatic_upload_permission_title" />
<Preference
app:iconSpaceReserved="false"
app:key="picture_uploads_source_path"
app:title="@string/prefs_camera_upload_source_path_title" />
<Preference
app:iconSpaceReserved="false"
app:selectable="false"
app:summary="@string/pref_behaviour_entries_remove_original_file"
app:title="@string/prefs_camera_upload_behaviour_title" />
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
android:title="@string/automatic_upload_destination_category"
app:iconSpaceReserved="false">
<ListPreference
app:dialogTitle="@string/prefs_picture_upload_account"
app:iconSpaceReserved="false"
@@ -36,34 +53,26 @@
app:iconSpaceReserved="false"
app:key="picture_uploads_path"
app:title="@string/prefs_camera_picture_upload_path_title" />
<Preference
app:iconSpaceReserved="false"
app:key="picture_uploads_source_path"
app:title="@string/prefs_camera_upload_source_path_title" />
<Preference
app:iconSpaceReserved="false"
app:key="picture_uploads_clear_source_paths"
app:title="@string/prefs_camera_upload_source_paths_clear_title" />
<Preference
app:iconSpaceReserved="false"
app:selectable="false"
app:summary="@string/pref_behaviour_entries_remove_original_file"
app:title="@string/prefs_camera_upload_behaviour_title" />
<Preference
app:iconSpaceReserved="false"
app:key="picture_uploads_last_sync"
app:title="@string/prefs_camera_upload_last_sync_title" />
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
android:title="@string/prefs_camera_picture_upload_conditions_title"
android:title="@string/automatic_upload_options_category"
app:iconSpaceReserved="false"
app:summary="@string/prefs_camera_picture_upload_conditions_summary">
<CheckBoxPreference
<SwitchPreferenceCompat
app:defaultValue="true"
app:iconSpaceReserved="false"
app:key="picture_uploads_on_wifi"
app:title="@string/prefs_camera_picture_upload_on_wifi" />
app:summary="@string/automatic_upload_mobile_data_summary"
app:title="@string/automatic_upload_mobile_data" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="picture_uploads_on_charging"
app:title="@string/prefs_camera_picture_upload_on_charging" />
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
<Preference
app:iconSpaceReserved="false"
app:key="picture_uploads_last_sync"
app:title="@string/prefs_camera_upload_last_sync_title" />
</PreferenceScreen>
@@ -23,6 +23,26 @@
app:key="enable_video_uploads"
app:summary="@string/prefs_camera_video_upload_summary"
app:title="@string/prefs_camera_video_upload" />
<Preference
app:iconSpaceReserved="false"
app:key="automatic_video_uploads_permissions"
app:title="@string/automatic_upload_permission_title" />
<Preference
app:iconSpaceReserved="false"
app:key="video_uploads_source_path"
app:title="@string/prefs_camera_upload_source_path_title" />
<ListPreference
app:defaultValue="NOTHING"
app:dialogTitle="@string/prefs_camera_upload_behaviour_dialog_title"
app:iconSpaceReserved="false"
app:key="video_uploads_behaviour"
app:negativeButtonText=""
app:title="@string/prefs_camera_upload_behaviour_title"
app:useSimpleSummaryProvider="true" />
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
android:title="@string/automatic_upload_destination_category"
app:iconSpaceReserved="false">
<ListPreference
app:dialogTitle="@string/prefs_video_upload_account"
app:iconSpaceReserved="false"
@@ -34,37 +54,26 @@
app:iconSpaceReserved="false"
app:key="video_uploads_path"
app:title="@string/prefs_camera_video_upload_path_title" />
<Preference
app:iconSpaceReserved="false"
app:key="video_uploads_source_path"
app:title="@string/prefs_camera_upload_source_path_title" />
<Preference
app:iconSpaceReserved="false"
app:key="video_uploads_clear_source_paths"
app:title="@string/prefs_camera_upload_source_paths_clear_title" />
<ListPreference
app:defaultValue="NOTHING"
app:dialogTitle="@string/prefs_camera_upload_behaviour_dialog_title"
app:iconSpaceReserved="false"
app:key="video_uploads_behaviour"
app:negativeButtonText=""
app:title="@string/prefs_camera_upload_behaviour_title"
app:useSimpleSummaryProvider="true" />
<Preference
app:iconSpaceReserved="false"
app:key="video_uploads_last_sync"
app:title="@string/prefs_camera_upload_last_sync_title" />
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
<eu.qsfera.android.presentation.settings.LargePreferenceCategory
android:title="@string/prefs_camera_picture_upload_conditions_title"
android:title="@string/automatic_upload_options_category"
app:iconSpaceReserved="false"
app:summary="@string/prefs_camera_picture_upload_conditions_summary">
<CheckBoxPreference
<SwitchPreferenceCompat
app:defaultValue="true"
app:iconSpaceReserved="false"
app:key="video_uploads_on_wifi"
app:title="@string/prefs_camera_video_upload_on_wifi" />
app:summary="@string/automatic_upload_mobile_data_summary"
app:title="@string/automatic_upload_mobile_data" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="video_uploads_on_charging"
app:title="@string/prefs_camera_video_upload_on_charging" />
</eu.qsfera.android.presentation.settings.LargePreferenceCategory>
<Preference
app:iconSpaceReserved="false"
app:key="video_uploads_last_sync"
app:title="@string/prefs_camera_upload_last_sync_title" />
</PreferenceScreen>
@@ -0,0 +1,40 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AutomaticUploadMediaSourceTest {
@Test
fun `source survives storage round trip`() {
val source = AutomaticUploadMediaSource.create(
AutomaticUploadMediaKind.IMAGE,
"Pictures/Семья",
)
assertEquals(source, AutomaticUploadMediaSource.parse(source.encodedValue))
}
@Test
fun `camera path is normalized and matched without case sensitivity`() {
assertEquals(
AutomaticUploadMediaSource.CAMERA_RELATIVE_PATH,
AutomaticUploadMediaSource.normalizeRelativePath("/DCIM\\Camera"),
)
assertTrue(AutomaticUploadMediaSource.isCameraPath("dcim/camera"))
assertFalse(AutomaticUploadMediaSource.isCameraPath("Pictures/Camera"))
}
@Test
fun `unrelated configuration value is not parsed as media source`() {
assertNull(AutomaticUploadMediaSource.parse("content://tree/primary"))
}
}
@@ -0,0 +1,33 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.common.http.methods.webdav
import eu.qsfera.android.lib.common.http.methods.nonwebdav.HttpMethod
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.URL
/**
* OkHttp wrapper for WebDAV REPORT requests with an XML body.
*/
class ReportMethod(
url: URL,
reportBody: String,
) : HttpMethod(url) {
init {
request = request.newBuilder()
.method(METHOD_REPORT, reportBody.toRequestBody(XML_MEDIA_TYPE))
.header(HEADER_ACCEPT, XML_MEDIA_TYPE_VALUE)
.build()
}
private companion object {
const val METHOD_REPORT = "REPORT"
const val HEADER_ACCEPT = "Accept"
const val XML_MEDIA_TYPE_VALUE = "application/xml; charset=utf-8"
val XML_MEDIA_TYPE = XML_MEDIA_TYPE_VALUE.toMediaType()
}
}
@@ -0,0 +1,27 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/** Builds the XML body expected by QSfera's `search-files` REPORT endpoint. */
object MediaSearchReportBody {
fun build(request: MediaSearchRequest): String = """
<?xml version="1.0" encoding="utf-8"?>
<oc:search-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<oc:search>
<oc:pattern>${request.toKqlPattern()}</oc:pattern>
<oc:limit>${request.limit}</oc:limit>
<oc:offset>${request.offset}</oc:offset>
</oc:search>
<d:prop>
<oc:name/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:getetag/>
</d:prop>
</oc:search-files>
""".trimIndent()
}
@@ -0,0 +1,35 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/**
* Parameters for a server-side media search.
*
* A deterministic enum order is used when the KQL expression is generated, so
* callers can pass any [Set] implementation without affecting the request body.
*/
data class MediaSearchRequest(
val mediaTypes: Set<MediaSearchType> = MediaSearchType.values().toSet(),
val limit: Int = DEFAULT_LIMIT,
val offset: Int = 0,
) {
init {
require(mediaTypes.isNotEmpty()) { "At least one media type is required" }
require(limit in 1..MAX_LIMIT) { "Limit must be between 1 and $MAX_LIMIT" }
require(offset >= 0) { "Offset must not be negative" }
}
internal fun toKqlPattern(): String =
MediaSearchType.values()
.filter(mediaTypes::contains)
.joinToString(separator = " OR ") { mediaType ->
"mediatype:${mediaType.queryValue}"
}
companion object {
const val DEFAULT_LIMIT = 200
const val MAX_LIMIT = 1_000
}
}
@@ -0,0 +1,210 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* Streaming parser for WebDAV Multi-Status media-search responses.
*
* DTD processing is disabled and rejected, the response byte count is capped,
* and only successful propstats contribute metadata to a result.
*/
class MediaSearchResponseParser(
private val maximumResponseBytes: Long = DEFAULT_MAXIMUM_RESPONSE_BYTES,
private val maximumResults: Int = DEFAULT_MAXIMUM_RESULTS,
) {
@Throws(IOException::class, XmlPullParserException::class)
fun parse(inputStream: InputStream): List<RemoteMediaFile> {
require(maximumResponseBytes > 0) { "Maximum response size must be positive" }
require(maximumResults > 0) { "Maximum result count must be positive" }
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
try {
setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false)
} catch (_: XmlPullParserException) {
// Some implementations do not expose this optional feature.
// DOCDECL events are still rejected below.
}
setInput(LimitedInputStream(inputStream, maximumResponseBytes), null)
}
val results = mutableListOf<RemoteMediaFile>()
var response: ResponseDraft? = null
var propStat: PropStatDraft? = null
var insideProp = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.DOCDECL -> throw XmlPullParserException("DTD declarations are not allowed")
XmlPullParser.START_TAG -> when (parser.name) {
TAG_RESPONSE -> response = ResponseDraft()
TAG_PROPSTAT -> propStat = PropStatDraft()
TAG_PROP -> insideProp = true
TAG_HREF -> if (response != null && propStat == null) {
response.href = parser.nextText().trim()
}
TAG_STATUS -> if (propStat != null) {
propStat.status = parser.nextText().trim()
}
TAG_NAME -> if (insideProp && propStat != null && parser.namespace == NAMESPACE_OC) {
propStat.name = parser.nextText()
}
TAG_CONTENT_TYPE -> if (insideProp && propStat != null) {
propStat.mimeType = parser.nextText().trim().ifEmpty { null }
}
TAG_CONTENT_LENGTH -> if (insideProp && propStat != null) {
propStat.size = parser.nextText().trim().toLongOrNull()?.takeIf { it >= 0 }
}
TAG_LAST_MODIFIED -> if (insideProp && propStat != null) {
propStat.modifiedTimestamp = parseModifiedTimestamp(parser.nextText().trim())
}
TAG_ETAG -> if (insideProp && propStat != null) {
propStat.etag = parser.nextText().trim().ifEmpty { null }
}
}
XmlPullParser.END_TAG -> when (parser.name) {
TAG_PROP -> {
insideProp = false
}
TAG_PROPSTAT -> {
response?.propStats?.add(propStat ?: PropStatDraft())
propStat = null
}
TAG_RESPONSE -> {
response?.toRemoteMediaFile()?.let { mediaFile ->
if (results.size >= maximumResults) {
throw IOException("Media search response exceeds $maximumResults results")
}
results.add(mediaFile)
}
response = null
}
}
}
eventType = parser.next()
}
return results
}
private fun ResponseDraft.toRemoteMediaFile(): RemoteMediaFile? {
val resultHref = href?.takeIf(String::isNotBlank) ?: return null
val successfulPropStats = propStats.filter(PropStatDraft::isSuccessful)
if (successfulPropStats.isEmpty()) return null
val decodedPath = decodeHrefPath(resultHref)
val resultName = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::name)
?.takeIf(String::isNotBlank)
?: decodedPath.trimEnd('/').substringAfterLast('/')
return RemoteMediaFile(
href = resultHref,
path = decodedPath,
name = resultName,
mimeType = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::mimeType),
size = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::size),
modifiedTimestamp = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::modifiedTimestamp),
etag = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::etag),
)
}
private fun parseModifiedTimestamp(value: String): Long? =
runCatching {
ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant()
.toEpochMilli()
}.getOrNull()
private fun decodeHrefPath(href: String): String {
val rawPath = runCatching { URI(href).rawPath }
.getOrNull()
?.takeIf(String::isNotEmpty)
?: href.substringBefore('?')
return runCatching {
URLDecoder.decode(
rawPath.replace("+", "%2B"),
StandardCharsets.UTF_8.name(),
)
}.getOrDefault(rawPath)
}
private data class ResponseDraft(
var href: String? = null,
val propStats: MutableList<PropStatDraft> = mutableListOf(),
)
private data class PropStatDraft(
var status: String? = null,
var name: String? = null,
var mimeType: String? = null,
var size: Long? = null,
var modifiedTimestamp: Long? = null,
var etag: String? = null,
) {
fun isSuccessful(): Boolean = status
?.substringAfter(' ', missingDelimiterValue = "")
?.substringBefore(' ')
?.toIntOrNull()
?.let { it in 200..299 }
?: false
}
private class LimitedInputStream(
inputStream: InputStream,
private val maximumBytes: Long,
) : FilterInputStream(inputStream) {
private var bytesRead = 0L
override fun read(): Int {
val value = super.read()
if (value >= 0) incrementAndCheck(1)
return value
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
val count = super.read(buffer, offset, length)
if (count > 0) incrementAndCheck(count.toLong())
return count
}
private fun incrementAndCheck(count: Long) {
bytesRead += count
if (bytesRead > maximumBytes) {
throw IOException("Media search response exceeds $maximumBytes bytes")
}
}
}
companion object {
const val DEFAULT_MAXIMUM_RESPONSE_BYTES = 8L * 1024L * 1024L
const val DEFAULT_MAXIMUM_RESULTS = 10_000
private const val NAMESPACE_OC = "http://owncloud.org/ns"
private const val TAG_RESPONSE = "response"
private const val TAG_PROPSTAT = "propstat"
private const val TAG_PROP = "prop"
private const val TAG_HREF = "href"
private const val TAG_STATUS = "status"
private const val TAG_NAME = "name"
private const val TAG_CONTENT_TYPE = "getcontenttype"
private const val TAG_CONTENT_LENGTH = "getcontentlength"
private const val TAG_LAST_MODIFIED = "getlastmodified"
private const val TAG_ETAG = "getetag"
}
}
@@ -0,0 +1,11 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/** Media categories understood by QSfera's KQL `mediatype` search field. */
enum class MediaSearchType(internal val queryValue: String) {
IMAGE("image"),
VIDEO("video"),
}
@@ -0,0 +1,21 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/**
* A media file returned by the WebDAV search endpoint.
*
* [href] is kept exactly as returned by the server for subsequent WebDAV calls.
* [path] is the URL-decoded path component intended for display and grouping.
*/
data class RemoteMediaFile(
val href: String,
val path: String,
val name: String,
val mimeType: String?,
val size: Long?,
val modifiedTimestamp: Long?,
val etag: String?,
)
@@ -0,0 +1,49 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
import eu.qsfera.android.lib.common.QSferaClient
import eu.qsfera.android.lib.common.http.HttpConstants.HTTP_MULTI_STATUS
import eu.qsfera.android.lib.common.http.HttpConstants.HTTP_OK
import eu.qsfera.android.lib.common.http.methods.webdav.ReportMethod
import eu.qsfera.android.lib.common.operations.RemoteOperation
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import eu.qsfera.android.lib.common.utils.isOneOf
import timber.log.Timber
import java.io.IOException
import java.net.URL
/** Executes a media search against a user's or space's WebDAV endpoint. */
class SearchRemoteMediaOperation(
private val request: MediaSearchRequest = MediaSearchRequest(),
private val webDavUrl: String? = null,
private val responseParser: MediaSearchResponseParser = MediaSearchResponseParser(),
) : RemoteOperation<List<RemoteMediaFile>>() {
override fun run(client: QSferaClient): RemoteOperationResult<List<RemoteMediaFile>> {
val endpoint = webDavUrl ?: client.userFilesWebDavUri.toString()
val reportMethod = ReportMethod(
url = URL(endpoint),
reportBody = MediaSearchReportBody.build(request),
)
return try {
val status = client.executeHttpMethod(reportMethod)
if (status.isOneOf(HTTP_OK, HTTP_MULTI_STATUS)) {
val responseStream = reportMethod.getResponseBodyAsStream()
?: throw IOException("Media search response has no body")
val mediaFiles = responseStream.use(responseParser::parse)
RemoteOperationResult<List<RemoteMediaFile>>(RemoteOperationResult.ResultCode.OK).apply {
data = mediaFiles
}
} else {
RemoteOperationResult(reportMethod)
}
} catch (exception: Exception) {
Timber.e(exception, "Media search REPORT failed")
RemoteOperationResult(exception)
}
}
}
@@ -0,0 +1,17 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search.services
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import eu.qsfera.android.lib.resources.Service
import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest
import eu.qsfera.android.lib.resources.files.search.RemoteMediaFile
interface MediaSearchService : Service {
fun searchMedia(
request: MediaSearchRequest = MediaSearchRequest(),
webDavUrl: String? = null,
): RemoteOperationResult<List<RemoteMediaFile>>
}
@@ -0,0 +1,23 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search.services.implementation
import eu.qsfera.android.lib.common.QSferaClient
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest
import eu.qsfera.android.lib.resources.files.search.RemoteMediaFile
import eu.qsfera.android.lib.resources.files.search.SearchRemoteMediaOperation
import eu.qsfera.android.lib.resources.files.search.services.MediaSearchService
class OCMediaSearchService(override val client: QSferaClient) : MediaSearchService {
override fun searchMedia(
request: MediaSearchRequest,
webDavUrl: String?,
): RemoteOperationResult<List<RemoteMediaFile>> =
SearchRemoteMediaOperation(
request = request,
webDavUrl = webDavUrl,
).execute(client)
}
@@ -0,0 +1,27 @@
package eu.qsfera.android.lib.common.http.methods.webdav
import okio.Buffer
import org.junit.Assert.assertEquals
import org.junit.Test
import java.net.URL
class ReportMethodTest {
@Test
fun `creates WebDAV report request with XML body`() {
val reportBody = "<oc:search-files/>"
val method = ReportMethod(
url = URL("https://cloud.example.test/remote.php/dav/files/alice"),
reportBody = reportBody,
)
val buffer = Buffer()
method.request.body?.writeTo(buffer)
assertEquals("REPORT", method.request.method)
assertEquals("application/xml; charset=utf-8", method.request.header("Accept"))
assertEquals("application/xml; charset=utf-8", method.request.body?.contentType().toString())
assertEquals(reportBody, buffer.readUtf8())
}
}
@@ -0,0 +1,52 @@
package eu.qsfera.android.lib.resources.files.search
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaSearchReportBodyTest {
@Test
fun `build creates deterministic image and video report`() {
val request = MediaSearchRequest(
mediaTypes = linkedSetOf(MediaSearchType.VIDEO, MediaSearchType.IMAGE),
limit = 75,
offset = 150,
)
val body = MediaSearchReportBody.build(request)
assertTrue(body.startsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
assertTrue(body.contains("<oc:pattern>mediatype:image OR mediatype:video</oc:pattern>"))
assertTrue(body.contains("<oc:limit>75</oc:limit>"))
assertTrue(body.contains("<oc:offset>150</oc:offset>"))
assertTrue(body.contains("<oc:name/>"))
assertTrue(body.contains("<d:getcontentlength/>"))
}
@Test
fun `build creates a single type expression without boolean operator`() {
val body = MediaSearchReportBody.build(
MediaSearchRequest(mediaTypes = setOf(MediaSearchType.IMAGE)),
)
assertTrue(body.contains("<oc:pattern>mediatype:image</oc:pattern>"))
assertFalse(body.contains(" OR "))
}
@Test
fun `request rejects invalid paging and empty media types`() {
assertEquals(200, MediaSearchRequest.DEFAULT_LIMIT)
assertThrows(IllegalArgumentException::class.java) {
MediaSearchRequest(mediaTypes = emptySet())
}
assertThrows(IllegalArgumentException::class.java) {
MediaSearchRequest(limit = 0)
}
assertThrows(IllegalArgumentException::class.java) {
MediaSearchRequest(offset = -1)
}
}
}
@@ -0,0 +1,130 @@
package eu.qsfera.android.lib.resources.files.search
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.IOException
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class MediaSearchResponseParserTest {
private val parser = MediaSearchResponseParser()
@Test
fun `parse reads successful WebDAV properties and decodes display path`() {
val xml = """
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/spaces/personal/DCIM/%D0%A4%D0%BE%D1%82%D0%BE+2025.jpg</d:href>
<d:propstat>
<d:prop>
<oc:name>Фото+2025.jpg</oc:name>
<d:getcontenttype>image/jpeg</d:getcontenttype>
<d:getcontentlength>12582912</d:getcontentlength>
<d:getlastmodified>Wed, 31 Dec 2025 23:59:59 GMT</d:getlastmodified>
<d:getetag>"abc:123"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
""".trimIndent()
val result = parser.parse(xml.byteInputStream())
assertEquals(1, result.size)
assertEquals(
RemoteMediaFile(
href = "/remote.php/dav/spaces/personal/DCIM/%D0%A4%D0%BE%D1%82%D0%BE+2025.jpg",
path = "/remote.php/dav/spaces/personal/DCIM/Фото+2025.jpg",
name = "Фото+2025.jpg",
mimeType = "image/jpeg",
size = 12_582_912,
modifiedTimestamp = ZonedDateTime
.parse("Wed, 31 Dec 2025 23:59:59 GMT", DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant()
.toEpochMilli(),
etag = "\"abc:123\"",
),
result.single(),
)
}
@Test
fun `parse ignores failed propstats and falls back to href file name`() {
val xml = """
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/spaces/personal/Videos/clip%2001.mp4</d:href>
<d:propstat>
<d:prop>
<d:getcontenttype>application/octet-stream</d:getcontenttype>
<d:getcontentlength>999</d:getcontentlength>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:getcontenttype>video/mp4</d:getcontenttype>
<d:getcontentlength>4096</d:getcontentlength>
<d:getlastmodified>not-a-date</d:getlastmodified>
<d:getetag/>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/spaces/personal/missing.jpg</d:href>
<d:propstat>
<d:prop><d:getcontenttype>image/jpeg</d:getcontenttype></d:prop>
<d:status>HTTP/1.1 403 Forbidden</d:status>
</d:propstat>
</d:response>
</d:multistatus>
""".trimIndent()
val result = parser.parse(xml.byteInputStream())
assertEquals(1, result.size)
assertEquals("clip 01.mp4", result.single().name)
assertEquals("video/mp4", result.single().mimeType)
assertEquals(4_096L, result.single().size)
assertNull(result.single().modifiedTimestamp)
assertNull(result.single().etag)
}
@Test
fun `parse rejects DTD declarations`() {
val xml = """
<?xml version="1.0"?>
<!DOCTYPE multistatus [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>&xxe;</d:href></d:response>
</d:multistatus>
""".trimIndent()
assertThrows(Exception::class.java) {
parser.parse(xml.byteInputStream())
}
}
@Test
fun `parse enforces response byte limit`() {
val limitedParser = MediaSearchResponseParser(maximumResponseBytes = 32)
val exception = assertThrows(Exception::class.java) {
limitedParser.parse("<d:multistatus xmlns:d=\"DAV:\"></d:multistatus>".byteInputStream())
}
assertTrue(exception is IOException || exception.cause is IOException)
}
}
@@ -0,0 +1,92 @@
package eu.qsfera.android.lib.resources.files.search
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import eu.qsfera.android.lib.common.QSferaClient
import eu.qsfera.android.lib.common.http.methods.HttpBaseMethod
import eu.qsfera.android.lib.common.http.methods.webdav.ReportMethod
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.Buffer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE)
class SearchRemoteMediaOperationTest {
@Test
fun `operation sends paged report and returns parsed media`() {
val responseXml = """
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/spaces/personal/Camera/photo.jpg</d:href>
<d:propstat>
<d:prop>
<oc:name>photo.jpg</oc:name>
<d:getcontenttype>image/jpeg</d:getcontenttype>
<d:getcontentlength>42</d:getcontentlength>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
""".trimIndent()
val client = StubQSferaClient(
context = ApplicationProvider.getApplicationContext(),
responseXml = responseXml,
)
val result = SearchRemoteMediaOperation(
request = MediaSearchRequest(
mediaTypes = setOf(MediaSearchType.IMAGE),
limit = 50,
offset = 100,
),
).execute(client)
val requestBody = Buffer().also { buffer ->
client.capturedMethod.request.body?.writeTo(buffer)
}.readUtf8()
assertTrue(result.isSuccess)
assertEquals(1, result.data?.size)
assertEquals("photo.jpg", result.data?.single()?.name)
assertEquals("REPORT", client.capturedMethod.request.method)
assertEquals("https://cloud.example.test/remote.php/dav/files/", client.capturedMethod.request.url.toString())
assertTrue(requestBody.contains("<oc:limit>50</oc:limit>"))
assertTrue(requestBody.contains("<oc:offset>100</oc:offset>"))
}
private class StubQSferaClient(
context: Context,
private val responseXml: String,
) : QSferaClient(
Uri.parse("https://cloud.example.test"),
null,
false,
null,
context,
) {
lateinit var capturedMethod: ReportMethod
override fun executeHttpMethod(method: HttpBaseMethod): Int {
capturedMethod = method as ReportMethod
capturedMethod.response = Response.Builder()
.request(capturedMethod.request)
.protocol(Protocol.HTTP_1_1)
.code(207)
.message("Multi-Status")
.body(responseXml.toResponseBody("application/xml".toMediaType()))
.build()
return 207
}
}
}
@@ -34,6 +34,8 @@ import eu.qsfera.android.lib.resources.appregistry.services.AppRegistryService
import eu.qsfera.android.lib.resources.appregistry.services.OCAppRegistryService
import eu.qsfera.android.lib.resources.files.services.FileService
import eu.qsfera.android.lib.resources.files.services.implementation.OCFileService
import eu.qsfera.android.lib.resources.files.search.services.MediaSearchService
import eu.qsfera.android.lib.resources.files.search.services.implementation.OCMediaSearchService
import eu.qsfera.android.lib.resources.shares.services.ShareService
import eu.qsfera.android.lib.resources.shares.services.ShareeService
import eu.qsfera.android.lib.resources.shares.services.implementation.OCShareService
@@ -138,6 +140,11 @@ class ClientManager(
return OCFileService(client = qsferaClient)
}
fun getMediaSearchService(accountName: String? = ""): MediaSearchService {
val qsferaClient = getClientForAccount(accountName)
return OCMediaSearchService(client = qsferaClient)
}
fun getCapabilityService(accountName: String? = ""): CapabilityService {
val qsferaClient = getClientForAccount(accountName)
return OCCapabilityService(client = qsferaClient)
@@ -5,6 +5,7 @@ import (
"encoding/xml"
"fmt"
"io"
"math"
"net/http"
"net/url"
"path"
@@ -33,6 +34,7 @@ import (
const (
elementNameSearchFiles = "search-files"
defaultSearchPageSize = 200
// TODO elementNameFilterFiles = "filter-files"
)
@@ -71,9 +73,19 @@ func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
ctx := revactx.ContextSetToken(r.Context(), t)
ctx = metadata.Set(ctx, revactx.TokenHeader, t)
pageSize, err := searchPageSize(
rep.SearchFiles.Search.Limit,
rep.SearchFiles.Search.Offset,
)
if err != nil {
renderError(w, r, errBadRequest(err.Error()))
logger.Debug().Err(err).Msg("invalid search pagination")
return
}
req := &searchsvc.SearchRequest{
Query: rep.SearchFiles.Search.Pattern,
PageSize: int32(rep.SearchFiles.Search.Limit),
PageSize: pageSize,
}
// Limit search to the according space when searching /dav/spaces/
@@ -105,10 +117,63 @@ func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
logger.Error().Err(err).Msg("could not get search results")
return
}
g.sendSearchResponse(rsp, w, r, user)
applySearchPage(
rsp,
rep.SearchFiles.Search.Offset,
rep.SearchFiles.Search.Limit,
)
g.sendSearchResponse(rsp, w, r, user, rep.SearchFiles.Search.Offset)
}
func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request, user *userv1beta1.User) {
// searchPageSize converts WebDAV offset/limit pagination into the larger first
// page requested from the search service. The search service currently exposes
// no numeric offset, so asking it for offset+limit and slicing below preserves
// its existing relevance ordering without changing the internal search API.
func searchPageSize(limit, offset int) (int32, error) {
if offset < 0 {
return 0, fmt.Errorf("search offset must not be negative")
}
if limit < -1 {
return 0, fmt.Errorf("search limit must be -1 or greater")
}
if limit == -1 {
return -1, nil
}
effectiveLimit := limit
if effectiveLimit == 0 {
effectiveLimit = defaultSearchPageSize
if offset == 0 {
// Keep zero so the search service remains the source of truth for
// its default page size when no offset was requested.
return 0, nil
}
}
if offset > math.MaxInt32-effectiveLimit {
return 0, fmt.Errorf("search offset and limit are too large")
}
return int32(offset + effectiveLimit), nil
}
func applySearchPage(rsp *searchsvc.SearchResponse, offset, limit int) {
if rsp == nil {
return
}
start := min(offset, len(rsp.Matches))
end := len(rsp.Matches)
if limit != -1 {
effectiveLimit := limit
if effectiveLimit == 0 {
effectiveLimit = defaultSearchPageSize
}
end = min(start+effectiveLimit, end)
}
rsp.Matches = rsp.Matches[start:end]
}
func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request, user *userv1beta1.User, offset int) {
logger := g.log.SubloggerWithRequestID(r.Context())
responsesXML, err := multistatusResponse(r.Context(), g.config.QsferaPublicURL, rsp.Matches, user)
if err != nil {
@@ -119,7 +184,7 @@ func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.Respons
w.Header().Set(net.HeaderDav, "1, 3, extended-mkcol")
w.Header().Set(net.HeaderContentType, "application/xml; charset=utf-8")
if len(rsp.Matches) > 0 {
w.Header().Set(net.HeaderContentRange, fmt.Sprintf("rows 0-%d/%d", len(rsp.Matches)-1, rsp.TotalMatches))
w.Header().Set(net.HeaderContentRange, searchContentRange(offset, len(rsp.Matches), rsp.TotalMatches))
}
w.WriteHeader(http.StatusMultiStatus)
if _, err := w.Write(responsesXML); err != nil {
@@ -127,6 +192,10 @@ func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.Respons
}
}
func searchContentRange(offset, count int, total int32) string {
return fmt.Sprintf("rows %d-%d/%d", offset, offset+count-1, total)
}
// multistatusResponse converts a list of matches into a multistatus response string
func multistatusResponse(ctx context.Context, publicURL string, matches []*searchmsg.Match, user *userv1beta1.User) ([]byte, error) {
responses := make([]*propfind.ResponseXML, 0, len(matches))
@@ -0,0 +1,126 @@
package svc
import (
"fmt"
"math"
"strings"
"testing"
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
)
func TestSearchPageSize(t *testing.T) {
tests := []struct {
name string
limit int
offset int
want int32
wantErr bool
}{
{name: "existing default without offset", want: 0},
{name: "explicit limit", limit: 25, want: 25},
{name: "explicit limit with offset", limit: 25, offset: 50, want: 75},
{name: "default limit with offset", offset: 10, want: 210},
{name: "unlimited", limit: -1, offset: 10, want: -1},
{name: "negative offset", limit: 25, offset: -1, wantErr: true},
{name: "invalid negative limit", limit: -2, wantErr: true},
{name: "overflow", limit: 1, offset: math.MaxInt32, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := searchPageSize(tt.limit, tt.offset)
if tt.wantErr {
if err == nil {
t.Fatal("expected an error")
}
return
}
if err != nil {
t.Fatalf("searchPageSize returned an error: %v", err)
}
if got != tt.want {
t.Fatalf("searchPageSize = %d, want %d", got, tt.want)
}
})
}
}
func TestApplySearchPage(t *testing.T) {
tests := []struct {
name string
count int
offset int
limit int
want []string
}{
{name: "explicit page", count: 5, offset: 2, limit: 2, want: []string{"2", "3"}},
{name: "unlimited after offset", count: 5, offset: 3, limit: -1, want: []string{"3", "4"}},
{name: "offset beyond results", count: 3, offset: 10, limit: 2, want: []string{}},
{name: "default page", count: 205, offset: 5, want: numberStrings(5, 205)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rsp := &searchsvc.SearchResponse{
Matches: namedMatches(tt.count),
TotalMatches: int32(tt.count + 10),
}
applySearchPage(rsp, tt.offset, tt.limit)
got := make([]string, len(rsp.Matches))
for i := range rsp.Matches {
got[i] = rsp.Matches[i].GetEntity().GetName()
}
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
t.Fatalf("page = %v, want %v", got, tt.want)
}
if rsp.TotalMatches != int32(tt.count+10) {
t.Fatalf("TotalMatches changed to %d", rsp.TotalMatches)
}
})
}
}
func TestReadReportParsesOffset(t *testing.T) {
rep, err := readReport(strings.NewReader(`
<oc:search-files xmlns:oc="http://owncloud.org/ns">
<oc:search>
<oc:pattern>mediatype:image</oc:pattern>
<oc:limit>40</oc:limit>
<oc:offset>80</oc:offset>
</oc:search>
</oc:search-files>`))
if err != nil {
t.Fatalf("readReport returned an error: %v", err)
}
if rep.SearchFiles == nil {
t.Fatal("search-files was not parsed")
}
if got := rep.SearchFiles.Search.Offset; got != 80 {
t.Fatalf("offset = %d, want 80", got)
}
}
func TestSearchContentRange(t *testing.T) {
if got, want := searchContentRange(80, 40, 137), "rows 80-119/137"; got != want {
t.Fatalf("searchContentRange = %q, want %q", got, want)
}
}
func namedMatches(count int) []*searchmsg.Match {
matches := make([]*searchmsg.Match, count)
for i, name := range numberStrings(0, count) {
matches[i] = &searchmsg.Match{Entity: &searchmsg.Entity{Name: name}}
}
return matches
}
func numberStrings(start, end int) []string {
values := make([]string, 0, end-start)
for i := start; i < end; i++ {
values = append(values, fmt.Sprint(i))
}
return values
}