Implement session-based account registration API.

This commit is contained in:
Nicholas
2023-02-21 16:04:23 -05:00
committed by Greyson Parrelli
parent 3de17fa2d0
commit a47e3900c1
40 changed files with 1215 additions and 418 deletions

View File

@@ -17,8 +17,7 @@ object KbsAuthTokens : AndroidBackupItem {
}
override fun getDataForBackup(): ByteArray {
val registrationRecoveryTokenList = SignalStore.kbsValues().kbsAuthTokenList
val proto = KbsAuthToken(tokens = registrationRecoveryTokenList)
val proto = KbsAuthToken(tokens = SignalStore.kbsValues().kbsAuthTokenList)
return proto.encode()
}

View File

@@ -2,8 +2,8 @@ package org.thoughtcrime.securesms.components.registration
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.StringRes
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
class ActionCountDownButton @JvmOverloads constructor(
@@ -11,6 +11,12 @@ class ActionCountDownButton @JvmOverloads constructor(
attrs: AttributeSet? = null,
defStyle: Int = 0
) : MaterialButton(context, attrs, defStyle) {
@StringRes
private var enabledText = 0
@StringRes
private var disabledText = 0
private var countDownToTime: Long = 0
private var listener: Listener? = null
@@ -24,8 +30,8 @@ class ActionCountDownButton @JvmOverloads constructor(
}
}
fun setCallEnabled() {
setText(R.string.RegistrationActivity_call)
private fun setActionEnabled() {
setText(enabledText)
isEnabled = true
alpha = 1.0f
}
@@ -38,11 +44,11 @@ class ActionCountDownButton @JvmOverloads constructor(
val totalRemainingSeconds = TimeUnit.MILLISECONDS.toSeconds(remainingMillis).toInt()
val minutesRemaining = totalRemainingSeconds / 60
val secondsRemaining = totalRemainingSeconds % 60
text = resources.getString(R.string.RegistrationActivity_call_me_instead_available_in, minutesRemaining, secondsRemaining)
text = resources.getString(disabledText, minutesRemaining, secondsRemaining)
listener?.onRemaining(this, totalRemainingSeconds)
postDelayed({ updateCountDown() }, 250)
} else {
setCallEnabled()
setActionEnabled()
}
}
@@ -50,6 +56,11 @@ class ActionCountDownButton @JvmOverloads constructor(
this.listener = listener
}
fun setTextResources(@StringRes enabled: Int, @StringRes disabled: Int) {
enabledText = enabled
disabledText = disabled
}
interface Listener {
fun onRemaining(view: ActionCountDownButton, secondsRemaining: Int)
}

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
@@ -48,17 +49,15 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
private fun requestCode() {
val mccMncProducer = MccMncProducer(requireContext())
lifecycleDisposable += viewModel
.ensureDecryptionsDrained()
.onErrorComplete()
.andThen(viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER))
.andThen(viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, mccMncProducer.mcc, mccMncProducer.mnc))
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processor ->
if (processor.hasResult()) {
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.localRateLimit()) {
Log.i(TAG, "Unable to request sms code due to local rate limit")
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.captchaRequired()) {
Log.i(TAG, "Unable to request sms code due to captcha required")
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())

View File

@@ -83,14 +83,15 @@ public class RefreshAttributesJob extends BaseJob {
return;
}
int registrationId = SignalStore.account().getRegistrationId();
boolean fetchesMessages = !SignalStore.account().isFcmEnabled() || SignalStore.internalValues().isWebsocketModeForced();
byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey());
boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context);
String registrationLockV1 = null;
String registrationLockV2 = null;
KbsValues kbsValues = SignalStore.kbsValues();
int pniRegistrationId = new RegistrationRepository(ApplicationDependencies.getApplication()).getPniRegistrationId();
int registrationId = SignalStore.account().getRegistrationId();
boolean fetchesMessages = !SignalStore.account().isFcmEnabled() || SignalStore.internalValues().isWebsocketModeForced();
byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey());
boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context);
String registrationLockV1 = null;
String registrationLockV2 = null;
KbsValues kbsValues = SignalStore.kbsValues();
int pniRegistrationId = new RegistrationRepository(ApplicationDependencies.getApplication()).getPniRegistrationId();
String registrationRecoveryPassword = kbsValues.getRegistrationRecoveryPassword();
if (kbsValues.isV2RegistrationLockEnabled()) {
registrationLockV2 = kbsValues.getRegistrationLockToken();
@@ -121,7 +122,8 @@ public class RefreshAttributesJob extends BaseJob {
capabilities,
phoneNumberDiscoverable,
encryptedDeviceName,
pniRegistrationId);
pniRegistrationId,
registrationRecoveryPassword);
hasRefreshedThisAppCycle = true;
}

View File

