From 5c75574f6539e7a66c96cc7d0b992190446a7d8a Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Mon, 23 Sep 2024 16:09:05 -0300
Subject: [PATCH] Use cached snapshots for auto-restore to save time

All snapshots we wrote out should be cached locally. Auto-restore is holding up app installs, so we should be as fast as possible.
---
 .../seedvault/repo/SnapshotManager.kt         | 19 +++++++++++++-
 .../transport/restore/RestorableBackup.kt     |  1 +
 .../transport/restore/RestoreCoordinator.kt   | 17 ++++++++----
 .../seedvault/repo/SnapshotManagerTest.kt     | 26 +++++++++++++++----
 .../restore/RestoreCoordinatorTest.kt         | 22 +++++-----------
 5 files changed, 59 insertions(+), 26 deletions(-)

diff --git a/app/src/main/java/com/stevesoltys/seedvault/repo/SnapshotManager.kt b/app/src/main/java/com/stevesoltys/seedvault/repo/SnapshotManager.kt
index f3946045..6d3bde26 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/repo/SnapshotManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/repo/SnapshotManager.kt
@@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.header.VERSION
 import com.stevesoltys.seedvault.proto.Snapshot
 import io.github.oshai.kotlinlogging.KotlinLogging
 import org.calyxos.seedvault.core.backends.AppBackupFileType
+import org.calyxos.seedvault.core.backends.Constants.appSnapshotRegex
 import org.calyxos.seedvault.core.toHexString
 import java.io.ByteArrayOutputStream
 import java.io.File
