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

Commit 54d1b5ec authored by Ricki Hirner's avatar Ricki Hirner
Browse files

Refactor WebDAV cache

parent 99ecfb1c
Loading
Loading
Loading
Loading
+74 −0
Original line number Diff line number Diff line
package at.bitfire.davdroid.webdav

import at.bitfire.davdroid.webdav.cache.MemoryCache
import org.apache.commons.io.FileUtils
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test

class MemoryCacheTest {

    companion object {
        val SAMPLE_KEY1 = "key1"
        val SAMPLE_CONTENT1 = "Sample Content 1".toByteArray()
        val SAMPLE_CONTENT2 = "Another Content".toByteArray()
    }

    lateinit var storage: MemoryCache<String>


    @Before
    fun createStorage() {
        storage = MemoryCache(1*FileUtils.ONE_MB.toInt())
    }


    @Test
    fun testGet() {
        // no entry yet, get should return null
        assertNull(storage.get(SAMPLE_KEY1))

        // add entry
        storage.getOrPut(SAMPLE_KEY1) { SAMPLE_CONTENT1 }
        assertArrayEquals(SAMPLE_CONTENT1, storage.get(SAMPLE_KEY1))
    }

    @Test
    fun testGetOrPut() {
        assertNull(storage.get(SAMPLE_KEY1))
        // no entry yet; SAMPLE_CONTENT1 should be generated
        var calledGenerateSampleContent1 = false
        assertArrayEquals(SAMPLE_CONTENT1, storage.getOrPut(SAMPLE_KEY1) {
            calledGenerateSampleContent1 = true
            SAMPLE_CONTENT1
        })
        assertTrue(calledGenerateSampleContent1)
        assertNotNull(storage.get(SAMPLE_KEY1))

        // now there's a SAMPLE_CONTENT1 entry, it should be returned while SAMPLE_CONTENT2 is not generated
        var calledGenerateSampleContent2 = false
        assertArrayEquals(SAMPLE_CONTENT1, storage.getOrPut(SAMPLE_KEY1) {
            calledGenerateSampleContent2 = true
            SAMPLE_CONTENT2
        })
        assertFalse(calledGenerateSampleContent2)
    }

    @Test
    fun testMaxCacheSize() {
        // Cache size is 1 MB. Add 11*100 kB -> the first entry should be gone then
        for (i in 0 until 11) {
            val key = "key$i"
            storage.getOrPut(key) {
                ByteArray(100 * FileUtils.ONE_KB.toInt()) { i.toByte() }
            }
            assertNotNull(storage.get(key))
        }

        // now key0 should have been evicted and only key1..key11 should be there
        assertNull(storage.get("key0"))
        for (i in 1 until 11)
            assertNotNull(storage.get("key$i"))
    }

}
 No newline at end of file
+0 −144
Original line number Diff line number Diff line
package at.bitfire.davdroid.webdav

