From e67307a961ee27d8cc95e0efbe6abfe4eaa7d4d2 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 13 Feb 2026 14:17:49 -0500 Subject: [PATCH] Add quickstart variant that launches with predefined credentials. --- app/build.gradle.kts | 76 ++++++++++ .../app/internal/InternalSettingsFragment.kt | 26 ++++ .../data/QuickstartCredentialExporter.kt | 71 ++++++++++ .../data/QuickstartCredentials.kt | 32 +++++ app/src/quickstart/AndroidManifest.xml | 7 + .../securesms/QuickstartApplicationContext.kt | 29 ++++ .../securesms/QuickstartInitializer.kt | 131 ++++++++++++++++++ 7 files changed, 372 insertions(+) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentialExporter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentials.kt create mode 100644 app/src/quickstart/AndroidManifest.xml create mode 100644 app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt create mode 100644 app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4a23f51850..58ec3dfb58 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,15 +1,20 @@ @file:Suppress("UnstableApiUsage") import com.android.build.api.dsl.ManagedVirtualDevice +import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.testing.logging.TestExceptionFormat import java.io.File import java.util.Properties @@ -50,6 +55,14 @@ val languagesForBuildConfigProvider = languagesProvider.map { languages -> languages.joinToString(separator = ", ") { language -> "\"$language\"" } } +val localPropertiesFile = File(rootProject.projectDir, "local.properties") +val localProperties: Properties? = if (localPropertiesFile.exists()) { + Properties().apply { localPropertiesFile.inputStream().use { load(it) } } +} else { + null +} +val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir") + val selectableVariants = listOf( "nightlyBackupRelease", "nightlyBackupSpinner", @@ -72,6 +85,8 @@ val selectableVariants = listOf( "playStagingPerf", "playStagingInstrumentation", "playStagingRelease", + "playProdQuickstart", + "playStagingQuickstart", "websiteProdSpinner", "websiteProdRelease", "githubProdSpinner", @@ -384,6 +399,14 @@ android { matchingFallbacks += "debug" buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Canary\"") } + + create("quickstart") { + initWith(getByName("debug")) + isDefault = false + isMinifyEnabled = false + matchingFallbacks += "debug" + buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Quickstart\"") + } } productFlavors { @@ -513,6 +536,24 @@ android { } } } + + onVariants(selector().withBuildType("quickstart")) { variant -> + val environment = variant.flavorName?.let { name -> + when { + name.contains("staging", ignoreCase = true) -> "staging" + name.contains("prod", ignoreCase = true) -> "prod" + else -> "prod" + } + } ?: "prod" + + val taskProvider = tasks.register("copyQuickstartCredentials${variant.name.capitalize()}") { + if (quickstartCredentialsDir != null) { + inputDir.set(File(quickstartCredentialsDir)) + } + filePrefix.set("${environment}_") + } + variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir } + } } val releaseDir = "$projectDir/src/release/java" @@ -838,3 +879,38 @@ abstract class PropertiesFileValueSource : ValueSource + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun copy() { + if (!inputDir.isPresent) { + throw GradleException("quickstart.credentials.dir is not set in local.properties. This is required for quickstart builds.") + } + + val prefix = filePrefix.get() + val candidates = inputDir.get().asFile.listFiles() + ?.filter { it.extension == "json" && it.name.startsWith(prefix) } + ?: emptyList() + + if (candidates.isEmpty()) { + throw GradleException("No credential files matching '$prefix*.json' found in ${inputDir.get().asFile}. Add files like '${prefix}account1.json' to your credentials directory.") + } + + val chosen = candidates.random() + logger.lifecycle("Selected quickstart credential: ${chosen.name}") + + val dest = outputDir.get().asFile.resolve("quickstart") + dest.mkdirs() + chosen.copyTo(dest.resolve(chosen.name), overwrite = true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 12191c8116..4be8c48900 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphones import org.thoughtcrime.securesms.payments.DataExportUtil import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.ConversationUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -164,6 +165,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) + if (BuildConfig.DEBUG) { + clickPref( + title = DSLSettingsText.from("Export quickstart credentials"), + summary = DSLSettingsText.from("Export registration credentials to a JSON file for quickstart builds."), + onClick = { + exportQuickstartCredentials() + } + ) + } + clickPref( title = DSLSettingsText.from("Unregister"), summary = DSLSettingsText.from("This will unregister your account without deleting it."), @@ -1144,6 +1155,21 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } } + private fun exportQuickstartCredentials() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Export quickstart credentials?") + .setMessage("This will export your account's private keys and credentials to an unencrypted file on disk. This is very dangerous! Only use it with test accounts.") + .setPositiveButton("Export") { _, _ -> + SimpleTask.run({ + QuickstartCredentialExporter.export(requireContext()) + }) { file -> + Toast.makeText(requireContext(), "Exported to ${file.absolutePath}", Toast.LENGTH_LONG).show() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun promptUserForSentTimestamp() { val input = EditText(requireContext()).apply { inputType = android.text.InputType.TYPE_CLASS_NUMBER 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 new file mode 100644 index 0000000000..69d1c6bdcc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentialExporter.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.data + +import android.content.Context +import android.util.Base64 +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import java.io.File + +/** + * Exports current account registration credentials to a JSON file + * that can be used with quickstart builds. + */ +object QuickstartCredentialExporter { + + private val TAG = Log.tag(QuickstartCredentialExporter::class.java) + + private val json = Json { prettyPrint = true } + + fun export(context: Context): File { + val aci = SignalStore.account.requireAci() + val pni = SignalStore.account.requirePni() + val e164 = SignalStore.account.requireE164() + val servicePassword = SignalStore.account.servicePassword ?: error("No service password") + + val aciIdentityKeyPair = SignalStore.account.aciIdentityKey + val pniIdentityKeyPair = SignalStore.account.pniIdentityKey + + val aciSignedPreKey = AppDependencies.protocolStore.aci().loadSignedPreKey(SignalStore.account.aciPreKeys.activeSignedPreKeyId) + val aciLastResortKyberPreKey = AppDependencies.protocolStore.aci().loadKyberPreKey(SignalStore.account.aciPreKeys.lastResortKyberPreKeyId) + val pniSignedPreKey = AppDependencies.protocolStore.pni().loadSignedPreKey(SignalStore.account.pniPreKeys.activeSignedPreKeyId) + val pniLastResortKyberPreKey = AppDependencies.protocolStore.pni().loadKyberPreKey(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId) + + val self = Recipient.self() + val profileKey = self.profileKey ?: error("No profile key") + val profileName = self.profileName + + val credentials = QuickstartCredentials( + aci = aci.toString(), + pni = pni.toString(), + e164 = e164, + servicePassword = servicePassword, + aciIdentityKeyPair = Base64.encodeToString(aciIdentityKeyPair.serialize(), Base64.NO_WRAP), + pniIdentityKeyPair = Base64.encodeToString(pniIdentityKeyPair.serialize(), Base64.NO_WRAP), + aciSignedPreKey = Base64.encodeToString(aciSignedPreKey.serialize(), Base64.NO_WRAP), + aciLastResortKyberPreKey = Base64.encodeToString(aciLastResortKyberPreKey.serialize(), Base64.NO_WRAP), + pniSignedPreKey = Base64.encodeToString(pniSignedPreKey.serialize(), Base64.NO_WRAP), + pniLastResortKyberPreKey = Base64.encodeToString(pniLastResortKyberPreKey.serialize(), Base64.NO_WRAP), + profileKey = Base64.encodeToString(profileKey, Base64.NO_WRAP), + registrationId = SignalStore.account.registrationId, + pniRegistrationId = SignalStore.account.pniRegistrationId, + profileGivenName = profileName.givenName, + profileFamilyName = profileName.familyName + ) + + val outputDir = context.getExternalFilesDir(null) ?: error("No external files directory") + val outputFile = File(outputDir, "quickstart-credentials.json") + outputFile.writeText(json.encodeToString(credentials)) + + Log.i(TAG, "Exported quickstart credentials to ${outputFile.absolutePath}") + return outputFile + } +} 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 new file mode 100644 index 0000000000..8dfae06ceb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/QuickstartCredentials.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.data + +import kotlinx.serialization.Serializable + +/** + * JSON-serializable bundle of registration credentials for quickstart builds. + * All byte arrays are base64-encoded strings. + */ +@Serializable +data class QuickstartCredentials( + val version: Int = 1, + val aci: String, + val pni: String, + val e164: String, + val servicePassword: String, + val aciIdentityKeyPair: String, + val pniIdentityKeyPair: String, + val aciSignedPreKey: String, + val aciLastResortKyberPreKey: String, + val pniSignedPreKey: String, + val pniLastResortKyberPreKey: String, + val profileKey: String, + val registrationId: Int, + val pniRegistrationId: Int, + val profileGivenName: String, + val profileFamilyName: String +) diff --git a/app/src/quickstart/AndroidManifest.xml b/app/src/quickstart/AndroidManifest.xml new file mode 100644 index 0000000000..a003d82980 --- /dev/null +++ b/app/src/quickstart/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt new file mode 100644 index 0000000000..54ee64e83e --- /dev/null +++ b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartApplicationContext.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** + * Application subclass for the quickstart build variant. + * On first launch, if the account is not yet registered, it triggers + * [QuickstartInitializer] to import pre-baked credentials from assets. + */ +class QuickstartApplicationContext : ApplicationContext() { + + companion object { + private val TAG = Log.tag(QuickstartApplicationContext::class.java) + } + + override fun onCreate() { + super.onCreate() + if (!SignalStore.account.isRegistered) { + Log.i(TAG, "Account not registered, attempting quickstart initialization...") + QuickstartInitializer.initialize(this) + } + } +} diff --git a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt new file mode 100644 index 0000000000..a5b5788a0d --- /dev/null +++ b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartInitializer.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms + +import android.content.Context +import android.preference.PreferenceManager +import android.util.Base64 +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.crypto.MasterSecretUtil +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.keyvalue.Skipped +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult +import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil +import org.thoughtcrime.securesms.registration.data.QuickstartCredentials +import org.thoughtcrime.securesms.registration.data.RegistrationData +import org.thoughtcrime.securesms.registration.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.util.RegistrationUtil +import org.whispersystems.signalservice.api.account.PreKeyCollection + +/** + * Reads pre-baked registration credentials from assets and performs + * local registration, bypassing the normal registration flow. + * + * Follows the same pattern as [org.signal.benchmark.setup.TestUsers.setupSelf]. + */ +object QuickstartInitializer { + + private val TAG = Log.tag(QuickstartInitializer::class.java) + + fun initialize(context: Context) { + val credentialJson = findCredentialJson(context) + if (credentialJson == null) { + Log.w(TAG, "No quickstart credentials found in assets. Falling through to normal registration.") + return + } + + val credentials = Json.decodeFromString(credentialJson) + Log.i(TAG, "Loaded quickstart credentials for ${credentials.e164}") + + // Master secret setup + PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean("pref_prompted_push_registration", true).commit() + val masterSecret = MasterSecretUtil.generateMasterSecret(context, MasterSecretUtil.UNENCRYPTED_PASSPHRASE) + MasterSecretUtil.generateAsymmetricMasterSecret(context, masterSecret) + context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0).edit().putBoolean("passphrase_initialized", true).commit() + + // Set registration IDs from credentials + SignalStore.account.registrationId = credentials.registrationId + SignalStore.account.pniRegistrationId = credentials.pniRegistrationId + + // Decode pre-baked keys + val aciIdentityKeyPair = IdentityKeyPair(Base64.decode(credentials.aciIdentityKeyPair, Base64.DEFAULT)) + val pniIdentityKeyPair = IdentityKeyPair(Base64.decode(credentials.pniIdentityKeyPair, Base64.DEFAULT)) + val aciSignedPreKey = SignedPreKeyRecord(Base64.decode(credentials.aciSignedPreKey, Base64.DEFAULT)) + val aciLastResortKyberPreKey = KyberPreKeyRecord(Base64.decode(credentials.aciLastResortKyberPreKey, Base64.DEFAULT)) + val pniSignedPreKey = SignedPreKeyRecord(Base64.decode(credentials.pniSignedPreKey, Base64.DEFAULT)) + val pniLastResortKyberPreKey = KyberPreKeyRecord(Base64.decode(credentials.pniLastResortKyberPreKey, Base64.DEFAULT)) + val profileKey = ProfileKey(Base64.decode(credentials.profileKey, Base64.DEFAULT)) + + val registrationData = RegistrationData( + code = "000000", + e164 = credentials.e164, + password = credentials.servicePassword, + registrationId = credentials.registrationId, + profileKey = profileKey, + fcmToken = null, + pniRegistrationId = credentials.pniRegistrationId, + recoveryPassword = null + ) + + val remoteResult = AccountRegistrationResult( + uuid = credentials.aci, + pni = credentials.pni, + storageCapable = false, + number = credentials.e164, + masterKey = null, + pin = null, + aciPreKeyCollection = PreKeyCollection(aciIdentityKeyPair.publicKey, aciSignedPreKey, aciLastResortKyberPreKey), + pniPreKeyCollection = PreKeyCollection(pniIdentityKeyPair.publicKey, pniSignedPreKey, pniLastResortKyberPreKey), + reRegistration = false + ) + + // Create metadata and register locally + val localRegistrationData = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata( + aciIdentityKeyPair, + pniIdentityKeyPair, + registrationData, + remoteResult, + false + ) + + runBlocking { + RegistrationRepository.registerAccountLocally(context, localRegistrationData) + } + + // Enable FCM so the app fetches a token through its normal startup flow + // rather than keeping a websocket open. + SignalStore.account.fcmEnabled = true + + // Finalize registration state + SignalStore.svr.optOut() + SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped + SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts(credentials.profileGivenName, credentials.profileFamilyName)) + RegistrationUtil.maybeMarkRegistrationComplete() + + Log.i(TAG, "Quickstart initialization complete for ${credentials.e164}") + } + + private fun findCredentialJson(context: Context): String? { + return try { + val files = context.assets.list("quickstart") ?: return null + val jsonFile = files.firstOrNull { it.endsWith(".json") } ?: return null + context.assets.open("quickstart/$jsonFile").bufferedReader().readText() + } catch (e: Exception) { + Log.w(TAG, "Error reading quickstart credentials", e) + null + } + } +}