Allow importing a backup as part of quickstart.

This commit is contained in:
Greyson Parrelli
2026-02-16 12:51:48 -05:00
committed by Alex Hart
parent ee73b0e229
commit 9cefe0bc04
6 changed files with 249 additions and 4 deletions
@@ -58,7 +58,8 @@ object QuickstartCredentialExporter {
registrationId = SignalStore.account.registrationId,
pniRegistrationId = SignalStore.account.pniRegistrationId,
profileGivenName = profileName.givenName,
profileFamilyName = profileName.familyName
profileFamilyName = profileName.familyName,
accountEntropyPool = SignalStore.account.accountEntropyPool.value
)
val outputDir = context.getExternalFilesDir(null) ?: error("No external files directory")
@@ -28,5 +28,6 @@ data class QuickstartCredentials(
val registrationId: Int,
val pniRegistrationId: Int,
val profileGivenName: String,
val profileFamilyName: String
val profileFamilyName: String,
val accountEntropyPool: String? = null
)
+4 -1
View File
@@ -10,5 +10,8 @@
<application
android:name=".QuickstartApplicationContext"
tools:replace="android:name"
android:requestLegacyExternalStorage="true" />
android:requestLegacyExternalStorage="true">
<activity android:name=".QuickstartRestoreActivity" />
</application>
</manifest>
@@ -5,6 +5,10 @@
package org.thoughtcrime.securesms
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.content.Intent
import android.os.Bundle
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -24,6 +28,25 @@ class QuickstartApplicationContext : ApplicationContext() {
if (!SignalStore.account.isRegistered) {
Log.i(TAG, "Account not registered, attempting quickstart initialization...")
QuickstartInitializer.initialize(this)
if (QuickstartInitializer.pendingBackupDir != null) {
Log.i(TAG, "Pending backup detected, will redirect to restore activity")
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity is QuickstartRestoreActivity) return
unregisterActivityLifecycleCallbacks(this)
activity.startActivity(Intent(activity, QuickstartRestoreActivity::class.java))
activity.finish()
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
})
}
}
}
}
@@ -11,6 +11,7 @@ import android.preference.PreferenceManager
import android.util.Base64
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
@@ -42,6 +43,8 @@ object QuickstartInitializer {
private val TAG = Log.tag(QuickstartInitializer::class.java)
var pendingBackupDir: File? = null
fun initialize(context: Context) {
val credentialJson = findCredentialJson(context)
if (credentialJson == null) {
@@ -52,6 +55,12 @@ object QuickstartInitializer {
val credentials = Json.decodeFromString<QuickstartCredentials>(credentialJson)
Log.i(TAG, "Loaded quickstart credentials for ${credentials.e164}")
// Restore AEP before any derived keys are accessed
if (credentials.accountEntropyPool != null) {
SignalStore.account.restoreAccountEntropyPool(AccountEntropyPool(credentials.accountEntropyPool))
Log.i(TAG, "Restored account entropy pool from quickstart credentials")
}
// Master secret setup
PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean("pref_prompted_push_registration", true).commit()
val masterSecret = MasterSecretUtil.generateMasterSecret(context, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
@@ -117,6 +126,16 @@ object QuickstartInitializer {
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts(credentials.profileGivenName, credentials.profileFamilyName))
RegistrationUtil.maybeMarkRegistrationComplete()
// Check for a local backup to restore
val backupsDir = File(DISK_CREDENTIALS_DIR, "SignalBackups")
if (backupsDir.exists() && backupsDir.isDirectory) {
val hasSnapshot = backupsDir.listFiles()?.any { it.isDirectory && it.name.startsWith("signal-backup") } == true
if (hasSnapshot) {
Log.i(TAG, "Found local backup directory at ${backupsDir.absolutePath}, will restore after launch")
pendingBackupDir = DISK_CREDENTIALS_DIR
}
}
Log.i(TAG, "Quickstart initialization complete for ${credentials.e164}")
}
@@ -126,7 +145,7 @@ object QuickstartInitializer {
get() = if (org.thoughtcrime.securesms.util.Environment.IS_STAGING) "staging_credentials.json" else "prod_credentials.json"
private fun findCredentialJson(context: Context): String? {
findCredentialJsonOnDisk() ?: findCredentialJsonInAssets(context)
return findCredentialJsonOnDisk() ?: findCredentialJsonInAssets(context)
}
private fun findCredentialJsonOnDisk(): String? {
@@ -0,0 +1,198 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.ImportResult
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import java.io.File
import java.io.FileInputStream
/**
* Shows a spinner while importing a local backup for the quickstart build variant.
*
* Bypasses [org.thoughtcrime.securesms.backup.v2.local.LocalArchiver] and
* [org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem] to avoid
* DocumentFile findFile issues. Instead, finds backup files using raw [File] I/O
* and calls [BackupRepository.importLocal] directly.
*
* Requires MANAGE_EXTERNAL_STORAGE on Android 11+ to read from /sdcard/.
* Will prompt the user to grant the permission if not already granted.
*/
class QuickstartRestoreActivity : BaseActivity() {
companion object {
private val TAG = Log.tag(QuickstartRestoreActivity::class.java)
/**
* Finds a file in [dir] by trying the base name first, then common SAF-appended
* extensions (.bin for application/octet-stream).
*/
private fun findBackupFile(dir: File, baseName: String): File? {
val candidates = listOf(baseName, "$baseName.bin")
return candidates
.map { File(dir, it) }
.firstOrNull { it.exists() }
}
}
private var restoreStatus by mutableStateOf("Restoring data...")
private val manageStorageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (hasStorageAccess()) {
startRestore()
} else {
Log.w(TAG, "MANAGE_EXTERNAL_STORAGE not granted after returning from Settings")
restoreStatus = "Storage permission required. Please grant 'All files access' and relaunch."
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SignalTheme {
Surface {
RegistrationScreen(
title = "Quickstart Restore",
subtitle = null,
bottomContent = { }
) {
Text(
text = restoreStatus,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp)
)
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
}
}
org.greenrobot.eventbus.EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
if (hasStorageAccess()) {
startRestore()
} else {
Log.i(TAG, "MANAGE_EXTERNAL_STORAGE not granted, requesting...")
restoreStatus = "Requesting storage permission..."
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"))
manageStorageLauncher.launch(intent)
}
}
private fun hasStorageAccess(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
}
private fun startRestore() {
lifecycleScope.launch(Dispatchers.IO) {
try {
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
val backupDir = QuickstartInitializer.pendingBackupDir!!
// Find snapshot directory using raw File API
val signalBackupsDir = File(backupDir, "SignalBackups")
val snapshotDir = signalBackupsDir.listFiles()
?.filter { it.isDirectory && it.name.startsWith("signal-backup") }
?.sortedByDescending { it.name }
?.firstOrNull()
?: error("No snapshot directory found in ${signalBackupsDir.absolutePath}")
Log.i(TAG, "Snapshot directory: ${snapshotDir.absolutePath}")
Log.i(TAG, "Snapshot contents: ${snapshotDir.listFiles()?.joinToString { "${it.name} (${if (it.isDirectory) "dir" else "${it.length()}b"})" }}")
// Find the main backup file (may be "main" or "main.bin" depending on how the backup was created)
val mainFile = findBackupFile(snapshotDir, "main")
?: error("No 'main' file found in snapshot directory ${snapshotDir.absolutePath}")
Log.i(TAG, "Using main file: ${mainFile.name} (${mainFile.length()} bytes)")
// Import directly via BackupRepository, bypassing SnapshotFileSystem/LocalArchiver
// to avoid DocumentFile.findFile name-matching issues
val importResult = BackupRepository.importLocal(
mainStreamFactory = { FileInputStream(mainFile) },
mainStreamLength = mainFile.length(),
selfData = selfData
)
Log.i(TAG, "Import result: $importResult")
if (importResult is ImportResult.Failure) {
error("BackupRepository.importLocal returned Failure")
}
withContext(Dispatchers.Main) { restoreStatus = "Restoring attachments..." }
// Enqueue attachment restore jobs via ArchiveFileSystem (which handles the files/ directory)
val archiveFileSystem = ArchiveFileSystem.fromFile(applicationContext, backupDir)
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
QuickstartInitializer.pendingBackupDir = null
withContext(Dispatchers.Main) {
Toast.makeText(this@QuickstartRestoreActivity, "Backup restored!", Toast.LENGTH_SHORT).show()
startActivity(MainActivity.clearTop(this@QuickstartRestoreActivity))
finishAffinity()
}
} catch (e: Exception) {
Log.w(TAG, "Error during quickstart restore", e)
QuickstartInitializer.pendingBackupDir = null
withContext(Dispatchers.Main) {
Toast.makeText(this@QuickstartRestoreActivity, "Backup restore failed: ${e.message}", Toast.LENGTH_LONG).show()
startActivity(MainActivity.clearTop(this@QuickstartRestoreActivity))
finishAffinity()
}
}
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(restoreEvent: RestoreV2Event) {
restoreStatus = "${restoreEvent.type}: ${restoreEvent.count} / ${restoreEvent.estimatedTotalCount}"
}
}