import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.apache.commons.io.FileUtils
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class RandomAccessBufferTest {

    companion object {
        const val FILE_LENGTH = 10*FileUtils.ONE_MB
    }


    @Test
    fun testRead_FirstPage_PartialStart() {
        var called = false
        val buffer = newBuffer(object: RandomAccessBuffer.Reader {
            override fun readDirect(offset: Long, size: Int, dst: ByteArray): Int {
                assertEquals(0, offset)
                assertEquals(100, size)
                called = true
                return size
            }
        })
        val result = ByteArray(100)
        buffer.read(0, 100, result)
        assertTrue(called)
    }

    @Test
    fun testRead_FirstAndSecondPage_Overlapping() {
        var called = 0
        val buffer = newBuffer(object: RandomAccessBuffer.Reader {
            override fun readDirect(offset: Long, size: Int, dst: ByteArray): Int {
                // first page: 10 ... RandomAccessBuffer.PAGE_SIZE = (RandomAccessBuffer.PAGE_SIZE - 10) bytes
                // second page: 0 ... 110 = 110 bytes
                // in total = RandomAccessBuffer.PAGE_SIZE + 100 bytes
                when (called) {
                    0 -> {
                        assertEquals(10L, offset)
                        assertEquals(RandomAccessBuffer.PAGE_SIZE - 10, size)
                    }
                    1 -> {
                        assertEquals(RandomAccessBuffer.PAGE_SIZE.toLong(), offset)
                        assertEquals(110, size)
                    }
                }
                called++
                return size
            }
        })
        val result = ByteArray(RandomAccessBuffer.PAGE_SIZE + 100)
        buffer.read(10, RandomAccessBuffer.PAGE_SIZE + 100, result)
        assertEquals(2, called)
    }

    @Test
    fun testRead_SecondPage_Full() {
        var called = false
        val buffer = newBuffer(object: RandomAccessBuffer.Reader {
            override fun readDirect(offset: Long, size: Int, dst: ByteArray): Int {
                assertEquals(0L, offset)
                assertEquals(RandomAccessBuffer.PAGE_SIZE, size)
                called = true
                return size
            }
        })
        val result = ByteArray(RandomAccessBuffer.PAGE_SIZE)
        buffer.read(0, RandomAccessBuffer.PAGE_SIZE, result)
        assertTrue(called)
    }


    @Test
    fun testGetPageDirect_FullPage() {
        var called = false
        val buffer = newBuffer(object: RandomAccessBuffer.Reader {
            override fun readDirect(offset: Long, size: Int, dst: ByteArray): Int {
                assertEquals(0, offset)
                assertEquals(RandomAccessBuffer.PAGE_SIZE, size)
                called = true
                return size
            }
        })

        buffer.getPageDirect(0, 0, RandomAccessBuffer.PAGE_SIZE)
        assertTrue(called)
    }

    @Test
    fun testGetPageDirect_Partial() {
        var called = false
        val buffer = newBuffer(object: RandomAccessBuffer.Reader {
            override fun readDirect(offset: Long, size: Int, dst: ByteArray): Int {
                assertEquals(100, offset)
                assertEquals(200, size)
                called = true
                return size
            }
        })

        buffer.getPageDirect(0, 100, 200)
        assertTrue(called)
    }

    @Test(expected = IndexOutOfBoundsException::class)
    fun testGetPageDirect_Partial_StartingAtPageEnd() {
        val buffer = newBuffer(object: RandomAccessBuffer.Reader {
            override fun readDirect(offset: Long, size: Int, dst: ByteArray) = throw IllegalArgumentException()
        })

        val result = buffer.getPageDirect(0, FILE_LENGTH, 1)
    }

    @Test
    fun testGetPageDirect_Partial_LargerThanPage() {
        var called = false
        val buffer = newBuffer(object: RandomAccessBuffer.Reader {
            override fun readDirect(offset: Long, size: Int, dst: ByteArray): Int {
                called = true
                return size
            }
        })

        val result = buffer.getPageDirect(0, 100, FILE_LENGTH.toInt())
        assertTrue(called)
        assertEquals(result.size.toLong(), FILE_LENGTH - 100)
    }



    private fun newBuffer(reader: RandomAccessBuffer.Reader) =
        RandomAccessBuffer(
            InstrumentationRegistry.getInstrumentation().targetContext,
            "http://example.com/webdav".toHttpUrl(),
            FILE_LENGTH,
            null,
            reader
        )

}
 No newline at end of file
+155 −0
Original line number Diff line number Diff line
package at.bitfire.davdroid.webdav

import at.bitfire.davdroid.webdav.cache.Cache
import at.bitfire.davdroid.webdav.cache.SegmentedCache
import org.apache.commons.io.FileUtils
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test

class SegmentedCacheTest {

    companion object {
        const val PAGE_SIZE = 100*FileUtils.ONE_KB.toInt()

        const val SAMPLE_KEY1 = "key1"
        const val PAGE2_SIZE = 123
    }

    val noCache = object: Cache<SegmentedCache.SegmentKey<String>> {
        override fun get(key: SegmentedCache.SegmentKey<String>) = null
        override fun getOrPut(key: SegmentedCache.SegmentKey<String>, generate: () -> ByteArray) = generate()
    }

    @Test
    fun testRead_AcrossPages() {
        val cache = SegmentedCache<String>(PAGE_SIZE, object: SegmentedCache.PageLoader<String> {
            override fun load(key: SegmentedCache.SegmentKey<String>, segmentSize: Int) =
                when (key.segment) {
                    0 -> ByteArray(PAGE_SIZE) { 1 }
                    1 -> ByteArray(PAGE2_SIZE) { 2 }
                    else -> throw IndexOutOfBoundsException()
                }
        }, noCache)
        val dst = ByteArray(20)
        assertEquals(20, cache.read(SAMPLE_KEY1, (PAGE_SIZE - 10).toLong(), dst.size, dst))
        assertArrayEquals(ByteArray(20) { i ->
            if (i < 10)
                1
            else
                2
        }, dst)
    }

