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}" + } +}