mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
WebDAV: remove notifications, timeout logic (#1630)
* Refactor openDocument operation to use OsConstants for mode parsing * RandomAccessCallbackWrapper: refactor so that it's only purpose is to avoid the memory leak * Use main looper instead of a new thread per RandomAccessCallback * Remove WebDAV access notification * Remove nsk90-kstatemachine dependency * Simplify fileDescriptor() method * Use dedicated I/O thread again; use Kotlin `copyTo` for copying
This commit is contained in:
@@ -188,7 +188,6 @@ dependencies {
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
@@ -47,7 +47,6 @@ class NotificationRegistry @Inject constructor(
|
||||
const val NOTIFY_DATABASE_CORRUPTED = 4
|
||||
const val NOTIFY_SYNC_ERROR = 10
|
||||
const val NOTIFY_INVALID_RESOURCE = 11
|
||||
const val NOTIFY_WEBDAV_ACCESS = 12
|
||||
const val NOTIFY_SYNC_EXPEDITED = 14
|
||||
const val NOTIFY_TASKS_PROVIDER_TOO_OLD = 20
|
||||
const val NOTIFY_PERMISSIONS = 21
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.ProxyFileDescriptorCallback
|
||||
import android.os.storage.StorageManager
|
||||
import android.system.ErrnoException
|
||||
import android.system.OsConstants
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import com.google.common.cache.CacheLoader
|
||||
@@ -40,17 +40,17 @@ import okhttp3.MediaType
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillClose
|
||||
|
||||
@RequiresApi(26)
|
||||
class RandomAccessCallback @AssistedInject constructor(
|
||||
@Assisted val httpClient: HttpClient,
|
||||
@Assisted val url: HttpUrl,
|
||||
@Assisted val mimeType: MediaType?,
|
||||
@Assisted @WillClose private val httpClient: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted headResponse: HeadResponse,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): ProxyFileDescriptorCallback() {
|
||||
|
||||
companion object {
|
||||
@@ -77,26 +77,34 @@ class RandomAccessCallback @AssistedInject constructor(
|
||||
private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size")
|
||||
private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified")
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentTitle(context.getString(R.string.webdav_notification_access))
|
||||
.setContentText(dav.fileName())
|
||||
.setSubText(Formatter.formatFileSize(context, fileSize))
|
||||
.setSmallIcon(R.drawable.ic_storage_notify)
|
||||
.setOngoing(true)
|
||||
private val notificationTag = url.toString()
|
||||
|
||||
private val pageLoader = PageLoader(externalScope)
|
||||
private val pageCache: LoadingCache<PageIdentifier, ByteArray> = CacheBuilder.newBuilder()
|
||||
.maximumSize(10) // don't cache more than 10 entries (MAX_PAGE_SIZE each)
|
||||
.softValues() // use SoftReference for the page contents so they will be garbage collected if memory is needed
|
||||
.softValues() // use SoftReference for the page contents so they will be garbage-collected if memory is needed
|
||||
.build(pageLoader) // fetch actual content using pageLoader
|
||||
|
||||
/** This thread will be used for I/O operations like [onRead]. Using the main looper would cause ANRs. */
|
||||
private val ioThread = HandlerThread("WebDAV I/O").apply {
|
||||
start()
|
||||
}
|
||||
|
||||
private val pagingReader = PagingReader(fileSize, MAX_PAGE_SIZE, pageCache)
|
||||
|
||||
|
||||
// file descriptor
|
||||
|
||||
/**
|
||||
* Returns a random-access file descriptor that can be used in a DocumentsProvider.
|
||||
*/
|
||||
fun fileDescriptor(): ParcelFileDescriptor {
|
||||
val storageManager = context.getSystemService<StorageManager>()!!
|
||||
val ioHandler = Handler(ioThread.looper)
|
||||
return storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, this, ioHandler)
|
||||
}
|
||||
|
||||
|
||||
// implementation
|
||||
|
||||
override fun onFsync() { /* not used */ }
|
||||
|
||||
override fun onGetSize(): Long = runBlockingFd("onGetFileSize") {
|
||||
@@ -117,7 +125,10 @@ class RandomAccessCallback @AssistedInject constructor(
|
||||
|
||||
override fun onRelease() {
|
||||
logger.fine("onRelease")
|
||||
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
|
||||
|
||||
// free resources
|
||||
ioThread.quitSafely()
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -185,16 +196,6 @@ class RandomAccessCallback @AssistedInject constructor(
|
||||
val size = key.size
|
||||
logger.fine("Loading page $url $offset/$size")
|
||||
|
||||
// update notification
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, tag = notificationTag) {
|
||||
val progress =
|
||||
if (fileSize == 0L) // avoid division by zero
|
||||
100
|
||||
else
|
||||
(offset * 100 / fileSize).toInt()
|
||||
notification.setProgress(100, progress, false).build()
|
||||
}
|
||||
|
||||
val ifMatch: Headers =
|
||||
documentState.eTag?.let { eTag ->
|
||||
Headers.headersOf("If-Match", "\"$eTag\"")
|
||||
|
||||
@@ -4,182 +4,85 @@
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.ProxyFileDescriptorCallback
|
||||
import android.system.ErrnoException
|
||||
import android.system.OsConstants
|
||||
import androidx.annotation.RequiresApi
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper.Companion.TIMEOUT_INTERVAL
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import ru.nsk.kstatemachine.event.Event
|
||||
import ru.nsk.kstatemachine.state.State
|
||||
import ru.nsk.kstatemachine.state.finalState
|
||||
import ru.nsk.kstatemachine.state.initialState
|
||||
import ru.nsk.kstatemachine.state.onEntry
|
||||
import ru.nsk.kstatemachine.state.onExit
|
||||
import ru.nsk.kstatemachine.state.onFinished
|
||||
import ru.nsk.kstatemachine.state.state
|
||||
import ru.nsk.kstatemachine.state.transitionOn
|
||||
import ru.nsk.kstatemachine.statemachine.StateMachine
|
||||
import ru.nsk.kstatemachine.statemachine.createStdLibStateMachine
|
||||
import ru.nsk.kstatemachine.statemachine.processEventBlocking
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
/**
|
||||
* (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak:
|
||||
* Use this wrapper to ensure that all memory is released as soon as [onRelease] is called.
|
||||
*
|
||||
* - (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak:
|
||||
* the given callback is registered in `com.android.internal.os.AppFuseMount` (which adds it to
|
||||
* a [Map]), but is not unregistered anymore. So it stays in the memory until the whole mount
|
||||
* is unloaded. See https://issuetracker.google.com/issues/208788568
|
||||
* is unloaded. See https://issuetracker.google.com/issues/208788568.
|
||||
* - (2024/08/24) [Fixed in Android.](https://android.googlesource.com/platform/frameworks/base/+/e7dbf78143ba083af7a8ecadd839a9dbf6f01655%5E%21/#F0)
|
||||
*
|
||||
* Use this wrapper to
|
||||
* **All fields of objects of this class must be set to `null` when [onRelease] is called!**
|
||||
* Otherwise they will leak memory.
|
||||
*
|
||||
* - ensure that all memory is released as soon as [onRelease] is called,
|
||||
* - provide timeout functionality: [RandomAccessCallback] will be closed when not
|
||||
*
|
||||
* used for more than [TIMEOUT_INTERVAL] ms and re-created when necessary.
|
||||
*
|
||||
* @param httpClient HTTP client – [RandomAccessCallbackWrapper] is responsible to close it
|
||||
* @param httpClient HTTP client ([RandomAccessCallbackWrapper] is responsible to close it)
|
||||
*/
|
||||
@RequiresApi(26)
|
||||
class RandomAccessCallbackWrapper @AssistedInject constructor(
|
||||
@Assisted private val httpClient: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted private val headResponse: HeadResponse,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
private val logger: Logger,
|
||||
private val callbackFactory: RandomAccessCallback.Factory
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted url: HttpUrl,
|
||||
@Assisted mimeType: MediaType?,
|
||||
@Assisted headResponse: HeadResponse,
|
||||
@Assisted externalScope: CoroutineScope,
|
||||
callbackFactory: RandomAccessCallback.Factory
|
||||
): ProxyFileDescriptorCallback() {
|
||||
|
||||
companion object {
|
||||
const val TIMEOUT_INTERVAL = 15000L
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallbackWrapper
|
||||
}
|
||||
|
||||
sealed class Events {
|
||||
object Transfer : Event
|
||||
object NowIdle : Event
|
||||
object GoStandby : Event
|
||||
object Close : Event
|
||||
}
|
||||
/* We don't use a sealed class for states here because the states would then be singletons, while we can have
|
||||
multiple instances of the state machine (which require multiple instances of the states, too). */
|
||||
private val machine = createStdLibStateMachine {
|
||||
lateinit var activeIdleState: State
|
||||
lateinit var activeTransferringState: State
|
||||
lateinit var standbyState: State
|
||||
lateinit var closedState: State
|
||||
|
||||
initialState("active") {
|
||||
onEntry {
|
||||
_callback = callbackFactory.create(httpClient, url, mimeType, headResponse, externalScope)
|
||||
}
|
||||
onExit {
|
||||
_callback?.onRelease()
|
||||
_callback = null
|
||||
}
|
||||
// callback reference
|
||||
|
||||
transitionOn<Events.GoStandby> { targetState = { standbyState } }
|
||||
transitionOn<Events.Close> { targetState = { closedState } }
|
||||
/**
|
||||
* This field is initialized with a strong reference to the callback. It is cleared when
|
||||
* [onRelease] is called so that the garbage collector can remove the actual [RandomAccessCallback].
|
||||
*/
|
||||
private var callbackRef: RandomAccessCallback? =
|
||||
callbackFactory.create(httpClient, url, mimeType, headResponse, externalScope)
|
||||
|
||||
// active has two nested states: transferring (I/O running) and idle (starts timeout timer)
|
||||
activeIdleState = initialState("idle") {
|
||||
val timer: Timer = Timer(true)
|
||||
var timeout: TimerTask? = null
|
||||
|
||||
onEntry {
|
||||
timeout = timer.schedule(TIMEOUT_INTERVAL) {
|
||||
machine.processEventBlocking(Events.GoStandby)
|
||||
}
|
||||
}
|
||||
onExit {
|
||||
timeout?.cancel()
|
||||
timeout = null
|
||||
}
|
||||
onFinished {
|
||||
timer.cancel()
|
||||
}
|
||||
|
||||
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
|
||||
}
|
||||
|
||||
activeTransferringState = state("transferring") {
|
||||
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
|
||||
}
|
||||
}
|
||||
|
||||
standbyState = state("standby") {
|
||||
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
|
||||
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
|
||||
transitionOn<Events.Close> { targetState = { closedState } }
|
||||
}
|
||||
|
||||
closedState = finalState("closed")
|
||||
onFinished {
|
||||
shutdown()
|
||||
}
|
||||
|
||||
logger = StateMachine.Logger { message ->
|
||||
this@RandomAccessCallbackWrapper.logger.finer(message())
|
||||
}
|
||||
}
|
||||
|
||||
private val workerThread = HandlerThread(javaClass.simpleName).apply { start() }
|
||||
val workerHandler: Handler = Handler(workerThread.looper)
|
||||
|
||||
private var _callback: RandomAccessCallback? = null
|
||||
|
||||
fun<T> requireCallback(block: (callback: RandomAccessCallback) -> T): T {
|
||||
machine.processEventBlocking(Events.Transfer)
|
||||
try {
|
||||
return block(_callback ?: throw IllegalStateException())
|
||||
} finally {
|
||||
machine.processEventBlocking(Events.NowIdle)
|
||||
}
|
||||
}
|
||||
private fun requireCallback(functionName: String): RandomAccessCallback =
|
||||
callbackRef ?: throw ErrnoException(functionName, OsConstants.EBADF)
|
||||
|
||||
|
||||
/// states ///
|
||||
// non-interface delegates
|
||||
|
||||
@Synchronized
|
||||
private fun shutdown() {
|
||||
httpClient.close()
|
||||
workerThread.quit()
|
||||
}
|
||||
fun fileDescriptor() =
|
||||
requireCallback("fileDescriptor").fileDescriptor()
|
||||
|
||||
|
||||
/// delegating implementation of ProxyFileDescriptorCallback ///
|
||||
// delegating implementation of ProxyFileDescriptorCallback
|
||||
|
||||
@Synchronized
|
||||
override fun onFsync() { /* not used */ }
|
||||
|
||||
@Synchronized
|
||||
override fun onGetSize() =
|
||||
requireCallback { it.onGetSize() }
|
||||
requireCallback("onGetSize").onGetSize()
|
||||
|
||||
@Synchronized
|
||||
override fun onRead(offset: Long, size: Int, data: ByteArray) =
|
||||
requireCallback { it.onRead(offset, size, data) }
|
||||
requireCallback("onRead").onRead(offset, size, data)
|
||||
|
||||
@Synchronized
|
||||
override fun onWrite(offset: Long, size: Int, data: ByteArray) =
|
||||
requireCallback { it.onWrite(offset, size, data) }
|
||||
requireCallback("onWrite").onWrite(offset, size, data)
|
||||
|
||||
@Synchronized
|
||||
override fun onRelease() {
|
||||
machine.processEventBlocking(Events.Close)
|
||||
requireCallback("onRelease").onRelease()
|
||||
|
||||
// remove reference to allow garbage collection
|
||||
callbackRef = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,23 +4,15 @@
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -32,27 +24,21 @@ import okio.BufferedSink
|
||||
import java.io.IOException
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillClose
|
||||
|
||||
/**
|
||||
* @param client HTTP client– [StreamingFileDescriptor] is responsible to close it
|
||||
* @param client HTTP client ([StreamingFileDescriptor] is responsible to close it)
|
||||
*/
|
||||
class StreamingFileDescriptor @AssistedInject constructor(
|
||||
@Assisted private val client: HttpClient,
|
||||
@Assisted @WillClose private val client: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
@Assisted private val finishedCallback: OnSuccessCallback,
|
||||
@ApplicationContext private val context: Context,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/** 1 MB transfer buffer */
|
||||
private const val BUFFER_SIZE = 1024*1024
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(client: HttpClient, url: HttpUrl, mimeType: MediaType?, externalScope: CoroutineScope, finishedCallback: OnSuccessCallback): StreamingFileDescriptor
|
||||
@@ -61,28 +47,21 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var transferred: Long = 0
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentText(dav.fileName())
|
||||
.setSmallIcon(R.drawable.ic_storage_notify)
|
||||
.setOngoing(true)
|
||||
val notificationTag = url.toString()
|
||||
|
||||
|
||||
fun download() = doStreaming(false)
|
||||
fun upload() = doStreaming(true)
|
||||
|
||||
private fun doStreaming(upload: Boolean): ParcelFileDescriptor {
|
||||
val (readFd, writeFd) = ParcelFileDescriptor.createReliablePipe()
|
||||
|
||||
externalScope.launch(ioDispatcher) {
|
||||
var success = false
|
||||
externalScope.launch {
|
||||
try {
|
||||
if (upload)
|
||||
uploadNow(readFd)
|
||||
else
|
||||
downloadNow(writeFd)
|
||||
|
||||
success = true
|
||||
} catch (e: HttpException) {
|
||||
logger.log(Level.WARNING, "HTTP error when opening remote file", e)
|
||||
writeFd.closeWithError("${e.code} ${e.message}")
|
||||
@@ -90,17 +69,15 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
logger.log(Level.INFO, "Couldn't serve file (not necessarily an error)", e)
|
||||
writeFd.closeWithError(e.message)
|
||||
} finally {
|
||||
// close pipe
|
||||
try {
|
||||
readFd.close()
|
||||
writeFd.close()
|
||||
} catch (_: IOException) {}
|
||||
|
||||
client.close()
|
||||
finishedCallback.onFinished(transferred, success)
|
||||
}
|
||||
|
||||
try {
|
||||
readFd.close()
|
||||
writeFd.close()
|
||||
} catch (_: IOException) {}
|
||||
|
||||
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
|
||||
|
||||
finishedCallback.onSuccess(transferred)
|
||||
}
|
||||
|
||||
return if (upload)
|
||||
@@ -109,49 +86,20 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
readFd
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible {
|
||||
/**
|
||||
* Downloads a WebDAV resource.
|
||||
*
|
||||
* @param writeFd destination file descriptor (could for instance represent a local file)
|
||||
*/
|
||||
private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) {
|
||||
dav.get(DavUtils.acceptAnything(preferred = mimeType), null) { response ->
|
||||
response.body.use { body ->
|
||||
if (response.isSuccessful) {
|
||||
val length = body.contentLength()
|
||||
|
||||
notification.setContentTitle(context.getString(R.string.webdav_notification_download))
|
||||
if (length == -1L)
|
||||
// unknown file size, show notification now (no updates on progress)
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
notification
|
||||
.setProgress(100, 0, true)
|
||||
.build()
|
||||
}
|
||||
else
|
||||
// known file size
|
||||
notification.setSubText(Formatter.formatFileSize(context, length))
|
||||
|
||||
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { output ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { destination ->
|
||||
body.byteStream().use { source ->
|
||||
// read first chunk
|
||||
var bytes = source.read(buffer)
|
||||
while (bytes != -1) {
|
||||
// update notification (if file size is known)
|
||||
if (length > 0)
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
val progress = (transferred*100/length).toInt()
|
||||
notification
|
||||
.setProgress(100, progress, false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// write chunk
|
||||
output.write(buffer, 0, bytes)
|
||||
transferred += bytes
|
||||
|
||||
// read next chunk
|
||||
bytes = source.read(buffer)
|
||||
}
|
||||
logger.finer("Downloaded $transferred byte(s) from $url")
|
||||
transferred += source.copyTo(destination)
|
||||
}
|
||||
logger.finer("Downloaded $transferred byte(s) from $url")
|
||||
}
|
||||
|
||||
} else
|
||||
@@ -160,31 +108,18 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible {
|
||||
/**
|
||||
* Uploads a WebDAV resource.
|
||||
*
|
||||
* @param readFd source file descriptor (could for instance represent a local file)
|
||||
*/
|
||||
private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) {
|
||||
val body = object: RequestBody() {
|
||||
override fun contentType(): MediaType? = mimeType
|
||||
override fun isOneShot() = true
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
notification
|
||||
.setContentTitle(context.getString(R.string.webdav_notification_upload))
|
||||
.build()
|
||||
}
|
||||
|
||||
ParcelFileDescriptor.AutoCloseInputStream(readFd).use { input ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
|
||||
// read first chunk
|
||||
var size = input.read(buffer)
|
||||
while (size != -1) {
|
||||
// write chunk
|
||||
sink.write(buffer, 0, size)
|
||||
transferred += size
|
||||
|
||||
// read next chunk
|
||||
size = input.read(buffer)
|
||||
}
|
||||
transferred += input.copyTo(sink.outputStream())
|
||||
logger.finer("Uploaded $transferred byte(s) to $url")
|
||||
}
|
||||
}
|
||||
@@ -196,7 +131,7 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
|
||||
|
||||
fun interface OnSuccessCallback {
|
||||
fun onSuccess(transferred: Long)
|
||||
fun onFinished(transferred: Long, success: Boolean)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.RequestBody
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
@@ -56,7 +56,7 @@ class CreateDocumentOperation @Inject constructor(
|
||||
// directory successfully created
|
||||
}
|
||||
else
|
||||
doc.put("".toRequestBody(null), ifNoneMatch = true) {
|
||||
doc.put(RequestBody.EMPTY, ifNoneMatch = true) {
|
||||
// document successfully created
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,11 @@ class CreateDocumentOperation @Inject constructor(
|
||||
mountId = parent.mountId,
|
||||
parentId = parent.id,
|
||||
name = newName,
|
||||
isDirectory = createDirectory,
|
||||
mimeType = mimeType.toMediaTypeOrNull(),
|
||||
isDirectory = createDirectory
|
||||
eTag = null,
|
||||
lastModified = null,
|
||||
size = if (createDirectory) null else 0
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ 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
|
||||
@@ -42,7 +40,6 @@ class OpenDocumentOperation @Inject constructor(
|
||||
) {
|
||||
|
||||
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")
|
||||
@@ -51,8 +48,7 @@ class OpenDocumentOperation @Inject constructor(
|
||||
val url = doc.toHttpUrl(db)
|
||||
val client = httpClientBuilder.build(doc.mountId, logBody = false)
|
||||
|
||||
val modeFlags = ParcelFileDescriptor.parseMode(mode)
|
||||
val readAccess = when (mode) {
|
||||
val readOnlyMode = when (mode) {
|
||||
"r" -> true
|
||||
"w", "wt" -> false
|
||||
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
|
||||
@@ -72,21 +68,24 @@ class OpenDocumentOperation @Inject constructor(
|
||||
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
|
||||
return@runBlocking if (
|
||||
androidSupportsRandomAccess &&
|
||||
readAccess && // WebDAV doesn't support random write access (natively)
|
||||
readOnlyMode && // 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
|
||||
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine when the document changes during access
|
||||
fileInfo.supportsPartial == true // WebDAV server must advertise random access
|
||||
) {
|
||||
logger.fine("Creating RandomAccessCallback for $url")
|
||||
val accessor = randomAccessCallbackWrapperFactory.create(client, url, doc.mimeType, fileInfo, accessScope)
|
||||
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
|
||||
accessor.fileDescriptor()
|
||||
|
||||
} else {
|
||||
logger.fine("Creating StreamingFileDescriptor for $url")
|
||||
val fd = streamingFileDescriptorFactory.create(client, url, doc.mimeType, accessScope) { transferred ->
|
||||
val fd = streamingFileDescriptorFactory.create(client, url, doc.mimeType, accessScope) { transferred, success ->
|
||||
// called when transfer is finished
|
||||
if (!success)
|
||||
return@create
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!readAccess /* write access */) {
|
||||
if (!readOnlyMode /* write access */) {
|
||||
// write access, update file size
|
||||
documentDao.update(doc.copy(size = transferred, lastModified = now))
|
||||
}
|
||||
@@ -94,7 +93,7 @@ class OpenDocumentOperation @Inject constructor(
|
||||
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
|
||||
}
|
||||
|
||||
if (readAccess)
|
||||
if (readOnlyMode)
|
||||
fd.download()
|
||||
else
|
||||
fd.upload()
|
||||
@@ -108,7 +107,7 @@ class OpenDocumentOperation @Inject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
// openProxyFileDescriptor exists since Android 8.0
|
||||
/** openProxyFileDescriptor (required for random access) exists since Android 8.0 */
|
||||
val androidSupportsRandomAccess = Build.VERSION.SDK_INT >= 26
|
||||
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ kotlinx-coroutines = "1.10.2"
|
||||
# see https://github.com/google/ksp/releases for version numbers
|
||||
ksp = "2.2.0-2.0.2"
|
||||
mikepenz-aboutLibraries = "12.2.4"
|
||||
nsk90-kstatemachine = "0.33.0"
|
||||
mockk = "1.14.5"
|
||||
okhttp = "5.1.0"
|
||||
openid-appauth = "0.11.1"
|
||||
@@ -96,7 +95,6 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
|
||||
mikepenz-aboutLibraries = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||
nsk90-kstatemachine = { module = "io.github.nsk90:kstatemachine-jvm", version.ref = "nsk90-kstatemachine" }
|
||||
okhttp-base = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp" }
|
||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
|
||||
Reference in New Issue
Block a user