    @Test
    fun testRead_AcrossPagesAndEOF() {
        val cache = SegmentedCache<String>(PAGE_SIZE, object: SegmentedCache.PageLoader<String> {
            override fun load(key: SegmentedCache.SegmentKey<String>, segmentSize: Int) =
                when (key.segment) {
                    0 -> ByteArray(PAGE_SIZE) { 1 }
                    1 -> ByteArray(PAGE2_SIZE) { 2 }
                    else -> throw IndexOutOfBoundsException()
                }
        }, noCache)
        val dst = ByteArray(10 + PAGE2_SIZE + 10)
        assertEquals(10 + PAGE2_SIZE, cache.read(SAMPLE_KEY1, (PAGE_SIZE - 10).toLong(), dst.size, dst))
        assertArrayEquals(ByteArray(10 + PAGE2_SIZE) { i ->
            if (i < 10)
                1
            else
                2
        }, dst.copyOf(10 + PAGE2_SIZE))
    }

    @Test
    fun testRead_ExactlyPageSize_BufferAlsoPageSize() {
        var loadCalled = 0
        val cache = SegmentedCache<String>(PAGE_SIZE, object: SegmentedCache.PageLoader<String> {
            override fun load(key: SegmentedCache.SegmentKey<String>, segmentSize: Int): ByteArray {
                loadCalled++
                if (key.segment == 0)
                    return ByteArray(PAGE_SIZE)
                else
                    throw IndexOutOfBoundsException()
            }
        }, noCache)
        val dst = ByteArray(PAGE_SIZE)
        assertEquals(PAGE_SIZE, cache.read(SAMPLE_KEY1, 0, dst.size, dst))
        assertEquals(1, loadCalled)
    }

    @Test
    fun testRead_ExactlyPageSize_ButLargerBuffer() {
        var loadCalled = 0
        val cache = SegmentedCache<String>(PAGE_SIZE, object: SegmentedCache.PageLoader<String> {
            override fun load(key: SegmentedCache.SegmentKey<String>, segmentSize: Int): ByteArray {
                loadCalled++
                if (key.segment == 0)
                    return ByteArray(PAGE_SIZE)
                else
                    throw IndexOutOfBoundsException()
            }
        }, noCache)
        val dst = ByteArray(PAGE_SIZE + 10)     // 10 bytes more so that the second segment is read
        assertEquals(PAGE_SIZE, cache.read(SAMPLE_KEY1, 0, dst.size, dst))
        assertEquals(2, loadCalled)
    }

    @Test
    fun testRead_Offset() {
        val cache = SegmentedCache<String>(PAGE_SIZE, object: SegmentedCache.PageLoader<String> {
            override fun load(key: SegmentedCache.SegmentKey<String>, segmentSize: Int): ByteArray {
                if (key.segment == 0)
                    return ByteArray(PAGE_SIZE) { 1 }
                else
                    throw IndexOutOfBoundsException()
            }
        }, noCache)
        val dst = ByteArray(PAGE_SIZE)
        assertEquals(PAGE_SIZE - 100, cache.read(SAMPLE_KEY1, 100, dst.size, dst))
        assertArrayEquals(ByteArray(PAGE_SIZE) { i ->
            if (i < PAGE_SIZE - 100)
                1
            else
                0
        }, dst)
    }

    @Test
    fun testRead_OnlyOnePageSmallerThanPageSize_From0() {
        val contentSize = 123
        val cache = SegmentedCache<String>(PAGE_SIZE, object: SegmentedCache.PageLoader<String> {
            override fun load(key: SegmentedCache.SegmentKey<String>, segmentSize: Int) =
                when (key.segment) {
                    0 -> ByteArray(contentSize) { it.toByte() }
                    else -> throw IndexOutOfBoundsException()
                }
        }, noCache)

        // read less than content size
        var dst = ByteArray(10)     // 10 < contentSize
        assertEquals(10, cache.read(SAMPLE_KEY1, 0, dst.size, dst))
        assertArrayEquals(ByteArray(10) { it.toByte() }, dst)

        // read more than content size
        dst = ByteArray(1000)       // 1000 > contentSize
        assertEquals(contentSize, cache.read(SAMPLE_KEY1, 0, dst.size, dst))
        assertArrayEquals(ByteArray(1000) { i ->
            if (i < contentSize)
                i.toByte()
            else
                0
        }, dst)
    }

