Add prototype plumbing for new v2 app backup
This commit is contained in:
parent
e6905c0365
commit
1efa8e8f59
18 changed files with 751 additions and 7 deletions
|
@ -1,12 +1,7 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value />
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
|
|
|
@ -32,6 +32,9 @@ android_app {
|
|||
"com.google.android.material_material",
|
||||
"kotlinx-coroutines-android",
|
||||
"kotlinx-coroutines-core",
|
||||
// app backup related libs
|
||||
"seedvault-lib-kotlin-logging-jvm",
|
||||
"seedvault-lib-chunker",
|
||||
"seedvault-lib-zstd-jni",
|
||||
// our own gradle module libs
|
||||
"seedvault-lib-core",
|
||||
|
|
|
@ -157,6 +157,8 @@ dependencies {
|
|||
|
||||
implementation(libs.google.protobuf.javalite)
|
||||
implementation(libs.google.tink.android)
|
||||
implementation(libs.kotlin.logging)
|
||||
implementation(libs.squareup.okio)
|
||||
|
||||
/**
|
||||
* Storage Dependencies
|
||||
|
@ -175,6 +177,7 @@ dependencies {
|
|||
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar"))
|
||||
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("seedvault-chunker-0.1.jar"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("zstd-jni-1.5.6-5.aar"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
||||
|
||||
|
@ -188,6 +191,7 @@ dependencies {
|
|||
// anything less than 'implementation' fails tests run with gradlew
|
||||
testImplementation(aospLibs)
|
||||
testImplementation("androidx.test.ext:junit:1.1.5")
|
||||
testImplementation("org.slf4j:slf4j-simple:2.0.3")
|
||||
testImplementation("org.robolectric:robolectric:4.12.2")
|
||||
testImplementation("org.hamcrest:hamcrest:2.2")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
|
||||
|
@ -198,6 +202,7 @@ dependencies {
|
|||
)
|
||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
|
||||
testImplementation("com.github.luben:zstd-jni:1.5.6-5")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
|
||||
|
||||
|
|
|
@ -7,8 +7,13 @@ package com.stevesoltys.seedvault.restore
|
|||
|
||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
|
||||
data class RestorableBackup(val backupMetadata: BackupMetadata) {
|
||||
data class RestorableBackup(
|
||||
val backupMetadata: BackupMetadata,
|
||||
val repoId: String? = null,
|
||||
val snapshot: Snapshot? = null,
|
||||
) {
|
||||
|
||||
val name: String
|
||||
get() = backupMetadata.deviceName
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport
|
||||
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.transport.restore.Loader
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||
|
||||
internal class SnapshotManager(
|
||||
private val crypto: Crypto,
|
||||
private val loader: Loader,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* The latest [Snapshot]. May be stale if [loadSnapshots] has not returned
|
||||
* or wasn't called since new snapshots have been created.
|
||||
*/
|
||||
var latestSnapshot: Snapshot? = null
|
||||
private set
|
||||
|
||||
suspend fun loadSnapshots(callback: (Snapshot) -> Unit) {
|
||||
log.info { "Loading snapshots..." }
|
||||
val handles = mutableListOf<AppBackupFileType.Snapshot>()
|
||||
backendManager.backend.list(
|
||||
topLevelFolder = TopLevelFolder(crypto.repoId),
|
||||
AppBackupFileType.Snapshot::class,
|
||||
) { fileInfo ->
|
||||
fileInfo.fileHandle as AppBackupFileType.Snapshot
|
||||
handles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot)
|
||||
}
|
||||
handles.forEach { fileHandle ->
|
||||
// TODO is it a fatal error when one snapshot is corrupted or couldn't get loaded?
|
||||
val snapshot = onSnapshotFound(fileHandle)
|
||||
callback(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onSnapshotFound(snapshotHandle: AppBackupFileType.Snapshot): Snapshot {
|
||||
// TODO set up local snapshot cache, so we don't need to download those all the time
|
||||
val snapshot = loader.loadFile(snapshotHandle).use { inputStream ->
|
||||
Snapshot.parseFrom(inputStream)
|
||||
}
|
||||
// update latest snapshot if this one is more recent
|
||||
if (snapshot.token > (latestSnapshot?.token ?: 0)) latestSnapshot = snapshot
|
||||
return snapshot
|
||||
}
|
||||
|
||||
suspend fun saveSnapshot(snapshot: Snapshot) {
|
||||
val buffer = Buffer()
|
||||
val bufferStream = buffer.outputStream()
|
||||
bufferStream.write(VERSION.toInt())
|
||||
crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream ->
|
||||
ZstdOutputStream(cryptoStream).use { zstdOutputStream ->
|
||||
snapshot.writeTo(zstdOutputStream)
|
||||
}
|
||||
}
|
||||
val sha256ByteString = buffer.sha256()
|
||||
val handle = AppBackupFileType.Snapshot(crypto.repoId, sha256ByteString.hex())
|
||||
// TODO exception handling
|
||||
backendManager.backend.save(handle).use { outputStream ->
|
||||
outputStream.sink().buffer().apply {
|
||||
writeAll(buffer)
|
||||
flush() // needs flushing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import com.stevesoltys.seedvault.transport.SnapshotManager
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal class AppBackupManager(
|
||||
private val blobsCache: BlobsCache,
|
||||
private val snapshotManager: SnapshotManager,
|
||||
private val snapshotCreatorFactory: SnapshotCreatorFactory,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
var snapshotCreator: SnapshotCreator? = null
|
||||
private set
|
||||
|
||||
suspend fun beforeBackup() {
|
||||
log.info { "Before backup" }
|
||||
snapshotCreator = snapshotCreatorFactory.createSnapshotCreator()
|
||||
blobsCache.populateCache()
|
||||
}
|
||||
|
||||
suspend fun afterBackupFinished() {
|
||||
log.info { "After backup finished" }
|
||||
blobsCache.clear()
|
||||
val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator")
|
||||
keepTrying {
|
||||
snapshotManager.saveSnapshot(snapshot)
|
||||
}
|
||||
snapshotCreator = null
|
||||
}
|
||||
|
||||
private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) {
|
||||
for (i in 1..n) {
|
||||
try {
|
||||
block()
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
if (i == n) throw e
|
||||
log.error(e) { "Error (#$i), we'll keep trying" }
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -5,11 +5,17 @@
|
|||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import com.stevesoltys.seedvault.transport.SnapshotManager
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val backupModule = module {
|
||||
single { BackupInitializer(get()) }
|
||||
single { BackupReceiver(get(), get(), get()) }
|
||||
single { BlobsCache(get(), get(), get()) }
|
||||
single { BlobCreator(get(), get()) }
|
||||
single { SnapshotManager(get(), get(), get()) }
|
||||
single { SnapshotCreatorFactory(androidContext(), get(), get(), get()) }
|
||||
single { InputFactory() }
|
||||
single {
|
||||
PackageService(
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import org.calyxos.seedvault.chunker.Chunk
|
||||
import org.calyxos.seedvault.chunker.Chunker
|
||||
import org.calyxos.seedvault.chunker.GearTableCreator
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.InputStream
|
||||
|
||||
data class BackupData(
|
||||
val chunks: List<String>,
|
||||
val chunkMap: Map<String, Blob>,
|
||||
)
|
||||
|
||||
internal class BackupReceiver(
|
||||
private val blobsCache: BlobsCache,
|
||||
private val blobCreator: BlobCreator,
|
||||
private val crypto: Crypto,
|
||||
private val replaceableChunker: Chunker? = null,
|
||||
) {
|
||||
|
||||
private val chunker: Chunker by lazy {
|
||||
// crypto.gearTableKey is not available at creation time, so use lazy instantiation
|
||||
replaceableChunker ?: Chunker(
|
||||
minSize = 1536 * 1024, // 1.5 MB
|
||||
avgSize = 3 * 1024 * 1024, // 3.0 MB
|
||||
maxSize = 7680 * 1024, // 7.5 MB
|
||||
normalization = 1,
|
||||
gearTable = GearTableCreator.create(crypto.gearTableKey),
|
||||
hashFunction = { bytes ->
|
||||
crypto.sha256(bytes).toHexString()
|
||||
},
|
||||
)
|
||||
}
|
||||
private val chunks = mutableListOf<String>()
|
||||
private val chunkMap = mutableMapOf<String, Blob>()
|
||||
|
||||
suspend fun addBytes(bytes: ByteArray) {
|
||||
chunker.addBytes(bytes).forEach { chunk ->
|
||||
onNewChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readFromStream(inputStream: InputStream) {
|
||||
try {
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = inputStream.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
if (bytes == buffer.size) {
|
||||
addBytes(buffer)
|
||||
} else {
|
||||
addBytes(buffer.copyOfRange(0, bytes))
|
||||
}
|
||||
bytes = inputStream.read(buffer)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
finalize()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun finalize(): BackupData {
|
||||
chunker.finalize().forEach { chunk ->
|
||||
onNewChunk(chunk)
|
||||
}
|
||||
// copy chunks and chunkMap before clearing
|
||||
val backupData = BackupData(chunks.toList(), chunkMap.toMap())
|
||||
chunks.clear()
|
||||
chunkMap.clear()
|
||||
return backupData
|
||||
}
|
||||
|
||||
private suspend fun onNewChunk(chunk: Chunk) {
|
||||
chunks.add(chunk.hash)
|
||||
|
||||
val existingBlob = blobsCache.getBlob(chunk.hash)
|
||||
if (existingBlob == null) {
|
||||
val blob = blobCreator.createNewBlob(chunk)
|
||||
chunkMap[chunk.hash] = blob
|
||||
blobsCache.saveNewBlob(chunk.hash, blob)
|
||||
} else {
|
||||
chunkMap[chunk.hash] = existingBlob
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import com.google.protobuf.ByteString
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.calyxos.seedvault.chunker.Chunk
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
|
||||
internal class BlobCreator(
|
||||
private val crypto: Crypto,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
||||
private val buffer = Buffer()
|
||||
|
||||
suspend fun createNewBlob(chunk: Chunk): Blob {
|
||||
buffer.clear()
|
||||
val bufferStream = buffer.outputStream()
|
||||
bufferStream.write(VERSION.toInt())
|
||||
crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream ->
|
||||
ZstdOutputStream(cryptoStream).use { zstdOutputStream ->
|
||||
zstdOutputStream.write(chunk.data)
|
||||
}
|
||||
}
|
||||
val sha256ByteString = buffer.sha256()
|
||||
val handle = AppBackupFileType.Blob(crypto.repoId, sha256ByteString.hex())
|
||||
// TODO exception handling and retries
|
||||
val size = backendManager.backend.save(handle).use { outputStream ->
|
||||
val outputBuffer = outputStream.sink().buffer()
|
||||
val length = outputBuffer.writeAll(buffer)
|
||||
// flushing is important here, otherwise data doesn't get fully written!
|
||||
outputBuffer.flush()
|
||||
length
|
||||
}
|
||||
return Blob.newBuilder()
|
||||
.setId(ByteString.copyFrom(sha256ByteString.asByteBuffer()))
|
||||
.setLength(size.toInt())
|
||||
.setUncompressedLength(chunk.length)
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import com.stevesoltys.seedvault.transport.SnapshotManager
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||
|
||||
internal class BlobsCache(
|
||||
private val crypto: Crypto,
|
||||
private val backendManager: BackendManager,
|
||||
private val snapshotManager: SnapshotManager,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val blobMap = mutableMapOf<String, Blob>()
|
||||
|
||||
/**
|
||||
* This must be called before saving files to the backend to avoid uploading duplicate blobs.
|
||||
*/
|
||||
suspend fun populateCache() {
|
||||
log.info { "Getting all blobs from backend..." }
|
||||
blobMap.clear()
|
||||
val blobs = mutableSetOf<String>()
|
||||
backendManager.backend.list(
|
||||
topLevelFolder = TopLevelFolder(crypto.repoId),
|
||||
AppBackupFileType.Blob::class,
|
||||
) { fileInfo ->
|
||||
fileInfo.fileHandle as AppBackupFileType.Blob
|
||||
// TODO we could save size info here and later check it is as expected
|
||||
blobs.add(fileInfo.fileHandle.name)
|
||||
}
|
||||
snapshotManager.loadSnapshots { snapshot ->
|
||||
snapshot.blobsMap.forEach { (chunkId, blob) ->
|
||||
// check if referenced blob still exists on backend
|
||||
if (blobs.contains(blob.id.hexFromProto())) {
|
||||
// only add blob to our mapping, if it still exists
|
||||
blobMap.putIfAbsent(chunkId, blob)?.let { previous ->
|
||||
if (previous.id != blob.id) log.warn {
|
||||
"Chunk ID ${chunkId.substring(0..5)} had more than one blob"
|
||||
}
|
||||
}
|
||||
} else log.warn {
|
||||
"Blob ${blob.id.hexFromProto()} referenced in snapshot ${snapshot.token}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBlob(hash: String): Blob? = blobMap[hash]
|
||||
|
||||
fun saveNewBlob(chunkId: String, blob: Blob) {
|
||||
blobMap[chunkId] = blob
|
||||
// TODO persist this blob locally in case backup gets interrupted
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
log.info { "Clearing cache..." }
|
||||
blobMap.clear()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build
|
||||
import android.os.UserManager
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.Secure.ANDROID_ID
|
||||
import com.google.protobuf.ByteString
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Apk
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.App
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
|
||||
internal class SnapshotCreatorFactory(
|
||||
private val context: Context,
|
||||
private val clock: Clock,
|
||||
private val packageService: PackageService,
|
||||
private val settingsManager: SettingsManager,
|
||||
) {
|
||||
fun createSnapshotCreator() = SnapshotCreator(context, clock, packageService, settingsManager)
|
||||
}
|
||||
|
||||
internal class SnapshotCreator(
|
||||
private val context: Context,
|
||||
private val clock: Clock,
|
||||
private val packageService: PackageService,
|
||||
private val settingsManager: SettingsManager,
|
||||
) {
|
||||
|
||||
private val snapshotBuilder = Snapshot.newBuilder()
|
||||
.setToken(clock.time())
|
||||
private val appBuilderMap = mutableMapOf<String, App.Builder>()
|
||||
private val blobsMap = mutableMapOf<String, Blob>()
|
||||
|
||||
private val launchableSystemApps by lazy {
|
||||
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||
}
|
||||
|
||||
fun onApkBackedUp(
|
||||
packageName: String,
|
||||
apk: Apk,
|
||||
chunkMap: Map<String, Blob>,
|
||||
) {
|
||||
val appBuilder = appBuilderMap.getOrPut(packageName) {
|
||||
App.newBuilder()
|
||||
}
|
||||
appBuilder.setApk(apk)
|
||||
blobsMap.putAll(chunkMap)
|
||||
}
|
||||
|
||||
fun onPackageBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
type: BackupType,
|
||||
backupData: BackupData,
|
||||
) {
|
||||
val packageName = packageInfo.packageName
|
||||
val builder = appBuilderMap.getOrPut(packageName) {
|
||||
App.newBuilder()
|
||||
}
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
val chunkIds = backupData.chunks.forProto()
|
||||
blobsMap.putAll(backupData.chunkMap)
|
||||
builder
|
||||
.setTime(clock.time())
|
||||
.setState(APK_AND_DATA.name)
|
||||
.setType(type.forSnapshot())
|
||||
.setName(packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString())
|
||||
.setSystem(isSystemApp)
|
||||
.setLaunchableSystemApp(isSystemApp && launchableSystemApps.contains(packageName))
|
||||
.addAllChunkIds(chunkIds)
|
||||
}
|
||||
|
||||
fun onIconsBackedUp(backupData: BackupData) {
|
||||
snapshotBuilder.addAllIconChunkIds(backupData.chunks.forProto())
|
||||
blobsMap.putAll(backupData.chunkMap)
|
||||
}
|
||||
|
||||
fun finalizeSnapshot(): Snapshot {
|
||||
val userName = getUserName()
|
||||
val deviceName = if (userName == null) {
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
} else {
|
||||
"${Build.MANUFACTURER} ${Build.MODEL} - $userName"
|
||||
}
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID)
|
||||
val snapshot = snapshotBuilder
|
||||
.setName(deviceName)
|
||||
.setAndroidId(androidId)
|
||||
.setSdkInt(Build.VERSION.SDK_INT)
|
||||
.setAndroidIncremental(Build.VERSION.INCREMENTAL)
|
||||
.setD2D(settingsManager.d2dBackupsEnabled())
|
||||
.putAllApps(appBuilderMap.mapValues { it.value.build() })
|
||||
.putAllBlobs(blobsMap)
|
||||
.build()
|
||||
appBuilderMap.clear()
|
||||
snapshotBuilder.clear()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun getUserName(): String? {
|
||||
val perm = "android.permission.QUERY_USERS"
|
||||
return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) {
|
||||
val userManager = context.getSystemService(UserManager::class.java) ?: return null
|
||||
userManager.userName
|
||||
} else null
|
||||
}
|
||||
|
||||
private fun BackupType.forSnapshot(): Snapshot.BackupType = when (this) {
|
||||
BackupType.KV -> Snapshot.BackupType.KV
|
||||
BackupType.FULL -> Snapshot.BackupType.FULL
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun Iterable<String>.forProto() = map { ByteString.fromHex(it) }
|
||||
fun Iterable<ByteString>.hexFromProto() = map { it.toByteArray().toHexString() }
|
||||
fun ByteString.hexFromProto() = toByteArray().toHexString()
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.restore
|
||||
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.io.SequenceInputStream
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.Enumeration
|
||||
|
||||
internal class Loader(
|
||||
private val crypto: Crypto,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
||||
/**
|
||||
* The responsibility with closing the returned stream lies with the caller.
|
||||
*/
|
||||
suspend fun loadFile(handle: AppBackupFileType): InputStream {
|
||||
// We load the entire ciphertext into memory,
|
||||
// so we can check the SHA-256 hash before decrypting and parsing the data.
|
||||
val cipherText = backendManager.backend.load(handle).use { inputStream ->
|
||||
inputStream.readAllBytes()
|
||||
}
|
||||
// check SHA-256 hash first thing
|
||||
val sha256 = crypto.sha256(cipherText).toHexString()
|
||||
val expectedHash = when (handle) {
|
||||
is AppBackupFileType.Snapshot -> handle.hash
|
||||
is AppBackupFileType.Blob -> handle.name
|
||||
}
|
||||
if (sha256 != expectedHash) {
|
||||
throw GeneralSecurityException("File had wrong SHA-256 hash: $handle")
|
||||
}
|
||||
// check that we can handle the version of that snapshot
|
||||
val version = cipherText[0]
|
||||
if (version <= 1) throw GeneralSecurityException("Unexpected version: $version")
|
||||
if (version > VERSION) throw UnsupportedVersionException(version)
|
||||
// get associated data for version, used for authenticated decryption
|
||||
val ad = crypto.getAdForVersion(version)
|
||||
// skip first version byte when creating cipherText stream
|
||||
val inputStream = ByteArrayInputStream(cipherText, 1, cipherText.size - 1)
|
||||
// decrypt and decompress cipherText stream and parse snapshot
|
||||
return ZstdInputStream(crypto.newDecryptingStream(inputStream, ad))
|
||||
}
|
||||
|
||||
suspend fun loadFiles(handles: List<AppBackupFileType>): InputStream {
|
||||
val enumeration: Enumeration<InputStream> = object : Enumeration<InputStream> {
|
||||
val iterator = handles.iterator()
|
||||
|
||||
override fun hasMoreElements(): Boolean {
|
||||
return iterator.hasNext()
|
||||
}
|
||||
|
||||
override fun nextElement(): InputStream {
|
||||
return runBlocking { loadFile(iterator.next()) }
|
||||
}
|
||||
}
|
||||
return SequenceInputStream(enumeration)
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ import org.koin.dsl.module
|
|||
|
||||
val restoreModule = module {
|
||||
single { OutputFactory() }
|
||||
single { Loader(get(), get()) }
|
||||
single { KVRestore(get(), get(), get(), get(), get(), get()) }
|
||||
single { FullRestore(get(), get(), get(), get(), get()) }
|
||||
single {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package com.stevesoltys.seedvault.worker
|
||||
|
||||
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
@ -23,6 +24,7 @@ val workerModule = module {
|
|||
crypto = get(),
|
||||
)
|
||||
}
|
||||
single { AppBackupManager(get(), get(), get()) }
|
||||
single {
|
||||
ApkBackup(
|
||||
pm = androidContext().packageManager,
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport
|
||||
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.transport.restore.Loader
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.FileInfo
|
||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||
import org.calyxos.seedvault.core.toByteArrayFromHex
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
|
||||
class SnapshotManagerTest {
|
||||
|
||||
private val crypto: Crypto = mockk()
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val backend: Backend = mockk()
|
||||
|
||||
private val loader = Loader(crypto, backendManager) // need a real loader
|
||||
private val snapshotManager = SnapshotManager(crypto, loader, backendManager)
|
||||
|
||||
private val ad = Random.nextBytes(1)
|
||||
private val passThroughOutputStream = slot<OutputStream>()
|
||||
private val passThroughInputStream = slot<InputStream>()
|
||||
private val repoId = Random.nextBytes(32).toHexString()
|
||||
private val snapshotHandle = slot<AppBackupFileType.Snapshot>()
|
||||
private val snapshot = Snapshot.newBuilder()
|
||||
.setToken(Random.nextLong())
|
||||
.setName(getRandomString())
|
||||
.setSdkInt(Random.nextInt())
|
||||
.putAllBlobs(mapOf(getRandomString() to Snapshot.Blob.getDefaultInstance()))
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `test saving and loading`() = runBlocking {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
every { crypto.getAdForVersion() } returns ad
|
||||
every { crypto.newEncryptingStream(capture(passThroughOutputStream), ad) } answers {
|
||||
passThroughOutputStream.captured // not really encrypting here
|
||||
}
|
||||
every { crypto.repoId } returns repoId
|
||||
every { backendManager.backend } returns backend
|
||||
coEvery { backend.save(capture(snapshotHandle)) } returns outputStream
|
||||
|
||||
snapshotManager.saveSnapshot(snapshot)
|
||||
|
||||
// check that file content hash matches snapshot hash
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
assertEquals(
|
||||
messageDigest.digest(outputStream.toByteArray()).toHexString(),
|
||||
snapshotHandle.captured.hash,
|
||||
)
|
||||
|
||||
val fileInfo = FileInfo(snapshotHandle.captured, Random.nextLong())
|
||||
assertTrue(outputStream.size() > 0)
|
||||
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
|
||||
coEvery {
|
||||
backend.list(
|
||||
topLevelFolder = TopLevelFolder(repoId),
|
||||
AppBackupFileType.Snapshot::class,
|
||||
callback = captureLambda<(FileInfo) -> Unit>()
|
||||
)
|
||||
} answers {
|
||||
lambda<(FileInfo) -> Unit>().captured.invoke(fileInfo)
|
||||
}
|
||||
coEvery { backend.load(snapshotHandle.captured) } returns inputStream
|
||||
every {
|
||||
crypto.sha256(outputStream.toByteArray())
|
||||
} returns snapshotHandle.captured.hash.toByteArrayFromHex()
|
||||
every { crypto.newDecryptingStream(capture(passThroughInputStream), ad) } answers {
|
||||
passThroughInputStream.captured
|
||||
}
|
||||
|
||||
var loadedSnapshot: Snapshot? = null
|
||||
snapshotManager.loadSnapshots { loadedSnapshot = it }
|
||||
assertEquals(snapshot, loadedSnapshot)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.chunker.Chunk
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
|
||||
internal class BlobCreatorTest : TransportTest() {
|
||||
|
||||
private val backendManager: BackendManager = mockk()
|
||||
private val backend: Backend = mockk()
|
||||
private val blobCreator = BlobCreator(crypto, backendManager)
|
||||
|
||||
private val ad = Random.nextBytes(1)
|
||||
private val passThroughOutputStream = slot<OutputStream>()
|
||||
private val blobHandle = slot<AppBackupFileType.Blob>()
|
||||
|
||||
@Test
|
||||
fun `test re-use for hashing two chunks`() = runBlocking {
|
||||
val data1 = Random.nextBytes(1337)
|
||||
val data2 = Random.nextBytes(2342)
|
||||
val chunk1 = Chunk(0L, data1.size, data1, "doesn't matter here")
|
||||
val chunk2 = Chunk(0L, data2.size, data2, "doesn't matter here")
|
||||
val outputStream1 = ByteArrayOutputStream()
|
||||
val outputStream2 = ByteArrayOutputStream()
|
||||
|
||||
every { crypto.getAdForVersion() } returns ad
|
||||
every { crypto.newEncryptingStream(capture(passThroughOutputStream), ad) } answers {
|
||||
passThroughOutputStream.captured // not really encrypting here
|
||||
}
|
||||
every { crypto.repoId } returns repoId
|
||||
every { backendManager.backend } returns backend
|
||||
coEvery { backend.save(capture(blobHandle)) } returns outputStream1
|
||||
|
||||
blobCreator.createNewBlob(chunk1)
|
||||
// check that file content hash matches snapshot hash
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
assertEquals(
|
||||
messageDigest.digest(outputStream1.toByteArray()).toHexString(),
|
||||
blobHandle.captured.name,
|
||||
)
|
||||
|
||||
// use same BlobCreator to create another blob, because we re-use a single buffer
|
||||
// and need to check clearing that does work as expected
|
||||
coEvery { backend.save(capture(blobHandle)) } returns outputStream2
|
||||
blobCreator.createNewBlob(chunk2)
|
||||
// check that file content hash matches snapshot hash
|
||||
assertEquals(
|
||||
messageDigest.digest(outputStream2.toByteArray()).toHexString(),
|
||||
blobHandle.captured.name,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -16,6 +16,12 @@ java_import {
|
|||
sdk_version: "current",
|
||||
}
|
||||
|
||||
java_import {
|
||||
name: "seedvault-lib-chunker",
|
||||
jars: ["seedvault-chunker-0.1.jar"],
|
||||
sdk_version: "current",
|
||||
}
|
||||
|
||||
java_import {
|
||||
name: "seedvault-lib-kotlin-logging-jvm",
|
||||
jars: ["kotlin-logging-jvm-6.0.3.jar"],
|
||||
|
|
BIN
libs/seedvault-chunker-0.1.jar
Normal file
BIN
libs/seedvault-chunker-0.1.jar
Normal file
Binary file not shown.
Loading…
Reference in a new issue