From dd212857009db27cf0117787a08aa29e11d5d6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Thu, 16 Jul 2026 01:47:36 +0300 Subject: [PATCH] Implement cloud media UI and reliable automatic uploads --- Android/qsferaApp/build.gradle | 4 +- .../qsferaApp/src/main/AndroidManifest.xml | 26 +- .../main/java/eu/qsfera/android/MainApp.kt | 8 + .../accounts/ManageAccountsDialogFragment.kt | 8 +- .../authentication/LoginActivity.kt | 9 +- .../presentation/cloud/CloudHomeActivity.kt | 127 ++++++ .../presentation/cloud/CloudHubAdapter.kt | 153 +++++++ .../presentation/cloud/CloudHubFragment.kt | 393 ++++++++++++++++++ .../cloud/CloudMediaPreviewActivity.kt | 141 +++++++ .../android/presentation/cloud/CloudModels.kt | 66 +++ .../presentation/settings/SettingsActivity.kt | 11 +- .../AutomaticUploadFoldersActivity.kt | 240 +++++++++++ .../AutomaticUploadMediaSource.kt | 75 ++++ .../AutomaticUploadsPermissions.kt | 55 +++ .../automaticuploads/PhoneMediaStore.kt | 183 ++++++++ .../SettingsPictureUploadsFragment.kt | 203 ++++++--- .../SettingsPictureUploadsViewModel.kt | 11 +- .../SettingsVideoUploadsFragment.kt | 202 ++++++--- .../SettingsVideoUploadsViewModel.kt | 11 +- .../thumbnails/ThumbnailsRequester.kt | 34 +- .../android/providers/WorkManagerProvider.kt | 4 +- .../ui/activity/ActivityEdgeToEdgeUtils.kt | 2 +- .../android/ui/activity/DrawerActivity.kt | 56 +-- .../ui/activity/FileDisplayActivity.kt | 8 +- .../android/ui/activity/SplashActivity.kt | 5 +- .../android/ui/activity/ToolbarActivity.kt | 4 +- .../ui/activity/UploadListActivity.java | 2 +- .../android/workers/AutomaticUploadsWorker.kt | 215 ++++++---- .../android/workers/RemoveSourceFileWorker.kt | 57 ++- .../workers/UploadFileFromContentUriWorker.kt | 42 +- .../res/color/bottom_navigation_item_tint.xml | 5 + .../res/color/bottom_navigation_ripple.xml | 6 + .../res/drawable/bg_bottom_navigation.xml | 13 + .../res/drawable/cloud_card_background.xml | 5 + .../res/drawable/cloud_media_placeholder.xml | 8 + .../res/drawable/ic_bottom_nav_albums.xml | 17 + .../main/res/drawable/ic_bottom_nav_feed.xml | 17 + .../main/res/drawable/ic_bottom_nav_files.xml | 14 + .../main/res/drawable/ic_bottom_nav_more.xml | 10 + .../res/drawable/ic_bottom_nav_photos.xml | 17 + .../main/res/drawable/ic_qsfera_folder.xml | 10 + .../layout/activity_cloud_media_preview.xml | 48 +++ .../main/res/layout/fragment_cloud_hub.xml | 17 + .../src/main/res/layout/item_cloud_action.xml | 50 +++ .../src/main/res/layout/item_cloud_album.xml | 36 ++ .../src/main/res/layout/item_cloud_header.xml | 12 + .../src/main/res/layout/item_cloud_media.xml | 27 ++ .../src/main/res/layout/item_cloud_status.xml | 35 ++ .../res/layout/nav_coordinator_layout.xml | 12 +- .../src/main/res/layout/qsfera_toolbar.xml | 9 +- .../src/main/res/menu/bottom_navbar_menu.xml | 30 +- .../main/res/values-ru/cloud_hub_strings.xml | 31 ++ .../src/main/res/values-ru/strings.xml | 29 +- .../src/main/res/values/cloud_hub_strings.xml | 31 ++ .../qsferaApp/src/main/res/values/colors.xml | 14 +- .../qsferaApp/src/main/res/values/setup.xml | 6 +- .../qsferaApp/src/main/res/values/strings.xml | 29 +- .../qsferaApp/src/main/res/values/styles.xml | 4 +- .../main/res/xml/settings_picture_uploads.xml | 49 ++- .../main/res/xml/settings_video_uploads.xml | 49 ++- .../AutomaticUploadMediaSourceTest.kt | 40 ++ .../http/methods/webdav/ReportMethod.kt | 33 ++ .../files/search/MediaSearchReportBody.kt | 27 ++ .../files/search/MediaSearchRequest.kt | 35 ++ .../files/search/MediaSearchResponseParser.kt | 210 ++++++++++ .../resources/files/search/MediaSearchType.kt | 11 + .../resources/files/search/RemoteMediaFile.kt | 21 + .../search/SearchRemoteMediaOperation.kt | 49 +++ .../search/services/MediaSearchService.kt | 17 + .../implementation/OCMediaSearchService.kt | 23 + .../http/methods/webdav/ReportMethodTest.kt | 27 ++ .../files/search/MediaSearchReportBodyTest.kt | 52 +++ .../search/MediaSearchResponseParserTest.kt | 130 ++++++ .../search/SearchRemoteMediaOperationTest.kt | 92 ++++ .../eu/qsfera/android/data/ClientManager.kt | 7 + .../services/webdav/pkg/service/v0/search.go | 77 +++- .../webdav/pkg/service/v0/search_test.go | 126 ++++++ 77 files changed, 3585 insertions(+), 387 deletions(-) create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadFoldersActivity.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSource.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadsPermissions.kt create mode 100644 Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/automaticuploads/PhoneMediaStore.kt create mode 100644 Android/qsferaApp/src/main/res/color/bottom_navigation_item_tint.xml create mode 100644 Android/qsferaApp/src/main/res/color/bottom_navigation_ripple.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/bg_bottom_navigation.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/cloud_card_background.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/cloud_media_placeholder.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/ic_bottom_nav_albums.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/ic_bottom_nav_feed.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/ic_bottom_nav_files.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/ic_bottom_nav_more.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/ic_bottom_nav_photos.xml create mode 100644 Android/qsferaApp/src/main/res/drawable/ic_qsfera_folder.xml create mode 100644 Android/qsferaApp/src/main/res/layout/activity_cloud_media_preview.xml create mode 100644 Android/qsferaApp/src/main/res/layout/fragment_cloud_hub.xml create mode 100644 Android/qsferaApp/src/main/res/layout/item_cloud_action.xml create mode 100644 Android/qsferaApp/src/main/res/layout/item_cloud_album.xml create mode 100644 Android/qsferaApp/src/main/res/layout/item_cloud_header.xml create mode 100644 Android/qsferaApp/src/main/res/layout/item_cloud_media.xml create mode 100644 Android/qsferaApp/src/main/res/layout/item_cloud_status.xml create mode 100644 Android/qsferaApp/src/main/res/values-ru/cloud_hub_strings.xml create mode 100644 Android/qsferaApp/src/main/res/values/cloud_hub_strings.xml create mode 100644 Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/settings/automaticuploads/AutomaticUploadMediaSourceTest.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethod.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBody.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchRequest.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParser.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/MediaSearchType.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/RemoteMediaFile.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperation.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/MediaSearchService.kt create mode 100644 Android/qsferaComLibrary/src/main/java/eu/qsfera/android/lib/resources/files/search/services/implementation/OCMediaSearchService.kt create mode 100644 Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/common/http/methods/webdav/ReportMethodTest.kt create mode 100644 Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchReportBodyTest.kt create mode 100644 Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/MediaSearchResponseParserTest.kt create mode 100644 Android/qsferaComLibrary/src/test/java/eu/qsfera/android/lib/resources/files/search/SearchRemoteMediaOperationTest.kt create mode 100644 Server/services/webdav/pkg/service/v0/search_test.go 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 --> - + + + + + + + + + android:id="@+id/nav_photos" + android:icon="@drawable/ic_bottom_nav_photos" + android:title="@string/bottom_nav_photos" /> + android:id="@+id/nav_albums" + android:icon="@drawable/ic_bottom_nav_albums" + android:title="@string/bottom_nav_albums" /> - + android:id="@+id/nav_more" + android:icon="@drawable/ic_bottom_nav_more" + android:title="@string/bottom_nav_more" /> 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 +}