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:
Ricki Hirner
2025-08-01 10:38:24 +02:00
committed by GitHub
parent 755863778b
commit be6c3311d7
8 changed files with 121 additions and 284 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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\"")

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
)

View File

@@ -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
}

View File

@@ -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" }