    @Test
    fun testRead_ZeroByteFile() {
        val cache = SegmentedCache<String>(PAGE_SIZE, object: SegmentedCache.PageLoader<String> {
            override fun load(key: SegmentedCache.SegmentKey<String>, segmentSize: Int) =
                throw IndexOutOfBoundsException()
        }, noCache)
        val dst = ByteArray(10)
        assertEquals(0, cache.read(SAMPLE_KEY1, 10, dst.size, dst))
    }

}
 No newline at end of file
+39 −0
Original line number Diff line number Diff line
package at.bitfire.davdroid

import android.content.Context
import java.lang.ref.WeakReference

object Singleton {

    private val singletons = mutableMapOf<Any, WeakReference<Any>>()


    inline fun<reified T> getInstance(noinline createInstance: () -> T): T =
        getInstance(T::class.java, createInstance)

    inline fun<reified T> getInstance(context: Context, noinline createInstance: (appContext: Context) -> T): T =
        getInstance(T::class.java) {
            createInstance(context.applicationContext)
        }


    @Synchronized
    fun<T> getInstance(clazz: Class<T>, createInstance: () -> T): T {
        var cached = singletons[clazz]
        if (cached != null && cached.get() == null) {
            singletons.remove(cached)
            cached = null
        }

        // found existing singleton
        if (cached != null)
            @Suppress("UNCHECKED_CAST")
            return cached.get() as T

        // create new singleton
        val newInstance = createInstance()
        singletons[clazz] = WeakReference(newInstance)
        return newInstance
    }

}
 No newline at end of file
+10 −10
Original line number Diff line number Diff line
@@ -11,10 +11,7 @@ import android.graphics.BitmapFactory
import android.graphics.Point
import android.media.ThumbnailUtils
import android.net.ConnectivityManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.os.*
import android.os.storage.StorageManager
import android.provider.DocumentsContract.*
import android.provider.DocumentsProvider
@@ -33,6 +30,7 @@ import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.*
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import at.bitfire.davdroid.webdav.cache.HeadResponseCache
import kotlinx.coroutines.*
import okhttp3.CookieJar
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -63,6 +61,7 @@ class DavDocumentsProvider: DocumentsProvider() {
        )

        const val MAX_NAME_ATTEMPTS = 5
        const val THUMBNAIL_TIMEOUT = 15L
    }

    lateinit var authority: String
@@ -73,7 +72,8 @@ class DavDocumentsProvider: DocumentsProvider() {
    private val webdavMountsLive by lazy { mountDao.getAllLive() }

    private val credentialsStore by lazy { CredentialsStore(context!!) }
    val cookieStore = mutableMapOf<Long, CookieJar>()
    val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
    val headResponseCache by lazy { HeadResponseCache() }
    val thumbnailCache by lazy { ThumbnailCache(context!!) }

    private val connectivityManager by lazy { ContextCompat.getSystemService(context!!, ConnectivityManager::class.java)!! }
@@ -98,7 +98,7 @@ class DavDocumentsProvider: DocumentsProvider() {

    override fun shutdown() {
        webdavMountsLive.removeObserver(webDavMountsObserver)
        executor.shutdownNow()
        executor.shutdown()
    }


@@ -502,7 +502,7 @@ class DavDocumentsProvider: DocumentsProvider() {
            else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
        }

        val fileInfo = HeadResponseCache.get(doc) {
        val fileInfo = headResponseCache.get(doc) {
            val deferredFileInfo = executor.submit(HeadInfoDownloader(client, url))
            signal?.setOnCancelListener {
                deferredFileInfo.cancel(true)
@@ -518,8 +518,8 @@ class DavDocumentsProvider: DocumentsProvider() {
            (fileInfo.eTag != null || fileInfo.lastModified != null) &&     // we need a method to determine whether the document has changed during access
            fileInfo.supportsPartial != false   // WebDAV server must support random access
        ) {
            val accessor = RandomAccessCallback(context!!, client, url, doc.mimeType, fileInfo, signal)
            storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
            val accessor = RandomAccessCallback.Wrapper(context!!, client, url, doc.mimeType, fileInfo, signal)
            storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.callback!!.workerHandler)
        } else {
            val fd = StreamingFileDescriptor(context!!, client, url, doc.mimeType, signal) { transferred ->
                // called when transfer is finished
@@ -584,7 +584,7 @@ class DavDocumentsProvider: DocumentsProvider() {

            val finalResult =
                try {
                    result.get(15, TimeUnit.SECONDS)
                    result.get(THUMBNAIL_TIMEOUT, TimeUnit.SECONDS)
                } catch (e: TimeoutException) {
                    Logger.log.warning("Couldn't generate thumbnail in time, cancelling")
                    result.cancel(true)
Loading