Add quickstart variant that launches with predefined credentials.

This commit is contained in:
Greyson Parrelli
2026-02-13 14:17:49 -05:00
committed by Alex Hart
parent 9922621945
commit e67307a961
7 changed files with 372 additions and 0 deletions

View File

@@ -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<CopyQuickstartCredentialsTask>("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<Properties?, PropertiesFi
fun String.capitalize(): String {
return this.replaceFirstChar { it.uppercase() }
}
abstract class CopyQuickstartCredentialsTask : DefaultTask() {
@get:InputDirectory
@get:Optional
abstract val inputDir: DirectoryProperty
@get:Input
abstract val filePrefix: Property<String>
@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)
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".QuickstartApplicationContext"
tools:replace="android:name" />
</manifest>

View File

@@ -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)
}
}
}

View File

@@ -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<QuickstartCredentials>(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
}
}
}