Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 44b52f65 authored by Ricki Hirner's avatar Ricki Hirner Committed by GitHub
Browse files

[WebDAV] Implement command pattern, streamline lifecycle, remove WebdavScope (#1617)

* Remove WebdavScope as it is no longer needed

* [WIP] DavDocumentsProviderImpl

* [WIP] Move DavDocumentsProvider to BaseDavDocumentsProvider and implementation to DavDocumentsProvider

* Adapt tests and DI

* [WIP] Implement Command pattern

* Finish Command pattern, add deprecation notices

* Unify DavDocumentsProvider with wrapper again

* Get rid of DavDocumentsActor

* Add notes about lifecycle, remove shutdown
parent e13c1405
Loading
Loading
Loading
Loading
+56 −76
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package at.bitfire.davdroid.webdav
package at.bitfire.davdroid.webdav.operation

import android.content.Context
import android.security.NetworkSecurityPolicy
@@ -14,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@@ -29,43 +30,40 @@ import java.util.logging.Logger
import javax.inject.Inject

@HiltAndroidTest
class DavDocumentsProviderTest {
class QueryChildDocumentsOperationTest {

    companion object {
        private const val PATH_WEBDAV_ROOT = "/webdav"
    }
    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @get:Rule
    val mockkRule = MockKRule(this)

    @Inject @ApplicationContext
    lateinit var context: Context

    @Inject
    lateinit var credentialsStore: CredentialsStore
    lateinit var db: AppDatabase

    @Inject
    lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
    lateinit var operation: QueryChildDocumentsOperation

    @Inject
    lateinit var httpClientBuilder: HttpClient.Builder

    @Inject
    lateinit var db: AppDatabase

    @Inject
    lateinit var testDispatcher: TestDispatcher

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @get:Rule
    val mockkRule = MockKRule(this)

    private lateinit var server: MockWebServer
    private lateinit var client: HttpClient

    private lateinit var mount: WebDavMount
    private lateinit var rootDocument: WebDavDocument

    @Before
    fun setUp() {
        hiltRule.inject()

        // create server and client
        server = MockWebServer().apply {
            dispatcher = testDispatcher
            start()
@@ -75,50 +73,49 @@ class DavDocumentsProviderTest {

        // mock server delivers HTTP without encryption
        assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)

        // create WebDAV mount and root document in DB
        runBlocking {
            val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
            mount = db.webDavMountDao().getById(mountId)
            rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
        }
    }

    @After
    fun tearDown() {
        client.close()
        server.shutdown()

        runBlocking {
            db.webDavMountDao().deleteAsync(mount)
        }
    }


    @Test
    fun testDoQueryChildren_insert() = runTest {
        // Create parent and root in database
        val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
        val webDavMount = db.webDavMountDao().getById(id)
        val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)

        // Query
        val actor = davDocumentsActorFactory.create(
            cookieStore = mutableMapOf(),
            credentialsStore = credentialsStore
        )
        actor.queryChildren(parent)
        operation.queryChildren(rootDocument)

        // Assert new children were inserted into db
        assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
        assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
        assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
        assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
        assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
        assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
        assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
        assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
    }

    @Test
    fun testDoQueryChildren_update() = runTest {
        // Create parent and root in database
        val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
        val webDavMount = db.webDavMountDao().getById(mountId)
        val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
        assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
        assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)

        // Create a folder
        val folderId = db.webDavDocumentDao().insert(
            WebDavDocument(
                0,
                mountId,
                parent.id,
                mount.id,
                rootDocument.id,
                "My_Books",
                true,
                "My Books",
@@ -128,38 +125,25 @@ class DavDocumentsProviderTest {
        assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)

        // Query - should update the parent displayname and folder name
        val actor = davDocumentsActorFactory.create(
            cookieStore = mutableMapOf(),
            credentialsStore = credentialsStore
        )
        actor.queryChildren(parent)
        operation.queryChildren(rootDocument)

        // Assert parent and children were updated in database
        assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
        assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
        assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
        assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
        assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
        assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)

    }

    @Test
    fun testDoQueryChildren_delete() = runTest {
        // Create parent and root in database
        val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
        val webDavMount = db.webDavMountDao().getById(mountId)
        val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)

        // Create a folder
        val folderId = db.webDavDocumentDao().insert(
            WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
            WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
        )
        assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)

        // Query - discovers serverside deletion
        val actor = davDocumentsActorFactory.create(
            cookieStore = mutableMapOf(),
            credentialsStore = credentialsStore
        )
        actor.queryChildren(parent)
        operation.queryChildren(rootDocument)

        // Assert folder got deleted
        assertEquals(null, db.webDavDocumentDao().get(folderId))
@@ -167,26 +151,17 @@ class DavDocumentsProviderTest {

    @Test
    fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
        // Create root in database
        val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
        val webDavMount = db.webDavMountDao().getById(mountId)
        val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)

        // Create two directories
        val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
        val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
        val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
        val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent2", true))
        val parent1 = db.webDavDocumentDao().get(parent1Id)!!
        val parent2 = db.webDavDocumentDao().get(parent2Id)!!
        assertEquals("parent1", parent1.name)
        assertEquals("parent2", parent2.name)

        // Query - find children of two nodes simultaneously
        val actor = davDocumentsActorFactory.create(
            cookieStore = mutableMapOf(),
            credentialsStore = credentialsStore
        )
        actor.queryChildren(parent1)
        actor.queryChildren(parent2)
        operation.queryChildren(parent1)
        operation.queryChildren(parent2)

        // Assert the two folders names have changed
        assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
@@ -264,4 +239,9 @@ class DavDocumentsProviderTest {

    }


    companion object {
        private const val PATH_WEBDAV_ROOT = "/webdav"
    }

}
 No newline at end of file
+55 −769

File changed.

Preview size limit exceeded, changes collapsed.

+49 −0
Original line number Diff line number Diff line
/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package at.bitfire.davdroid.webdav

import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.MemoryCookieStore
import okhttp3.CookieJar
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Inject
import javax.inject.Provider

class DavHttpClientBuilder @Inject constructor(
    private val credentialsStore: CredentialsStore,
    private val httpClientBuilder: Provider<HttpClient.Builder>,
) {

    /**
     * Creates an HTTP client that can be used to access resources in the given mount.
     *
     * @param mountId    ID of the mount to access
     * @param logBody    whether to log the body of HTTP requests (disable for potentially large files)
     */
    fun build(mountId: Long, logBody: Boolean = true): HttpClient {
        val cookieStore = cookieStores.getOrPut(mountId) {
            MemoryCookieStore()
        }
        val builder = httpClientBuilder.get()
            .loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
            .setCookieStore(cookieStore)

        credentialsStore.getCredentials(mountId)?.let { credentials ->
            builder.authenticate(host = null, getCredentials = { credentials })
        }

        return builder.build()
    }


    companion object {

        /** in-memory cookie stores (one per mount ID) that are available until the content
         * provider (= process) is terminated */
        private val cookieStores = mutableMapOf<Long, CookieJar>()

    }

}
 No newline at end of file
+91 −0
Original line number Diff line number Diff line
/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package at.bitfire.davdroid.webdav

import android.app.AuthenticationRequiredException
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract.buildChildDocumentsUri
import android.provider.DocumentsContract.buildRootsUri
import android.webkit.MimeTypeMap
import androidx.core.app.TaskStackBuilder
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import java.io.FileNotFoundException
import java.net.HttpURLConnection

object DocumentProviderUtils  {

    const val MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS = 5

    internal fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
        val safeName = displayName.filterNot { it.isISOControl() }

        if (appendNumber != 0) {
            val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
            if (extension != null) {
                val baseName = safeName.removeSuffix(".$extension")
                return "${baseName}_$appendNumber.$extension"
            } else
                return "${safeName}_$appendNumber"
        } else
            return safeName
    }

    internal fun notifyFolderChanged(context: Context, parentDocumentId: Long?) {
        if (parentDocumentId != null)
            context.contentResolver.notifyChange(
                buildChildDocumentsUri(
                    context.getString(R.string.webdav_authority),
                    parentDocumentId.toString()
                ),
                null
            )
    }

    internal fun notifyFolderChanged(context: Context, parentDocumentId: String) {
        context.contentResolver.notifyChange(
            buildChildDocumentsUri(
                context.getString(R.string.webdav_authority),
                parentDocumentId
            ),
            null
        )
    }

    internal fun notifyMountsChanged(context: Context) {
        context.contentResolver.notifyChange(
            buildRootsUri(context.getString(R.string.webdav_authority)),
            null)
    }

}