@@ -149,6 +149,15 @@ public final class KbsValues extends SignalStoreValues {
}
}
public synchronized @Nullable String getRegistrationRecoveryPassword() {
MasterKey masterKey = getPinBackedMasterKey();
if (masterKey == null) {
return null;
} else {
return masterKey.deriveRegistrationRecoveryPassword();
}
}
public synchronized @Nullable String getPin() {
return getString(PIN, null);
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.CheckResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Collections;
import java.util.List;
@@ -11,6 +12,8 @@ public final class RegistrationValues extends SignalStoreValues {
private static final String REGISTRATION_COMPLETE = "registration.complete";
private static final String PIN_REQUIRED = "registration.pin_required";
private static final String HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile";
private static final String SESSION_E164 = "registration.session_e164";
private static final String SESSION_ID = "registration.session_id";
RegistrationValues(@NonNull KeyValueStore store) {
super(store);
@@ -60,4 +63,22 @@ public final class RegistrationValues extends SignalStoreValues {
public void clearHasUploadedProfile() {
putBoolean(HAS_UPLOADED_PROFILE, false);
}
public void setSessionId(String sessionId) {
putString(SESSION_ID, sessionId);
}
@Nullable
public String getSessionId() {
return getString(SESSION_ID, null);
}
public void setSessionE164(String sessionE164) {
putString(SESSION_E164, sessionE164);
}
@Nullable
public String getSessionE164() {
return getString(SESSION_E164, null);
}
}

View File

@@ -7,6 +7,7 @@ import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import java.io.IOException;
@@ -27,14 +28,14 @@ public final class PushChallengeRequest {
*
* @param accountManager Account manager to request the push from.
* @param fcmToken Optional FCM token. If not present will return absent.
* @param e164number Local number.
* @param sessionId Local number.
* @param timeoutMs Timeout in milliseconds
* @return Either returns a challenge, or absent.
*/
@WorkerThread
public static Optional<String> getPushChallengeBlocking(@NonNull SignalServiceAccountManager accountManager,
@NonNull String sessionId,
@NonNull Optional<String> fcmToken,
@NonNull String e164number,
long timeoutMs)
{
if (!fcmToken.isPresent()) {
@@ -44,8 +45,7 @@ public final class PushChallengeRequest {
long startTime = System.currentTimeMillis();
Log.i(TAG, "Requesting a push challenge");
Request request = new Request(accountManager, fcmToken.get(), e164number, timeoutMs);
Request request = new Request(accountManager, fcmToken.get(), sessionId, timeoutMs);
Optional<String> challenge = request.requestAndReceiveChallengeBlocking();
@@ -69,19 +69,19 @@ public final class PushChallengeRequest {
private final AtomicReference<String> challenge;
private final SignalServiceAccountManager accountManager;
private final String fcmToken;
private final String e164number;
private final String sessionId;
private final long timeoutMs;
private Request(@NonNull SignalServiceAccountManager accountManager,
@NonNull String fcmToken,
@NonNull String e164number,
@NonNull String sessionId,
long timeoutMs)
{
this.latch = new CountDownLatch(1);
this.challenge = new AtomicReference<>();
this.accountManager = accountManager;
this.fcmToken = fcmToken;
this.e164number = e164number;
this.sessionId = sessionId;
this.timeoutMs = timeoutMs;
}
@@ -91,7 +91,7 @@ public final class PushChallengeRequest {
eventBus.register(this);
try {
accountManager.requestRegistrationPushChallenge(fcmToken, e164number);
accountManager.requestRegistrationPushChallenge(sessionId, fcmToken);
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
@@ -117,5 +117,9 @@ public final class PushChallengeRequest {
PushChallengeEvent(String challenge) {
this.challenge = challenge;
}
public String getChallenge() {
return challenge;
}
}
}

View File

@@ -9,7 +9,8 @@ data class RegistrationData(
val registrationId: Int,
val profileKey: ProfileKey,
val fcmToken: String?,
val pniRegistrationId: Int
val pniRegistrationId: Int,
val recoveryPassword: String?
) {
val isFcm: Boolean = fcmToken != null
val isNotFcm: Boolean = fcmToken == null

View File

@@ -209,4 +209,9 @@ public final class RegistrationRepository {
return null;
}
@Nullable
public String getRecoveryPassword() {
return SignalStore.kbsValues().getRegistrationRecoveryPassword();
}
}

View File

@@ -0,0 +1,177 @@
package org.thoughtcrime.securesms.registration
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException
import org.whispersystems.signalservice.api.push.exceptions.ExternalServiceFailureException
import org.whispersystems.signalservice.api.push.exceptions.ImpossiblePhoneNumberException
import org.whispersystems.signalservice.api.push.exceptions.InvalidTransportModeException
import org.whispersystems.signalservice.api.push.exceptions.MustRequestNewCodeException
import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException
import org.whispersystems.signalservice.api.push.exceptions.NonNormalizedPhoneNumberException
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException
import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import kotlin.time.Duration.Companion.seconds
/**
* Makes the server's response describing the state of the registration session as digestible as possible.
*/
sealed class RegistrationSessionProcessor(response: ServiceResponse<RegistrationSessionMetadataResponse>) : ServiceResponseProcessor<RegistrationSessionMetadataResponse>(response) {
companion object {
const val CAPTCHA_KEY = "captcha"
const val PUSH_CHALLENGE_KEY = "pushChallenge"
val REQUESTABLE_INFORMATION = listOf(PUSH_CHALLENGE_KEY, CAPTCHA_KEY)
}
public override fun captchaRequired(): Boolean {
return super.captchaRequired() || (hasResult() && CAPTCHA_KEY == getChallenge())
}
public override fun rateLimit(): Boolean {
return error is RateLimitException
}
public override fun getError(): Throwable? {
return super.getError()
}
fun pushChallengeRequired(): Boolean {
return PUSH_CHALLENGE_KEY == getChallenge()
}
fun isImpossibleNumber(): Boolean {
return error is ImpossiblePhoneNumberException
}
fun isNonNormalizedNumber(): Boolean {
return error is NonNormalizedPhoneNumberException
}
fun getRateLimit(): Long {
Preconditions.checkState(error is RateLimitException, "This can only be called when isRateLimited()")
return (error as RateLimitException).retryAfterMilliseconds.orElse(-1L)
}
/**
* The soonest time at which the server will accept a request to send a new code via SMS.
* @return a unix timestamp in milliseconds, or 0 to represent null
*/
fun getNextCodeViaSmsAttempt(): Long {
return deriveTimestamp(result.body.nextSms)
}
/**
* The soonest time at which the server will accept a request to send a new code via a voice call.
* @return a unix timestamp in milliseconds, or 0 to represent null
*/
fun getNextCodeViaCallAttempt(): Long {
return deriveTimestamp(result.body.nextCall)
}
fun canSubmitProofImmediately(): Boolean {
Preconditions.checkState(hasResult(), "This can only be called when result is present!")
return 0 == result.body.nextVerificationAttempt
}
/**
* The soonest time at which the server will accept a submission of proof of ownership.
* @return a unix timestamp in milliseconds, or 0 to represent null
*/
fun getNextProofSubmissionAttempt(): Long {
Preconditions.checkState(hasResult(), "This can only be called when result is present!")
return deriveTimestamp(result.body.nextVerificationAttempt)
}
fun exhaustedVerificationCodeAttempts(): Boolean {
return rateLimit() && getRateLimit() == -1L
}
fun isInvalidSession(): Boolean {
return error is NoSuchSessionException
}
fun getSessionId(): String {
Preconditions.checkState(hasResult(), "This can only be called when result is present!")
return result.body.id
}
fun isAllowedToRequestCode(): Boolean {
Preconditions.checkState(hasResult(), "This can only be called when result is present!")
return result.body.allowedToRequestCode
}
fun getChallenge(): String? {
Preconditions.checkState(hasResult(), "This can only be called when result is present!")
return result.body.requestedInformation.firstOrNull { REQUESTABLE_INFORMATION.contains(it) }
}
fun isVerified(): Boolean {
return hasResult() && result.body.verified
}
/** Should only be called if [isNonNormalizedNumber] */
fun getOriginalNumber(): String {
if (error !is NonNormalizedPhoneNumberException) {
throw IllegalStateException("This can only be called when isNonNormalizedNumber()")
}
return (error as NonNormalizedPhoneNumberException).originalNumber
}
/** Should only be called if [isNonNormalizedNumber] */
fun getNormalizedNumber(): String {
if (error !is NonNormalizedPhoneNumberException) {
throw IllegalStateException("This can only be called when isNonNormalizedNumber()")
}
return (error as NonNormalizedPhoneNumberException).normalizedNumber
}
fun cannotSubmitVerificationAttempt(): Boolean {
return !hasResult() || result.body.nextVerificationAttempt == null
}
/**
* @param deltaSeconds the number of whole seconds to be added to the server timestamp
* @return a unix timestamp in milliseconds, or 0 to represent null
*/
private fun deriveTimestamp(deltaSeconds: Int?): Long {
Preconditions.checkState(hasResult(), "This can only be called when result is present!")
if (deltaSeconds == null) {
return 0L
}
val timestamp: Long = result.headers.timestamp
return timestamp + deltaSeconds.seconds.inWholeMilliseconds
}
abstract fun verificationCodeRequestSuccess(): Boolean
class RegistrationSessionProcessorForSession(response: ServiceResponse<RegistrationSessionMetadataResponse>) : RegistrationSessionProcessor(response) {
override fun verificationCodeRequestSuccess(): Boolean = false
}
class RegistrationSessionProcessorForVerification(response: ServiceResponse<RegistrationSessionMetadataResponse>) : RegistrationSessionProcessor(response) {
override fun verificationCodeRequestSuccess(): Boolean = hasResult()
fun isAlreadyVerified(): Boolean {
return error is AlreadyVerifiedException
}
fun mustRequestNewCode(): Boolean {
return error is MustRequestNewCodeException
}
fun externalServiceFailure(): Boolean {
return error is ExternalServiceFailureException
}
fun invalidTransportModeFailure(): Boolean {
return error is InvalidTransportModeException
}
}
}

View File

@@ -1,63 +0,0 @@
package org.thoughtcrime.securesms.registration
import org.whispersystems.signalservice.api.push.exceptions.ImpossiblePhoneNumberException
import org.whispersystems.signalservice.api.push.exceptions.LocalRateLimitException
import org.whispersystems.signalservice.api.push.exceptions.NonNormalizedPhoneNumberException
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse
/**
* Process responses from requesting an SMS or Phone code from the server.
*/
class RequestVerificationCodeResponseProcessor(response: ServiceResponse<RequestVerificationCodeResponse>) : ServiceResponseProcessor<RequestVerificationCodeResponse>(response) {
public override fun captchaRequired(): Boolean {
return super.captchaRequired()
}
public override fun rateLimit(): Boolean {
return super.rateLimit()
}
public override fun getError(): Throwable? {
return super.getError()
}
fun localRateLimit(): Boolean {
return error is LocalRateLimitException
}
fun isImpossibleNumber(): Boolean {
return error is ImpossiblePhoneNumberException
}
fun isNonNormalizedNumber(): Boolean {
return error is NonNormalizedPhoneNumberException
}
/** Should only be called if [isNonNormalizedNumber] */
fun getOriginalNumber(): String {
if (error !is NonNormalizedPhoneNumberException) {
throw IllegalStateException("This can only be called when isNonNormalizedNumber()")
}
return (error as NonNormalizedPhoneNumberException).originalNumber
}
/** Should only be called if [isNonNormalizedNumber] */
fun getNormalizedNumber(): String {
if (error !is NonNormalizedPhoneNumberException) {
throw IllegalStateException("This can only be called when isNonNormalizedNumber()")
}
return (error as NonNormalizedPhoneNumberException).normalizedNumber
}
companion object {
@JvmStatic
fun forLocalRateLimit(): RequestVerificationCodeResponseProcessor {
val response: ServiceResponse<RequestVerificationCodeResponse> = ServiceResponse.forExecutionError(LocalRateLimitException())
return RequestVerificationCodeResponseProcessor(response)
}
}
}

View File

@@ -3,24 +3,29 @@ package org.thoughtcrime.securesms.registration
import android.app.Application
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.AppCapabilities
import org.thoughtcrime.securesms.gcm.FcmUtil
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.registration.PushChallengeRequest.PushChallengeEvent
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.KbsPinData
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import java.io.IOException
import java.util.Locale
import java.util.Optional
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
@@ -28,31 +33,105 @@ import java.util.concurrent.TimeUnit
*/
class VerifyAccountRepository(private val context: Application) {
fun requestVerificationCode(
fun validateSession(
sessionId: String?,
e164: String,
password: String
): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
return if (sessionId.isNullOrBlank()) {
Single.just(ServiceResponse.forApplicationError(NoSuchSessionException(), 409, null))
} else {
val accountManager: SignalServiceAccountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
Single.fromCallable { accountManager.getRegistrationSession(sessionId) }.subscribeOn(Schedulers.io())
}
}
fun requestValidSession(
e164: String,
password: String,
mode: Mode,
captchaToken: String? = null
): Single<ServiceResponse<RequestVerificationCodeResponse>> {
mcc: String?,
mnc: String?
): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
return Single.fromCallable {
val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
val accountManager: SignalServiceAccountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
if (fcmToken == null) {
return@fromCallable accountManager.createRegistrationSession(null, mcc, mnc)
} else {
return@fromCallable createSessionAndBlockForPushChallenge(accountManager, fcmToken, mcc, mnc)
}
}
.subscribeOn(Schedulers.io())
}
private fun createSessionAndBlockForPushChallenge(accountManager: SignalServiceAccountManager, fcmToken: String, mcc: String?, mnc: String?): ServiceResponse<RegistrationSessionMetadataResponse> {
val subscriber = PushTokenChallengeSubscriber()
val eventBus = EventBus.getDefault()
eventBus.register(subscriber)
val response: ServiceResponse<RegistrationSessionMetadataResponse> = accountManager.createRegistrationSession(fcmToken, mcc, mnc)
if (!response.result.isPresent) {
return response
}
subscriber.latch.await(PUSH_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS)
eventBus.unregister(subscriber)
val challenge = subscriber.challenge
return if (challenge != null) {
accountManager.submitPushChallengeToken(response.result.get().body.id, challenge)
} else {
response
}
}
fun requestAndVerifyPushToken(
sessionId: String,
e164: String,
password: String
): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
val fcmToken: Optional<String> = FcmUtil.getToken(context)
val accountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, sessionId, fcmToken, PUSH_REQUEST_TIMEOUT)
return Single.fromCallable {
return@fromCallable accountManager.submitPushChallengeToken(sessionId, pushChallenge.orElse(null))
}.subscribeOn(Schedulers.io())
}
fun verifyCaptcha(
sessionId: String,
captcha: String,
e164: String,
password: String
): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
val accountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
return Single.fromCallable {
return@fromCallable accountManager.submitCaptchaToken(sessionId, captcha)
}.subscribeOn(Schedulers.io())
}
fun requestVerificationCode(
sessionId: String,
e164: String,
password: String,
mode: Mode
): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
Log.d(TAG, "SMS Verification requested")
return Single.fromCallable {
val fcmToken: Optional<String> = FcmUtil.getToken(context)
val accountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, fcmToken, e164, PUSH_REQUEST_TIMEOUT)
if (mode == Mode.PHONE_CALL) {
accountManager.requestVoiceVerificationCode(Locale.getDefault(), Optional.ofNullable(captchaToken), pushChallenge, fcmToken)
return@fromCallable accountManager.requestVoiceVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported)
} else {
accountManager.requestSmsVerificationCode(Locale.getDefault(), mode.isSmsRetrieverSupported, Optional.ofNullable(captchaToken), pushChallenge, fcmToken)
return@fromCallable accountManager.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported)
}
}.subscribeOn(Schedulers.io())
}
fun verifyAccount(registrationData: RegistrationData): Single<ServiceResponse<VerifyResponse>> {
val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
fun verifyAccount(sessionId: String, registrationData: RegistrationData): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
val accountManager: SignalServiceAccountManager = AccountManagerFactory.createUnauthenticated(
context,
registrationData.e164,
@@ -61,21 +140,14 @@ class VerifyAccountRepository(private val context: Application) {
)
return Single.fromCallable {
val response = accountManager.verifyAccount(
accountManager.verifyAccount(
registrationData.code,
registrationData.registrationId,
registrationData.isNotFcm,
unidentifiedAccessKey,
universalUnidentifiedAccess,
AppCapabilities.getCapabilities(true),
SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isDiscoverable,
registrationData.pniRegistrationId
sessionId
)
VerifyResponse.from(response, null, null)
}.subscribeOn(Schedulers.io())
}
fun verifyAccountWithPin(registrationData: RegistrationData, pin: String, kbsPinDataProducer: KbsPinDataProducer): Single<ServiceResponse<VerifyResponse>> {
fun registerAccount(sessionId: String, registrationData: RegistrationData, pin: String? = null, kbsPinDataProducer: KbsPinDataProducer? = null): Single<ServiceResponse<VerifyResponse>> {
val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
@@ -86,30 +158,27 @@ class VerifyAccountRepository(private val context: Application) {
registrationData.password
)
return Single.fromCallable {
try {
val kbsData = kbsPinDataProducer.produceKbsPinData()
val registrationLockV2: String = kbsData.masterKey.deriveRegistrationLock()
val kbsData = kbsPinDataProducer?.produceKbsPinData()
val registrationLockV2: String? = kbsData?.masterKey?.deriveRegistrationLock()
val response: ServiceResponse<VerifyAccountResponse> = accountManager.verifyAccountWithRegistrationLockPin(
registrationData.code,
registrationData.registrationId,
registrationData.isNotFcm,
registrationLockV2,
unidentifiedAccessKey,
universalUnidentifiedAccess,
AppCapabilities.getCapabilities(true),
SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isDiscoverable,
registrationData.pniRegistrationId
)
VerifyResponse.from(response, kbsData, pin)
} catch (e: KeyBackupSystemWrongPinException) {
ServiceResponse.forExecutionError(e)
} catch (e: KeyBackupSystemNoDataException) {
ServiceResponse.forExecutionError(e)
} catch (e: IOException) {
ServiceResponse.forExecutionError(e)
}
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = registrationData.registrationId,
isFetchesMessages = registrationData.isNotFcm,
pin = pin,
registrationLock = registrationLockV2,
unidentifiedAccessKey = unidentifiedAccessKey,
isUnrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
capabilities = AppCapabilities.getCapabilities(true),
isDiscoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isDiscoverable,
name = null,
pniRegistrationId = registrationData.pniRegistrationId,
recoveryPassword = registrationData.recoveryPassword
)
return Single.fromCallable {
val response = accountManager.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, true)
VerifyResponse.from(response, kbsData, pin)
}.subscribeOn(Schedulers.io())
}
@@ -124,6 +193,17 @@ class VerifyAccountRepository(private val context: Application) {
PHONE_CALL(false);
}
private class PushTokenChallengeSubscriber {
var challenge: String? = null
val latch = CountDownLatch(1)
@Subscribe
fun onChallengeEvent(pushChallengeEvent: PushChallengeEvent) {
challenge = pushChallengeEvent.challenge
latch.countDown()
}
}
companion object {
private val TAG = Log.tag(VerifyAccountRepository::class.java)
private val PUSH_REQUEST_TIMEOUT = TimeUnit.SECONDS.toMillis(5)

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.registration
import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException
import org.thoughtcrime.securesms.pin.TokenData
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
@@ -32,6 +33,10 @@ sealed class VerifyResponseProcessor(response: ServiceResponse<VerifyResponse>)
return super.getError()
}
fun invalidSession(): Boolean {
return error is NoSuchSessionException
}
fun getLockedException(): LockedException {
return error as LockedException
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.registration.fragments;
import android.animation.Animator;
import android.os.Bundle;
import android.view.View;
import android.widget.ScrollView;
@@ -11,6 +10,7 @@ import androidx.annotation.CallSuper;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.navigation.Navigation;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
@@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer;
import org.whispersystems.signalservice.internal.push.LockedException;
import java.util.ArrayList;
@@ -59,6 +60,7 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
private VerificationCodeView verificationCodeView;
private VerificationPinKeyboard keyboard;
private ActionCountDownButton callMeCountDown;
private ActionCountDownButton resendSmsCountDown;
private View wrongNumber;
private boolean autoCompleting;
@@ -82,6 +84,7 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
verificationCodeView = view.findViewById(R.id.code);
keyboard = view.findViewById(R.id.keyboard);
callMeCountDown = view.findViewById(R.id.call_me_count_down);
resendSmsCountDown = view.findViewById(R.id.resend_sms_count_down);
wrongNumber = view.findViewById(R.id.wrong_number);
new SignalStrengthPhoneStateListener(this, this);
@@ -91,9 +94,13 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
setOnCodeFullyEnteredListener(verificationCodeView);
wrongNumber.setOnClickListener(v -> onWrongNumber());
wrongNumber.setOnClickListener(v -> returnToPhoneEntryScreen());
callMeCountDown.setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in);
resendSmsCountDown.setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in);
callMeCountDown.setOnClickListener(v -> handlePhoneCallRequest());
resendSmsCountDown.setOnClickListener(v -> handleSmsRequest());
callMeCountDown.setListener((v, remaining) -> {
if (remaining <= 30) {
@@ -102,16 +109,21 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
}
});
resendSmsCountDown.setListener((v, remaining) -> {
if (remaining <= 30) {
scrollView.smoothScrollTo(0, v.getBottom());
resendSmsCountDown.setListener(null);
}
});
disposables.bindTo(getViewLifecycleOwner().getLifecycle());
viewModel = getViewModel();
viewModel.getSuccessfulCodeRequestAttempts().observe(getViewLifecycleOwner(), (attempts) -> {
if (attempts >= 3) {
// TODO Add bottom sheet for help
new ContactSupportBottomSheetFragment(this::openTroubleshootingSteps, this::sendEmailToSupport).show(getChildFragmentManager(), "support_bottom_sheet");
}
});
viewModel.onStartEnterCode();
}
protected abstract ViewModel getViewModel();
@@ -124,7 +136,8 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
protected abstract void navigateToKbsAccountLocked();
private void onWrongNumber() {
private void returnToPhoneEntryScreen() {
viewModel.resetSession();
Navigation.findNavController(requireView()).navigateUp();
}
@@ -132,6 +145,7 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
verificationCodeView.setOnCompleteListener(code -> {
callMeCountDown.setVisibility(View.INVISIBLE);
resendSmsCountDown.setVisibility(View.INVISIBLE);
wrongNumber.setVisibility(View.INVISIBLE);
keyboard.displayProgress();
@@ -181,6 +195,7 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
callMeCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.setVisibility(View.VISIBLE);
wrongNumber.setVisibility(View.VISIBLE);
verificationCodeView.clear();
keyboard.displayKeyboard();
@@ -209,6 +224,7 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
@Override
public void onSuccess(Boolean result) {
callMeCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.setVisibility(View.VISIBLE);
wrongNumber.setVisibility(View.VISIBLE);
verificationCodeView.clear();
keyboard.displayKeyboard();
@@ -222,6 +238,7 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
@Override
public void onSuccess(Boolean result) {
callMeCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.setVisibility(View.VISIBLE);
wrongNumber.setVisibility(View.VISIBLE);
verificationCodeView.clear();
keyboard.displayKeyboard();
@@ -285,19 +302,29 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
showConfirmNumberDialogIfTranslated(requireContext(),
R.string.RegistrationActivity_you_will_receive_a_call_to_verify_this_number,
viewModel.getNumber().getE164Number(),
this::handlePhoneCallRequestAfterConfirm,
this::onWrongNumber);
() -> handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode.PHONE_CALL),
this::returnToPhoneEntryScreen);
}
private void handlePhoneCallRequestAfterConfirm() {
Disposable request = viewModel.requestVerificationCode(VerifyAccountRepository.Mode.PHONE_CALL)
private void handleSmsRequest() {
showConfirmNumberDialogIfTranslated(requireContext(),
R.string.RegistrationActivity_a_verification_code_will_be_sent_to,
viewModel.getNumber().getE164Number(),
() -> handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode.SMS_WITH_LISTENER),
this::returnToPhoneEntryScreen);
}
private void handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode mode) {
MccMncProducer mccMncProducer = new MccMncProducer(requireContext());
Disposable request = viewModel.requestVerificationCode(mode, mccMncProducer.getMcc(), mccMncProducer.getMnc())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (processor.hasResult()) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_call_requested, Toast.LENGTH_LONG).show();
Toast.makeText(requireContext(), getCodeRequestedToastText(mode), Toast.LENGTH_LONG).show();
} else if (processor.captchaRequired()) {
navigateToCaptcha();
} else if (processor.rateLimit()) {
long rateLimit = processor.getRateLimit();
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show();
} else {
Log.w(TAG, "Unable to request phone code", processor.getError());
@@ -308,6 +335,19 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
disposables.add(request);
}
@StringRes
private int getCodeRequestedToastText(VerifyAccountRepository.Mode mode) {
switch (mode) {
case PHONE_CALL:
return R.string.RegistrationActivity_call_requested;
case SMS_WITH_LISTENER:
case SMS_WITHOUT_LISTENER:
return R.string.RegistrationActivity_sms_requested;
default:
return R.string.RegistrationActivity_code_requested;
}
}
private void connectKeyboard(VerificationCodeView verificationCodeView, VerificationPinKeyboard keyboard) {
keyboard.setOnKeyPressListener(key -> {
if (!autoCompleting) {
@@ -323,10 +363,52 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
@Override
public void onResume() {
super.onResume();
String sessionE164 = viewModel.getSessionE164();
if (sessionE164 == null) {
returnToPhoneEntryScreen();
return;
}
subheader.setText(requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, viewModel.getNumber().getFullFormattedNumber()));
viewModel.getCanCallAtTime().observe(getViewLifecycleOwner(), callAtTime -> callMeCountDown.startCountDownTo(callAtTime));
MccMncProducer mccMncProducer = new MccMncProducer(requireContext());
Disposable request = viewModel.validateSession(sessionE164, mccMncProducer.getMcc(), mccMncProducer.getMnc())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (!processor.hasResult()) {
returnToPhoneEntryScreen();
} else if (processor.isInvalidSession()) {
returnToPhoneEntryScreen();
} else if (processor.cannotSubmitVerificationAttempt()) {
returnToPhoneEntryScreen();
} else if (!processor.canSubmitProofImmediately()) {
handleRateLimited();
}
// else session state is valid and server is ready to accept code
});
disposables.add(request);
viewModel.getCanCallAtTime().observe(getViewLifecycleOwner(), callAtTime -> {
if (callAtTime > 0) {
callMeCountDown.setVisibility(View.VISIBLE);
callMeCountDown.startCountDownTo(callAtTime);
} else {
callMeCountDown.setVisibility(View.INVISIBLE);
}
});
viewModel.getCanSmsAtTime().observe(getViewLifecycleOwner(), smsAtTime -> {
if (smsAtTime > 0) {
resendSmsCountDown.setVisibility(View.VISIBLE);
resendSmsCountDown.startCountDownTo(smsAtTime);
} else {
resendSmsCountDown.setVisibility(View.INVISIBLE);
}
});
}
private void openTroubleshootingSteps() {
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.support_center_url));
}
private void sendEmailToSupport() {
@@ -347,6 +429,6 @@ public abstract class BaseEnterSmsCodeFragment<ViewModel extends BaseRegistratio
@Override
public void onCellSignalPresent() {
// TODO animate away bottom sheet
// TODO animate away bottom sheet
}
}

View File

@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.registration.fragments
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
/**
* Helpful bottom sheet dialog displayed during registration when the user enters the wrong verification code too many times.
*/
class ContactSupportBottomSheetFragment(private val troubleshootingStepsListener: Runnable, private val contactSupportListener: Runnable) : ComposeBottomSheetDialogFragment() {
@Composable
override fun SheetContent() {
val annotatedText = buildAnnotatedString {
withStyle(SpanStyle(fontSize = 28.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)) {
append(stringResource(R.string.RegistrationActivity_support_bottom_sheet_title))
}
append(stringResource(R.string.RegistrationActivity_support_bottom_sheet_body_part_1))
pushStringAnnotation(
tag = "URL",
annotation = TROUBLESHOOTING_STEPS_KEY
)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
) {
append(stringResource(R.string.RegistrationActivity_support_bottom_sheet_body_part_2))
}
pop()
append(stringResource(R.string.RegistrationActivity_support_bottom_sheet_body_part_3))
pushStringAnnotation(
tag = "URL",
annotation = CONTACT_SUPPORT_KEY
)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
) {
append(stringResource(R.string.RegistrationActivity_support_bottom_sheet_body_part_4))
}
pop()
}
return Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(Alignment.Center)
) {
Handle()
ClickableText(
text = annotatedText,
onClick = { offset ->
// We check if there is an *URL* annotation attached to the text
// at the clicked position
annotatedText.getStringAnnotations(
tag = "URL",
start = offset,
end = offset
)
.firstOrNull()?.let { annotation ->
when (annotation.item) {
TROUBLESHOOTING_STEPS_KEY -> troubleshootingStepsListener.run()
CONTACT_SUPPORT_KEY -> contactSupportListener.run()
}
}
},
modifier = Modifier.padding(16.dp)
)
}
}
companion object {
private const val TROUBLESHOOTING_STEPS_KEY = "troubleshooting"
private const val CONTACT_SUPPORT_KEY = "contact_support"
}
}

