mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
[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
This commit is contained in:
@@ -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,29 +30,7 @@ import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavDocumentsProviderTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var credentialsStore: CredentialsStore
|
||||
|
||||
@Inject
|
||||
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
class QueryChildDocumentsOperationTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -59,13 +38,32 @@ class DavDocumentsProviderTest {
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var operation: QueryChildDocumentsOperation
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
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)
|
||||
@@ -214,7 +189,7 @@ class DavDocumentsProviderTest {
|
||||
PATH_WEBDAV_ROOT to arrayOf(
|
||||
Resource("",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
),
|
||||
Resource("Secret_Document.pages",
|
||||
"<displayname>Secret_Document.pages</displayname>",
|
||||
@@ -224,7 +199,7 @@ class DavDocumentsProviderTest {
|
||||
),
|
||||
Resource("Library",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Library</displayname>"
|
||||
"<displayname>Library</displayname>"
|
||||
)
|
||||
),
|
||||
|
||||
@@ -243,15 +218,15 @@ class DavDocumentsProviderTest {
|
||||
val responses = propsMap[requestPath]?.joinToString { resource ->
|
||||
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
|
||||
resource.props +
|
||||
"</prop></propstat></response>"
|
||||
"</prop></propstat></response>"
|
||||
}
|
||||
|
||||
val multistatus =
|
||||
"<multistatus xmlns='DAV:' " +
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
|
||||
logger.info("Response: $multistatus")
|
||||
return MockResponse()
|
||||
@@ -264,4 +239,9 @@ class DavDocumentsProviderTest {
|
||||
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,817 +4,103 @@
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.app.AuthenticationRequiredException
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Point
|
||||
import android.media.ThumbnailUtils
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsContract.buildChildDocumentsUri
|
||||
import android.provider.DocumentsContract.buildRootsUri
|
||||
import android.provider.DocumentsProvider
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentLength
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetLastModified
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavDocumentDao
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.MemoryCookieStore
|
||||
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
|
||||
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import at.bitfire.davdroid.webdav.operation.CopyDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.CreateDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.DeleteDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.IsChildDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.MoveDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.OpenDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.OpenDocumentThumbnailOperation
|
||||
import at.bitfire.davdroid.webdav.operation.QueryChildDocumentsOperation
|
||||
import at.bitfire.davdroid.webdav.operation.QueryDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.QueryRootsOperation
|
||||
import at.bitfire.davdroid.webdav.operation.RenameDocumentOperation
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.EntryPoints
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Provides functionality on WebDav documents.
|
||||
*
|
||||
* Actual implementation should go into [DavDocumentsActor].
|
||||
* Hilt constructor injection can't be used for content providers because SingletonComponent
|
||||
* may not ready yet when the content provider is created. So we use an explicit EntryPoint.
|
||||
*
|
||||
* Note: A DocumentsProvider is a ContentProvider and thus has no well-defined lifecycle. It
|
||||
* is created by Android when it's first accessed and then stays in memory until the process
|
||||
* is killed.
|
||||
*/
|
||||
class DavDocumentsProvider(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
): DocumentsProvider() {
|
||||
class DavDocumentsProvider: DocumentsProvider() {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DavDocumentsProviderEntryPoint {
|
||||
fun appDatabase(): AppDatabase
|
||||
fun davDocumentsActorFactory(): DavDocumentsActor.Factory
|
||||
fun documentSortByMapper(): DocumentSortByMapper
|
||||
fun logger(): Logger
|
||||
fun randomAccessCallbackWrapperFactory(): RandomAccessCallbackWrapper.Factory
|
||||
fun streamingFileDescriptorFactory(): StreamingFileDescriptor.Factory
|
||||
fun webdavComponentBuilder(): WebdavComponentBuilder
|
||||
fun copyDocumentOperation(): CopyDocumentOperation
|
||||
fun createDocumentOperation(): CreateDocumentOperation
|
||||
fun deleteDocumentOperation(): DeleteDocumentOperation
|
||||
fun isChildDocumentOperation(): IsChildDocumentOperation
|
||||
fun moveDocumentOperation(): MoveDocumentOperation
|
||||
fun openDocumentOperation(): OpenDocumentOperation
|
||||
fun openDocumentThumbnailOperation(): OpenDocumentThumbnailOperation
|
||||
fun queryChildDocumentsOperation(): QueryChildDocumentsOperation
|
||||
fun queryDocumentOperation(): QueryDocumentOperation
|
||||
fun queryRootsOperation(): QueryRootsOperation
|
||||
fun renameDocumentOperation(): RenameDocumentOperation
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(WebdavComponent::class)
|
||||
interface DavDocumentsProviderWebdavEntryPoint {
|
||||
fun credentialsStore(): CredentialsStore
|
||||
fun thumbnailCache(): ThumbnailCache
|
||||
private val entryPoint: DavDocumentsProviderEntryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(context!!)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DAV_FILE_FIELDS = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
GetETag.NAME,
|
||||
GetContentType.NAME,
|
||||
GetContentLength.NAME,
|
||||
GetLastModified.NAME,
|
||||
QuotaAvailableBytes.NAME,
|
||||
QuotaUsedBytes.NAME,
|
||||
)
|
||||
|
||||
const val MAX_NAME_ATTEMPTS = 5
|
||||
const val THUMBNAIL_TIMEOUT_MS = 15000L
|
||||
|
||||
fun notifyMountsChanged(context: Context) {
|
||||
context.contentResolver.notifyChange(buildRootsUri(context.getString(R.string.webdav_authority)), null)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val documentProviderScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
private val ourContext by lazy { context!! } // requireContext() requires API level 30
|
||||
private val authority by lazy { ourContext.getString(R.string.webdav_authority) }
|
||||
private val globalEntryPoint by lazy { EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(ourContext) }
|
||||
private val webdavEntryPoint by lazy {
|
||||
EntryPoints.get(
|
||||
globalEntryPoint.webdavComponentBuilder().build(),
|
||||
DavDocumentsProviderWebdavEntryPoint::class.java
|
||||
)
|
||||
}
|
||||
|
||||
private val logger by lazy { globalEntryPoint.logger() }
|
||||
|
||||
private val db by lazy { globalEntryPoint.appDatabase() }
|
||||
private val mountDao by lazy { db.webDavMountDao() }
|
||||
private val documentDao by lazy { db.webDavDocumentDao() }
|
||||
|
||||
private val thumbnailCache by lazy { webdavEntryPoint.thumbnailCache() }
|
||||
|
||||
private val connectivityManager by lazy { ourContext.getSystemService<ConnectivityManager>()!! }
|
||||
private val storageManager by lazy { ourContext.getSystemService<StorageManager>()!! }
|
||||
|
||||
/** List of currently active [queryChildDocuments] runners.
|
||||
*
|
||||
* Key: document ID (directory) for which children are listed.
|
||||
* Value: whether the runner is still running (*true*) or has already finished (*false*).
|
||||
*/
|
||||
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
private val credentialsStore by lazy { webdavEntryPoint.credentialsStore() }
|
||||
private val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
|
||||
private val actor by lazy { globalEntryPoint.davDocumentsActorFactory().create(cookieStore, credentialsStore) }
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun shutdown() {
|
||||
documentProviderScope.cancel()
|
||||
}
|
||||
/* Note: shutdown() is NOT called automatically by Android; a content provider lives until
|
||||
the process is killed. */
|
||||
|
||||
|
||||
/*** query ***/
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryRoots")
|
||||
val roots = MatrixCursor(projection ?: arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_ICON,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_SUMMARY
|
||||
))
|
||||
override fun queryRoots(projection: Array<out String>?) =
|
||||
entryPoint.queryRootsOperation().invoke(projection)
|
||||
|
||||
runBlocking {
|
||||
for (mount in mountDao.getAll()) {
|
||||
val rootDocument = documentDao.getOrCreateRoot(mount)
|
||||
logger.info("Root ID: $rootDocument")
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?) =
|
||||
entryPoint.queryDocumentOperation().invoke(documentId, projection)
|
||||
|
||||
roots.newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, mount.id)
|
||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(Root.COLUMN_TITLE, ourContext.getString(R.string.webdav_provider_root_title))
|
||||
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
|
||||
add(Root.COLUMN_SUMMARY, mount.name)
|
||||
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
|
||||
entryPoint.queryChildDocumentsOperation().invoke(parentDocumentId, projection, sortOrder)
|
||||
|
||||
val quotaAvailable = rootDocument.quotaAvailable
|
||||
if (quotaAvailable != null)
|
||||
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
|
||||
|
||||
val quotaUsed = rootDocument.quotaUsed
|
||||
if (quotaAvailable != null && quotaUsed != null)
|
||||
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val parent = doc.parentId?.let { parentId ->
|
||||
documentDao.get(parentId)
|
||||
}
|
||||
|
||||
return DocumentsCursor(projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_ICON,
|
||||
Document.COLUMN_SUMMARY
|
||||
)).apply {
|
||||
val bundle = doc.toBundle(parent)
|
||||
logger.fine("queryDocument($documentId) = $bundle")
|
||||
|
||||
// override display names of root documents
|
||||
if (parent == null) {
|
||||
val mount = runBlocking { mountDao.getById(doc.mountId) }
|
||||
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
|
||||
}
|
||||
|
||||
addRow(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets old or new children of given parent.
|
||||
*
|
||||
* Dispatches a worker querying the server for new children of given parent, and instantly
|
||||
* returns old children (or nothing, on initial call).
|
||||
* Once the worker finishes its query, it notifies the [android.content.ContentResolver] about
|
||||
* change, which calls this method again. The worker being done
|
||||
*/
|
||||
@Synchronized
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
|
||||
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
|
||||
val parentId = parentDocumentId.toLong()
|
||||
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
|
||||
|
||||
val columns = projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
// Register watcher
|
||||
val result = DocumentsCursor(columns)
|
||||
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
|
||||
result.setNotificationUri(ourContext.contentResolver, notificationUri)
|
||||
|
||||
// Dispatch worker querying for the children and keep track of it
|
||||
val running = runningQueryChildren.getOrPut(parentId) {
|
||||
documentProviderScope.launch {
|
||||
actor.queryChildren(parent)
|
||||
// Once the query is done, set query as finished (not running)
|
||||
runningQueryChildren[parentId] = false
|
||||
// .. and notify - effectively calling this method again
|
||||
ourContext.contentResolver.notifyChange(notificationUri, null)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
if (running) // worker still running
|
||||
result.loading = true
|
||||
else // remove worker from list if done
|
||||
runningQueryChildren.remove(parentId)
|
||||
|
||||
// Prepare SORT BY clause
|
||||
val mapper = globalEntryPoint.documentSortByMapper()
|
||||
val sqlSortBy = if (sortOrder != null)
|
||||
mapper.mapContentProviderToSql(sortOrder)
|
||||
else
|
||||
WebDavDocumentDao.DEFAULT_ORDER
|
||||
|
||||
// Regardless of whether the worker is done, return the children we already have
|
||||
val children = documentDao.getChildren(parentId, sqlSortBy)
|
||||
for (child in children) {
|
||||
val bundle = child.toBundle(parent)
|
||||
result.addRow(bundle)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
|
||||
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
while (iter != null) {
|
||||
val currentParentId = iter.parentId
|
||||
if (currentParentId == parent.id)
|
||||
return true
|
||||
|
||||
iter = if (currentParentId != null)
|
||||
documentDao.get(currentParentId)
|
||||
else
|
||||
null
|
||||
}
|
||||
return false
|
||||
}
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String) =
|
||||
entryPoint.isChildDocumentOperation().invoke(parentDocumentId, documentId)
|
||||
|
||||
|
||||
/*** copy/create/delete/move/rename ***/
|
||||
|
||||
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
|
||||
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val name = srcDoc.name
|
||||
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String) =
|
||||
entryPoint.copyDocumentOperation().invoke(sourceDocumentId, targetParentDocumentId)
|
||||
|
||||
if (srcDoc.mountId != dstFolder.mountId)
|
||||
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
|
||||
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? =
|
||||
entryPoint.createDocumentOperation().invoke(parentDocumentId, mimeType, displayName)
|
||||
|
||||
actor.httpClient(srcDoc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
|
||||
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(name)
|
||||
.build()
|
||||
override fun deleteDocument(documentId: String) =
|
||||
entryPoint.deleteDocumentOperation().invoke(documentId)
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.copy(dstUrl, false) {
|
||||
// successfully copied
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String) =
|
||||
entryPoint.moveDocumentOperation().invoke(sourceDocumentId, sourceParentDocumentId, targetParentDocumentId)
|
||||
|
||||
val dstDocId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = dstFolder.mountId,
|
||||
parentId = dstFolder.id,
|
||||
name = name,
|
||||
isDirectory = srcDoc.isDirectory,
|
||||
displayName = srcDoc.displayName,
|
||||
mimeType = srcDoc.mimeType,
|
||||
size = srcDoc.size
|
||||
)
|
||||
).toString()
|
||||
|
||||
actor.notifyFolderChanged(targetParentDocumentId)
|
||||
|
||||
/* return */ dstDocId
|
||||
}
|
||||
}
|
||||
|
||||
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val createDirectory = mimeType == Document.MIME_TYPE_DIR
|
||||
|
||||
var docId: Long? = null
|
||||
actor.httpClient(parent.mountId).use { client ->
|
||||
for (attempt in 0..MAX_NAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
val newLocation = parentUrl.newBuilder()
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
val doc = DavResource(client.okHttpClient, newLocation)
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
if (createDirectory)
|
||||
doc.mkCol(null) {
|
||||
// directory successfully created
|
||||
}
|
||||
else
|
||||
doc.put("".toRequestBody(null), ifNoneMatch = true) {
|
||||
// document successfully created
|
||||
}
|
||||
}
|
||||
|
||||
docId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = parent.mountId,
|
||||
parentId = parent.id,
|
||||
name = newName,
|
||||
mimeType = mimeType.toMediaTypeOrNull(),
|
||||
isDirectory = createDirectory
|
||||
)
|
||||
)
|
||||
|
||||
actor.notifyFolderChanged(parentDocumentId)
|
||||
|
||||
return@runBlocking docId.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(ignorePreconditionFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) = runBlocking {
|
||||
logger.fine("WebDAV removeDocument $documentId")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.delete {
|
||||
// successfully deleted
|
||||
}
|
||||
}
|
||||
logger.fine("Successfully removed")
|
||||
documentDao.delete(doc)
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
|
||||
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
if (doc.mountId != dstParent.mountId)
|
||||
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
|
||||
|
||||
val newLocation = dstParent.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(doc.name)
|
||||
.build()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully moved
|
||||
}
|
||||
}
|
||||
|
||||
documentDao.update(doc.copy(parentId = dstParent.id))
|
||||
|
||||
actor.notifyFolderChanged(sourceParentDocumentId)
|
||||
actor.notifyFolderChanged(targetParentDocumentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
}
|
||||
|
||||
doc.id.toString()
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV renameDocument $documentId $displayName")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
for (attempt in 0..MAX_NAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val oldUrl = doc.toHttpUrl(db)
|
||||
val newLocation = oldUrl.newBuilder()
|
||||
.removePathSegment(oldUrl.pathSegments.lastIndex)
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
try {
|
||||
val dav = DavResource(client.okHttpClient, oldUrl)
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully renamed
|
||||
}
|
||||
}
|
||||
documentDao.update(doc.copy(name = newName))
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
|
||||
return@runBlocking doc.id.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
override fun renameDocument(documentId: String, displayName: String): String? =
|
||||
entryPoint.renameDocumentOperation().invoke(documentId, displayName)
|
||||
|
||||
|
||||
/*** read/write ***/
|
||||
|
||||
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
|
||||
HeadResponse.fromUrl(client, url)
|
||||
}
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?) =
|
||||
entryPoint.openDocumentOperation().invoke(documentId, mode, signal)
|
||||
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
|
||||
logger.fine("WebDAV openDocument $documentId $mode $signal")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val url = doc.toHttpUrl(db)
|
||||
val client = actor.httpClient(doc.mountId, logBody = false)
|
||||
|
||||
val modeFlags = ParcelFileDescriptor.parseMode(mode)
|
||||
val readAccess = when (mode) {
|
||||
"r" -> true
|
||||
"w", "wt" -> false
|
||||
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
|
||||
}
|
||||
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal?.setOnCancelListener {
|
||||
logger.fine("Cancelling WebDAV access to $url")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val fileInfo = accessScope.async {
|
||||
headRequest(client, url)
|
||||
}.await()
|
||||
logger.fine("Received file info: $fileInfo")
|
||||
|
||||
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
|
||||
return@runBlocking if (
|
||||
Build.VERSION.SDK_INT >= 26 && // openProxyFileDescriptor exists since Android 8.0
|
||||
readAccess && // WebDAV doesn't support random write access natively
|
||||
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
|
||||
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine whether the document has changed during access
|
||||
fileInfo.supportsPartial == true // WebDAV server must support random access
|
||||
) {
|
||||
logger.fine("Creating RandomAccessCallback for $url")
|
||||
val factory = globalEntryPoint.randomAccessCallbackWrapperFactory()
|
||||
val accessor = factory.create(client, url, doc.mimeType, fileInfo, accessScope)
|
||||
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
|
||||
} else {
|
||||
logger.fine("Creating StreamingFileDescriptor for $url")
|
||||
val factory = globalEntryPoint.streamingFileDescriptorFactory()
|
||||
val fd = factory.create(client, url, doc.mimeType, accessScope) { transferred ->
|
||||
// called when transfer is finished
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!readAccess /* write access */) {
|
||||
// write access, update file size
|
||||
documentDao.update(doc.copy(size = transferred, lastModified = now))
|
||||
}
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
}
|
||||
|
||||
if (readAccess)
|
||||
fd.download()
|
||||
else
|
||||
fd.upload()
|
||||
}
|
||||
}
|
||||
|
||||
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
|
||||
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
|
||||
|
||||
if (connectivityManager.isActiveNetworkMetered)
|
||||
// don't download the large images just to create a thumbnail on metered networks
|
||||
return null
|
||||
|
||||
if (signal == null) {
|
||||
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
|
||||
return null
|
||||
}
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal.setOnCancelListener {
|
||||
logger.fine("Cancelling thumbnail generation for $documentId")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
val docCacheKey = doc.cacheKey()
|
||||
if (docCacheKey == null) {
|
||||
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
|
||||
return null
|
||||
}
|
||||
|
||||
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
|
||||
// create thumbnail
|
||||
val job = accessScope.async {
|
||||
withTimeout(THUMBNAIL_TIMEOUT_MS) {
|
||||
actor.httpClient(doc.mountId, logBody = false).use { client ->
|
||||
val url = doc.toHttpUrl(db)
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var result: ByteArray? = null
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.get("image/*", null) { response ->
|
||||
response.body.byteStream().use { data ->
|
||||
BitmapFactory.decodeStream(data)?.let { bitmap ->
|
||||
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
|
||||
val baos = ByteArrayOutputStream()
|
||||
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
|
||||
result = baos.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
job.await()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbFile != null)
|
||||
return AssetFileDescriptor(
|
||||
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
|
||||
0, thumbFile.length()
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Acts on behalf of [DavDocumentsProvider].
|
||||
*
|
||||
* Encapsulates functionality to make it easily testable without generating lots of
|
||||
* DocumentProviders during the tests.
|
||||
*
|
||||
* By containing the actual implementation logic of [DavDocumentsProvider], it adds a layer of separation
|
||||
* to make the methods of [DavDocumentsProvider] more easily testable.
|
||||
* [DavDocumentsProvider]s methods should do nothing more, but to call [DavDocumentsActor]s methods.
|
||||
*/
|
||||
class DavDocumentsActor @AssistedInject constructor(
|
||||
@Assisted private val cookieStores: MutableMap<Long, CookieJar>,
|
||||
@Assisted private val credentialsStore: CredentialsStore,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(cookieStore: MutableMap<Long, CookieJar>, credentialsStore: CredentialsStore): DavDocumentsActor
|
||||
}
|
||||
|
||||
private val authority = context.getString(R.string.webdav_authority)
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
/**
|
||||
* Finds children of given parent [WebDavDocument]. After querying, it
|
||||
* updates existing children, adds new ones or removes deleted ones.
|
||||
*
|
||||
* There must never be more than one running instance per [parent]!
|
||||
*
|
||||
* @param parent folder to search for children
|
||||
*/
|
||||
internal suspend fun queryChildren(parent: WebDavDocument) {
|
||||
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
|
||||
val newChildrenList = hashMapOf<String, WebDavDocument>()
|
||||
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
httpClient(parent.mountId).use { client ->
|
||||
val folder = DavCollection(client.okHttpClient, parentUrl)
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
|
||||
logger.fine("$relation $response")
|
||||
|
||||
val resource: WebDavDocument =
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF -> // it's about the parent
|
||||
parent
|
||||
|
||||
Response.HrefRelation.MEMBER -> // it's about a member
|
||||
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
|
||||
|
||||
else -> {
|
||||
// we didn't request this; log a warning and ignore it
|
||||
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
|
||||
return@propfind
|
||||
}
|
||||
}
|
||||
|
||||
val updatedResource = resource.copy(
|
||||
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
|
||||
?: resource.isDirectory,
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
mimeType = response[GetContentType::class.java]?.type,
|
||||
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.let { resource.eTag },
|
||||
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
|
||||
size = response[GetContentLength::class.java]?.contentLength,
|
||||
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
|
||||
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
|
||||
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
|
||||
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
|
||||
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
|
||||
)
|
||||
|
||||
if (resource == parent)
|
||||
documentDao.update(updatedResource)
|
||||
else {
|
||||
documentDao.insertOrUpdate(updatedResource)
|
||||
newChildrenList[resource.name] = updatedResource
|
||||
}
|
||||
|
||||
// remove resource from known child nodes, because not found on server
|
||||
oldChildren.remove(resource.name)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't query children", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete child nodes which were not rediscovered (deleted serverside)
|
||||
for ((_, oldChild) in oldChildren)
|
||||
documentDao.delete(oldChild)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Creates a 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)
|
||||
*/
|
||||
internal fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient {
|
||||
val builder = httpClientBuilder.get()
|
||||
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
|
||||
.setCookieStore(
|
||||
cookieStores.getOrPut(mountId) { MemoryCookieStore() }
|
||||
)
|
||||
|
||||
credentialsStore.getCredentials(mountId)?.let { credentials ->
|
||||
builder.authenticate(host = null, getCredentials = { credentials })
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(parentDocumentId: Long?) {
|
||||
if (parentDocumentId != null)
|
||||
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null)
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(parentDocumentId: String) {
|
||||
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun HttpException.throwForDocumentProvider(ignorePreconditionFailed: Boolean = false) {
|
||||
when (code) {
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED -> {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
// TODO edit mount
|
||||
val intent = Intent(ourContext, WebdavMountsActivity::class.java)
|
||||
throw AuthenticationRequiredException(
|
||||
this,
|
||||
TaskStackBuilder.create(ourContext)
|
||||
.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
|
||||
}
|
||||
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?) =
|
||||
entryPoint.openDocumentThumbnailOperation().invoke(documentId, sizeHint, signal)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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>()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -66,7 +66,7 @@ class WebDavMountRepository @Inject constructor(
|
||||
credentialsStore.setCredentials(id, credentials)
|
||||
|
||||
// notify content URI listeners
|
||||
DavDocumentsProvider.notifyMountsChanged(context)
|
||||
DocumentProviderUtils.notifyMountsChanged(context)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -79,7 +79,7 @@ class WebDavMountRepository @Inject constructor(
|
||||
CredentialsStore(context).setCredentials(mount.id, null)
|
||||
|
||||
// notify content URI listeners
|
||||
DavDocumentsProvider.notifyMountsChanged(context)
|
||||
DocumentProviderUtils.notifyMountsChanged(context)
|
||||
}
|
||||
|
||||
fun getAllFlow() = mountDao.getAllFlow()
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.DefineComponent
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Scope
|
||||
|
||||
@Scope
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class WebdavScoped
|
||||
|
||||
@WebdavScoped
|
||||
@DefineComponent(parent = SingletonComponent::class)
|
||||
interface WebdavComponent
|
||||
|
||||
@DefineComponent.Builder
|
||||
interface WebdavComponentBuilder {
|
||||
fun build(): WebdavComponent
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import android.os.storage.StorageManager
|
||||
import android.text.format.Formatter
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.webdav.WebdavScoped
|
||||
import com.google.common.hash.Hashing
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
@@ -21,7 +20,6 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Simple disk cache for image thumbnails.
|
||||
*/
|
||||
@WebdavScoped
|
||||
class ThumbnailCache @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class CopyDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
|
||||
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val name = srcDoc.name
|
||||
|
||||
if (srcDoc.mountId != dstFolder.mountId)
|
||||
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
|
||||
|
||||
httpClientBuilder.build(srcDoc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
|
||||
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(name)
|
||||
.build()
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.copy(dstUrl, false) {
|
||||
// successfully copied
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context)
|
||||
}
|
||||
|
||||
val dstDocId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = dstFolder.mountId,
|
||||
parentId = dstFolder.id,
|
||||
name = name,
|
||||
isDirectory = srcDoc.isDirectory,
|
||||
displayName = srcDoc.displayName,
|
||||
mimeType = srcDoc.mimeType,
|
||||
size = srcDoc.size
|
||||
)
|
||||
).toString()
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
|
||||
|
||||
/* return */ dstDocId
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract.Document
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class CreateDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val createDirectory = mimeType == Document.MIME_TYPE_DIR
|
||||
|
||||
var docId: Long?
|
||||
httpClientBuilder.build(parent.mountId).use { client ->
|
||||
for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
val newLocation = parentUrl.newBuilder()
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
val doc = DavResource(client.okHttpClient, newLocation)
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
if (createDirectory)
|
||||
doc.mkCol(null) {
|
||||
// directory successfully created
|
||||
}
|
||||
else
|
||||
doc.put("".toRequestBody(null), ifNoneMatch = true) {
|
||||
// document successfully created
|
||||
}
|
||||
}
|
||||
|
||||
docId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = parent.mountId,
|
||||
parentId = parent.id,
|
||||
name = newName,
|
||||
mimeType = mimeType.toMediaTypeOrNull(),
|
||||
isDirectory = createDirectory
|
||||
)
|
||||
)
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, parentDocumentId)
|
||||
|
||||
return@runBlocking docId.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context, ignorePreconditionFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeleteDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(documentId: String) = runBlocking {
|
||||
logger.fine("WebDAV removeDocument $documentId")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
httpClientBuilder.build(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.delete {
|
||||
// successfully deleted
|
||||
}
|
||||
}
|
||||
logger.fine("Successfully removed")
|
||||
documentDao.delete(doc)
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class IsChildDocumentOperation @Inject constructor(
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(parentDocumentId: String, documentId: String): Boolean {
|
||||
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
while (iter != null) {
|
||||
val currentParentId = iter.parentId
|
||||
if (currentParentId == parent.id)
|
||||
return true
|
||||
|
||||
iter = if (currentParentId != null)
|
||||
documentDao.get(currentParentId)
|
||||
else
|
||||
null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class MoveDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
|
||||
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
if (doc.mountId != dstParent.mountId)
|
||||
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
|
||||
|
||||
val newLocation = dstParent.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(doc.name)
|
||||
.build()
|
||||
|
||||
httpClientBuilder.build(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully moved
|
||||
}
|
||||
}
|
||||
|
||||
documentDao.update(doc.copy(parentId = dstParent.id))
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, sourceParentDocumentId)
|
||||
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context)
|
||||
}
|
||||
}
|
||||
|
||||
doc.id.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.storage.StorageManager
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.HeadResponse
|
||||
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper
|
||||
import at.bitfire.davdroid.webdav.StreamingFileDescriptor
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpenDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val randomAccessCallbackWrapperFactory: RandomAccessCallbackWrapper.Factory,
|
||||
private val streamingFileDescriptorFactory: StreamingFileDescriptor.Factory
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
private val storageManager = context.getSystemService<StorageManager>()!!
|
||||
|
||||
operator fun invoke(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
|
||||
logger.fine("WebDAV openDocument $documentId $mode $signal")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val url = doc.toHttpUrl(db)
|
||||
val client = httpClientBuilder.build(doc.mountId, logBody = false)
|
||||
|
||||
val modeFlags = ParcelFileDescriptor.parseMode(mode)
|
||||
val readAccess = when (mode) {
|
||||
"r" -> true
|
||||
"w", "wt" -> false
|
||||
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
|
||||
}
|
||||
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal?.setOnCancelListener {
|
||||
logger.fine("Cancelling WebDAV access to $url")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val fileInfo = accessScope.async {
|
||||
headRequest(client, url)
|
||||
}.await()
|
||||
logger.fine("Received file info: $fileInfo")
|
||||
|
||||
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
|
||||
return@runBlocking if (
|
||||
androidSupportsRandomAccess &&
|
||||
readAccess && // WebDAV doesn't support random write access (natively)
|
||||
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
|
||||
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine whether the document has changed during access
|
||||
fileInfo.supportsPartial == true // WebDAV server must support random access
|
||||
) {
|
||||
logger.fine("Creating RandomAccessCallback for $url")
|
||||
val accessor = randomAccessCallbackWrapperFactory.create(client, url, doc.mimeType, fileInfo, accessScope)
|
||||
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
|
||||
} else {
|
||||
logger.fine("Creating StreamingFileDescriptor for $url")
|
||||
val fd = streamingFileDescriptorFactory.create(client, url, doc.mimeType, accessScope) { transferred ->
|
||||
// called when transfer is finished
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!readAccess /* write access */) {
|
||||
// write access, update file size
|
||||
documentDao.update(doc.copy(size = transferred, lastModified = now))
|
||||
}
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
|
||||
}
|
||||
|
||||
if (readAccess)
|
||||
fd.download()
|
||||
else
|
||||
fd.upload()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
|
||||
HeadResponse.fromUrl(client, url)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
// openProxyFileDescriptor exists since Android 8.0
|
||||
val androidSupportsRandomAccess = Build.VERSION.SDK_INT >= 26
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Point
|
||||
import android.media.ThumbnailUtils
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.use
|
||||
|
||||
class OpenDocumentThumbnailOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val thumbnailCache: ThumbnailCache
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
|
||||
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
|
||||
|
||||
// don't download the large images just to create a thumbnail on metered networks
|
||||
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
|
||||
if (connectivityManager.isActiveNetworkMetered)
|
||||
return null
|
||||
|
||||
if (signal == null) {
|
||||
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
|
||||
return null
|
||||
}
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal.setOnCancelListener {
|
||||
logger.fine("Cancelling thumbnail generation for $documentId")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
val docCacheKey = doc.cacheKey()
|
||||
if (docCacheKey == null) {
|
||||
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
|
||||
return null
|
||||
}
|
||||
|
||||
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
|
||||
// create thumbnail
|
||||
val job = accessScope.async {
|
||||
withTimeout(THUMBNAIL_TIMEOUT_MS) {
|
||||
httpClientBuilder.build(doc.mountId, logBody = false).use { client ->
|
||||
val url = doc.toHttpUrl(db)
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var result: ByteArray? = null
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.get("image/*", null) { response ->
|
||||
response.body.byteStream().use { data ->
|
||||
BitmapFactory.decodeStream(data)?.let { bitmap ->
|
||||
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
|
||||
val baos = ByteArrayOutputStream()
|
||||
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
|
||||
result = baos.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
job.await()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbFile != null)
|
||||
return AssetFileDescriptor(
|
||||
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
|
||||
0, thumbFile.length()
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val THUMBNAIL_TIMEOUT_MS = 15000L
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.buildChildDocumentsUri
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentLength
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetLastModified
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavDocumentDao
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentSortByMapper
|
||||
import at.bitfire.davdroid.webdav.DocumentsCursor
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class QueryChildDocumentsOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val documentSortByMapper: Lazy<DocumentSortByMapper>,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val authority = context.getString(R.string.webdav_authority)
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
private val backgroundScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
operator fun invoke(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
|
||||
synchronized(QueryChildDocumentsOperation::class.java) {
|
||||
queryChildDocuments(parentDocumentId, projection, sortOrder)
|
||||
}
|
||||
|
||||
private fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): DocumentsCursor {
|
||||
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
|
||||
val parentId = parentDocumentId.toLong()
|
||||
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
|
||||
|
||||
val columns = projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
// Register watcher
|
||||
val result = DocumentsCursor(columns)
|
||||
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
|
||||
result.setNotificationUri(context.contentResolver, notificationUri)
|
||||
|
||||
// Dispatch worker querying for the children and keep track of it
|
||||
val running = runningQueryChildren.getOrPut(parentId) {
|
||||
backgroundScope.launch {
|
||||
queryChildren(parent)
|
||||
// Once the query is done, set query as finished (not running)
|
||||
runningQueryChildren[parentId] = false
|
||||
// .. and notify - effectively calling this method again
|
||||
context.contentResolver.notifyChange(notificationUri, null)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
if (running) // worker still running
|
||||
result.loading = true
|
||||
else // remove worker from list if done
|
||||
runningQueryChildren.remove(parentId)
|
||||
|
||||
// Prepare SORT BY clause
|
||||
val mapper = documentSortByMapper.get()
|
||||
val sqlSortBy = if (sortOrder != null)
|
||||
mapper.mapContentProviderToSql(sortOrder)
|
||||
else
|
||||
WebDavDocumentDao.DEFAULT_ORDER
|
||||
|
||||
// Regardless of whether the worker is done, return the children we already have
|
||||
val children = documentDao.getChildren(parentId, sqlSortBy)
|
||||
for (child in children) {
|
||||
val bundle = child.toBundle(parent)
|
||||
result.addRow(bundle)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds children of given parent [WebDavDocument]. After querying, it
|
||||
* updates existing children, adds new ones or removes deleted ones.
|
||||
*
|
||||
* There must never be more than one running instance per [parent]!
|
||||
*
|
||||
* @param parent folder to search for children
|
||||
*/
|
||||
internal suspend fun queryChildren(parent: WebDavDocument) {
|
||||
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
|
||||
val newChildrenList = hashMapOf<String, WebDavDocument>()
|
||||
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
httpClientBuilder.build(parent.mountId).use { client ->
|
||||
val folder = DavCollection(client.okHttpClient, parentUrl)
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
|
||||
logger.fine("$relation $response")
|
||||
|
||||
val resource: WebDavDocument =
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF -> // it's about the parent
|
||||
parent
|
||||
|
||||
Response.HrefRelation.MEMBER -> // it's about a member
|
||||
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
|
||||
|
||||
else -> {
|
||||
// we didn't request this; log a warning and ignore it
|
||||
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
|
||||
return@propfind
|
||||
}
|
||||
}
|
||||
|
||||
val updatedResource = resource.copy(
|
||||
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
|
||||
?: resource.isDirectory,
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
mimeType = response[GetContentType::class.java]?.type,
|
||||
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.let { resource.eTag },
|
||||
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
|
||||
size = response[GetContentLength::class.java]?.contentLength,
|
||||
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
|
||||
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
|
||||
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
|
||||
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
|
||||
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
|
||||
)
|
||||
|
||||
if (resource == parent)
|
||||
documentDao.update(updatedResource)
|
||||
else {
|
||||
documentDao.insertOrUpdate(updatedResource)
|
||||
newChildrenList[resource.name] = updatedResource
|
||||
}
|
||||
|
||||
// remove resource from known child nodes, because not found on server
|
||||
oldChildren.remove(resource.name)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't query children", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete child nodes which were not rediscovered (deleted serverside)
|
||||
for ((_, oldChild) in oldChildren)
|
||||
documentDao.delete(oldChild)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val DAV_FILE_FIELDS = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
GetETag.NAME,
|
||||
GetContentType.NAME,
|
||||
GetContentLength.NAME,
|
||||
GetLastModified.NAME,
|
||||
QuotaAvailableBytes.NAME,
|
||||
QuotaUsedBytes.NAME,
|
||||
)
|
||||
|
||||
/** List of currently active [queryChildDocuments] runners.
|
||||
*
|
||||
* Key: document ID (directory) for which children are listed.
|
||||
* Value: whether the runner is still running (*true*) or has already finished (*false*).
|
||||
*/
|
||||
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.database.Cursor
|
||||
import android.provider.DocumentsContract.Document
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.webdav.DocumentsCursor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class QueryDocumentOperation @Inject constructor(
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
private val mountDao = db.webDavMountDao()
|
||||
|
||||
operator fun invoke(documentId: String, projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val parent = doc.parentId?.let { parentId ->
|
||||
documentDao.get(parentId)
|
||||
}
|
||||
|
||||
return DocumentsCursor(projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_ICON,
|
||||
Document.COLUMN_SUMMARY
|
||||
)).apply {
|
||||
val bundle = doc.toBundle(parent)
|
||||
logger.fine("queryDocument($documentId) = $bundle")
|
||||
|
||||
// override display names of root documents
|
||||
if (parent == null) {
|
||||
val mount = runBlocking { mountDao.getById(doc.mountId) }
|
||||
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
|
||||
}
|
||||
|
||||
addRow(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.provider.DocumentsContract.Root
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class QueryRootsOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
private val mountDao = db.webDavMountDao()
|
||||
|
||||
operator fun invoke(projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryRoots")
|
||||
val roots = MatrixCursor(projection ?: arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_ICON,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_SUMMARY
|
||||
))
|
||||
|
||||
runBlocking {
|
||||
for (mount in mountDao.getAll()) {
|
||||
val rootDocument = documentDao.getOrCreateRoot(mount)
|
||||
logger.info("Root ID: $rootDocument")
|
||||
|
||||
roots.newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, mount.id)
|
||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(Root.COLUMN_TITLE, context.getString(R.string.webdav_provider_root_title))
|
||||
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
|
||||
add(Root.COLUMN_SUMMARY, mount.name)
|
||||
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
|
||||
|
||||
val quotaAvailable = rootDocument.quotaAvailable
|
||||
if (quotaAvailable != null)
|
||||
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
|
||||
|
||||
val quotaUsed = rootDocument.quotaUsed
|
||||
if (quotaAvailable != null && quotaUsed != null)
|
||||
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class RenameDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(documentId: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV renameDocument $documentId $displayName")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
httpClientBuilder.build(doc.mountId).use { client ->
|
||||
for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val oldUrl = doc.toHttpUrl(db)
|
||||
val newLocation = oldUrl.newBuilder()
|
||||
.removePathSegment(oldUrl.pathSegments.lastIndex)
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
try {
|
||||
val dav = DavResource(client.okHttpClient, oldUrl)
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully renamed
|
||||
}
|
||||
}
|
||||
documentDao.update(doc.copy(name = newName))
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
|
||||
|
||||
return@runBlocking doc.id.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user