Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Warn users when they run out of space #656

Merged
merged 9 commits into from
May 14, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ class KoinInstrumentationTestApp : App() {
single { spyk(SettingsManager(context)) }

single { spyk(BackupNotificationManager(context)) }
single { spyk(FullBackup(get(), get(), get(), get())) }
single { spyk(KVBackup(get(), get(), get(), get(), get())) }
single { spyk(FullBackup(get(), get(), get(), get(), get())) }
single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
single { spyk(InputFactory()) }

single { spyk(FullRestore(get(), get(), get(), get(), get())) }
Expand Down
11 changes: 11 additions & 0 deletions app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ class PluginTest : KoinComponent {
assertNotNull(storagePlugin.providerPackageName)
}

@Test
fun testTest() = runBlocking(Dispatchers.IO) {
assertTrue(storagePlugin.test())
}

@Test
fun testGetFreeSpace() = runBlocking(Dispatchers.IO) {
val freeBytes = storagePlugin.getFreeSpace() ?: error("no free space retrieved")
assertTrue(freeBytes > 0)
}

/**
* This test initializes the storage three times while creating two new restore sets.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ data class BackupMetadata(
internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}",
internal var d2dBackup: Boolean = false,
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
)
) {
val size: Long?
get() = packageMetadataMap.values.sumOf { m ->
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L)
}
}

internal const val JSON_METADATA = "@meta@"
internal const val JSON_METADATA_VERSION = "version"
Expand Down Expand Up @@ -89,6 +94,7 @@ data class PackageMetadata(

data class ApkSplit(
val name: String,
val size: Long?,
val sha256: String,
// There's also a revisionCode, but it doesn't seem to be used just yet
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ internal class MetadataManager(
return field
}

val backupSize: Long? get() = metadata.size

/**
* Call this when initializing a new device.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
val jsonApkSplit = jsonSplits.getJSONObject(i)
val apkSplit = ApkSplit(
name = jsonApkSplit.getString(JSON_PACKAGE_SPLIT_NAME),
size = jsonApkSplit.optLong(JSON_PACKAGE_SIZE, -1L).let {
if (it < 0L) null else it
},
sha256 = jsonApkSplit.getString(JSON_PACKAGE_SHA256)
)
splits.add(apkSplit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
put(JSON_PACKAGE_SPLITS, JSONArray().apply {
for (split in splits) put(JSONObject().apply {
put(JSON_PACKAGE_SPLIT_NAME, split.name)
if (split.size != null) put(JSON_PACKAGE_SIZE, split.size)
put(JSON_PACKAGE_SHA256, split.sha256)
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ interface StoragePlugin<T> {
*/
suspend fun test(): Boolean

/**
* Retrieves the available storage space in bytes.
* @return the number of bytes available or null if the number is unknown.
* Returning a negative number or zero to indicate unknown is discouraged.
*/
suspend fun getFreeSpace(): Long?

/**
* Start a new [RestoreSet] with the given token.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package com.stevesoltys.seedvault.plugins

import android.content.Context
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.permitDiskReads
Expand Down Expand Up @@ -131,4 +132,17 @@ class StoragePluginManager(
return storage.isUnavailableUsb(systemContext)
}

/**
* Retrieves the amount of free space in bytes, or null if unknown.
*/
@WorkerThread
suspend fun getFreeSpace(): Long? {
return try {
appPlugin.getFreeSpace()
} catch (e: Exception) {
Log.e("StoragePluginManager", "Error getting free space: ", e)
null
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import androidx.annotation.WorkerThread
import at.bitfire.dav4jvm.exception.HttpException
import java.io.IOException

abstract class StorageProperties<T> {
abstract val config: T
Expand All @@ -34,3 +36,14 @@ abstract class StorageProperties<T> {
return capabilities.hasCapability(NET_CAPABILITY_INTERNET) && (allowMetered || !isMetered)
}
}

fun Exception.isOutOfSpace(): Boolean {
return when (this) {
is IOException -> message?.contains("No space left on device") == true ||
(cause as? HttpException)?.code == 507

is HttpException -> code == 507

else -> false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import android.os.StatFs
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import android.util.Log
import androidx.core.database.getIntOrNull
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.tokenRegex
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
import java.io.FileNotFoundException
import java.io.IOException
Expand All @@ -35,6 +43,32 @@ internal class DocumentsProviderStoragePlugin(
return dir != null && dir.exists()
}

override suspend fun getFreeSpace(): Long? {
val rootId = storage.safStorage.rootId ?: return null
val authority = storage.safStorage.uri.authority
// using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work
val rootUri = DocumentsContract.buildRootsUri(authority)
val projection = arrayOf(COLUMN_AVAILABLE_BYTES)
// query directly for our rootId
val bytesAvailable = context.contentResolver.query(
rootUri, projection, "$COLUMN_ROOT_ID=?", arrayOf(rootId), null
)?.use { c ->
if (!c.moveToNext()) return@use null // no results
val bytes = c.getIntOrNull(c.getColumnIndex(COLUMN_AVAILABLE_BYTES))
if (bytes != null && bytes >= 0) return@use bytes.toLong()
else return@use null
}
// if we didn't get anything from SAF, try some known hacks
return if (bytesAvailable == null && authority == AUTHORITY_STORAGE) {
if (rootId == ROOT_ID_DEVICE) {
StatFs(Environment.getDataDirectory().absolutePath).availableBytes
} else if (storage.safStorage.isUsb) {
val documentId = storage.safStorage.uri.lastPathSegment ?: return null
StatFs("/mnt/media_rw/${documentId.trimEnd(':')}").availableBytes
} else null
} else bytesAvailable
}

@Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) {
// reset current storage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,14 @@ internal suspend fun DocumentFile.createOrGetFile(
if (this.name != name) {
throw IOException("File named ${this.name}, but should be $name")
}
} ?: throw IOException()
} ?: throw IOException("could not find nor create")
} catch (e: Exception) {
// SAF can throw all sorts of exceptions, so wrap it in IOException.
// E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument()
// when flash drive is not plugged-in:
// http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
throw IOException(e)
if (e is IOException) throw e
else throw IOException(e)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ internal class SafHandler(
} else {
safOption.title
}
return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork)
return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork, safOption.rootId)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.plugins.saf

import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.plugins.StorageProperties
Expand All @@ -16,6 +17,11 @@ data class SafStorage(
override val name: String,
override val isUsb: Boolean,
override val requiresNetwork: Boolean,
/**
* The [COLUMN_ROOT_ID] for the [uri].
* This is only nullable for historic reasons, because we didn't always store it.
*/
val rootId: String?,
) : StorageProperties<Uri>() {

val uri: Uri = config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import android.content.Context
import android.database.Cursor
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Environment
import android.os.StatFs
import android.os.UserHandle
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
Expand Down Expand Up @@ -99,18 +101,30 @@ internal object StorageRootResolver {
val rootId = cursor.getString(COLUMN_ROOT_ID)!!
if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null
val documentId = cursor.getString(COLUMN_DOCUMENT_ID) ?: return null
val isUsb = flags and FLAG_REMOVABLE_USB != 0
return SafOption(
authority = authority,
rootId = rootId,
documentId = documentId,
icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)),
title = cursor.getString(COLUMN_TITLE)!!,
summary = cursor.getString(COLUMN_SUMMARY),
availableBytes = cursor.getLong(COLUMN_AVAILABLE_BYTES).let { bytes ->
// AOSP 11 reports -1 instead of null
if (bytes == -1L) null else bytes
availableBytes = cursor.getInt(COLUMN_AVAILABLE_BYTES).let { bytes ->
// AOSP 11+ reports -1 instead of null
if (bytes == -1) {
try {
if (isUsb) {
StatFs("/mnt/media_rw/${documentId.trimEnd(':')}").availableBytes
} else if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE) {
StatFs(Environment.getDataDirectory().absolutePath).availableBytes
} else null
} catch (e: Exception) {
Log.e(TAG, "Error getting available bytes for $rootId ", e)
null
}
} else bytes.toLong()
},
isUsb = flags and FLAG_REMOVABLE_USB != 0,
isUsb = isUsb,
requiresNetwork = flags and FLAG_LOCAL_ONLY == 0 // not local only == requires network
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.QuotaAvailableBytes
import at.bitfire.dav4jvm.property.ResourceType
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin
Expand Down Expand Up @@ -43,6 +44,25 @@ internal class WebDavStoragePlugin(
return webDavSupported
}

override suspend fun getFreeSpace(): Long? {
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)

val availableBytes = suspendCoroutine { cont ->
davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ ->
debugLog { "getFreeSpace() = $response" }
val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes
val availableBytes = quota?.quotaAvailableBytes ?: -1
if (availableBytes > 0) {
cont.resume(availableBytes)
} else {
cont.resume(null)
}
}
}
return availableBytes
}

@Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) {
val location = "$url/$token".toHttpUrl()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ data class RestorableBackup(val backupMetadata: BackupMetadata) {
val time: Long
get() = backupMetadata.time

val size: Long?
get() = backupMetadata.size

val deviceName: String
get() = backupMetadata.deviceName

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package com.stevesoltys.seedvault.restore
import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
import android.text.format.DateUtils.HOUR_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter
Expand Down Expand Up @@ -33,6 +36,7 @@ internal class RestoreSetAdapter(

private val titleView = v.requireViewById<TextView>(R.id.titleView)
private val subtitleView = v.requireViewById<TextView>(R.id.subtitleView)
private val sizeView = v.requireViewById<TextView>(R.id.sizeView)

internal fun bind(item: RestorableBackup) {
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
Expand All @@ -42,6 +46,16 @@ internal class RestoreSetAdapter(
val setup = getRelativeTime(item.token)
subtitleView.text =
v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
val size = item.size
if (size == null) {
sizeView.visibility = GONE
} else {
sizeView.text = v.context.getString(
R.string.restore_restore_set_size,
Formatter.formatShortFileSize(v.context, size),
)
sizeView.visibility = VISIBLE
}
}

private fun getRelativeTime(time: Long): CharSequence {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ class RestoreSetFragment : Fragment() {
// decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)

viewModel.restoreSetResults.observe(viewLifecycleOwner, { result ->
viewModel.restoreSetResults.observe(viewLifecycleOwner) { result ->
onRestoreResultsLoaded(result)
})
}

skipView.setOnClickListener {
viewModel.onFinishClickedAfterRestoringAppData()
Expand All @@ -72,7 +72,10 @@ class RestoreSetFragment : Fragment() {
listView.visibility = VISIBLE
progressBar.visibility = INVISIBLE

listView.adapter = RestoreSetAdapter(viewModel, results.restorableBackups)
listView.adapter = RestoreSetAdapter(
listener = viewModel,
items = results.restorableBackups.sortedByDescending { it.time },
)
}
}

Expand Down
Loading
Loading