View File

@@ -10,6 +10,7 @@ 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;
import androidx.annotation.NonNull;
@@ -49,9 +50,11 @@ import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.SupportEmailUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -153,7 +156,8 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R
}
private void handleRegister(@NonNull Context context) {
if (TextUtils.isEmpty(countryCode.getEditText().getText())) {
final EditText countryCodeEditText = countryCode.getEditText();
if (countryCodeEditText == null || TextUtils.isEmpty(countryCodeEditText.getText())) {
showErrorDialog(context, getString(R.string.RegistrationActivity_you_must_specify_your_country_code));
return;
}
@@ -245,23 +249,23 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R
}
private void requestVerificationCode(@NonNull Mode mode) {
NavController navController = NavHostFragment.findNavController(this);
Disposable request = viewModel.requestVerificationCode(mode)
NavController navController = NavHostFragment.findNavController(this);
MccMncProducer mccMncProducer = new MccMncProducer(requireContext());
Disposable request = viewModel.requestVerificationCode(mode, mccMncProducer.getMcc(), mccMncProducer.getMnc())
.doOnSubscribe(unused -> SignalStore.account().setRegistered(false))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (processor.hasResult()) {
SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
} else if (processor.localRateLimit()) {
Log.i(TAG, "Unable to request sms code due to local rate limit");
if (processor.verificationCodeRequestSuccess()) {
SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
} else if (processor.captchaRequired()) {
Log.i(TAG, "Unable to request sms code due to captcha required");
SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionRequestCaptcha());
} else if (processor.exhaustedVerificationCodeAttempts()) {
Log.i(TAG, "Unable to request sms code due to exhausting attempts");
showErrorDialog(register.getContext(), getString(R.string.RegistrationActivity_rate_limited_to_service));
} else if (processor.rateLimit()) {
Log.i(TAG, "Unable to request sms code due to rate limit");
showErrorDialog(register.getContext(), getString(R.string.RegistrationActivity_rate_limited_to_service));
showErrorDialog(register.getContext(), getString(R.string.RegistrationActivity_rate_limited_to_try_again, formatMillisecondsToString(processor.getRateLimit())));
} else if (processor.isImpossibleNumber()) {
Log.w(TAG, "Impossible number", processor.getError());
Dialogs.showAlertDialog(requireContext(),
@@ -281,6 +285,14 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R
disposables.add(request);
}
private String formatMillisecondsToString(long milliseconds) {
long totalSeconds = milliseconds / 1000;
long HH = totalSeconds / 3600;
long MM = (totalSeconds % 3600) / 60;
long SS = totalSeconds % 60;
return String.format(Locale.getDefault(), "%02d:%02d:%02d", HH, MM, SS);
}
public void showErrorDialog(Context context, String msg) {
new MaterialAlertDialogBuilder(context).setMessage(msg).setPositiveButton(R.string.ok, null).show();
}
@@ -306,6 +318,31 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R
viewModel.onCountrySelected(null, countryCode);
}
@Override
public void onStart() {
super.onStart();
String sessionE164 = viewModel.getSessionE164();
if (sessionE164 != null && viewModel.getSessionId() != null) {
checkIfSessionIsInProgressAndAdvance(sessionE164);
}
}
private void checkIfSessionIsInProgressAndAdvance(@NonNull String sessionE164) {
NavController navController = NavHostFragment.findNavController(this);
MccMncProducer mccMncProducer = new MccMncProducer(requireContext());
Disposable request = viewModel.validateSession(sessionE164, mccMncProducer.getMcc(), mccMncProducer.getMnc())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(processor -> {
if (processor.hasResult() && processor.canSubmitProofImmediately()) {
SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode());
} else {
viewModel.resetSession();
}
});
disposables.add(request);
}
private void handleNonNormalizedNumberError(@NonNull String originalNumber, @NonNull String normalizedNumber, @NonNull Mode mode) {
try {
Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null);
@@ -346,9 +383,9 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R
.show();
}
protected final void confirmNumberPrompt(@NonNull Context context,
@NonNull String e164number,
@NonNull Runnable onConfirmed)
private void confirmNumberPrompt(@NonNull Context context,
@NonNull String e164number,
@NonNull Runnable onConfirmed)
{
showConfirmNumberDialogIfTranslated(context,
R.string.RegistrationActivity_a_verification_code_will_be_sent_to,

View File

@@ -9,19 +9,18 @@ import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.pin.KbsRepository;
import org.thoughtcrime.securesms.pin.TokenData;
import org.thoughtcrime.securesms.registration.RequestVerificationCodeResponseProcessor;
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode;
import org.thoughtcrime.securesms.registration.VerifyResponse;
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor;
import org.thoughtcrime.securesms.registration.VerifyResponseWithFailedKbs;
import org.thoughtcrime.securesms.registration.VerifyResponseWithRegistrationLockProcessor;
import org.thoughtcrime.securesms.registration.VerifyResponseWithSuccessfulKbs;
import org.thoughtcrime.securesms.registration.VerifyResponseWithoutKbs;
import org.thoughtcrime.securesms.registration.VerifyResponseWithRegistrationLockProcessor;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
@@ -33,9 +32,6 @@ import io.reactivex.rxjava3.core.Single;
*/
public abstract class BaseRegistrationViewModel extends ViewModel {
private static final long FIRST_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(64);
private static final long SUBSEQUENT_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(300);
private static final String STATE_NUMBER = "NUMBER";
private static final String STATE_REGISTRATION_SECRET = "REGISTRATION_SECRET";
private static final String STATE_VERIFICATION_CODE = "TEXT_CODE_ENTERED";
@@ -45,6 +41,7 @@ public abstract class BaseRegistrationViewModel extends ViewModel {
private static final String STATE_KBS_TOKEN = "KBS_TOKEN";
private static final String STATE_TIME_REMAINING = "TIME_REMAINING";
private static final String STATE_CAN_CALL_AT_TIME = "CAN_CALL_AT_TIME";
private static final String STATE_CAN_SMS_AT_TIME = "CAN_SMS_AT_TIME";
protected final SavedStateHandle savedState;
protected final VerifyAccountRepository verifyAccountRepository;
@@ -73,6 +70,27 @@ public abstract class BaseRegistrationViewModel extends ViewModel {
}
}
public @Nullable String getSessionId() {
return SignalStore.registrationValues().getSessionId();
}
public void setSessionId(String sessionId) {
SignalStore.registrationValues().setSessionId(sessionId);
}
public @Nullable String getSessionE164() {
return SignalStore.registrationValues().getSessionE164();
}
public void setSessionE164(String sessionE164) {
SignalStore.registrationValues().setSessionE164(sessionE164);
}
public void resetSession() {
setSessionE164(null);
setSessionId(null);
}
public @NonNull NumberViewState getNumber() {
//noinspection ConstantConditions
return savedState.get(STATE_NUMBER);
@@ -138,15 +156,6 @@ public abstract class BaseRegistrationViewModel extends ViewModel {
return savedState.getLiveData(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, 0);
}
public @NonNull LocalCodeRequestRateLimiter getRequestLimiter() {
//noinspection ConstantConditions
return savedState.get(STATE_REQUEST_RATE_LIMITER);
}
public void updateLimiter() {
savedState.set(STATE_REQUEST_RATE_LIMITER, savedState.get(STATE_REQUEST_RATE_LIMITER));
}
public @Nullable TokenData getKeyBackupCurrentToken() {
return savedState.get(STATE_KBS_TOKEN);
}
@@ -163,43 +172,118 @@ public abstract class BaseRegistrationViewModel extends ViewModel {
return savedState.getLiveData(STATE_CAN_CALL_AT_TIME, 0L);
}
public LiveData<Long> getCanSmsAtTime() {
return savedState.getLiveData(STATE_CAN_SMS_AT_TIME, 0L);
}
public void setLockedTimeRemaining(long lockedTimeRemaining) {
savedState.set(STATE_TIME_REMAINING, lockedTimeRemaining);
}
public void onStartEnterCode() {
savedState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + FIRST_CALL_AVAILABLE_AFTER_MS);
public void setCanCallAtTime(long callingTimestamp) {
savedState.getLiveData(STATE_CAN_CALL_AT_TIME).postValue(callingTimestamp);
}
public void onCallRequested() {
savedState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + SUBSEQUENT_CALL_AVAILABLE_AFTER_MS);
public void setCanSmsAtTime(long smsTimestamp) {
savedState.getLiveData(STATE_CAN_SMS_AT_TIME).postValue(smsTimestamp);
}
public Single<RequestVerificationCodeResponseProcessor> requestVerificationCode(@NonNull Mode mode) {
String captcha = getCaptchaToken();
clearCaptchaResponse();
public Single<RegistrationSessionProcessor> requestVerificationCode(@NonNull Mode mode, @Nullable String mcc, @Nullable String mnc) {
if (mode == Mode.PHONE_CALL) {
onCallRequested();
} else if (!getRequestLimiter().canRequest(mode, getNumber().getE164Number(), System.currentTimeMillis())) {
return Single.just(RequestVerificationCodeResponseProcessor.forLocalRateLimit());
final String e164 = getNumber().getE164Number();
return getValidSession(e164, mcc, mnc)
.flatMap(processor -> {
if (!processor.hasResult()) {
return Single.just(processor);
}
String sessionId = processor.getSessionId();
setSessionId(sessionId);
setSessionE164(e164);
return handleRequiredChallenges(processor, e164);
})
.flatMap(processor -> {
if (!processor.hasResult()) {
return Single.just(processor);
}
if (!processor.isAllowedToRequestCode()) {
return Single.just(processor);
}
String sessionId = processor.getSessionId();
clearCaptchaResponse();
return verifyAccountRepository.requestVerificationCode(sessionId,
getNumber().getE164Number(),
getRegistrationSecret(),
mode)
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new);
})
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess((RegistrationSessionProcessor processor) -> {
if (processor.hasResult()) {
markASuccessfulAttempt();
}
if (processor.hasResult() && processor.isAllowedToRequestCode()) {
setCanSmsAtTime(processor.getNextCodeViaSmsAttempt());
setCanCallAtTime(processor.getNextCodeViaCallAttempt());
}
});
}
public Single<RegistrationSessionProcessor.RegistrationSessionProcessorForSession> validateSession(String e164, @Nullable String mcc, @Nullable String mnc) {
String storedSessionId = null;
if (e164.equals(getSessionE164())) {
storedSessionId = getSessionId();
}
return verifyAccountRepository.validateSession(storedSessionId, e164, getRegistrationSecret())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new);
}
public Single<RegistrationSessionProcessor.RegistrationSessionProcessorForSession> getValidSession(String e164, @Nullable String mcc, @Nullable String mnc) {
return validateSession(e164, mcc, mnc)
.flatMap(processor -> {
if (processor.isInvalidSession()) {
return verifyAccountRepository.requestValidSession(e164, getRegistrationSecret(), mcc, mnc)
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new);
} else {
return Single.just(processor);
}
});
}
public Single<RegistrationSessionProcessor> handleRequiredChallenges(RegistrationSessionProcessor processor, String e164) {
final String sessionId = processor.getSessionId();
if (processor.isAllowedToRequestCode()) {
return Single.just(processor);
}
return verifyAccountRepository.requestVerificationCode(getNumber().getE164Number(),
getRegistrationSecret(),
mode,
captcha)
.map(RequestVerificationCodeResponseProcessor::new)
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess(processor -> {
if (processor.hasResult()) {
markASuccessfulAttempt();
getRequestLimiter().onSuccessfulRequest(mode, getNumber().getE164Number(), System.currentTimeMillis());
} else {
getRequestLimiter().onUnsuccessfulRequest();
}
updateLimiter();
});
if (hasCaptchaToken() && processor.captchaRequired()) {
return verifyAccountRepository.verifyCaptcha(sessionId, Objects.requireNonNull(getCaptchaToken()), e164, getRegistrationSecret())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new);
} else {
String challenge = processor.getChallenge();
if (challenge != null) {
switch (challenge) {
case RegistrationSessionProcessor.PUSH_CHALLENGE_KEY:
return verifyAccountRepository.requestAndVerifyPushToken(sessionId,
getNumber().getE164Number(),
getRegistrationSecret())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new);
case RegistrationSessionProcessor.CAPTCHA_KEY:
// fall through to passing the processor back so that the eventual subscriber will check captchaRequired() and handle accordingly
default:
break;
}
}
}
return Single.just(processor);
}
public Single<VerifyResponseProcessor> verifyCodeWithoutRegistrationLock(@NonNull String code) {
@@ -261,4 +345,5 @@ public abstract class BaseRegistrationViewModel extends ViewModel {
protected abstract Single<VerifyResponseProcessor> onVerifySuccess(@NonNull VerifyResponseProcessor processor);
protected abstract Single<VerifyResponseWithRegistrationLockProcessor> onVerifySuccessWithRegistrationLock(@NonNull VerifyResponseWithRegistrationLockProcessor processor, String pin);
}

View File

@@ -14,7 +14,7 @@ import org.thoughtcrime.securesms.pin.KbsRepository;
import org.thoughtcrime.securesms.pin.TokenData;
import org.thoughtcrime.securesms.registration.RegistrationData;
import org.thoughtcrime.securesms.registration.RegistrationRepository;
import org.thoughtcrime.securesms.registration.RequestVerificationCodeResponseProcessor;
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor;
import org.thoughtcrime.securesms.registration.VerifyAccountRepository;
import org.thoughtcrime.securesms.registration.VerifyResponse;
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor;
@@ -23,10 +23,13 @@ import org.thoughtcrime.securesms.registration.VerifyResponseWithoutKbs;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.KbsPinData;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse;
import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class RegistrationViewModel extends BaseRegistrationViewModel {
@@ -96,43 +99,69 @@ public final class RegistrationViewModel extends BaseRegistrationViewModel {
return completed != null ? completed : false;
}
@Override
public Single<RequestVerificationCodeResponseProcessor> requestVerificationCode(@NonNull VerifyAccountRepository.Mode mode) {
return super.requestVerificationCode(mode)
.doOnSuccess(processor -> {
if (processor.hasResult()) {
setFcmToken(processor.getResult().getFcmToken().orElse(null));
}
});
}
@Override
protected Single<ServiceResponse<VerifyResponse>> verifyAccountWithoutRegistrationLock() {
return verifyAccountRepository.verifyAccount(getRegistrationData())
.flatMap(verifyAccountWithoutKbsResponse -> {
VerifyResponseProcessor processor = new VerifyResponseWithoutKbs(verifyAccountWithoutKbsResponse);
String pin = SignalStore.kbsValues().getPin();
final String sessionId = getSessionId();
if (sessionId == null) {
throw new IllegalStateException("No valid registration session");
}
return verifyAccountRepository.verifyAccount(sessionId, getRegistrationData())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new)
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess(processor -> {
setCanSmsAtTime(processor.getNextCodeViaSmsAttempt());
setCanCallAtTime(processor.getNextCodeViaCallAttempt());
})
.observeOn(Schedulers.io())
.flatMap( processor -> {
if (processor.isAlreadyVerified() || (processor.hasResult() && processor.isVerified())) {
return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), null, null);
} else {
return Single.just(ServiceResponse.<VerifyResponse, RegistrationSessionMetadataResponse>coerceError(processor.getResponse()));
}
})
.flatMap(verifyAccountWithoutKbsResponse -> {
VerifyResponseProcessor processor = new VerifyResponseWithoutKbs(verifyAccountWithoutKbsResponse);
String pin = SignalStore.kbsValues().getPin();
if (processor.registrationLock() && SignalStore.kbsValues().getRegistrationLockToken() != null && pin != null) {
KbsPinData pinData = new KbsPinData(SignalStore.kbsValues().getOrCreateMasterKey(), SignalStore.kbsValues().getRegistrationLockTokenResponse());
if (processor.registrationLock() && SignalStore.kbsValues().getRegistrationLockToken() != null && pin != null) {
KbsPinData pinData = new KbsPinData(SignalStore.kbsValues().getOrCreateMasterKey(), SignalStore.kbsValues().getRegistrationLockTokenResponse());
return verifyAccountRepository.verifyAccountWithPin(getRegistrationData(), pin, () -> pinData)
.map(verifyAccountWithPinResponse -> {
if (verifyAccountWithPinResponse.getResult().isPresent() && verifyAccountWithPinResponse.getResult().get().getKbsData() != null) {
return verifyAccountWithPinResponse;
} else {
return verifyAccountWithoutKbsResponse;
}
});
} else {
return Single.just(verifyAccountWithoutKbsResponse);
}
});
return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), pin, () -> pinData)
.map(verifyAccountWithPinResponse -> {
if (verifyAccountWithPinResponse.getResult().isPresent() && verifyAccountWithPinResponse.getResult().get().getKbsData() != null) {
return verifyAccountWithPinResponse;
} else {
return verifyAccountWithoutKbsResponse;
}
});
} else {
return Single.just(verifyAccountWithoutKbsResponse);
}
});
}
@Override
protected Single<ServiceResponse<VerifyResponse>> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull TokenData kbsTokenData) {
return verifyAccountRepository.verifyAccountWithPin(getRegistrationData(), pin, () -> Objects.requireNonNull(KbsRepository.restoreMasterKey(pin, kbsTokenData.getEnclave(), kbsTokenData.getBasicAuth(), kbsTokenData.getTokenResponse())));
final String sessionId = getSessionId();
if (sessionId == null) {
throw new IllegalStateException("No valid registration session");
}
return verifyAccountRepository.verifyAccount(sessionId, getRegistrationData())
.map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new)
.doOnSuccess(processor -> {
if (processor.hasResult()) {
setCanSmsAtTime(processor.getNextCodeViaSmsAttempt());
setCanCallAtTime(processor.getNextCodeViaCallAttempt());
}
})
.flatMap( processor -> {
if (processor.isAlreadyVerified() || (processor.hasResult() && processor.isVerified())) {
return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), pin, () -> Objects.requireNonNull(KbsRepository.restoreMasterKey(pin, kbsTokenData.getEnclave(), kbsTokenData.getBasicAuth(), kbsTokenData.getTokenResponse())));
} else {
return Single.just(ServiceResponse.coerceError(processor.getResponse()));
}
});
}
@Override
@@ -154,7 +183,8 @@ public final class RegistrationViewModel extends BaseRegistrationViewModel {
registrationRepository.getRegistrationId(),
registrationRepository.getProfileKey(getNumber().getE164Number()),
getFcmToken(),
registrationRepository.getPniRegistrationId());
registrationRepository.getPniRegistrationId(),
registrationRepository.getRecoveryPassword());
}
public static final class Factory extends AbstractSavedStateViewModelFactory {

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.util.dualsim
import android.content.Context
import android.content.pm.PackageManager.FEATURE_TELEPHONY_RADIO_ACCESS
import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
/**
* The mobile country code consists of three decimal digits and the mobile network code consists of two or three decimal digits.
*/
class MccMncProducer(context: Context) {
var mcc: String? = null
private set
var mnc: String? = null
private set
init {
if (context.packageManager.hasSystemFeature(FEATURE_TELEPHONY_RADIO_ACCESS)) {
val tel = ContextCompat.getSystemService(context, TelephonyManager::class.java)
val networkOperator = tel?.networkOperator
if (networkOperator?.isNotBlank() == true && networkOperator.length >= 5) {
mcc = networkOperator.substring(0, 3)
mnc = networkOperator.substring(3)
}
}
}
}