diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentialExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentialExporter.kt
index 69d1c6bdcc..19d1e23588 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentialExporter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentialExporter.kt
@@ -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")
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentials.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentials.kt
index 8dfae06ceb..bc7dfd358d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentials.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentials.kt
@@ -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
)
diff --git a/app/src/quickstart/AndroidManifest.xml b/app/src/quickstart/AndroidManifest.xml
index 0eea525634..c94ccab70b 100644
--- a/app/src/quickstart/AndroidManifest.xml
+++ b/app/src/quickstart/AndroidManifest.xml
@@ -10,5 +10,8 @@
+ android:requestLegacyExternalStorage="true">
+
+
+
diff --git a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt
index 54ee64e83e..3036191e44 100644
--- a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt
+++ b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt
@@ -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
+ })
+ }
}
}
}
diff --git a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt
index b37770b709..682f30d11c 100644
--- a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt
+++ b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt
@@ -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(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? {
diff --git a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartRestoreActivity.kt b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartRestoreActivity.kt
new file mode 100644
index 0000000000..3b6d91fb8c
--- /dev/null
+++ b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartRestoreActivity.kt
@@ -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}"
+ }
+}