Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,35 @@ class AndroidVirtualFileCacheInstrumentedTest {
assertEquals(0, androidDocumentPendingWritebackCount(context, session))
}

@Test
fun processRestoredWritebackBlocksDestructiveMutationUntilRecovery() {
val recovery = File(context.filesDir, "documents-recovery").apply { mkdirs() }
val stage = File(recovery, "writeback-restored.stage").apply { writeText("local edit") }
File(recovery, stage.name + ".json").writeText(
JSONObject()
.put("version", 1)
.put("account", NextcloudDocumentIds.accountKey(session))
.put("path", "Projects/Active/notes.txt")
.put("etag", "\"v1\"")
.put("displayName", "notes.txt")
.put("stage", stage.name)
.put("startedAt", 10L)
.put("ready", true)
.toString(),
)

org.junit.Assert.assertThrows(IllegalStateException::class.java) {
withNoBlockingAndroidDocumentWriteback(context, session, "Projects/Active") {
error("The blocked mutation must not run.")
}
}
var unrelatedMutationRan = false
withNoBlockingAndroidDocumentWriteback(context, session, "Projects/Archive") {
unrelatedMutationRan = true
}
org.junit.Assert.assertTrue(unrelatedMutationRan)
}

@Test
fun providerStartupDiscardsIncompleteWritebacksAndKeepsReadyRecovery() {
val recovery = File(context.filesDir, "documents-recovery").apply { mkdirs() }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
package dev.obiente.nextcloudnative

import dev.obiente.nextcloudnative.app.NextcloudSession
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
import org.json.JSONObject

internal const val MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES = Long.MAX_VALUE
internal const val MIN_ANDROID_DOCUMENT_FREE_BYTES = 512L * 1024L * 1024L
Comment thread
veryCrunchy marked this conversation as resolved.

internal fun requireAndroidDocumentWritebackCapacity(remoteSize: Long, availableBytes: Long) {
require(remoteSize >= 0L && availableBytes >= 0L)
require(remoteSize <= (availableBytes - MIN_ANDROID_DOCUMENT_FREE_BYTES).coerceAtLeast(0L)) {
"There is not enough free space to stage this edit safely."
}
}

internal fun requireAndroidDocumentStagedWritebackCapacity(stagedBytes: Long, availableBytes: Long) {
require(stagedBytes >= 0L && availableBytes >= 0L)
require(availableBytes >= MIN_ANDROID_DOCUMENT_FREE_BYTES) {
"There is not enough free space to retain this edit safely."
}
}

internal data class AndroidDocumentPendingWriteback(
val staging: File,
val manifest: File,
val accountId: String,
val remotePath: String,
val expectedRemoteEtag: String,
val conflict: Boolean = false,
) {
init {
require(accountId.isNotBlank())
require(remotePath.isNotBlank() && remotePath.split('/').none { it.isEmpty() || it == "." || it == ".." })
require(expectedRemoteEtag.isNotBlank() && '\r' !in expectedRemoteEtag && '\n' !in expectedRemoteEtag)
require(staging.isFile && manifest.isFile)
}

fun markReadyAndActive() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val payload = JSONObject(manifest.readText()).put("ready", true).toString().encodeToByteArray()
val temporary = File.createTempFile("manifest-", ".tmp", manifest.parentFile)
try {
FileOutputStream(temporary).use { output ->
output.write(payload)
output.fd.sync()
}
try {
Files.move(
temporary.toPath(),
manifest.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(temporary.toPath(), manifest.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
ACTIVE_ANDROID_DOCUMENT_WRITEBACKS += manifest.activeWritebackKey()
} finally {
temporary.delete()
}
}

fun markConflict(observedRemoteEtag: String?) = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val data = JSONObject(manifest.readText())
.put("conflict", true)
.put("observedEtag", observedRemoteEtag ?: JSONObject.NULL)
val payload = data.toString().encodeToByteArray()
require(payload.size <= 64 * 1024)
val temporary = File.createTempFile("manifest-", ".tmp", manifest.parentFile)
try {
FileOutputStream(temporary).use { output ->
output.write(payload)
output.fd.sync()
}
try {
Files.move(
temporary.toPath(),
manifest.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(temporary.toPath(), manifest.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} finally {
temporary.delete()
}
}

fun complete() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
staging.delete()
manifest.delete()
ACTIVE_ANDROID_DOCUMENT_WRITEBACKS -= manifest.activeWritebackKey()
ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activeWritebackPath()
}

fun discard() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
manifest.delete()
staging.delete()
ACTIVE_ANDROID_DOCUMENT_WRITEBACKS -= manifest.activeWritebackKey()
ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activeWritebackPath()
}

fun releaseActive() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
ACTIVE_ANDROID_DOCUMENT_WRITEBACKS -= manifest.activeWritebackKey()
ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activeWritebackPath()
}

private fun activeWritebackPath() = ActiveAndroidDocumentWritebackPath(accountId, remotePath)
}

internal fun androidDocumentPendingWritebackCount(context: android.content.Context, session: NextcloudSession): Int {
return androidDocumentPendingWritebacks(context, session).size
}

internal fun androidDocumentPendingWritebacks(
context: android.content.Context,
session: NextcloudSession,
): List<AndroidDocumentPendingWriteback> = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val root = File(context.filesDir, "documents-recovery")
if (!root.isDirectory) return emptyList()
val accountId = NextcloudDocumentIds.accountKey(session)
return root.listFiles().orEmpty().mapNotNull { manifest ->
parseAndroidDocumentWriteback(root, manifest, accountId)
}.filterNot { writeback ->
writeback.manifest.activeWritebackKey() in ACTIVE_ANDROID_DOCUMENT_WRITEBACKS
}.sortedBy { writeback -> writeback.manifest.lastModified() }
}

internal fun androidDocumentPendingWriteback(
context: android.content.Context?,
session: NextcloudSession,
remotePath: String,
): AndroidDocumentPendingWriteback? = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val root = context?.let { File(it.filesDir, "documents-recovery") } ?: return null
if (!root.isDirectory) return null
val account = NextcloudDocumentIds.accountKey(session)
return root.listFiles().orEmpty().asSequence()
.mapNotNull { manifest -> parseAndroidDocumentWriteback(root, manifest, account) }
.filter { writeback -> writeback.remotePath == remotePath }
.filterNot { writeback ->
writeback.manifest.activeWritebackKey() in ACTIVE_ANDROID_DOCUMENT_WRITEBACKS
}
.maxByOrNull { writeback -> writeback.manifest.lastModified() }
}

