mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-08 17:29:02 +01:00
Allow importing a backup as part of quickstart.
This commit is contained in:
committed by
Alex Hart
parent
ee73b0e229
commit
9cefe0bc04
+2
-1
@@ -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")
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user