diff --git a/Android/qsferaApp/build.gradle b/Android/qsferaApp/build.gradle
index 0b7b26736..6b646bf40 100644
--- a/Android/qsferaApp/build.gradle
+++ b/Android/qsferaApp/build.gradle
@@ -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() + "\""
diff --git a/Android/qsferaApp/src/main/AndroidManifest.xml b/Android/qsferaApp/src/main/AndroidManifest.xml
index c41b58977..ecb9d8f4e 100644
--- a/Android/qsferaApp/src/main/AndroidManifest.xml
+++ b/Android/qsferaApp/src/main/AndroidManifest.xml
@@ -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
-->
-
+
+
+
+
+
+
+
diff --git a/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml b/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml
new file mode 100644
index 000000000..ae4cb8622
--- /dev/null
+++ b/Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml
@@ -0,0 +1,31 @@
+
+
+ Лента
+ Фото
+ Альбомы
+ Ещё
+ Лента готова
+ Новые автозагрузки появятся здесь после сохранения в QSfera.
+ Фотографий пока нет
+ Загрузите фото или включите автозагрузку в настройках.
+ Альбомов пока нет
+ Альбомы создаются из папок, в которых есть фото и видео.
+ Загружаем ваше облако…
+ Не удалось загрузить медиафайлы
+ Нажмите, чтобы повторить
+ Недавние загрузки
+ Все фото
+ Объектов: %1$d
+ Загрузки
+ Текущие и завершённые передачи
+ Офлайн
+ Файлы, доступные без сети
+ Общие ссылки
+ Файлы, которыми вы поделились
+ Пространства
+ Личное и проектное хранилище
+ Настройки
+ Автозагрузка, учётная запись и безопасность
+ Хранилище
+ Управление файлами QSfera
+
diff --git a/Android/qsferaApp/src/main/res/values-ru/strings.xml b/Android/qsferaApp/src/main/res/values-ru/strings.xml
index bdfecc7a6..68e5c2061 100644
--- a/Android/qsferaApp/src/main/res/values-ru/strings.xml
+++ b/Android/qsferaApp/src/main/res/values-ru/strings.xml
@@ -139,7 +139,11 @@
Графический ключ будет запрашиваться каждый раз при запуске приложения
Заблокировано
Допустимое ДЕЙСТВИЕ требуется для передачи в намерениях
+ Лента
Файлы
+ Фото
+ Альбомы
+ Ещё
Личный
Загрузки
Оффлайн
@@ -555,10 +559,31 @@
%1$d файлов, %2$d каталогов
Учётная запись для закачки изображений
Учётная запись для закачки видео
- Каталог камеры (%1$s)
- обязательно
+ Папки
+ камера включена автоматически
Очистить выбранные папки
Папки не выбраны
+ Выберите папку с разрешением на чтение и удаление файлов
+ Папки на телефоне
+ Фото и видео из папки «Камера» загружаются автоматически. Загрузку из остальных папок можно настроить ниже.
+ Остальные папки
+ Ищем папки с медиафайлами…
+ Других папок с медиафайлами не найдено.
+ Не удалось прочитать папки. Проверьте доступ к фото и видео.
+ Медиафайлов: %1$d
+ Доступ к фото и файлам
+ Доступ разрешён. Автозагрузка камеры работает в фоне, а оригинал удаляется после успешной загрузки.
+ Доступ к медиафайлам разрешён.
+ Разрешите доступ к фото и видео, чтобы QSfera обнаруживала новые файлы.
+ Разрешите полный доступ к файлам, чтобы QSfera удаляла оригинал только после успешной загрузки.
+ Нужны два разрешения: читать фото и удалять успешно загруженный оригинал.
+ Камера — автоматически%1$s
+ ; выбрано других папок: %1$d
+ Папка «Камера» отслеживается автоматически. Другие папки выбираются в разделе «Папки». Оригинал удаляется только после подтверждённой сервером успешной загрузки.
+ Использовать мобильный интернет
+ Автозагрузка будет работать и без Wi-Fi
+ Куда загружать
+ Параметры загрузки
Очистить выбранные папки
Автозагрузка перестанет проверять папки телефона, пока вы снова не добавите папку.
Исходный файл будет
diff --git a/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml b/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml
new file mode 100644
index 000000000..5ba8f835e
--- /dev/null
+++ b/Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml
@@ -0,0 +1,31 @@
+
+
+ Feed
+ Photos
+ Albums
+ More
+ Your feed is ready
+ New automatic uploads will appear here after they reach QSfera.
+ No photos found
+ Upload a photo or enable automatic uploads in Settings.
+ No albums yet
+ Albums are created from folders that contain photos and videos.
+ Loading your cloud…
+ Could not load cloud media
+ Tap to retry
+ Recent uploads
+ All photos
+ %1$d items
+ Transfers
+ Current and completed uploads
+ Offline
+ Files available without a network
+ Shared links
+ Files shared by link
+ Spaces
+ Personal and project storage
+ Settings
+ Automatic uploads, account and security
+ Storage
+ Manage files in QSfera
+
diff --git a/Android/qsferaApp/src/main/res/values/colors.xml b/Android/qsferaApp/src/main/res/values/colors.xml
index c788151e6..209752f9a 100644
--- a/Android/qsferaApp/src/main/res/values/colors.xml
+++ b/Android/qsferaApp/src/main/res/values/colors.xml
@@ -20,14 +20,22 @@
- @color/qsfera_petrol
- #19353F
- @color/qsfera_petrol_accent
+ @color/qsfera_blue
+ #2F61C7
+ @color/qsfera_blue
#21434F
#396676
#00ddff
+ #4B7BEC
+ #3566D1
+ #FFFFFF
+ #F3F4F6
+ #17181A
+ #6D7178
+ #E8E9EC
+ #F4C95D
#525757
#707575
#BDBDBD
diff --git a/Android/qsferaApp/src/main/res/values/setup.xml b/Android/qsferaApp/src/main/res/values/setup.xml
index ed93e138e..5d27830d9 100644
--- a/Android/qsferaApp/src/main/res/values/setup.xml
+++ b/Android/qsferaApp/src/main/res/values/setup.xml
@@ -38,15 +38,15 @@
@color/white
@color/white
@color/color_accent
- #FFFFFF
- @color/primary
+ @color/qsfera_surface
+ @color/qsfera_surface
@color/color_accent
@color/white
#D6D7D7
@color/black
@color/qsfera_petrol_accent
#edf3fa
- @color/actionbar_start_color
+ @color/qsfera_surface_muted
@color/qsfera_petrol
diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml
index 2a9046755..e08f6fe04 100644
--- a/Android/qsferaApp/src/main/res/values/strings.xml
+++ b/Android/qsferaApp/src/main/res/values/strings.xml
@@ -142,7 +142,11 @@
A valid ACTION is needed in the intent passed to
+ Feed
Files
+ Photos
+ Albums
+ More
Personal
Uploads
Offline
@@ -593,10 +597,31 @@
%1$d files, %2$d folders
Account to upload pictures
Account to upload videos
- Camera folder (%1$s)
- required
+ Folders
+ Camera is included automatically
Clear selected folders
No folders selected
+ Select a folder that grants both read and write access
+ Folders on phone
+ Photos and videos from the Camera folder are uploaded automatically. Uploads from other folders can be enabled below.
+ Other folders
+ Looking for media folders…
+ No other media folders were found.
+ Folders could not be read. Check photo and video access.
+ %1$d media files
+ Photo and file access
+ Access granted. Camera uploads can run in the background and remove originals after a successful upload.
+ Media access is allowed.
+ Allow access to photos and videos so QSfera can detect new media.
+ Allow full file access so QSfera can remove an original only after its upload succeeds.
+ Two permissions are required: read photos and remove a successfully uploaded original.
+ Camera automatically%1$s
+ ; %1$d other folders selected
+ 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.
+ Use mobile data
+ Automatic uploads will also work when Wi-Fi is unavailable
+ Upload destination
+ Upload options
Clear selected folders
Automatic uploads will stop scanning phone folders until you add a folder again.
Original file will be
diff --git a/Android/qsferaApp/src/main/res/values/styles.xml b/Android/qsferaApp/src/main/res/values/styles.xml
index 29cc95bf0..85632d826 100644
--- a/Android/qsferaApp/src/main/res/values/styles.xml
+++ b/Android/qsferaApp/src/main/res/values/styles.xml
@@ -107,13 +107,13 @@
overrides colorPrimary to allow customization of the app bar color,
independent of Material Design guidelines, if desired
-->
-
diff --git a/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml b/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml
index 15120224b..4f53ecdda 100644
--- a/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml
+++ b/Android/qsferaApp/src/main/res/xml/settings_picture_uploads.xml
@@ -25,45 +25,54 @@
app:key="enable_picture_uploads"
app:summary="@string/prefs_camera_picture_upload_summary"
app:title="@string/prefs_camera_picture_upload" />
-
+ app:key="automatic_picture_uploads_permissions"
+ app:title="@string/automatic_upload_permission_title" />
-
-
+
+
+
+
+
+
-
+ app:summary="@string/automatic_upload_mobile_data_summary"
+ app:title="@string/automatic_upload_mobile_data" />
+
+
diff --git a/Android/qsferaApp/src/main/res/xml/settings_video_uploads.xml b/Android/qsferaApp/src/main/res/xml/settings_video_uploads.xml
index 4bbaa0a90..8f8f94e28 100644
--- a/Android/qsferaApp/src/main/res/xml/settings_video_uploads.xml
+++ b/Android/qsferaApp/src/main/res/xml/settings_video_uploads.xml
@@ -23,25 +23,14 @@
app:key="enable_video_uploads"
app:summary="@string/prefs_camera_video_upload_summary"
app:title="@string/prefs_camera_video_upload" />
-
+ app:key="automatic_video_uploads_permissions"
+ app:title="@string/automatic_upload_permission_title" />
-
-
+
+
+
+
+
+
-
+ app:summary="@string/automatic_upload_mobile_data_summary"
+ app:title="@string/automatic_upload_mobile_data" />
+
+
diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSourceTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSourceTest.kt
new file mode 100644
index 000000000..561b7988b
--- /dev/null
+++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSourceTest.kt
@@ -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"))
+ }
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethod.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethod.kt
new file mode 100644
index 000000000..dcc5eb6d5
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethod.kt
@@ -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()
+ }
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBody.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBody.kt
new file mode 100644
index 000000000..f9ccdd1ec
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBody.kt
@@ -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 = """
+
+
+
+ ${request.toKqlPattern()}
+ ${request.limit}
+ ${request.offset}
+
+
+
+
+
+
+
+
+
+ """.trimIndent()
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchRequest.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchRequest.kt
new file mode 100644
index 000000000..ebf16a4ac
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchRequest.kt
@@ -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.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
+ }
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParser.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParser.kt
new file mode 100644
index 000000000..b26261cae
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParser.kt
@@ -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 {
+ 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()
+ 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 = 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"
+ }
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchType.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchType.kt
new file mode 100644
index 000000000..83b54b388
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchType.kt
@@ -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"),
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/RemoteMediaFile.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/RemoteMediaFile.kt
new file mode 100644
index 000000000..7c1c6cb16
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/RemoteMediaFile.kt
@@ -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?,
+)
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperation.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperation.kt
new file mode 100644
index 000000000..e131a79fc
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperation.kt
@@ -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>() {
+
+ override fun run(client: QSferaClient): RemoteOperationResult> {
+ 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>(RemoteOperationResult.ResultCode.OK).apply {
+ data = mediaFiles
+ }
+ } else {
+ RemoteOperationResult(reportMethod)
+ }
+ } catch (exception: Exception) {
+ Timber.e(exception, "Media search REPORT failed")
+ RemoteOperationResult(exception)
+ }
+ }
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/MediaSearchService.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/MediaSearchService.kt
new file mode 100644
index 000000000..49507c246
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/MediaSearchService.kt
@@ -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>
+}
diff --git a/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/implementation/OCMediaSearchService.kt b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/implementation/OCMediaSearchService.kt
new file mode 100644
index 000000000..aa9020e34
--- /dev/null
+++ b/Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/implementation/OCMediaSearchService.kt
@@ -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> =
+ SearchRemoteMediaOperation(
+ request = request,
+ webDavUrl = webDavUrl,
+ ).execute(client)
+}
diff --git a/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethodTest.kt b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethodTest.kt
new file mode 100644
index 000000000..a81daa6d2
--- /dev/null
+++ b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethodTest.kt
@@ -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 = ""
+
+ 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())
+ }
+}
diff --git a/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBodyTest.kt b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBodyTest.kt
new file mode 100644
index 000000000..bf4c20eef
--- /dev/null
+++ b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBodyTest.kt
@@ -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(""))
+ assertTrue(body.contains("mediatype:image OR mediatype:video"))
+ assertTrue(body.contains("75"))
+ assertTrue(body.contains("150"))
+ assertTrue(body.contains(""))
+ assertTrue(body.contains(""))
+ }
+
+ @Test
+ fun `build creates a single type expression without boolean operator`() {
+ val body = MediaSearchReportBody.build(
+ MediaSearchRequest(mediaTypes = setOf(MediaSearchType.IMAGE)),
+ )
+
+ assertTrue(body.contains("mediatype:image"))
+ 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)
+ }
+ }
+}
diff --git a/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParserTest.kt b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParserTest.kt
new file mode 100644
index 000000000..6f11898e1
--- /dev/null
+++ b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParserTest.kt
@@ -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 = """
+
+
+
+ /remote.php/dav/spaces/personal/DCIM/%D0%A4%D0%BE%D1%82%D0%BE+2025.jpg
+
+
+ Фото+2025.jpg
+ image/jpeg
+ 12582912
+ Wed, 31 Dec 2025 23:59:59 GMT
+ "abc:123"
+
+ HTTP/1.1 200 OK
+
+
+
+ """.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 = """
+
+
+ /remote.php/dav/spaces/personal/Videos/clip%2001.mp4
+
+
+ application/octet-stream
+ 999
+
+ HTTP/1.1 404 Not Found
+
+
+
+ video/mp4
+ 4096
+ not-a-date
+
+
+ HTTP/1.1 200 OK
+
+
+
+ /remote.php/dav/spaces/personal/missing.jpg
+
+ image/jpeg
+ HTTP/1.1 403 Forbidden
+
+
+
+ """.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 = """
+
+ ]>
+
+ &xxe;
+
+ """.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("".byteInputStream())
+ }
+
+ assertTrue(exception is IOException || exception.cause is IOException)
+ }
+}
diff --git a/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperationTest.kt b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperationTest.kt
new file mode 100644
index 000000000..4c2a8aa9b
--- /dev/null
+++ b/Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperationTest.kt
@@ -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 = """
+
+
+ /remote.php/dav/spaces/personal/Camera/photo.jpg
+
+
+ photo.jpg
+ image/jpeg
+ 42
+
+ HTTP/1.1 200 OK
+
+
+
+ """.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("50"))
+ assertTrue(requestBody.contains("100"))
+ }
+
+ 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
+ }
+ }
+}
diff --git a/Android/qsferaData/src/main/java/eu/qsfera/android/data/ClientManager.kt b/Android/qsferaData/src/main/java/eu/qsfera/android/data/ClientManager.kt
index bf6bfc041..5c3e15906 100644
--- a/Android/qsferaData/src/main/java/eu/qsfera/android/data/ClientManager.kt
+++ b/Android/qsferaData/src/main/java/eu/qsfera/android/data/ClientManager.kt
@@ -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)
diff --git a/Server/services/webdav/pkg/service/v0/search.go b/Server/services/webdav/pkg/service/v0/search.go
index 743c3b2c1..f5090c21c 100644
--- a/Server/services/webdav/pkg/service/v0/search.go
+++ b/Server/services/webdav/pkg/service/v0/search.go
@@ -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))
diff --git a/Server/services/webdav/pkg/service/v0/search_test.go b/Server/services/webdav/pkg/service/v0/search_test.go
new file mode 100644
index 000000000..4b1b7f7eb
--- /dev/null
+++ b/Server/services/webdav/pkg/service/v0/search_test.go
@@ -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(`
+
+
+ mediatype:image
+ 40
+ 80
+
+ `))
+ 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
+}