mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 18:26:57 +00:00
Add quickstart variant that launches with predefined credentials.
This commit is contained in:
committed by
Alex Hart
parent
9922621945
commit
e67307a961
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
7
app/src/quickstart/AndroidManifest.xml
Normal file
7
app/src/quickstart/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user