internal fun HttpException.throwForDocumentProvider(context: Context, ignorePreconditionFailed: Boolean = false) {
    when (code) {
        HttpURLConnection.HTTP_UNAUTHORIZED -> {
            if (Build.VERSION.SDK_INT >= 26) {
                val intent = Intent(context, WebdavMountsActivity::class.java)
                throw AuthenticationRequiredException(
                    this,
                    TaskStackBuilder.create(context)
                        .addNextIntentWithParentStack(intent)
                        .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
                )
            }
        }
        HttpURLConnection.HTTP_NOT_FOUND ->
            throw FileNotFoundException()
        HttpURLConnection.HTTP_PRECON_FAILED ->
            if (ignorePreconditionFailed)
                return
    }

    // re-throw
    throw this
}
 No newline at end of file
+1 −2
Original line number Diff line number Diff line
@@ -4,7 +4,6 @@

package at.bitfire.davdroid.webdav

import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.ProxyFileDescriptorCallback
@@ -49,7 +48,7 @@ import kotlin.concurrent.schedule
 *
 * @param httpClient    HTTP client – [RandomAccessCallbackWrapper] is responsible to close it
 */
@RequiresApi(Build.VERSION_CODES.O)
@RequiresApi(26)
class RandomAccessCallbackWrapper @AssistedInject constructor(
    @Assisted private val httpClient: HttpClient,
    @Assisted private val url: HttpUrl,
Loading