internal fun claimAndroidDocumentPendingWriteback(
context: android.content.Context?,
session: NextcloudSession,
remotePath: String,
): AndroidDocumentPendingWriteback? = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
androidDocumentPendingWriteback(context, session, remotePath)?.also { writeback ->
ACTIVE_ANDROID_DOCUMENT_WRITEBACKS += writeback.manifest.activeWritebackKey()
}
}

internal fun claimAndroidDocumentPendingWritebackForRecovery(
context: android.content.Context,
session: NextcloudSession,
remotePath: String,
): AndroidDocumentPendingWriteback? = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val activePath = ActiveAndroidDocumentWritebackPath(NextcloudDocumentIds.accountKey(session), remotePath)
if (!ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.add(activePath)) return null
val pending = androidDocumentPendingWriteback(context, session, remotePath)
if (pending == null) {
ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activePath
return null
}
ACTIVE_ANDROID_DOCUMENT_WRITEBACKS += pending.manifest.activeWritebackKey()
pending
}

internal fun reserveAndroidDocumentWritebackPath(session: NextcloudSession, remotePath: String) =
synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val active = ActiveAndroidDocumentWritebackPath(NextcloudDocumentIds.accountKey(session), remotePath)
check(ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.add(active)) {
"This document already has an active local edit."
}
}

internal fun releaseAndroidDocumentWritebackPath(session: NextcloudSession, remotePath: String) =
synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -=
ActiveAndroidDocumentWritebackPath(NextcloudDocumentIds.accountKey(session), remotePath)
}

