Work around DocumentFile bug happening with cloud-based DocumentsProviders

These might return outdated or now content when queried,
then check their cloud storage and report back with up-to-date content.
We now detect this (when looking for backups on newly setup storage)
and wait until the content has been loaded before acting on the
response.

This is affecting and was tested with NextCloud.
This commit is contained in:
Torsten Grote 2019-12-12 16:27:57 -03:00
parent bbc8bdfaa5
commit 440491425a
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
8 changed files with 104 additions and 20 deletions

View file

@ -49,6 +49,6 @@ class PluginManager(context: Context) {
private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto) private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto)
private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto) private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto)
internal val restoreCoordinator = RestoreCoordinator(settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader) internal val restoreCoordinator = RestoreCoordinator(context, settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader)
} }

View file

@ -1,14 +1,21 @@
package com.stevesoltys.seedvault.transport.backup.plugins package com.stevesoltys.seedvault.transport.backup.plugins
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.database.ContentObserver
import android.net.Uri
import android.provider.DocumentsContract.*
import android.provider.DocumentsContract.Document.*
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import libcore.io.IoUtils.closeQuietly
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.TimeUnit.MINUTES
const val DIRECTORY_ROOT = ".AndroidBackup" const val DIRECTORY_ROOT = ".AndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_FULL_BACKUP = "full"
@ -126,3 +133,70 @@ fun DocumentFile.deleteContents() {
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
if (name != packageInfo.packageName) throw AssertionError() if (name != packageInfo.packageName) throw AssertionError()
} }
/**
* Works like [DocumentFile.listFiles] except that it waits until the DocumentProvider has a result.
* This prevents getting an empty list even though there are children to be listed.
*/
@Throws(IOException::class)
fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE)
val result = ArrayList<DocumentFile>()
@SuppressLint("Recycle") // gets closed in with(), only earlier exit when null
var cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
var loaded = false
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
loaded = true
}
})
val timeout = MINUTES.toMillis(2)
var time = 0
while (!loaded && time < timeout) {
Thread.sleep(50)
time += 50
}
if (time >= timeout) Log.w(TAG, "Timed out while waiting for children to load")
closeQuietly(cursor)
// do a new query after content was loaded
@SuppressLint("Recycle") // gets closed after with block
cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
}
with(cursor) {
while (moveToNext()) {
val documentId = getString(0)
val isDirectory = getString(1) == MIME_TYPE_DIR
val file = if (isDirectory) {
val treeUri = buildTreeDocumentUri(uri.authority, documentId)
DocumentFile.fromTreeUri(context, treeUri)!!
} else {
val documentUri = buildDocumentUriUsingTree(uri, documentId)
DocumentFile.fromSingleUri(context, documentUri)!!
}
result.add(file)
}
}
return result
}
fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? {
val files = try {
listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error finding file blocking", e)
return null
}
for (doc in files) {
if (displayName == doc.name) return doc
}
return null
}

View file