@@ -25,13 +26,14 @@ internal const val FOLDER_SNAPSHOTS = "snapshots"
  * Also keeps a reference to the [latestSnapshot] that holds important re-usable data.
  */
 internal class SnapshotManager(
-    private val snapshotFolder: File,
+    private val snapshotFolderRoot: File,
     private val crypto: Crypto,
     private val loader: Loader,
     private val backendManager: BackendManager,
 ) {
 
     private val log = KotlinLogging.logger {}
+    private val snapshotFolder: File get() = File(snapshotFolderRoot, crypto.repoId)
 
     /**
      * The latest [Snapshot]. May be stale if [onSnapshotsLoaded] has not returned
@@ -123,6 +125,7 @@ internal class SnapshotManager(
     @Throws(IOException::class)
     suspend fun loadSnapshot(snapshotHandle: AppBackupFileType.Snapshot): Snapshot {
         val file = File(snapshotFolder, snapshotHandle.name)
+        snapshotFolder.mkdirs()
         val inputStream = if (file.isFile) {
             loader.loadFile(file, snapshotHandle.hash)
         } else {
@@ -131,4 +134,18 @@ internal class SnapshotManager(
         return inputStream.use { Snapshot.parseFrom(it) }
     }
 
+    @Throws(IOException::class)
+    fun loadCachedSnapshots(): List<Snapshot> {
+        if (!snapshotFolder.isDirectory) return emptyList()
+        return snapshotFolder.listFiles()?.mapNotNull { file ->
+            val match = appSnapshotRegex.matchEntire(file.name)
+            if (match == null) {
+                log.error { "Unexpected file found: $file" }
+                null
+            } else {
+                loader.loadFile(file, match.groupValues[1]).use { Snapshot.parseFrom(it) }
+            }
+        } ?: throw IOException("Could not access snapshotFolder")
+    }
+
 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt
index 9ff14b29..cbadca53 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorableBackup.kt
@@ -20,6 +20,7 @@ data class RestorableBackup(
     val snapshot: Snapshot? = null,
 ) {
 
+    // FIXME creating this mapping is expensive, a single call can take several seconds to complete
     constructor(repoId: String, snapshot: Snapshot) : this(
         backupMetadata = BackupMetadata.fromSnapshot(snapshot),
         repoId = repoId,
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
index 15fd465f..4958aa32 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
@@ -216,13 +216,20 @@ internal class RestoreCoordinator(
                     ?: return TRANSPORT_ERROR
                 backup.backups.find { it.token == token } ?: return TRANSPORT_ERROR
             } else {
-                // this is auto-restore, so we try harder to find a working restore set
+                // this is auto-restore, so we use cache and try hard to find a working restore set
                 Log.i(TAG, "No cached backups, loading all and look for $token")
-                // TODO may be cold start and need snapshot loading (ideally from cache only?)
-                val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult
-                    ?: return TRANSPORT_ERROR
+                val backups = try {
+                    snapshotManager.loadCachedSnapshots().map { snapshot ->
+                        RestorableBackup(crypto.repoId, snapshot)
+                    }
+                } catch (e: Exception) {
+                    Log.e(TAG, "Error loading cached snapshots: ", e)
+                    (getAvailableBackups() as? RestorableBackupResult.SuccessResult)?.backups
+                        ?: return TRANSPORT_ERROR
+                }
+                Log.i(TAG, "Found ${backups.size} snapshots.")
                 val autoRestorePackageName = autoRestorePackageInfo.packageName
-                val sortedBackups = backup.backups.sortedByDescending { it.token }
+                val sortedBackups = backups.sortedByDescending { it.token } // latest first
                 sortedBackups.find { it.token == token } ?: sortedBackups.find {
                     val chunkIds = it.packageMetadataMap[autoRestorePackageName]?.chunkIds
                     // try a backup where our auto restore package has data
diff --git a/app/src/test/java/com/stevesoltys/seedvault/repo/SnapshotManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/repo/SnapshotManagerTest.kt
index 960f9125..ee712c7f 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/repo/SnapshotManagerTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/repo/SnapshotManagerTest.kt
@@ -52,6 +52,11 @@ internal class SnapshotManagerTest : TransportTest() {
         every { backendManager.backend } returns backend
     }
 
+    private fun getSnapshotFolder(tmpDir: Path, hash: String): File {
+        val repoFolder = File(tmpDir.toString(), repoId)
+        return File(repoFolder, hash)
+    }
+
     @Test
     fun `test onSnapshotsLoaded sets latestSnapshot`(@TempDir tmpDir: Path) = runBlocking {
         val snapshotManager = getSnapshotManager(File(tmpDir.toString()))
@@ -63,6 +68,7 @@ internal class SnapshotManagerTest : TransportTest() {
         val snapshotHandle1 = AppBackupFileType.Snapshot(repoId, chunkId1)
         val snapshotHandle2 = AppBackupFileType.Snapshot(repoId, chunkId2)
 
+        every { crypto.repoId } returns repoId
         coEvery { loader.loadFile(snapshotHandle1, any()) } returns inputStream1
         coEvery { loader.loadFile(snapshotHandle2, any()) } returns inputStream2
         snapshotManager.onSnapshotsLoaded(listOf(snapshotHandle1, snapshotHandle2))
@@ -87,7 +93,7 @@ internal class SnapshotManagerTest : TransportTest() {
 
         snapshotManager.saveSnapshot(snapshot)
 
-        val snapshotFile = File(tmpDir.toString(), snapshotHandle.name)
+        val snapshotFile = getSnapshotFolder(tmpDir, snapshotHandle.name)
         assertTrue(snapshotFile.isFile)
         assertTrue(outputStream.size() > 0)
         val cachedBytes = snapshotFile.inputStream().use { it.readAllBytes() }
@@ -97,21 +103,28 @@ internal class SnapshotManagerTest : TransportTest() {
     @Test
     fun `snapshot loads from cache without backend`(@TempDir tmpDir: Path) = runBlocking {
         val snapshotManager = getSnapshotManager(File(tmpDir.toString()))
-        val snapshotData = snapshot { token = 1337 }.toByteArray()
+        val snapshot = snapshot { token = 1337 }
+        val snapshotData = snapshot.toByteArray()
         val inputStream = ByteArrayInputStream(snapshotData)
         val snapshotHandle = AppBackupFileType.Snapshot(repoId, chunkId1)
 
         // create cached file
-        val file = File(tmpDir.toString(), snapshotHandle.name)
+        val file = getSnapshotFolder(tmpDir, snapshotHandle.name)
+        file.parentFile?.mkdirs()
         file.outputStream().use { it.write(snapshotData) }
 
+        every { crypto.repoId } returns repoId
         coEvery { loader.loadFile(file, snapshotHandle.hash) } returns inputStream
 
-        snapshotManager.onSnapshotsLoaded(listOf(snapshotHandle))
+        assertEquals(listOf(snapshot), snapshotManager.onSnapshotsLoaded(listOf(snapshotHandle)))
 
         coVerify(exactly = 0) { // did not load from backend
             loader.loadFile(snapshotHandle, any())
         }
+
+        // now load all snapshots from cache
+        inputStream.reset()
+        assertEquals(listOf(snapshot), snapshotManager.loadCachedSnapshots())
     }
 
     @Test
@@ -124,6 +137,7 @@ internal class SnapshotManagerTest : TransportTest() {
         val snapshotHandle1 = AppBackupFileType.Snapshot(repoId, chunkId1)
         val snapshotHandle2 = AppBackupFileType.Snapshot(repoId, chunkId2)
 
+        every { crypto.repoId } returns repoId
         coEvery { loader.loadFile(snapshotHandle1, any()) } returns inputStream
         coEvery { loader.loadFile(snapshotHandle2, any()) } throws IOException()
         snapshotManager.onSnapshotsLoaded(listOf(snapshotHandle1, snapshotHandle2))
@@ -179,10 +193,12 @@ internal class SnapshotManagerTest : TransportTest() {
         val snapshotManager = getSnapshotManager(File(tmpDir.toString()))
 
         val snapshotHandle = AppBackupFileType.Snapshot(repoId, chunkId1)
-        val file = File(tmpDir.toString(), snapshotHandle.name)
+        val file = getSnapshotFolder(tmpDir, snapshotHandle.name)
+        file.parentFile?.mkdirs()
         file.createNewFile()
         assertTrue(file.isFile)
 
+        every { crypto.repoId } returns repoId
         coEvery { backend.remove(snapshotHandle) } just Runs
 
         snapshotManager.removeSnapshot(snapshotHandle)
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
index 76bd9da1..fd468f51 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
@@ -256,26 +256,18 @@ internal class RestoreCoordinatorTest : TransportTest() {
         }
 
     @Test
-    fun `startRestore() loads snapshots for auto-restore`() = runBlocking {
-        val handle = AppBackupFileType.Snapshot(repoId, getRandomByteArray(32).toHexString())
-        val info = FileInfo(handle, 1)
-
+    fun `startRestore() loads snapshots for auto-restore from local cache`() = runBlocking {
         every { backendManager.backendProperties } returns safStorage
         every { safStorage.isUnavailableUsb(context) } returns false
 
-        coEvery {
-            backend.list(
-                topLevelFolder = null,
-                AppBackupFileType.Snapshot::class, LegacyAppBackupFile.Metadata::class,
-                callback = captureLambda<(FileInfo) -> Unit>()
-            )
-        } answers {
-            val callback = lambda<(FileInfo) -> Unit>().captured
-            callback(info)
-        }
-        coEvery { snapshotManager.loadSnapshot(handle) } returns snapshot
+        every { crypto.repoId } returns repoId
+        every { snapshotManager.loadCachedSnapshots() } returns listOf(snapshot)
 
         assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
+
+        verify {
+            snapshotManager.loadCachedSnapshots()
+        }
     }
 
     @Test