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

This commit is contained in:
Курнат Андрей
2026-07-16 01:47:36 +03:00
parent e48e1e36a5
commit dd21285700
77 changed files with 3585 additions and 387 deletions
@@ -0,0 +1,33 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.common.http.methods.webdav
import eu.qsfera.android.lib.common.http.methods.nonwebdav.HttpMethod
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.URL
/**
* OkHttp wrapper for WebDAV REPORT requests with an XML body.
*/
class ReportMethod(
url: URL,
reportBody: String,
) : HttpMethod(url) {
init {
request = request.newBuilder()
.method(METHOD_REPORT, reportBody.toRequestBody(XML_MEDIA_TYPE))
.header(HEADER_ACCEPT, XML_MEDIA_TYPE_VALUE)
.build()
}
private companion object {
const val METHOD_REPORT = "REPORT"
const val HEADER_ACCEPT = "Accept"
const val XML_MEDIA_TYPE_VALUE = "application/xml; charset=utf-8"
val XML_MEDIA_TYPE = XML_MEDIA_TYPE_VALUE.toMediaType()
}
}
@@ -0,0 +1,27 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/** Builds the XML body expected by QSfera's `search-files` REPORT endpoint. */
object MediaSearchReportBody {
fun build(request: MediaSearchRequest): String = """
<?xml version="1.0" encoding="utf-8"?>
<oc:search-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<oc:search>
<oc:pattern>${request.toKqlPattern()}</oc:pattern>
<oc:limit>${request.limit}</oc:limit>
<oc:offset>${request.offset}</oc:offset>
</oc:search>
<d:prop>
<oc:name/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:getetag/>
</d:prop>
</oc:search-files>
""".trimIndent()
}
@@ -0,0 +1,35 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/**
* Parameters for a server-side media search.
*
* A deterministic enum order is used when the KQL expression is generated, so
* callers can pass any [Set] implementation without affecting the request body.
*/
data class MediaSearchRequest(
val mediaTypes: Set<MediaSearchType> = MediaSearchType.values().toSet(),
val limit: Int = DEFAULT_LIMIT,
val offset: Int = 0,
) {
init {
require(mediaTypes.isNotEmpty()) { "At least one media type is required" }
require(limit in 1..MAX_LIMIT) { "Limit must be between 1 and $MAX_LIMIT" }
require(offset >= 0) { "Offset must not be negative" }
}
internal fun toKqlPattern(): String =
MediaSearchType.values()
.filter(mediaTypes::contains)
.joinToString(separator = " OR ") { mediaType ->
"mediatype:${mediaType.queryValue}"
}
companion object {
const val DEFAULT_LIMIT = 200
const val MAX_LIMIT = 1_000
}
}
@@ -0,0 +1,210 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* Streaming parser for WebDAV Multi-Status media-search responses.
*
* DTD processing is disabled and rejected, the response byte count is capped,
* and only successful propstats contribute metadata to a result.
*/
class MediaSearchResponseParser(
private val maximumResponseBytes: Long = DEFAULT_MAXIMUM_RESPONSE_BYTES,
private val maximumResults: Int = DEFAULT_MAXIMUM_RESULTS,
) {
@Throws(IOException::class, XmlPullParserException::class)
fun parse(inputStream: InputStream): List<RemoteMediaFile> {
require(maximumResponseBytes > 0) { "Maximum response size must be positive" }
require(maximumResults > 0) { "Maximum result count must be positive" }
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
try {
setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false)
} catch (_: XmlPullParserException) {
// Some implementations do not expose this optional feature.
// DOCDECL events are still rejected below.
}
setInput(LimitedInputStream(inputStream, maximumResponseBytes), null)
}
val results = mutableListOf<RemoteMediaFile>()
var response: ResponseDraft? = null
var propStat: PropStatDraft? = null
var insideProp = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.DOCDECL -> throw XmlPullParserException("DTD declarations are not allowed")
XmlPullParser.START_TAG -> when (parser.name) {
TAG_RESPONSE -> response = ResponseDraft()
TAG_PROPSTAT -> propStat = PropStatDraft()
TAG_PROP -> insideProp = true
TAG_HREF -> if (response != null && propStat == null) {
response.href = parser.nextText().trim()
}
TAG_STATUS -> if (propStat != null) {
propStat.status = parser.nextText().trim()
}
TAG_NAME -> if (insideProp && propStat != null && parser.namespace == NAMESPACE_OC) {
propStat.name = parser.nextText()
}
TAG_CONTENT_TYPE -> if (insideProp && propStat != null) {
propStat.mimeType = parser.nextText().trim().ifEmpty { null }
}
TAG_CONTENT_LENGTH -> if (insideProp && propStat != null) {
propStat.size = parser.nextText().trim().toLongOrNull()?.takeIf { it >= 0 }
}
TAG_LAST_MODIFIED -> if (insideProp && propStat != null) {
propStat.modifiedTimestamp = parseModifiedTimestamp(parser.nextText().trim())
}
TAG_ETAG -> if (insideProp && propStat != null) {
propStat.etag = parser.nextText().trim().ifEmpty { null }
}
}
XmlPullParser.END_TAG -> when (parser.name) {
TAG_PROP -> {
insideProp = false
}
TAG_PROPSTAT -> {
response?.propStats?.add(propStat ?: PropStatDraft())
propStat = null
}
TAG_RESPONSE -> {
response?.toRemoteMediaFile()?.let { mediaFile ->
if (results.size >= maximumResults) {
throw IOException("Media search response exceeds $maximumResults results")
}
results.add(mediaFile)
}
response = null
}
}
}
eventType = parser.next()
}
return results
}
private fun ResponseDraft.toRemoteMediaFile(): RemoteMediaFile? {
val resultHref = href?.takeIf(String::isNotBlank) ?: return null
val successfulPropStats = propStats.filter(PropStatDraft::isSuccessful)
if (successfulPropStats.isEmpty()) return null
val decodedPath = decodeHrefPath(resultHref)
val resultName = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::name)
?.takeIf(String::isNotBlank)
?: decodedPath.trimEnd('/').substringAfterLast('/')
return RemoteMediaFile(
href = resultHref,
path = decodedPath,
name = resultName,
mimeType = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::mimeType),
size = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::size),
modifiedTimestamp = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::modifiedTimestamp),
etag = successfulPropStats.firstNotNullOfOrNull(PropStatDraft::etag),
)
}
private fun parseModifiedTimestamp(value: String): Long? =
runCatching {
ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant()
.toEpochMilli()
}.getOrNull()
private fun decodeHrefPath(href: String): String {
val rawPath = runCatching { URI(href).rawPath }
.getOrNull()
?.takeIf(String::isNotEmpty)
?: href.substringBefore('?')
return runCatching {
URLDecoder.decode(
rawPath.replace("+", "%2B"),
StandardCharsets.UTF_8.name(),
)
}.getOrDefault(rawPath)
}
private data class ResponseDraft(
var href: String? = null,
val propStats: MutableList<PropStatDraft> = mutableListOf(),
)
private data class PropStatDraft(
var status: String? = null,
var name: String? = null,
var mimeType: String? = null,
var size: Long? = null,
var modifiedTimestamp: Long? = null,
var etag: String? = null,
) {
fun isSuccessful(): Boolean = status
?.substringAfter(' ', missingDelimiterValue = "")
?.substringBefore(' ')
?.toIntOrNull()
?.let { it in 200..299 }
?: false
}
private class LimitedInputStream(
inputStream: InputStream,
private val maximumBytes: Long,
) : FilterInputStream(inputStream) {
private var bytesRead = 0L
override fun read(): Int {
val value = super.read()
if (value >= 0) incrementAndCheck(1)
return value
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
val count = super.read(buffer, offset, length)
if (count > 0) incrementAndCheck(count.toLong())
return count
}
private fun incrementAndCheck(count: Long) {
bytesRead += count
if (bytesRead > maximumBytes) {
throw IOException("Media search response exceeds $maximumBytes bytes")
}
}
}
companion object {
const val DEFAULT_MAXIMUM_RESPONSE_BYTES = 8L * 1024L * 1024L
const val DEFAULT_MAXIMUM_RESULTS = 10_000
private const val NAMESPACE_OC = "http://owncloud.org/ns"
private const val TAG_RESPONSE = "response"
private const val TAG_PROPSTAT = "propstat"
private const val TAG_PROP = "prop"
private const val TAG_HREF = "href"
private const val TAG_STATUS = "status"
private const val TAG_NAME = "name"
private const val TAG_CONTENT_TYPE = "getcontenttype"
private const val TAG_CONTENT_LENGTH = "getcontentlength"
private const val TAG_LAST_MODIFIED = "getlastmodified"
private const val TAG_ETAG = "getetag"
}
}
@@ -0,0 +1,11 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/** Media categories understood by QSfera's KQL `mediatype` search field. */
enum class MediaSearchType(internal val queryValue: String) {
IMAGE("image"),
VIDEO("video"),
}
@@ -0,0 +1,21 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
/**
* A media file returned by the WebDAV search endpoint.
*
* [href] is kept exactly as returned by the server for subsequent WebDAV calls.
* [path] is the URL-decoded path component intended for display and grouping.
*/
data class RemoteMediaFile(
val href: String,
val path: String,
val name: String,
val mimeType: String?,
val size: Long?,
val modifiedTimestamp: Long?,
val etag: String?,
)
@@ -0,0 +1,49 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search
import eu.qsfera.android.lib.common.QSferaClient
import eu.qsfera.android.lib.common.http.HttpConstants.HTTP_MULTI_STATUS
import eu.qsfera.android.lib.common.http.HttpConstants.HTTP_OK
import eu.qsfera.android.lib.common.http.methods.webdav.ReportMethod
import eu.qsfera.android.lib.common.operations.RemoteOperation
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import eu.qsfera.android.lib.common.utils.isOneOf
import timber.log.Timber
import java.io.IOException
import java.net.URL
/** Executes a media search against a user's or space's WebDAV endpoint. */
class SearchRemoteMediaOperation(
private val request: MediaSearchRequest = MediaSearchRequest(),
private val webDavUrl: String? = null,
private val responseParser: MediaSearchResponseParser = MediaSearchResponseParser(),
) : RemoteOperation<List<RemoteMediaFile>>() {
override fun run(client: QSferaClient): RemoteOperationResult<List<RemoteMediaFile>> {
val endpoint = webDavUrl ?: client.userFilesWebDavUri.toString()
val reportMethod = ReportMethod(
url = URL(endpoint),
reportBody = MediaSearchReportBody.build(request),
)
return try {
val status = client.executeHttpMethod(reportMethod)
if (status.isOneOf(HTTP_OK, HTTP_MULTI_STATUS)) {
val responseStream = reportMethod.getResponseBodyAsStream()
?: throw IOException("Media search response has no body")
val mediaFiles = responseStream.use(responseParser::parse)
RemoteOperationResult<List<RemoteMediaFile>>(RemoteOperationResult.ResultCode.OK).apply {
data = mediaFiles
}
} else {
RemoteOperationResult(reportMethod)
}
} catch (exception: Exception) {
Timber.e(exception, "Media search REPORT failed")
RemoteOperationResult(exception)
}
}
}
@@ -0,0 +1,17 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search.services
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import eu.qsfera.android.lib.resources.Service
import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest
import eu.qsfera.android.lib.resources.files.search.RemoteMediaFile
interface MediaSearchService : Service {
fun searchMedia(
request: MediaSearchRequest = MediaSearchRequest(),
webDavUrl: String? = null,
): RemoteOperationResult<List<RemoteMediaFile>>
}
@@ -0,0 +1,23 @@
/* qsfera Android Library is available under MIT license
* Copyright (C) 2026 QSfera contributors.
*/
package eu.qsfera.android.lib.resources.files.search.services.implementation
import eu.qsfera.android.lib.common.QSferaClient
import eu.qsfera.android.lib.common.operations.RemoteOperationResult
import eu.qsfera.android.lib.resources.files.search.MediaSearchRequest
import eu.qsfera.android.lib.resources.files.search.RemoteMediaFile
import eu.qsfera.android.lib.resources.files.search.SearchRemoteMediaOperation
import eu.qsfera.android.lib.resources.files.search.services.MediaSearchService
class OCMediaSearchService(override val client: QSferaClient) : MediaSearchService {
override fun searchMedia(
request: MediaSearchRequest,
webDavUrl: String?,
): RemoteOperationResult<List<RemoteMediaFile>> =
SearchRemoteMediaOperation(
request = request,
webDavUrl = webDavUrl,
).execute(client)
}
@@ -0,0 +1,27 @@
package eu.qsfera.android.lib.common.http.methods.webdav
import okio.Buffer
import org.junit.Assert.assertEquals
import org.junit.Test
import java.net.URL
class ReportMethodTest {
@Test
fun `creates WebDAV report request with XML body`() {
val reportBody = "<oc:search-files/>"
val method = ReportMethod(
url = URL("https://cloud.example.test/remote.php/dav/files/alice"),
reportBody = reportBody,
)
val buffer = Buffer()
method.request.body?.writeTo(buffer)
assertEquals("REPORT", method.request.method)
assertEquals("application/xml; charset=utf-8", method.request.header("Accept"))
assertEquals("application/xml; charset=utf-8", method.request.body?.contentType().toString())
assertEquals(reportBody, buffer.readUtf8())
}
}
@@ -0,0 +1,52 @@
package eu.qsfera.android.lib.resources.files.search
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaSearchReportBodyTest {
@Test
fun `build creates deterministic image and video report`() {
val request = MediaSearchRequest(
mediaTypes = linkedSetOf(MediaSearchType.VIDEO, MediaSearchType.IMAGE),
limit = 75,
offset = 150,
)
val body = MediaSearchReportBody.build(request)
assertTrue(body.startsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>"))
assertTrue(body.contains("<oc:pattern>mediatype:image OR mediatype:video</oc:pattern>"))
assertTrue(body.contains("<oc:limit>75</oc:limit>"))
assertTrue(body.contains("<oc:offset>150</oc:offset>"))
assertTrue(body.contains("<oc:name/>"))
assertTrue(body.contains("<d:getcontentlength/>"))
}
@Test
fun `build creates a single type expression without boolean operator`() {
val body = MediaSearchReportBody.build(
MediaSearchRequest(mediaTypes = setOf(MediaSearchType.IMAGE)),
)
assertTrue(body.contains("<oc:pattern>mediatype:image</oc:pattern>"))
assertFalse(body.contains(" OR "))
}
@Test
fun `request rejects invalid paging and empty media types`() {
assertEquals(200, MediaSearchRequest.DEFAULT_LIMIT)
assertThrows(IllegalArgumentException::class.java) {
MediaSearchRequest(mediaTypes = emptySet())
}
assertThrows(IllegalArgumentException::class.java) {
MediaSearchRequest(limit = 0)
}
assertThrows(IllegalArgumentException::class.java) {
MediaSearchRequest(offset = -1)
}
}
}
@@ -0,0 +1,130 @@
package eu.qsfera.android.lib.resources.files.search
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.IOException
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class MediaSearchResponseParserTest {
private val parser = MediaSearchResponseParser()
@Test
fun `parse reads successful WebDAV properties and decodes display path`() {
val xml = """
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/spaces/personal/DCIM/%D0%A4%D0%BE%D1%82%D0%BE+2025.jpg</d:href>
<d:propstat>
<d:prop>
<oc:name>Фото+2025.jpg</oc:name>
<d:getcontenttype>image/jpeg</d:getcontenttype>
<d:getcontentlength>12582912</d:getcontentlength>
<d:getlastmodified>Wed, 31 Dec 2025 23:59:59 GMT</d:getlastmodified>
<d:getetag>"abc:123"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
""".trimIndent()
val result = parser.parse(xml.byteInputStream())
assertEquals(1, result.size)
assertEquals(
RemoteMediaFile(
href = "/remote.php/dav/spaces/personal/DCIM/%D0%A4%D0%BE%D1%82%D0%BE+2025.jpg",
path = "/remote.php/dav/spaces/personal/DCIM/Фото+2025.jpg",
name = "Фото+2025.jpg",
mimeType = "image/jpeg",
size = 12_582_912,
modifiedTimestamp = ZonedDateTime
.parse("Wed, 31 Dec 2025 23:59:59 GMT", DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant()
.toEpochMilli(),
etag = "\"abc:123\"",
),
result.single(),
)
}
@Test
fun `parse ignores failed propstats and falls back to href file name`() {
val xml = """
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/spaces/personal/Videos/clip%2001.mp4</d:href>
<d:propstat>
<d:prop>
<d:getcontenttype>application/octet-stream</d:getcontenttype>
<d:getcontentlength>999</d:getcontentlength>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:getcontenttype>video/mp4</d:getcontenttype>
<d:getcontentlength>4096</d:getcontentlength>
<d:getlastmodified>not-a-date</d:getlastmodified>
<d:getetag/>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/spaces/personal/missing.jpg</d:href>
<d:propstat>
<d:prop><d:getcontenttype>image/jpeg</d:getcontenttype></d:prop>
<d:status>HTTP/1.1 403 Forbidden</d:status>
</d:propstat>
</d:response>
</d:multistatus>
""".trimIndent()
val result = parser.parse(xml.byteInputStream())
assertEquals(1, result.size)
assertEquals("clip 01.mp4", result.single().name)
assertEquals("video/mp4", result.single().mimeType)
assertEquals(4_096L, result.single().size)
assertNull(result.single().modifiedTimestamp)
assertNull(result.single().etag)
}
@Test
fun `parse rejects DTD declarations`() {
val xml = """
<?xml version="1.0"?>
<!DOCTYPE multistatus [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>&xxe;</d:href></d:response>
</d:multistatus>
""".trimIndent()
assertThrows(Exception::class.java) {
parser.parse(xml.byteInputStream())
}
}
@Test
fun `parse enforces response byte limit`() {
val limitedParser = MediaSearchResponseParser(maximumResponseBytes = 32)
val exception = assertThrows(Exception::class.java) {
limitedParser.parse("<d:multistatus xmlns:d=\"DAV:\"></d:multistatus>".byteInputStream())
}
assertTrue(exception is IOException || exception.cause is IOException)
}
}
@@ -0,0 +1,92 @@
package eu.qsfera.android.lib.resources.files.search
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import eu.qsfera.android.lib.common.QSferaClient
import eu.qsfera.android.lib.common.http.methods.HttpBaseMethod
import eu.qsfera.android.lib.common.http.methods.webdav.ReportMethod
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.Buffer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE)
class SearchRemoteMediaOperationTest {
@Test
fun `operation sends paged report and returns parsed media`() {
val responseXml = """
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/spaces/personal/Camera/photo.jpg</d:href>
<d:propstat>
<d:prop>
<oc:name>photo.jpg</oc:name>
<d:getcontenttype>image/jpeg</d:getcontenttype>
<d:getcontentlength>42</d:getcontentlength>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>
""".trimIndent()
val client = StubQSferaClient(
context = ApplicationProvider.getApplicationContext(),
responseXml = responseXml,
)
val result = SearchRemoteMediaOperation(
request = MediaSearchRequest(
mediaTypes = setOf(MediaSearchType.IMAGE),
limit = 50,
offset = 100,
),
).execute(client)
val requestBody = Buffer().also { buffer ->
client.capturedMethod.request.body?.writeTo(buffer)
}.readUtf8()
assertTrue(result.isSuccess)
assertEquals(1, result.data?.size)
assertEquals("photo.jpg", result.data?.single()?.name)
assertEquals("REPORT", client.capturedMethod.request.method)
assertEquals("https://cloud.example.test/remote.php/dav/files/", client.capturedMethod.request.url.toString())
assertTrue(requestBody.contains("<oc:limit>50</oc:limit>"))
assertTrue(requestBody.contains("<oc:offset>100</oc:offset>"))
}
private class StubQSferaClient(
context: Context,
private val responseXml: String,
) : QSferaClient(
Uri.parse("https://cloud.example.test"),
null,
false,
null,
context,
) {
lateinit var capturedMethod: ReportMethod
override fun executeHttpMethod(method: HttpBaseMethod): Int {
capturedMethod = method as ReportMethod
capturedMethod.response = Response.Builder()
.request(capturedMethod.request)
.protocol(Protocol.HTTP_1_1)
.code(207)
.message("Multi-Status")
.body(responseXml.toResponseBody("application/xml".toMediaType()))
.build()
return 207
}
}
}