@ -5,6 +5,7 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.* import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet import android.app.backup.RestoreSet
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
@ -22,6 +23,7 @@ private class RestoreCoordinatorState(
private val TAG = RestoreCoordinator::class.java.simpleName private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator( internal class RestoreCoordinator(
private val context: Context,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val plugin: RestorePlugin, private val plugin: RestorePlugin,
private val kv: KVRestore, private val kv: KVRestore,
@ -37,7 +39,7 @@ internal class RestoreCoordinator(
* or null if an error occurred (the attempt should be rescheduled). * or null if an error occurred (the attempt should be rescheduled).
**/ **/
fun getAvailableRestoreSets(): Array<RestoreSet>? { fun getAvailableRestoreSets(): Array<RestoreSet>? {
val availableBackups = plugin.getAvailableBackups() ?: return null val availableBackups = plugin.getAvailableBackups(context) ?: return null
val restoreSets = ArrayList<RestoreSet>() val restoreSets = ArrayList<RestoreSet>()
for (encryptedMetadata in availableBackups) { for (encryptedMetadata in availableBackups) {
if (encryptedMetadata.error) continue if (encryptedMetadata.error) continue

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.transport.restore package com.stevesoltys.seedvault.transport.restore
import android.content.Context
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
interface RestorePlugin { interface RestorePlugin {
@ -14,6 +15,6 @@ interface RestorePlugin {
* @return metadata for the set of restore images available, * @return metadata for the set of restore images available,
* or null if an error occurred (the attempt should be rescheduled). * or null if an error occurred (the attempt should be rescheduled).
**/ **/
fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? fun getAvailableBackups(context: Context): Sequence<EncryptedBackupMetadata>?
} }

View file

@ -1,11 +1,11 @@
package com.stevesoltys.seedvault.transport.restore.plugins package com.stevesoltys.seedvault.transport.restore.plugins
import android.content.Context
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
import com.stevesoltys.seedvault.transport.backup.plugins.DocumentsStorage import com.stevesoltys.seedvault.transport.backup.plugins.*
import com.stevesoltys.seedvault.transport.backup.plugins.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.backup.plugins.FILE_NO_MEDIA
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
@ -23,9 +23,9 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re
DocumentsProviderFullRestorePlugin(storage) DocumentsProviderFullRestorePlugin(storage)
} }
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? { override fun getAvailableBackups(context: Context): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(rootDir) val backupSets = getBackups(context, rootDir)
val iterator = backupSets.iterator() val iterator = backupSets.iterator()
return generateSequence { return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence if (!iterator.hasNext()) return@generateSequence null // end sequence
@ -41,9 +41,17 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re
} }
companion object { companion object {
fun getBackups(rootDir: DocumentFile): List<BackupSet> { @WorkerThread
fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>() val backupSets = ArrayList<BackupSet>()
for (set in rootDir.listFiles()) { val files = try {
// block until the DocumentsProvider has results
rootDir.listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error loading backups from storage", e)
return backupSets
}
for (set in files) {
if (!set.isDirectory || set.name == null) { if (!set.isDirectory || set.name == null) {
if (set.name != FILE_NO_MEDIA) { if (set.name != FILE_NO_MEDIA) {
Log.w(TAG, "Found invalid backup set folder: ${set.name}") Log.w(TAG, "Found invalid backup set folder: ${set.name}")
@ -53,10 +61,11 @@ class DocumentsProviderRestorePlugin(private val storage: DocumentsStorage) : Re
val token = try { val token = try {
set.name!!.toLong() set.name!!.toLong()
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
Log.w(TAG, "Found invalid backup set folder: ${set.name}", e) Log.w(TAG, "Found invalid backup set folder: ${set.name}")
continue continue
} }
val metadata = set.findFile(FILE_BACKUP_METADATA) // block until children of set are available
val metadata = set.findFileBlocking(context, FILE_BACKUP_METADATA)
if (metadata == null) { if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}") Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
} else { } else {

View file

@ -7,6 +7,7 @@ import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.backup.plugins.DIRECTORY_ROOT import com.stevesoltys.seedvault.transport.backup.plugins.DIRECTORY_ROOT
import com.stevesoltys.seedvault.transport.backup.plugins.findFileBlocking
import com.stevesoltys.seedvault.transport.restore.plugins.DocumentsProviderRestorePlugin import com.stevesoltys.seedvault.transport.restore.plugins.DocumentsProviderRestorePlugin
private val TAG = RestoreStorageViewModel::class.java.simpleName private val TAG = RestoreStorageViewModel::class.java.simpleName
@ -40,12 +41,9 @@ internal class RestoreStorageViewModel(private val app: Application) : StorageVi
*/ */
@WorkerThread @WorkerThread
private fun hasBackup(folderUri: Uri): Boolean { private fun hasBackup(folderUri: Uri): Boolean {
// FIXME This currently fails for NextCloud's DocumentsProvider,
// if called right after setting up an account.
// It requires three attempts to finally find existing backups.
val parent = DocumentFile.fromTreeUri(app, folderUri) ?: throw AssertionError() val parent = DocumentFile.fromTreeUri(app, folderUri) ?: throw AssertionError()
val rootDir = parent.findFile(DIRECTORY_ROOT) ?: return false val rootDir = parent.findFileBlocking(app, DIRECTORY_ROOT) ?: return false
val backupSets = DocumentsProviderRestorePlugin.getBackups(rootDir) val backupSets = DocumentsProviderRestorePlugin.getBackups(app, rootDir)
return backupSets.isNotEmpty() return backupSets.isNotEmpty()
} }

View file

@ -50,7 +50,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl) private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk<FullRestorePlugin>() private val fullRestorePlugin = mockk<FullRestorePlugin>()
private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl) private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator(settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader) private val restore = RestoreCoordinator(context, settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader)
private val backupDataInput = mockk<BackupDataInput>() private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true) private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)

View file

@ -27,7 +27,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val full = mockk<FullRestore>() private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>() private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator(settingsManager, plugin, kv, full, metadataReader) private val restore = RestoreCoordinator(context, settingsManager, plugin, kv, full, metadataReader)
private val token = Random.nextLong() private val token = Random.nextLong()
private val inputStream = mockk<InputStream>() private val inputStream = mockk<InputStream>()
@ -43,7 +43,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
androidVersion = Random.nextInt(), androidVersion = Random.nextInt(),
deviceName = getRandomString()) deviceName = getRandomString())
every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata) every { plugin.getAvailableBackups(context) } returns sequenceOf(encryptedMetadata, encryptedMetadata)
every { metadataReader.readMetadata(inputStream, token) } returns metadata every { metadataReader.readMetadata(inputStream, token) } returns metadata
every { inputStream.close() } just Runs every { inputStream.close() } just Runs