internal fun <T> withNoBlockingAndroidDocumentWriteback(
context: android.content.Context?,
session: NextcloudSession,
vararg remotePaths: String,
operation: () -> T,
): T = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val accountId = NextcloudDocumentIds.accountKey(session)
val activePaths = ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.asSequence()
.filter { active -> active.accountId == accountId }
.map(ActiveAndroidDocumentWritebackPath::remotePath)
val providerContext = requireNotNull(context) { "Provider context is unavailable." }
val retainedPaths = androidDocumentPendingWritebacks(providerContext, session)
.asSequence()
.map(AndroidDocumentPendingWriteback::remotePath)
check(!androidDocumentWritebacksBlockMutation(activePaths, retainedPaths, *remotePaths)) {
"This document cannot be changed while a local edit still needs recovery."
}
operation()
}

internal fun androidDocumentWritebacksBlockMutation(
activePaths: Sequence<String>,
retainedPaths: Sequence<String>,
vararg mutationPaths: String,
): Boolean = (activePaths + retainedPaths).any { path ->
androidDocumentWritebackPathBlocksMutation(path, *mutationPaths)
}

internal fun androidDocumentWritebackPathBlocksMutation(
activePath: String,
vararg mutationPaths: String,
): Boolean = mutationPaths.any { path -> activePath == path || activePath.startsWith("$path/") }

private fun parseAndroidDocumentWriteback(
root: File,
manifest: File,
expectedAccount: String?,
): AndroidDocumentPendingWriteback? = runCatching {
require(manifest.isFile && manifest.name.endsWith(".stage.json") && manifest.length() <= 64 * 1024L)
val data = JSONObject(manifest.readText())
val stageName = data.getString("stage")
require(data.getInt("version") == 1 && data.optBoolean("ready", false))
val account = data.getString("account")
require(expectedAccount == null || account == expectedAccount)
require(data.getLong("startedAt") >= 0L)
require(stageName.startsWith("writeback-") && stageName.endsWith(".stage"))
require('/' !in stageName && '\\' !in stageName)
require(manifest.name == "$stageName.json")
val stage = File(root, stageName)
require(stage.isFile)
AndroidDocumentPendingWriteback(
staging = stage,
manifest = manifest,
accountId = account,
remotePath = data.getString("path"),
expectedRemoteEtag = data.getString("etag"),
conflict = data.optBoolean("conflict", false),
)
}.getOrNull()

/** Removes writeback transactions that could not reach the close-ready state before process death. */
internal fun cleanupIncompleteAndroidDocumentWritebacks(context: android.content.Context): Int =
synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) {
val root = File(context.filesDir, "documents-recovery")
if (!root.isDirectory) return 0
val files = root.listFiles().orEmpty().filter(File::isFile)
val retainedNames = files.mapNotNull { manifest ->
parseAndroidDocumentWriteback(root, manifest, expectedAccount = null)
}.flatMapTo(hashSetOf()) { writeback ->
listOf(writeback.staging.name, writeback.manifest.name)
}
return files.count { file ->
val owned =
(file.name.startsWith("writeback-") && file.name.endsWith(".stage")) ||
(file.name.startsWith("writeback-") && file.name.endsWith(".stage.json")) ||
(file.name.startsWith("manifest-") && file.name.endsWith(".tmp"))
owned && file.name !in retainedNames && file.delete()
}
}

private fun File.activeWritebackKey(): String = absoluteFile.normalize().path

private data class ActiveAndroidDocumentWritebackPath(
val accountId: String,
val remotePath: String,
)

private val ANDROID_DOCUMENT_WRITEBACK_LOCK = Any()
private val ACTIVE_ANDROID_DOCUMENT_WRITEBACKS = ConcurrentHashMap.newKeySet<String>()
private val ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS =
ConcurrentHashMap.newKeySet<ActiveAndroidDocumentWritebackPath>()
Loading
Loading