Implement cloud media UI and reliable automatic uploads
This commit is contained in:
+33
@@ -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()
|
||||
}
|
||||
}
|
||||
+27
@@ -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()
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
}
|
||||
+210
@@ -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"
|
||||
}
|
||||
}
|
||||
+11
@@ -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"),
|
||||
}
|
||||
+21
@@ -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?,
|
||||
)
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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>>
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
+27
@@ -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())
|
||||
}
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -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)
|
||||
}
|
||||
}
|
||||
+92
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user