diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b292a8337c..56b494aa38 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -837,6 +837,13 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
+
+
Unit,
+ onNotNowClicked: () -> Unit
+) {
+ Surface {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .padding(top = 40.dp, bottom = 24.dp)
+ ) {
+ LazyColumn(
+ modifier = Modifier.weight(1f)
+ ) {
+ item {
+ Text(
+ text = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
+ style = MaterialTheme.typography.headlineMedium
+ )
+ }
+
+ item {
+ Text(
+ text = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 12.dp, bottom = 41.dp)
+ )
+ }
+
+ if (deviceBuildVersion >= 33) {
+ item {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
+ title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
+ )
+ }
+ }
+
+ item {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
+ title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
+ )
+ }
+
+ if (deviceBuildVersion < 29 || !isBackupSelectionRequired) {
+ item {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
+ title = stringResource(id = R.string.GrantPermissionsFragment__storage),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
+ )
+ }
+ }
+
+ item {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
+ title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
+ )
+ }
+ }
+
+ Row {
+ TextButton(onClick = onNotNowClicked) {
+ Text(
+ text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ if (isSearchingForBackup) {
+ Box {
+ NextButton(
+ isSearchingForBackup = true,
+ onNextClicked = onNextClicked
+ )
+
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ } else {
+ NextButton(
+ isSearchingForBackup = false,
+ onNextClicked = onNextClicked
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PermissionRowPreview() {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
+ title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
+ )
+}
+
+@Composable
+fun PermissionRow(
+ imageVector: ImageVector,
+ title: String,
+ subtitle: String
+) {
+ Row(modifier = Modifier.padding(bottom = 32.dp)) {
+ Image(
+ imageVector = imageVector,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp)
+ )
+
+ Spacer(modifier = Modifier.size(16.dp))
+
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall
+ )
+
+ Text(
+ text = subtitle,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.size(32.dp))
+ }
+}
+
+@Composable
+fun NextButton(
+ isSearchingForBackup: Boolean,
+ onNextClicked: () -> Unit
+) {
+ val alpha = if (isSearchingForBackup) {
+ 0f
+ } else {
+ 1f
+ }
+
+ Buttons.LargeTonal(
+ onClick = onNextClicked,
+ enabled = !isSearchingForBackup,
+ modifier = Modifier.alpha(alpha)
+ ) {
+ Text(
+ text = stringResource(id = R.string.GrantPermissionsFragment__next)
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java
index 0855f90e5c..aa5c9a0864 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java
@@ -10,7 +10,6 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
-import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.ScrollView;
@@ -138,7 +137,7 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R
}
controller.setNumberAndCountryCode(viewModelNumber);
- showKeyboard(number.getEditText());
+ ViewUtil.focusAndShowKeyboard(number.getEditText());
if (viewModel.hasUserSkippedReRegisterFlow() && viewModel.shouldAutoShowSmsConfirmDialog()) {
viewModel.setAutoShowSmsConfirmDialog(false);
@@ -146,12 +145,6 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R
}
}
- private void showKeyboard(View viewToFocus) {
- viewToFocus.requestFocus();
- InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE);
- imm.showSoftInput(viewToFocus, InputMethodManager.SHOW_IMPLICIT);
- }
-
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.enter_phone_number, menu);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt
index 94ccdbdab8..c37801b045 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/GrantPermissionsFragment.kt
@@ -6,38 +6,15 @@
package org.thoughtcrime.securesms.registration.fragments
import android.os.Build
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.res.vectorResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.navArgs
-import org.signal.core.ui.Buttons
-import org.signal.core.ui.theme.SignalTheme
-import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.permissions.Permissions
+import org.thoughtcrime.securesms.registration.compose.GrantPermissionsScreen
import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel
import org.thoughtcrime.securesms.util.BackupUtil
@@ -124,180 +101,3 @@ class GrantPermissionsFragment : ComposeFragment() {
RESTORE_BACKUP
}
}
-
-@Preview
-@Composable
-fun GrantPermissionsScreenPreview() {
- SignalTheme(isDarkMode = false) {
- GrantPermissionsScreen(
- deviceBuildVersion = 33,
- isBackupSelectionRequired = true,
- isSearchingForBackup = true,
- {},
- {}
- )
- }
-}
-
-@Composable
-fun GrantPermissionsScreen(
- deviceBuildVersion: Int,
- isBackupSelectionRequired: Boolean,
- isSearchingForBackup: Boolean,
- onNextClicked: () -> Unit,
- onNotNowClicked: () -> Unit
-) {
- Surface {
- Column(
- modifier = Modifier
- .padding(horizontal = 24.dp)
- .padding(top = 40.dp, bottom = 24.dp)
- ) {
- LazyColumn(
- modifier = Modifier.weight(1f)
- ) {
- item {
- Text(
- text = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
- style = MaterialTheme.typography.headlineMedium
- )
- }
-
- item {
- Text(
- text = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 12.dp, bottom = 41.dp)
- )
- }
-
- if (deviceBuildVersion >= 33) {
- item {
- PermissionRow(
- imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
- title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
- subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
- )
- }
- }
-
- item {
- PermissionRow(
- imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
- title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
- subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
- )
- }
-
- if (deviceBuildVersion < 29 || !isBackupSelectionRequired) {
- item {
- PermissionRow(
- imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
- title = stringResource(id = R.string.GrantPermissionsFragment__storage),
- subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
- )
- }
- }
-
- item {
- PermissionRow(
- imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
- title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
- subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
- )
- }
- }
-
- Row {
- TextButton(onClick = onNotNowClicked) {
- Text(
- text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
- )
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- if (isSearchingForBackup) {
- Box {
- NextButton(
- isSearchingForBackup = true,
- onNextClicked = onNextClicked
- )
-
- CircularProgressIndicator(
- modifier = Modifier.align(Alignment.Center)
- )
- }
- } else {
- NextButton(
- isSearchingForBackup = false,
- onNextClicked = onNextClicked
- )
- }
- }
- }
- }
-}
-
-@Preview
-@Composable
-fun PermissionRowPreview() {
- PermissionRow(
- imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
- title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
- subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
- )
-}
-
-@Composable
-fun PermissionRow(
- imageVector: ImageVector,
- title: String,
- subtitle: String
-) {
- Row(modifier = Modifier.padding(bottom = 32.dp)) {
- Image(
- imageVector = imageVector,
- contentDescription = null,
- modifier = Modifier.size(48.dp)
- )
-
- Spacer(modifier = Modifier.size(16.dp))
-
- Column {
- Text(
- text = title,
- style = MaterialTheme.typography.titleSmall
- )
-
- Text(
- text = subtitle,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- Spacer(modifier = Modifier.size(32.dp))
- }
-}
-
-@Composable
-fun NextButton(
- isSearchingForBackup: Boolean,
- onNextClicked: () -> Unit
-) {
- val alpha = if (isSearchingForBackup) {
- 0f
- } else {
- 1f
- }
-
- Buttons.LargeTonal(
- onClick = onNextClicked,
- enabled = !isSearchingForBackup,
- modifier = Modifier.alpha(alpha)
- ) {
- Text(
- text = stringResource(id = R.string.GrantPermissionsFragment__next)
- )
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java
index fded5f0aaf..a751313473 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java
@@ -1,3 +1,8 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
package org.thoughtcrime.securesms.registration.fragments;
import android.content.Context;
@@ -14,7 +19,8 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.Debouncer;
-final class SignalStrengthPhoneStateListener extends PhoneStateListener
+// TODO [nicholas]: move to v2 package and make package-private. convert to Kotlin
+public final class SignalStrengthPhoneStateListener extends PhoneStateListener
implements DefaultLifecycleObserver
{
private static final String TAG = Log.tag(SignalStrengthPhoneStateListener.class);
@@ -22,7 +28,8 @@ final class SignalStrengthPhoneStateListener extends PhoneStateListener
private final Callback callback;
private final Debouncer debouncer = new Debouncer(1000);
- SignalStrengthPhoneStateListener(@NonNull LifecycleOwner lifecycleOwner, @NonNull Callback callback) {
+ @SuppressWarnings("deprecation")
+ public SignalStrengthPhoneStateListener(@NonNull LifecycleOwner lifecycleOwner, @NonNull Callback callback) {
this.callback = callback;
lifecycleOwner.getLifecycle().addObserver(this);
@@ -51,7 +58,7 @@ final class SignalStrengthPhoneStateListener extends PhoneStateListener
}
}
- interface Callback {
+ public interface Callback {
void onNoCellSignalPresent();
void onCellSignalPresent();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt
new file mode 100644
index 0000000000..a1f583103e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt
@@ -0,0 +1,392 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.data
+
+import android.content.Context
+import androidx.annotation.WorkerThread
+import androidx.core.app.NotificationManagerCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.signal.core.util.logging.Log
+import org.signal.libsignal.protocol.IdentityKeyPair
+import org.signal.libsignal.protocol.util.KeyHelper
+import org.signal.libsignal.zkgroup.profiles.ProfileKey
+import org.thoughtcrime.securesms.AppCapabilities
+import org.thoughtcrime.securesms.crypto.PreKeyUtil
+import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
+import org.thoughtcrime.securesms.crypto.SenderKeyUtil
+import org.thoughtcrime.securesms.crypto.storage.PreKeyMetadataStore
+import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl
+import org.thoughtcrime.securesms.database.IdentityTable
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.gcm.FcmUtil
+import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
+import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
+import org.thoughtcrime.securesms.jobs.RotateCertificateJob
+import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.notifications.NotificationIds
+import org.thoughtcrime.securesms.pin.SvrRepository.onRegistrationComplete
+import org.thoughtcrime.securesms.push.AccountManagerFactory
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.registration.PushChallengeRequest
+import org.thoughtcrime.securesms.registration.RegistrationData
+import org.thoughtcrime.securesms.registration.VerifyAccountRepository
+import org.thoughtcrime.securesms.service.DirectoryRefreshListener
+import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
+import org.thoughtcrime.securesms.util.TextSecurePreferences
+import org.whispersystems.signalservice.api.NetworkResult
+import org.whispersystems.signalservice.api.account.AccountAttributes
+import org.whispersystems.signalservice.api.account.PreKeyCollection
+import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
+import org.whispersystems.signalservice.api.kbs.MasterKey
+import org.whispersystems.signalservice.api.push.ServiceId
+import org.whispersystems.signalservice.api.push.ServiceId.ACI
+import org.whispersystems.signalservice.api.push.ServiceId.PNI
+import org.whispersystems.signalservice.api.push.SignalServiceAddress
+import org.whispersystems.signalservice.api.registration.RegistrationApi
+import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataHeaders
+import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
+import java.util.Locale
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * A repository that deals with disk I/O during account registration.
+ */
+object RegistrationRepository {
+
+ private val TAG = Log.tag(RegistrationRepository::class.java)
+
+ private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
+
+ /**
+ * Retrieve the FCM token from the Firebase service.
+ */
+ suspend fun getFcmToken(context: Context): String? =
+ withContext(Dispatchers.Default) {
+ FcmUtil.getToken(context).orElse(null)
+ }
+
+ /**
+ * Queries the local store for whether a PIN is set.
+ */
+ @JvmStatic
+ fun hasPin(): Boolean {
+ return SignalStore.svr().hasPin()
+ }
+
+ /**
+ * Queries, and creates if needed, the local registration ID.
+ */
+ @JvmStatic
+ fun getRegistrationId(): Int {
+ // TODO [regv2]: make creation more explicit instead of hiding it in this getter
+ var registrationId = SignalStore.account().registrationId
+ if (registrationId == 0) {
+ registrationId = KeyHelper.generateRegistrationId(false)
+ SignalStore.account().registrationId = registrationId
+ }
+ return registrationId
+ }
+
+ /**
+ * Queries, and creates if needed, the local PNI registration ID.
+ */
+ @JvmStatic
+ fun getPniRegistrationId(): Int {
+ // TODO [regv2]: make creation more explicit instead of hiding it in this getter
+ var pniRegistrationId = SignalStore.account().pniRegistrationId
+ if (pniRegistrationId == 0) {
+ pniRegistrationId = KeyHelper.generateRegistrationId(false)
+ SignalStore.account().pniRegistrationId = pniRegistrationId
+ }
+ return pniRegistrationId
+ }
+
+ /**
+ * Queries, and creates if needed, the local profile key.
+ */
+ @JvmStatic
+ suspend fun getProfileKey(e164: String): ProfileKey =
+ withContext(Dispatchers.IO) {
+ // TODO [regv2]: make creation more explicit instead of hiding it in this getter
+ val recipientTable = SignalDatabase.recipients
+ val recipient = recipientTable.getByE164(e164)
+ var profileKey = if (recipient.isPresent) {
+ ProfileKeyUtil.profileKeyOrNull(Recipient.resolved(recipient.get()).profileKey)
+ } else {
+ null
+ }
+ if (profileKey == null) {
+ profileKey = ProfileKeyUtil.createNew()
+ Log.i(TAG, "No profile key found, created a new one")
+ }
+ profileKey
+ }
+
+ /**
+ * Takes a server response from a successful registration and persists the relevant data.
+ */
+ @WorkerThread
+ @JvmStatic
+ suspend fun registerAccountLocally(context: Context, registrationData: RegistrationData, response: AccountRegistrationResult, reglockEnabled: Boolean) =
+ withContext(Dispatchers.IO) {
+ val aciPreKeyCollection: PreKeyCollection = response.aciPreKeyCollection
+ val pniPreKeyCollection: PreKeyCollection = response.pniPreKeyCollection
+ val aci: ACI = ACI.parseOrThrow(response.uuid)
+ val pni: PNI = PNI.parseOrThrow(response.pni)
+ val hasPin: Boolean = response.storageCapable
+
+ SignalStore.account().setAci(aci)
+ SignalStore.account().setPni(pni)
+
+ ApplicationDependencies.resetProtocolStores()
+
+ ApplicationDependencies.getProtocolStore().aci().sessions().archiveAllSessions()
+ ApplicationDependencies.getProtocolStore().pni().sessions().archiveAllSessions()
+ SenderKeyUtil.clearAllState()
+
+ val aciProtocolStore = ApplicationDependencies.getProtocolStore().aci()
+ val aciMetadataStore = SignalStore.account().aciPreKeys
+
+ val pniProtocolStore = ApplicationDependencies.getProtocolStore().pni()
+ val pniMetadataStore = SignalStore.account().pniPreKeys
+
+ storeSignedAndLastResortPreKeys(aciProtocolStore, aciMetadataStore, aciPreKeyCollection)
+ storeSignedAndLastResortPreKeys(pniProtocolStore, pniMetadataStore, pniPreKeyCollection)
+
+ val recipientTable = SignalDatabase.recipients
+ val selfId = Recipient.trustedPush(aci, pni, registrationData.e164).id
+
+ recipientTable.setProfileSharing(selfId, true)
+ recipientTable.markRegisteredOrThrow(selfId, aci)
+ recipientTable.linkIdsForSelf(aci, pni, registrationData.e164)
+ recipientTable.setProfileKey(selfId, registrationData.profileKey)
+
+ ApplicationDependencies.getRecipientCache().clearSelf()
+
+ SignalStore.account().setE164(registrationData.e164)
+ SignalStore.account().fcmToken = registrationData.fcmToken
+ SignalStore.account().fcmEnabled = registrationData.isFcm
+
+ val now = System.currentTimeMillis()
+ saveOwnIdentityKey(selfId, aci, aciProtocolStore, now)
+ saveOwnIdentityKey(selfId, pni, pniProtocolStore, now)
+
+ SignalStore.account().setServicePassword(registrationData.password)
+ SignalStore.account().setRegistered(true)
+ TextSecurePreferences.setPromptedPushRegistration(context, true)
+ TextSecurePreferences.setUnauthorizedReceived(context, false)
+ NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
+
+ onRegistrationComplete(response.masterKey, response.pin, hasPin, reglockEnabled)
+
+ ApplicationDependencies.closeConnections()
+ ApplicationDependencies.getIncomingMessageObserver()
+ PreKeysSyncJob.enqueue()
+
+ val jobManager = ApplicationDependencies.getJobManager()
+ jobManager.add(DirectoryRefreshJob(false))
+ jobManager.add(RotateCertificateJob())
+
+ DirectoryRefreshListener.schedule(context)
+ RotateSignedPreKeyListener.schedule(context)
+ }
+
+ @JvmStatic
+ private fun saveOwnIdentityKey(selfId: RecipientId, serviceId: ServiceId, protocolStore: SignalServiceAccountDataStoreImpl, now: Long) {
+ protocolStore.identities().saveIdentityWithoutSideEffects(
+ selfId,
+ serviceId,
+ protocolStore.identityKeyPair.publicKey,
+ IdentityTable.VerifiedStatus.VERIFIED,
+ true,
+ now,
+ true
+ )
+ }
+
+ @JvmStatic
+ private fun storeSignedAndLastResortPreKeys(protocolStore: SignalServiceAccountDataStoreImpl, metadataStore: PreKeyMetadataStore, preKeyCollection: PreKeyCollection) {
+ PreKeyUtil.storeSignedPreKey(protocolStore, metadataStore, preKeyCollection.signedPreKey)
+ metadataStore.isSignedPreKeyRegistered = true
+ metadataStore.activeSignedPreKeyId = preKeyCollection.signedPreKey.id
+ metadataStore.lastSignedPreKeyRotationTime = System.currentTimeMillis()
+
+ PreKeyUtil.storeLastResortKyberPreKey(protocolStore, metadataStore, preKeyCollection.lastResortKyberPreKey)
+ metadataStore.lastResortKyberPreKeyId = preKeyCollection.lastResortKyberPreKey.id
+ metadataStore.lastResortKyberPreKeyRotationTime = System.currentTimeMillis()
+ }
+
+ /**
+ * Asks the service to send a verification code through one of our supported channels (SMS, phone call).
+ * This requires two or more network calls:
+ * 1. Create (or reuse) a session.
+ * 2. (Optional) If the session has any proof requirements ("challenges"), the user must solve them and submit the proof.
+ * 3. Once the service responds we are allowed to, we request the verification code.
+ */
+ suspend fun requestSmsCode(context: Context, e164: String, password: String, mcc: String?, mnc: String?, mode: Mode = Mode.SMS_WITHOUT_LISTENER): NetworkResult =
+ withContext(Dispatchers.IO) {
+ val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+ val activeSession = if (fcmToken == null) {
+ // TODO [regv2]
+ val notImplementedError = NotImplementedError()
+ Log.w(TAG, "Not yet implemented!", notImplementedError)
+ NetworkResult.ApplicationError(notImplementedError)
+ } else {
+ createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
+ }
+
+ activeSession.then { session ->
+ val sessionId = session.body.id
+ SignalStore.registrationValues().sessionId = sessionId
+ SignalStore.registrationValues().sessionE164 = e164
+ if (!session.body.allowedToRequestCode) {
+ val challenges = session.body.requestedInformation.joinToString()
+ Log.w(TAG, "Not allowed to request code! Remaining challenges: $challenges")
+ // TODO [regv2]: actually handle challenges
+ }
+ // TODO [regv2]: support other verification code [Mode] options
+ if (mode == Mode.PHONE_CALL) {
+ // TODO [regv2]
+ val notImplementedError = NotImplementedError()
+ Log.w(TAG, "Not yet implemented!", notImplementedError)
+ NetworkResult.ApplicationError(notImplementedError)
+ } else {
+ api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported)
+ }
+ }
+ }
+
+ /**
+ * Submits the user-entered verification code to the service.
+ */
+ suspend fun submitVerificationCode(context: Context, e164: String, password: String, sessionId: String, registrationData: RegistrationData): NetworkResult =
+ withContext(Dispatchers.IO) {
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+ api.verifyAccount(registrationData.code, sessionId)
+ }
+
+ /**
+ * Submit the necessary assets as a verified account so that the user can actually use the service.
+ */
+ suspend fun registerAccount(context: Context, e164: String, password: String, sessionId: String, registrationData: RegistrationData, pin: String? = null, masterKeyProducer: VerifyAccountRepository.MasterKeyProducer? = null): NetworkResult =
+ withContext(Dispatchers.IO) {
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+
+ val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
+ val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
+
+ val masterKey: MasterKey? = masterKeyProducer?.produceMasterKey()
+ val registrationLock: String? = masterKey?.deriveRegistrationLock()
+
+ val accountAttributes = AccountAttributes(
+ signalingKey = null,
+ registrationId = registrationData.registrationId,
+ fetchesMessages = registrationData.isNotFcm,
+ registrationLock = registrationLock,
+ unidentifiedAccessKey = unidentifiedAccessKey,
+ unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
+ capabilities = AppCapabilities.getCapabilities(true),
+ discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE,
+ name = null,
+ pniRegistrationId = registrationData.pniRegistrationId,
+ recoveryPassword = registrationData.recoveryPassword
+ )
+
+ SignalStore.account().generateAciIdentityKeyIfNecessary()
+ val aciIdentity: IdentityKeyPair = SignalStore.account().aciIdentityKey
+
+ SignalStore.account().generatePniIdentityKeyIfNecessary()
+ val pniIdentity: IdentityKeyPair = SignalStore.account().pniIdentityKey
+
+ val aciPreKeyCollection = org.thoughtcrime.securesms.registration.RegistrationRepository.generateSignedAndLastResortPreKeys(aciIdentity, SignalStore.account().aciPreKeys)
+ val pniPreKeyCollection = org.thoughtcrime.securesms.registration.RegistrationRepository.generateSignedAndLastResortPreKeys(pniIdentity, SignalStore.account().pniPreKeys)
+
+ api.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, aciPreKeyCollection, pniPreKeyCollection, registrationData.fcmToken, true)
+ .map { accountRegistrationResponse ->
+ AccountRegistrationResult(
+ uuid = accountRegistrationResponse.uuid,
+ pni = accountRegistrationResponse.pni,
+ storageCapable = accountRegistrationResponse.storageCapable,
+ number = accountRegistrationResponse.number,
+ masterKey = masterKey,
+ pin = pin,
+ aciPreKeyCollection = aciPreKeyCollection,
+ pniPreKeyCollection = pniPreKeyCollection
+ )
+ }
+ }
+
+ private suspend fun createSessionAndBlockForPushChallenge(accountManager: RegistrationApi, fcmToken: String, mcc: String?, mnc: String?): NetworkResult =
+ withContext(Dispatchers.IO) {
+ // TODO [regv2]: do not use event bus nor latch
+ val subscriber = PushTokenChallengeSubscriber()
+ val eventBus = EventBus.getDefault()
+ eventBus.register(subscriber)
+
+ val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc).successOrThrow() // TODO: error handling
+ val receivedPush = subscriber.latch.await(PUSH_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS)
+ eventBus.unregister(subscriber)
+
+ if (receivedPush) {
+ val challenge = subscriber.challenge
+ if (challenge != null) {
+ Log.w(TAG, "Push challenge token received.")
+ return@withContext accountManager.submitPushChallengeToken(sessionCreationResponse.body.id, challenge)
+ } else {
+ Log.w(TAG, "Push received but challenge token was null.")
+ }
+ } else {
+ Log.i(TAG, "Push challenge timed out.")
+ }
+ Log.i(TAG, "Push challenge unsuccessful. Updating registration state accordingly.")
+ return@withContext NetworkResult.ApplicationError(NullPointerException())
+ }
+
+ @JvmStatic
+ fun deriveTimestamp(headers: RegistrationSessionMetadataHeaders, deltaSeconds: Int?): Long {
+ if (deltaSeconds == null) {
+ return 0L
+ }
+
+ val timestamp: Long = headers.timestamp
+ return timestamp + deltaSeconds.seconds.inWholeMilliseconds
+ }
+
+ enum class Mode(val isSmsRetrieverSupported: Boolean) {
+ SMS_WITH_LISTENER(true), SMS_WITHOUT_LISTENER(false), PHONE_CALL(false)
+ }
+
+ private class PushTokenChallengeSubscriber {
+ var challenge: String? = null
+ val latch = CountDownLatch(1)
+
+ @Subscribe
+ fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) {
+ challenge = pushChallengeEvent.challenge
+ latch.countDown()
+ }
+ }
+
+ data class AccountRegistrationResult(
+ val uuid: String,
+ val pni: String,
+ val storageCapable: Boolean,
+ val number: String,
+ val masterKey: MasterKey?,
+ val pin: String?,
+ val aciPreKeyCollection: PreKeyCollection,
+ val pniPreKeyCollection: PreKeyCollection
+ )
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt
new file mode 100644
index 0000000000..1271a2133b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2ViewModel
+
+/**
+ * Activity to hold the entire registration process.
+ */
+class RegistrationV2Activity : AppCompatActivity() {
+
+ private val TAG = Log.tag(RegistrationV2Activity::class.java)
+
+ val sharedViewModel: RegistrationV2ViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_registration_navigation_v2)
+ }
+
+ companion object {
+
+ @JvmStatic
+ fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
+ return Intent(context, RegistrationV2Activity::class.java).apply {
+ setData(originalIntent.data)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Extensions.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Extensions.kt
new file mode 100644
index 0000000000..9b76f73561
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Extensions.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui
+
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
+
+fun PhoneNumber.toE164(): String {
+ return PhoneNumberUtil.getInstance().format(this, PhoneNumberUtil.PhoneNumberFormat.E164)
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt
new file mode 100644
index 0000000000..225504ae13
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.entercode
+
+import android.os.Bundle
+import android.view.View
+import androidx.activity.OnBackPressedCallback
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.ActivityNavigator
+import androidx.navigation.fragment.NavHostFragment
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.MainActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeV2Binding
+import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
+import org.thoughtcrime.securesms.profiles.AvatarHelper
+import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2ViewModel
+
+/**
+ * The final screen of account registration, where the user enters their verification code.
+ */
+class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter_code_v2) {
+
+ private val TAG = Log.tag(EnterCodeV2Fragment::class.java)
+
+ private val sharedViewModel by activityViewModels()
+ private val binding: FragmentRegistrationEnterCodeV2Binding by ViewBinderDelegate(FragmentRegistrationEnterCodeV2Binding::bind)
+
+ private lateinit var phoneStateListener: SignalStrengthPhoneStateListener
+
+ private var autopilotCodeEntryActive = false
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setDebugLogSubmitMultiTapView(binding.verifyHeader)
+
+ phoneStateListener = SignalStrengthPhoneStateListener(this, PhoneStateCallback())
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ popBackStack()
+ }
+ }
+ )
+
+ binding.wrongNumber.setOnClickListener {
+ popBackStack()
+ }
+
+ binding.code.setOnCompleteListener {
+ sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it)
+ }
+
+ binding.keyboard.setOnKeyPressListener { key ->
+ if (!autopilotCodeEntryActive) {
+ if (key >= 0) {
+ binding.code.append(key)
+ } else {
+ binding.code.delete()
+ }
+ }
+ }
+
+ sharedViewModel.uiState.observe(viewLifecycleOwner) {
+ if (it.registrationCheckpoint == RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED) {
+ handleSuccessfulVerify()
+ }
+ }
+ }
+
+ private fun handleSuccessfulVerify() {
+ // TODO [regv2]: add functionality of [RegistrationCompleteFragment]
+ val activity = requireActivity()
+ val isProfileNameEmpty = Recipient.self().profileName.isEmpty
+ val isAvatarEmpty = !AvatarHelper.hasAvatar(activity, Recipient.self().id)
+ val needsProfile = isProfileNameEmpty || isAvatarEmpty
+ val needsPin = !sharedViewModel.hasPin()
+
+ Log.i(TAG, "Pin restore flow not required. Profile name: $isProfileNameEmpty | Profile avatar: $isAvatarEmpty | Needs PIN: $needsPin")
+
+ if (!needsProfile && !needsPin) {
+ sharedViewModel.completeRegistration()
+ }
+
+ val startIntent = MainActivity.clearTop(activity).apply {
+ if (needsPin) {
+ putExtra("next_intent", CreateSvrPinActivity.getIntentForPinCreate(activity))
+ }
+
+ if (needsProfile) {
+ putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(activity))
+ }
+ }
+
+ activity.startActivity(startIntent)
+ sharedViewModel.setInProgress(false)
+ activity.finish()
+ ActivityNavigator.applyPopAnimationsToPendingTransition(activity)
+ }
+
+ private fun popBackStack() {
+ sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PUSH_NETWORK_AUDITED)
+ NavHostFragment.findNavController(this).popBackStack()
+ }
+
+ private class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback {
+ override fun onNoCellSignalPresent() {
+ // TODO [regv2]: animate in bottom sheet
+ }
+
+ override fun onCellSignalPresent() {
+ // TODO [regv2]: animate in bottom sheet
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/grantpermissions/GrantPermissionsV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/grantpermissions/GrantPermissionsV2Fragment.kt
new file mode 100644
index 0000000000..e61987787c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/grantpermissions/GrantPermissionsV2Fragment.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.grantpermissions
+
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.platform.LocalContext
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.fragment.navArgs
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.registration.compose.GrantPermissionsScreen
+import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2State
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2ViewModel
+import org.thoughtcrime.securesms.util.BackupUtil
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * Screen in account registration that provides rationales for the suggested runtime permissions.
+ */
+@RequiresApi(23)
+class GrantPermissionsV2Fragment : ComposeFragment() {
+
+ private val TAG = Log.tag(GrantPermissionsV2Fragment::class.java)
+
+ private val sharedViewModel by activityViewModels()
+ private val args by navArgs()
+ private val isSearchingForBackup = mutableStateOf(false)
+
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions(),
+ ::permissionsGranted
+ )
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ sharedViewModel.uiState.observe(viewLifecycleOwner) {
+ if (it.registrationCheckpoint >= RegistrationCheckpoint.PERMISSIONS_GRANTED) {
+ proceedToNextScreen(it)
+ }
+ }
+ }
+
+ private fun proceedToNextScreen(it: RegistrationV2State) {
+ // TODO [nicholas]: conditionally go to backup flow
+ NavHostFragment.findNavController(this).safeNavigate(GrantPermissionsV2FragmentDirections.actionSkipRestore())
+ }
+
+ @Composable
+ override fun FragmentContent() {
+ val isSearchingForBackup by this.isSearchingForBackup
+
+ GrantPermissionsScreen(
+ deviceBuildVersion = Build.VERSION.SDK_INT,
+ isSearchingForBackup = isSearchingForBackup,
+ isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current),
+ onNextClicked = this::onNextClicked,
+ onNotNowClicked = this::onNotNowClicked
+ )
+ }
+
+ private fun onNextClicked() {
+ when (args.welcomeAction) {
+ WelcomeAction.CONTINUE -> continueNext()
+ WelcomeAction.RESTORE_BACKUP -> Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2]
+ }
+ }
+
+ private fun continueNext() {
+ val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
+ val requiredPermissions = WelcomePermissions.getWelcomePermissions(isUserSelectionRequired)
+ requestPermissionLauncher.launch(requiredPermissions)
+ }
+
+ private fun onNotNowClicked() {
+ when (args.welcomeAction) {
+ WelcomeAction.CONTINUE -> continueNotNow()
+ WelcomeAction.RESTORE_BACKUP -> Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2]
+ }
+ }
+
+ private fun continueNotNow() {
+ NavHostFragment.findNavController(this).popBackStack()
+ }
+
+ private fun permissionsGranted(permissions: Map) {
+ permissions.forEach {
+ Log.d(TAG, "${it.key} = ${it.value}")
+ }
+ sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
+ }
+
+ /**
+ * Which welcome action the user selected which prompted this
+ * screen.
+ */
+ enum class WelcomeAction {
+ CONTINUE,
+ RESTORE_BACKUP
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2Fragment.kt
new file mode 100644
index 0000000000..cca66bb7e7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2Fragment.kt
@@ -0,0 +1,338 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.phonenumber
+
+import android.content.Context
+import android.os.Bundle
+import android.text.SpannableStringBuilder
+import android.view.KeyEvent
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.widget.ArrayAdapter
+import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.MenuProvider
+import androidx.core.widget.addTextChangedListener
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.NavHostFragment
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import com.google.android.material.textfield.TextInputEditText
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterPhoneNumberV2Binding
+import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registration.util.CountryPrefix
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2State
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2ViewModel
+import org.thoughtcrime.securesms.registration.v2.ui.toE164
+import org.thoughtcrime.securesms.util.PlayServicesUtil
+import org.thoughtcrime.securesms.util.SpanUtil
+import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.livedata.LiveDataObserverCallback
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+import org.thoughtcrime.securesms.util.visible
+
+/**
+ * Screen in registration where the user enters their phone number.
+ */
+class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number_v2) {
+
+ private val TAG = Log.tag(EnterPhoneNumberV2Fragment::class.java)
+ private val sharedViewModel by activityViewModels()
+ private val fragmentViewModel by viewModels()
+ private val binding: FragmentRegistrationEnterPhoneNumberV2Binding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberV2Binding::bind)
+
+ private lateinit var spinnerAdapter: ArrayAdapter
+ private lateinit var phoneNumberInputLayout: TextInputEditText
+ private lateinit var spinnerView: MaterialAutoCompleteTextView
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ popBackStack()
+ }
+ }
+ )
+ phoneNumberInputLayout = binding.number.editText as TextInputEditText
+ spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView
+ spinnerAdapter = ArrayAdapter(
+ requireContext(),
+ R.layout.registration_country_code_dropdown_item,
+ fragmentViewModel.supportedCountryPrefixes
+ )
+ setDebugLogSubmitMultiTapView(binding.verifyHeader)
+ binding.registerButton.setOnClickListener { onRegistrationButtonClicked() }
+
+ binding.toolbar.title = null
+ val activity = requireActivity() as AppCompatActivity
+ activity.setSupportActionBar(binding.toolbar)
+
+ requireActivity().addMenuProvider(UseProxyMenuProvider(), viewLifecycleOwner)
+
+ val existingPhoneNumber = sharedViewModel.uiState.value?.phoneNumber
+ if (existingPhoneNumber != null) {
+ fragmentViewModel.restoreState(existingPhoneNumber)
+ fragmentViewModel.phoneNumber()?.let {
+ phoneNumberInputLayout.setText(it.nationalNumber.toString())
+ }
+ } else if (spinnerView.editableText.isBlank()) {
+ spinnerView.setText(fragmentViewModel.countryPrefix().toString())
+ }
+
+ sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
+ presentRegisterButton(sharedState)
+ presentProgressBar(sharedState.inProgress, sharedState.isReRegister)
+ if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
+ moveToVerificationEntryScreen()
+ }
+ }
+
+ fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
+ if (fragmentViewModel.isEnteredNumberValid(fragmentState)) {
+ sharedViewModel.setPhoneNumber(fragmentViewModel.parsePhoneNumber(fragmentState))
+ } else {
+ sharedViewModel.setPhoneNumber(null)
+ }
+
+ if (fragmentState.error != EnterPhoneNumberV2State.Error.NONE) {
+ presentError(fragmentState)
+ }
+ }
+
+ initializeInputFields()
+
+ ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout)
+ }
+
+ private fun initializeInputFields() {
+ phoneNumberInputLayout.addTextChangedListener {
+ // TODO [regv2]: country code as you type formatter
+ fragmentViewModel.setPhoneNumber(it?.toString())
+ }
+ phoneNumberInputLayout.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
+ if (hasFocus) {
+ binding.scrollView.postDelayed({ binding.scrollView.smoothScrollTo(0, binding.registerButton.bottom) }, 250)
+ }
+ }
+ phoneNumberInputLayout.imeOptions = EditorInfo.IME_ACTION_DONE
+ phoneNumberInputLayout.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
+ if (actionId == EditorInfo.IME_ACTION_DONE && v != null) {
+ onRegistrationButtonClicked()
+ return@setOnEditorActionListener true
+ }
+ false
+ }
+
+ spinnerView.threshold = 100
+ spinnerView.setAdapter(spinnerAdapter)
+ spinnerView.addTextChangedListener { s ->
+ if (s.isNullOrEmpty()) {
+ return@addTextChangedListener
+ }
+
+ if (s[0] != '+') {
+ s.insert(0, "+")
+ }
+
+ fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let {
+ // TODO [regv2]: setCountryFormatter(it.regionCode)
+ fragmentViewModel.setCountry(it.digits)
+ val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0
+ phoneNumberInputLayout.setSelection(numberLength, numberLength)
+ }
+ }
+ }
+
+ private fun presentRegisterButton(sharedState: RegistrationV2State) {
+ binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isValidNumber(sharedState.phoneNumber) && !sharedState.inProgress
+ // TODO [regv2]: always enable the button but display error dialogs if the entered phone number is invalid
+ }
+
+ private fun presentError(state: EnterPhoneNumberV2State) {
+ when (state.error) {
+ EnterPhoneNumberV2State.Error.NONE -> {
+ Unit
+ }
+
+ EnterPhoneNumberV2State.Error.INVALID_PHONE_NUMBER -> {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(getString(R.string.RegistrationActivity_invalid_number))
+ setMessage(
+ String.format(
+ getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid),
+ state.phoneNumber
+ )
+ )
+ setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
+ setOnCancelListener { fragmentViewModel.clearError() }
+ setOnDismissListener { fragmentViewModel.clearError() }
+ show()
+ }
+ }
+
+ EnterPhoneNumberV2State.Error.PLAY_SERVICES_MISSING -> {
+ Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2]
+ }
+
+ EnterPhoneNumberV2State.Error.PLAY_SERVICES_NEEDS_UPDATE -> {
+ GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0)?.show()
+ }
+
+ EnterPhoneNumberV2State.Error.PLAY_SERVICES_TRANSIENT -> {
+ Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2]
+ }
+ }
+ }
+
+ private fun onRegistrationButtonClicked() {
+ ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout)
+ sharedViewModel.setInProgress(true)
+ val hasFcm = validateFcmStatus(requireContext())
+ if (hasFcm) {
+ sharedViewModel.uiState.observe(viewLifecycleOwner, FcmTokenRetrievedObserver())
+ sharedViewModel.fetchFcmToken(requireContext())
+ } else {
+ sharedViewModel.setInProgress(false)
+ // TODO [regv2]: handle if FCM isn't available
+ }
+ }
+
+ private fun onFcmTokenRetrieved(value: RegistrationV2State) {
+ if (value.phoneNumber == null) {
+ fragmentViewModel.setError(EnterPhoneNumberV2State.Error.INVALID_PHONE_NUMBER)
+ sharedViewModel.setInProgress(false)
+ } else {
+ presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms)
+ }
+ }
+
+ private fun presentProgressBar(showProgress: Boolean, isReRegister: Boolean) {
+ if (showProgress) {
+ binding.registerButton.setSpinning()
+ } else {
+ binding.registerButton.cancelSpinning()
+ }
+ binding.countryCode.isEnabled = !showProgress
+ binding.number.isEnabled = !showProgress
+ binding.cancelButton.visible = !showProgress && isReRegister
+ }
+
+ private fun validateFcmStatus(context: Context): Boolean {
+ val fcmStatus = PlayServicesUtil.getPlayServicesStatus(context)
+ Log.d(TAG, "Got $fcmStatus for Play Services status.")
+ when (fcmStatus) {
+ PlayServicesUtil.PlayServicesStatus.SUCCESS -> {
+ return true
+ }
+
+ PlayServicesUtil.PlayServicesStatus.MISSING -> {
+ fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_MISSING)
+ return false
+ }
+
+ PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE -> {
+ fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_NEEDS_UPDATE)
+ return false
+ }
+
+ PlayServicesUtil.PlayServicesStatus.TRANSIENT_ERROR -> {
+ fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_TRANSIENT)
+ return false
+ }
+
+ null -> {
+ Log.w(TAG, "Null result received from PlayServicesUtil, marking Play Services as missing.")
+ fragmentViewModel.setError(EnterPhoneNumberV2State.Error.PLAY_SERVICES_MISSING)
+ return false
+ }
+ }
+ }
+
+ private fun onConfirmNumberDialogCanceled() {
+ Log.d(TAG, "User canceled confirm number, returning to edit number.")
+ sharedViewModel.setInProgress(false)
+ ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(phoneNumberInputLayout)
+ }
+
+ private fun presentConfirmNumberDialog(phoneNumber: PhoneNumber, isReRegister: Boolean, canSkipSms: Boolean) {
+ val title = if (isReRegister) {
+ R.string.RegistrationActivity_additional_verification_required
+ } else {
+ R.string.RegistrationActivity_phone_number_verification_dialog_title
+ }
+
+ val message: CharSequence = SpannableStringBuilder().apply {
+ append(SpanUtil.bold(PhoneNumberFormatter.prettyPrint(phoneNumber.toE164())))
+ if (!canSkipSms) {
+ append("\n\n")
+ append(getString(R.string.RegistrationActivity_a_verification_code_will_be_sent_to_this_number))
+ }
+ }
+
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(title)
+ setMessage(message)
+ setPositiveButton(android.R.string.ok) { _, _ ->
+ Log.d(TAG, "User confirmed number.")
+ sharedViewModel.onUserConfirmedPhoneNumber(requireContext())
+ }
+ setNegativeButton(R.string.RegistrationActivity_edit_number) { _, _ -> onConfirmNumberDialogCanceled() }
+ setOnCancelListener { _ -> onConfirmNumberDialogCanceled() }
+ }.show()
+ }
+
+ private fun moveToVerificationEntryScreen() {
+ NavHostFragment.findNavController(this).safeNavigate(EnterPhoneNumberV2FragmentDirections.actionEnterVerificationCode())
+ sharedViewModel.setInProgress(false)
+ }
+
+ private fun popBackStack() {
+ sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
+ NavHostFragment.findNavController(this).popBackStack()
+ }
+
+ private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback(sharedViewModel.uiState) {
+ override fun onValue(value: RegistrationV2State): Boolean {
+ val fcmRetrieved = value.isFcmSupported
+ if (fcmRetrieved) {
+ onFcmTokenRetrieved(value)
+ }
+ return fcmRetrieved
+ }
+ }
+
+ private inner class UseProxyMenuProvider : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.enter_phone_number, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return if (menuItem.itemId == R.id.phone_menu_use_proxy) {
+ NavHostFragment.findNavController(this@EnterPhoneNumberV2Fragment).safeNavigate(EnterPhoneNumberV2FragmentDirections.actionEditProxy())
+ true
+ } else {
+ false
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2State.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2State.kt
new file mode 100644
index 0000000000..6f399443f6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2State.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.phonenumber
+
+/**
+ * State holder for the phone number entry screen, including phone number and Play Services errors.
+ */
+data class EnterPhoneNumberV2State(val countryPrefixIndex: Int, val phoneNumber: String, val error: Error = Error.NONE) {
+
+ companion object {
+ @JvmStatic
+ val INIT = EnterPhoneNumberV2State(0, "")
+ }
+
+ enum class Error {
+ NONE,
+ INVALID_PHONE_NUMBER,
+ PLAY_SERVICES_MISSING,
+ PLAY_SERVICES_NEEDS_UPDATE,
+ PLAY_SERVICES_TRANSIENT
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2ViewModel.kt
new file mode 100644
index 0000000000..4a7804f67e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/phonenumber/EnterPhoneNumberV2ViewModel.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.phonenumber
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import com.google.i18n.phonenumbers.NumberParseException
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.registration.util.CountryPrefix
+
+/**
+ * ViewModel for the phone number entry screen.
+ */
+class EnterPhoneNumberV2ViewModel : ViewModel() {
+
+ private val TAG = Log.tag(EnterPhoneNumberV2ViewModel::class.java)
+
+ private val store = MutableStateFlow(EnterPhoneNumberV2State.INIT)
+ val uiState = store.asLiveData()
+
+ val supportedCountryPrefixes: List = PhoneNumberUtil.getInstance().supportedCallingCodes
+ .map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
+ .sortedBy { it.digits.toString() }
+
+ fun countryPrefix(): CountryPrefix {
+ return supportedCountryPrefixes[store.value.countryPrefixIndex]
+ }
+
+ fun phoneNumber(): PhoneNumber? {
+ return try {
+ parsePhoneNumber(store.value)
+ } catch (ex: NumberParseException) {
+ Log.w(TAG, "Could not parse phone number in current state.", ex)
+ null
+ }
+ }
+
+ fun setPhoneNumber(phoneNumber: String?) {
+ store.update { it.copy(phoneNumber = phoneNumber ?: "") }
+ }
+
+ fun setCountry(digits: Int) {
+ val matchingIndex = countryCodeToAdapterIndex(digits)
+ store.update {
+ it.copy(countryPrefixIndex = matchingIndex)
+ }
+ }
+
+ fun parsePhoneNumber(state: EnterPhoneNumberV2State): PhoneNumber {
+ return PhoneNumberUtil.getInstance().parse(state.phoneNumber, supportedCountryPrefixes[state.countryPrefixIndex].regionCode)
+ }
+
+ fun isEnteredNumberValid(state: EnterPhoneNumberV2State): Boolean {
+ return try {
+ PhoneNumberUtil.getInstance().isValidNumber(parsePhoneNumber(state))
+ } catch (ex: NumberParseException) {
+ false
+ }
+ }
+
+ fun restoreState(value: PhoneNumber) {
+ val prefixIndex = countryCodeToAdapterIndex(value.countryCode)
+ if (prefixIndex != -1) {
+ store.update {
+ it.copy(
+ countryPrefixIndex = prefixIndex,
+ phoneNumber = value.nationalNumber.toString()
+ )
+ }
+ }
+ }
+
+ private fun countryCodeToAdapterIndex(countryCode: Int): Int {
+ return supportedCountryPrefixes.indexOfFirst { prefix -> prefix.digits == countryCode }
+ }
+
+ fun clearError() {
+ setError(EnterPhoneNumberV2State.Error.NONE)
+ }
+
+ fun setError(error: EnterPhoneNumberV2State.Error) {
+ store.update {
+ it.copy(error = error)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationCheckpoint.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationCheckpoint.kt
new file mode 100644
index 0000000000..744268df98
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationCheckpoint.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.shared
+
+/**
+ * An ordered list of checkpoints of the registration process.
+ * This is used for screens to know when to advance, as well as restoring state after process death.
+ */
+enum class RegistrationCheckpoint {
+ INITIALIZATION,
+ PERMISSIONS_GRANTED,
+ BACKUP_DETECTED,
+ BACKUP_SELECTED,
+ BACKUP_RESTORED,
+ PUSH_NETWORK_AUDITED,
+ PHONE_NUMBER_CONFIRMED,
+ VERIFICATION_CODE_REQUESTED,
+ CHALLENGE_RECEIVED,
+ CHALLENGE_COMPLETED,
+ VERIFICATION_CODE_ENTERED,
+ VERIFICATION_CODE_VALIDATED,
+ SERVICE_REGISTRATION_COMPLETED,
+ LOCAL_REGISTRATION_COMPLETE
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationV2State.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationV2State.kt
new file mode 100644
index 0000000000..45511acf0b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationV2State.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.shared
+
+import com.google.i18n.phonenumbers.Phonenumber
+
+/**
+ * State holder shared across all of registration.
+ */
+data class RegistrationV2State(
+ val sessionId: String? = null,
+ val phoneNumber: Phonenumber.PhoneNumber? = null,
+ val inProgress: Boolean = false,
+ val isReRegister: Boolean = false,
+ val canSkipSms: Boolean = false,
+ val isFcmSupported: Boolean = false,
+ val fcmToken: String? = null,
+ val nextSms: Long = 0L,
+ val nextCall: Long = 0L,
+ val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationV2ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationV2ViewModel.kt
new file mode 100644
index 0000000000..e20449fe92
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/shared/RegistrationV2ViewModel.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.shared
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import com.google.i18n.phonenumbers.NumberParseException
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
+import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
+import org.thoughtcrime.securesms.jobs.ProfileUploadJob
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registration.RegistrationData
+import org.thoughtcrime.securesms.registration.RegistrationUtil
+import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
+import org.thoughtcrime.securesms.registration.v2.ui.toE164
+import org.thoughtcrime.securesms.util.FeatureFlags
+import org.thoughtcrime.securesms.util.Util
+import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
+import java.io.IOException
+
+/**
+ * ViewModel shared across all of registration.
+ */
+class RegistrationV2ViewModel : ViewModel() {
+
+ private val store = MutableStateFlow(RegistrationV2State())
+
+ private val password = Util.getSecret(18) // TODO [regv2]: persist this
+
+ val uiState = store.asLiveData()
+
+ init {
+ val existingE164 = SignalStore.registrationValues().sessionE164
+ if (existingE164 != null) {
+ try {
+ val existingPhoneNumber = PhoneNumberUtil.getInstance().parse(existingE164, null)
+ if (existingPhoneNumber != null) {
+ setPhoneNumber(existingPhoneNumber)
+ }
+ } catch (ex: NumberParseException) {
+ Log.w(TAG, "Could not parse stored E164.", ex)
+ }
+ }
+ }
+
+ fun setInProgress(inProgress: Boolean) {
+ store.update {
+ it.copy(inProgress = inProgress)
+ }
+ }
+
+ fun setRegistrationCheckpoint(checkpoint: RegistrationCheckpoint) {
+ store.update {
+ it.copy(registrationCheckpoint = checkpoint)
+ }
+ }
+
+ fun setPhoneNumber(phoneNumber: Phonenumber.PhoneNumber?) {
+ store.update {
+ it.copy(phoneNumber = phoneNumber)
+ }
+ }
+
+ fun fetchFcmToken(context: Context) {
+ viewModelScope.launch {
+ val fcmToken = RegistrationRepository.getFcmToken(context)
+ store.update {
+ it.copy(
+ registrationCheckpoint = RegistrationCheckpoint.PUSH_NETWORK_AUDITED,
+ isFcmSupported = true,
+ fcmToken = fcmToken
+ )
+ }
+ }
+ }
+
+ fun onUserConfirmedPhoneNumber(context: Context) {
+ setRegistrationCheckpoint(RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED)
+ // TODO [regv2]: check if can skip sms flow
+ val state = store.value
+ if (state.phoneNumber == null) {
+ Log.w(TAG, "Phone number was null after confirmation.")
+ onErrorOccurred()
+ return
+ }
+ if (state.canSkipSms) {
+ Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2]
+ } else {
+ // TODO [regv2]: initialize Play Services sms retriever
+ val mccMncProducer = MccMncProducer(context)
+ val e164 = state.phoneNumber.toE164()
+ viewModelScope.launch {
+ val codeRequestResponse = RegistrationRepository.requestSmsCode(context, e164, password, mccMncProducer.mcc, mccMncProducer.mnc).successOrThrow()
+ store.update {
+ it.copy(
+ sessionId = codeRequestResponse.body.id,
+ nextSms = RegistrationRepository.deriveTimestamp(codeRequestResponse.headers, codeRequestResponse.body.nextSms),
+ nextCall = RegistrationRepository.deriveTimestamp(codeRequestResponse.headers, codeRequestResponse.body.nextCall),
+ registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED
+ )
+ }
+ }
+ }
+ }
+
+ fun verifyCodeWithoutRegistrationLock(context: Context, code: String) {
+ store.update {
+ it.copy(
+ inProgress = true,
+ registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_ENTERED
+ )
+ }
+
+ val sessionId = store.value.sessionId
+ if (sessionId == null) {
+ Log.w(TAG, "Session ID was null. TODO: handle this better in the UI.")
+ return
+ }
+ val e164: String = getCurrentE164() ?: throw IllegalStateException()
+
+ viewModelScope.launch {
+ val registrationData = getRegistrationData(code)
+ val verificationResponse = RegistrationRepository.submitVerificationCode(context, e164, password, sessionId, registrationData).successOrThrow()
+
+ if (!verificationResponse.body.verified) {
+ Log.w(TAG, "Could not verify code!")
+ // TODO [regv2]: error handling
+ return@launch
+ }
+
+ setRegistrationCheckpoint(RegistrationCheckpoint.VERIFICATION_CODE_VALIDATED)
+
+ val registrationResponse = RegistrationRepository.registerAccount(context, e164, password, sessionId, registrationData).successOrThrow()
+
+ localRegisterAccount(context, registrationData, registrationResponse, false)
+
+ refreshFeatureFlags()
+
+ store.update {
+ it.copy(
+ registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED
+ )
+ }
+ }
+ }
+
+ fun hasPin(): Boolean {
+ return RegistrationRepository.hasPin() || store.value.isReRegister
+ }
+
+ fun completeRegistration() {
+ ApplicationDependencies.getJobManager()
+ .startChain(ProfileUploadJob())
+ .then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob()))
+ .enqueue()
+ RegistrationUtil.maybeMarkRegistrationComplete()
+ }
+
+ private fun getCurrentE164(): String? {
+ return store.value.phoneNumber?.toE164()
+ }
+
+ private suspend fun localRegisterAccount(
+ context: Context,
+ registrationData: RegistrationData,
+ remoteResult: RegistrationRepository.AccountRegistrationResult,
+ reglockEnabled: Boolean
+ ) {
+ RegistrationRepository.registerAccountLocally(context, registrationData, remoteResult, reglockEnabled)
+ }
+
+ private suspend fun getRegistrationData(code: String): RegistrationData {
+ val e164: String = getCurrentE164() ?: throw IllegalStateException()
+ return RegistrationData(
+ code,
+ e164,
+ password,
+ RegistrationRepository.getRegistrationId(),
+ RegistrationRepository.getProfileKey(e164),
+ store.value.fcmToken,
+ RegistrationRepository.getPniRegistrationId(),
+ null // TODO [regv2]: recovery password
+ )
+ }
+
+ /**
+ * This is a generic error UI handler that re-enables the UI so that the user can recover from errors.
+ * Do not forget to log any errors when calling this method!
+ */
+ private fun onErrorOccurred() {
+ setInProgress(false)
+ }
+
+ companion object {
+ private val TAG = Log.tag(RegistrationV2ViewModel::class.java)
+
+ private suspend fun refreshFeatureFlags() = withContext(Dispatchers.IO) {
+ val startTime = System.currentTimeMillis()
+ try {
+ FeatureFlags.refreshSync()
+ Log.i(TAG, "Took " + (System.currentTimeMillis() - startTime) + " ms to get feature flags.")
+ } catch (e: IOException) {
+ Log.w(TAG, "Failed to refresh flags after " + (System.currentTimeMillis() - startTime) + " ms.", e)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/welcome/WelcomeV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/welcome/WelcomeV2Fragment.kt
new file mode 100644
index 0000000000..1abb0512ba
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/welcome/WelcomeV2Fragment.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.v2.ui.welcome
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.NavHostFragment
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationWelcomeV2Binding
+import org.thoughtcrime.securesms.permissions.Permissions
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
+import org.thoughtcrime.securesms.registration.v2.ui.grantpermissions.GrantPermissionsV2Fragment
+import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2ViewModel
+import org.thoughtcrime.securesms.util.BackupUtil
+import org.thoughtcrime.securesms.util.TextSecurePreferences
+import org.thoughtcrime.securesms.util.Util
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+import kotlin.jvm.optionals.getOrNull
+
+/**
+ * First screen that is displayed on the very first app launch.
+ */
+class WelcomeV2Fragment : LoggingFragment(R.layout.fragment_registration_welcome_v2) {
+ private val TAG = Log.tag(WelcomeV2Fragment::class.java)
+ private val sharedViewModel by activityViewModels()
+ private val binding: FragmentRegistrationWelcomeV2Binding by ViewBinderDelegate(FragmentRegistrationWelcomeV2Binding::bind)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ maybePrefillE164()
+ setDebugLogSubmitMultiTapView(binding.image)
+ setDebugLogSubmitMultiTapView(binding.title)
+ binding.welcomeContinueButton.setOnClickListener { onContinueClicked() }
+ binding.welcomeTermsButton.setOnClickListener { onTermsClicked() }
+ binding.welcomeTransferOrRestore.setOnClickListener { onRestoreFromBackupClicked() }
+ }
+
+ private fun onContinueClicked() {
+ TextSecurePreferences.setHasSeenWelcomeScreen(requireContext(), true)
+ if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
+ NavHostFragment.findNavController(this).safeNavigate(WelcomeV2FragmentDirections.actionWelcomeFragmentToGrantPermissionsV2Fragment(GrantPermissionsV2Fragment.WelcomeAction.CONTINUE))
+ } else {
+ skipRestore()
+ }
+ }
+
+ private fun hasAllPermissions(): Boolean {
+ val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
+ return WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).all { ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED }
+ }
+
+ private fun skipRestore() {
+ NavHostFragment.findNavController(this).safeNavigate(WelcomeV2FragmentDirections.actionSkipRestore())
+ }
+
+ private fun onRestoreFromBackupClicked() {
+ Toast.makeText(requireContext(), "Not yet implemented.", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun onTermsClicked() {
+ Toast.makeText(requireContext(), "Not yet implemented.", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun maybePrefillE164() {
+ if (Permissions.hasAll(requireContext(), Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) {
+ val localNumber = Util.getDeviceNumber(requireContext()).getOrNull()
+
+ if (localNumber != null) {
+ Log.v(TAG, "Phone number detected.")
+ sharedViewModel.setPhoneNumber(localNumber)
+ } else {
+ Log.i(TAG, "Could not read phone number.")
+ }
+ } else {
+ Log.i(TAG, "No phone permission.")
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
index 08f450dff4..b5baf4fb4d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -90,9 +90,9 @@ public final class FeatureFlags {
private static final String CAMERAX_MODEL_BLOCKLIST = "android.cameraXModelBlockList";
private static final String CAMERAX_MIXED_MODEL_BLOCKLIST = "android.cameraXMixedModelBlockList";
private static final String PAYMENTS_REQUEST_ACTIVATE_FLOW = "android.payments.requestActivateFlow";
- public static final String GOOGLE_PAY_DISABLED_REGIONS = "global.donations.gpayDisabledRegions";
- public static final String CREDIT_CARD_DISABLED_REGIONS = "global.donations.ccDisabledRegions";
- public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions";
+ public static final String GOOGLE_PAY_DISABLED_REGIONS = "global.donations.gpayDisabledRegions";
+ public static final String CREDIT_CARD_DISABLED_REGIONS = "global.donations.ccDisabledRegions";
+ public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions";
private static final String CDS_HARD_LIMIT = "android.cds.hardLimit";
private static final String PAYPAL_ONE_TIME_DONATIONS = "android.oneTimePayPalDonations.2";
private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.3";
@@ -104,15 +104,15 @@ public final class FeatureFlags {
private static final String SVR2_KILLSWITCH = "android.svr2.killSwitch";
private static final String CDS_DISABLE_COMPAT_MODE = "cds.disableCompatibilityMode";
private static final String FCM_MAY_HAVE_MESSAGES_KILL_SWITCH = "android.fcmNotificationFallbackKillSwitch";
- public static final String PROMPT_FOR_NOTIFICATION_LOGS = "android.logs.promptNotifications";
+ public static final String PROMPT_FOR_NOTIFICATION_LOGS = "android.logs.promptNotifications";
private static final String PROMPT_FOR_NOTIFICATION_CONFIG = "android.logs.promptNotificationsConfig";
- public static final String PROMPT_BATTERY_SAVER = "android.promptBatterySaver";
- public static final String INSTANT_VIDEO_PLAYBACK = "android.instantVideoPlayback.1";
- public static final String CRASH_PROMPT_CONFIG = "android.crashPromptConfig";
+ public static final String PROMPT_BATTERY_SAVER = "android.promptBatterySaver";
+ public static final String INSTANT_VIDEO_PLAYBACK = "android.instantVideoPlayback.1";
+ public static final String CRASH_PROMPT_CONFIG = "android.crashPromptConfig";
private static final String SEPA_DEBIT_DONATIONS = "android.sepa.debit.donations.5";
private static final String IDEAL_DONATIONS = "android.ideal.donations.5";
- public static final String IDEAL_ENABLED_REGIONS = "global.donations.idealEnabledRegions";
- public static final String SEPA_ENABLED_REGIONS = "global.donations.sepaEnabledRegions";
+ public static final String IDEAL_ENABLED_REGIONS = "global.donations.idealEnabledRegions";
+ public static final String SEPA_ENABLED_REGIONS = "global.donations.sepaEnabledRegions";
private static final String CALLING_REACTIONS = "android.calling.reactions";
private static final String NOTIFICATION_THUMBNAIL_BLOCKLIST = "android.notificationThumbnailProductBlocklist";
private static final String CALLING_RAISE_HAND = "android.calling.raiseHand";
@@ -127,6 +127,7 @@ public final class FeatureFlags {
private static final String LINKED_DEVICE_LIFESPAN_SECONDS = "android.linkedDeviceLifespanSeconds";
private static final String MESSAGE_BACKUPS = "android.messageBackups";
private static final String CAMERAX_CUSTOM_CONTROLLER = "android.cameraXCustomController";
+ private static final String REGISTRATION_V2 = "android.registration.v2";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -209,7 +210,7 @@ public final class FeatureFlags {
);
@VisibleForTesting
- static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet(MESSAGE_BACKUPS);
+ static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet(MESSAGE_BACKUPS, REGISTRATION_V2);
/**
* Values in this map will take precedence over any value. This should only be used for local
@@ -741,6 +742,11 @@ public final class FeatureFlags {
return getBoolean(CAMERAX_CUSTOM_CONTROLLER, false);
}
+ /** Whether or not to use the V2 refactor of registration. */
+ public static boolean registrationV2() {
+ return getBoolean(REGISTRATION_V2, false);
+ }
+
/** Only for rendering debug info. */
public static synchronized @NonNull Map getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataObserverCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataObserverCallback.kt
new file mode 100644
index 0000000000..8f6caff24b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataObserverCallback.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.util.livedata
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+
+/**
+ * A wrapper class that can be implemented in order to create a [LiveData] [Observer] that cleans up after itself.
+ *
+ * Useful for one-shot observers that can be executed as a callback on an asynchronous call that updates a [LiveData] upon completion.
+ */
+abstract class LiveDataObserverCallback(private val liveData: LiveData) : Observer {
+ final override fun onChanged(value: T) {
+ val shouldRemove = onValue(value)
+ if (shouldRemove) {
+ liveData.removeObserver(this)
+ }
+ }
+
+ /**
+ * The body of the observer that gets executed when the value is changed.
+ * Recommended usage is to check some condition in the [LiveData] to determine whether the data has been handled and therefore can be removed.
+ *
+ * @return should remove this observer from the [LiveData]
+ */
+ abstract fun onValue(value: T): Boolean
+}
diff --git a/app/src/main/res/layout/activity_registration_navigation_v2.xml b/app/src/main/res/layout/activity_registration_navigation_v2.xml
new file mode 100644
index 0000000000..37a71416cd
--- /dev/null
+++ b/app/src/main/res/layout/activity_registration_navigation_v2.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_registration_enter_code_v2.xml b/app/src/main/res/layout/fragment_registration_enter_code_v2.xml
new file mode 100644
index 0000000000..3d30f729dd
--- /dev/null
+++ b/app/src/main/res/layout/fragment_registration_enter_code_v2.xml
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_registration_enter_phone_number_v2.xml b/app/src/main/res/layout/fragment_registration_enter_phone_number_v2.xml
new file mode 100644
index 0000000000..fea95edc37
--- /dev/null
+++ b/app/src/main/res/layout/fragment_registration_enter_phone_number_v2.xml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_registration_welcome_v2.xml b/app/src/main/res/layout/fragment_registration_welcome_v2.xml
new file mode 100644
index 0000000000..5f5a350be4
--- /dev/null
+++ b/app/src/main/res/layout/fragment_registration_welcome_v2.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/navigation/registration_v2.xml b/app/src/main/res/navigation/registration_v2.xml
new file mode 100644
index 0000000000..9d363338d1
--- /dev/null
+++ b/app/src/main/res/navigation/registration_v2.xml
@@ -0,0 +1,492 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
index 9e2e8ef7f1..801615e3d5 100644
--- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
@@ -13,7 +13,6 @@ import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.protocol.logging.Log;
-import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.signal.libsignal.usernames.Username.UsernameLink;
@@ -47,6 +46,7 @@ import org.whispersystems.signalservice.api.push.exceptions.NoContentException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
+import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.services.CdsiV2Service;
import org.whispersystems.signalservice.api.storage.SignalStorageCipher;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
@@ -219,7 +219,7 @@ public class SignalServiceAccountManager {
public ServiceResponse createRegistrationSession(@Nullable String fcmToken, @Nullable String mcc, @Nullable String mnc) {
try {
- final RegistrationSessionMetadataResponse response = pushServiceSocket.createVerificationSession(fcmToken, mcc, mnc);
+ final RegistrationSessionMetadataResponse response = pushServiceSocket.createVerificationSession(fcmToken, mcc, mnc);
return ServiceResponse.forResult(response, 200, null);
} catch (IOException e) {
return ServiceResponse.forUnknownError(e);
@@ -311,6 +311,10 @@ public class SignalServiceAccountManager {
}
}
+ public @Nonnull VerifyAccountResponse registerAccountV2(@Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, PreKeyCollection aciPreKeys, PreKeyCollection pniPreKeys, String fcmToken, boolean skipDeviceTransfer) throws IOException {
+ return pushServiceSocket.submitRegistrationRequest(sessionId, recoveryPassword, attributes, aciPreKeys, pniPreKeys, fcmToken, skipDeviceTransfer);
+ }
+
public @Nonnull ServiceResponse changeNumber(@Nonnull ChangePhoneNumberRequest changePhoneNumberRequest) {
try {
VerifyAccountResponse response = this.pushServiceSocket.changeNumber(changePhoneNumberRequest);
@@ -870,6 +874,10 @@ public class SignalServiceAccountManager {
return KeysApi.create(pushServiceSocket);
}
+ public RegistrationApi getRegistrationApi() {
+ return new RegistrationApi(pushServiceSocket);
+ }
+
public AuthCredentials getPaymentsAuthorization() throws IOException {
return pushServiceSocket.getPaymentsAuthorization();
}
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt
new file mode 100644
index 0000000000..80dce4bc2e
--- /dev/null
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.signalservice.api.registration
+
+import org.whispersystems.signalservice.api.NetworkResult
+import org.whispersystems.signalservice.api.account.AccountAttributes
+import org.whispersystems.signalservice.api.account.PreKeyCollection
+import org.whispersystems.signalservice.internal.push.PushServiceSocket
+import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
+import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
+import java.util.Locale
+
+/**
+ * Class to interact with various registration-related endpoints.
+ */
+class RegistrationApi(
+ private val pushServiceSocket: PushServiceSocket
+) {
+
+ /**
+ * Request that the service initialize a new registration session.
+ */
+ fun createRegistrationSession(fcmToken: String?, mcc: String?, mnc: String?): NetworkResult {
+ return NetworkResult.fromFetch {
+ pushServiceSocket.createVerificationSession(fcmToken, mcc, mnc)
+ }
+ }
+
+ /**
+ * Submit an FCM token to the service as proof that this is an honest user attempting to register.
+ */
+ fun submitPushChallengeToken(sessionId: String?, pushChallengeToken: String?): NetworkResult {
+ return NetworkResult.fromFetch {
+ pushServiceSocket.patchVerificationSession(sessionId, null, null, null, null, pushChallengeToken)
+ }
+ }
+
+ /**
+ * Request an SMS verification code. On success, the server will send
+ * an SMS verification code to this Signal user.
+ *
+ * @param androidSmsRetrieverSupported whether the system framework will automatically parse the incoming verification message.
+ */
+ fun requestSmsVerificationCode(sessionId: String?, locale: Locale?, androidSmsRetrieverSupported: Boolean): NetworkResult {
+ return NetworkResult.fromFetch {
+ pushServiceSocket.requestVerificationCode(sessionId, locale, androidSmsRetrieverSupported, PushServiceSocket.VerificationCodeTransport.SMS)
+ }
+ }
+
+ /**
+ * Submit a verification code sent by the service via one of the supported channels (SMS, phone call) to prove the registrant's control of the phone number.
+ */
+ fun verifyAccount(verificationCode: String, sessionId: String): NetworkResult {
+ return NetworkResult.fromFetch {
+ pushServiceSocket.submitVerificationCode(sessionId, verificationCode)
+ }
+ }
+
+ /**
+ * Submit the cryptographic assets required for an account to use the service.
+ */
+ fun registerAccount(sessionId: String?, recoveryPassword: String?, attributes: AccountAttributes?, aciPreKeys: PreKeyCollection?, pniPreKeys: PreKeyCollection?, fcmToken: String?, skipDeviceTransfer: Boolean): NetworkResult {
+ return NetworkResult.fromFetch {
+ pushServiceSocket.submitRegistrationRequest(sessionId, recoveryPassword, attributes, aciPreKeys, pniPreKeys, fcmToken, skipDeviceTransfer)
+ }
+ }
+}
diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java
index 7d780d50ea..0b41c96c40 100644
--- a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java
+++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java
@@ -45,7 +45,7 @@ import java.lang.annotation.RetentionPolicy;
@SuppressWarnings("WeakerAccess")
public final class MediaConverter {
private static final String TAG = "media-converter";
- private static final boolean VERBOSE = false; // lots of logging
+ private static final boolean VERBOSE = true; // lots of logging
// Describes when the annotation will be discarded
@Retention(RetentionPolicy.SOURCE)