mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Support voice verification in registration v2.
This commit is contained in:
committed by
Clark Chen
parent
eb114de5c8
commit
503faea3a9
@@ -8,9 +8,12 @@ package org.thoughtcrime.securesms.registration.v2.data
|
||||
import android.app.backup.BackupManager
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.google.android.gms.auth.api.phone.SmsRetriever
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.signal.core.util.Base64
|
||||
@@ -62,6 +65,7 @@ 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.AuthCredentials
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataHeaders
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
|
||||
import java.io.IOException
|
||||
@@ -262,6 +266,7 @@ object RegistrationRepository {
|
||||
suspend fun validateSession(context: Context, sessionId: String, e164: String, password: String): RegistrationSessionCheckResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
|
||||
Log.d(TAG, "Validating registration session with service.")
|
||||
val registrationSessionResult = api.getRegistrationSessionStatus(sessionId)
|
||||
return@withContext RegistrationSessionCheckResult.from(registrationSessionResult)
|
||||
}
|
||||
@@ -275,12 +280,15 @@ object RegistrationRepository {
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
|
||||
|
||||
val registrationSessionResult = if (fcmToken == null) {
|
||||
Log.d(TAG, "Creating registration session without FCM token.")
|
||||
api.createRegistrationSession(null, mcc, mnc)
|
||||
} else {
|
||||
Log.d(TAG, "Creating registration session with FCM token.")
|
||||
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
|
||||
}
|
||||
val result = RegistrationSessionCreationResult.from(registrationSessionResult)
|
||||
if (result is RegistrationSessionCreationResult.Success) {
|
||||
Log.d(TAG, "Updating registration session and E164 in value store.")
|
||||
SignalStore.registrationValues().sessionId = result.getMetadata().body.id
|
||||
SignalStore.registrationValues().sessionE164 = e164
|
||||
}
|
||||
@@ -293,9 +301,13 @@ object RegistrationRepository {
|
||||
*/
|
||||
suspend fun createOrValidateSession(context: Context, sessionId: String?, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionResult {
|
||||
if (sessionId != null) {
|
||||
Log.d(TAG, "Validating existing registration session.")
|
||||
val sessionValidationResult = validateSession(context, sessionId, e164, password)
|
||||
when (sessionValidationResult) {
|
||||
is RegistrationSessionCheckResult.Success -> return sessionValidationResult
|
||||
is RegistrationSessionCheckResult.Success -> {
|
||||
Log.d(TAG, "Existing registration session is valid.")
|
||||
return sessionValidationResult
|
||||
}
|
||||
is RegistrationSessionCheckResult.UnknownError -> {
|
||||
Log.w(TAG, "Encountered error when validating existing session.", sessionValidationResult.getCause())
|
||||
return sessionValidationResult
|
||||
@@ -313,19 +325,11 @@ object RegistrationRepository {
|
||||
/**
|
||||
* Asks the service to send a verification code through one of our supported channels (SMS, phone call).
|
||||
*/
|
||||
suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: Mode = Mode.SMS_WITHOUT_LISTENER): VerificationCodeRequestResult =
|
||||
suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: Mode): VerificationCodeRequestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
|
||||
|
||||
// TODO [regv2]: support other verification code [Mode] options
|
||||
val codeRequestResult = 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)
|
||||
}
|
||||
val codeRequestResult = api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported, mode.transport)
|
||||
|
||||
return@withContext VerificationCodeRequestResult.from(codeRequestResult)
|
||||
}
|
||||
@@ -342,7 +346,7 @@ object RegistrationRepository {
|
||||
/**
|
||||
* Submits the solved captcha token to the service.
|
||||
*/
|
||||
suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String) =
|
||||
suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String): VerificationCodeRequestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
|
||||
val captchaSubmissionResult = api.submitCaptchaToken(sessionId = sessionId, captchaToken = captchaToken)
|
||||
@@ -408,6 +412,7 @@ object RegistrationRepository {
|
||||
eventBus.register(subscriber)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Requesting a registration session with FCM token…")
|
||||
val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc)
|
||||
if (sessionCreationResponse !is NetworkResult.Success) {
|
||||
return@withContext sessionCreationResponse
|
||||
@@ -450,7 +455,13 @@ object RegistrationRepository {
|
||||
val usernamePasswords = async { retrieveLocalSvrCredentials() }
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
|
||||
|
||||
val result = api.getSvrAuthCredential(e164, usernamePasswords.await())
|
||||
val authTokens = usernamePasswords.await()
|
||||
|
||||
if (authTokens.isEmpty()) {
|
||||
return@withContext BackupAuthCheckResult.SuccessWithoutCredentials()
|
||||
}
|
||||
|
||||
val result = api.getSvrAuthCredential(e164, authTokens)
|
||||
.runIfSuccessful {
|
||||
val removedInvalidTokens = SignalStore.svr().removeAuthTokens(it.invalid)
|
||||
if (removedInvalidTokens) {
|
||||
@@ -484,8 +495,37 @@ object RegistrationRepository {
|
||||
.toList()
|
||||
}
|
||||
|
||||
enum class Mode(val isSmsRetrieverSupported: Boolean) {
|
||||
SMS_WITH_LISTENER(true), SMS_WITHOUT_LISTENER(false), PHONE_CALL(false)
|
||||
/**
|
||||
* Starts an SMS listener to auto-enter a verification code.
|
||||
*
|
||||
* The listener [lives for 5 minutes](https://developers.google.com/android/reference/com/google/android/gms/auth/api/phone/SmsRetrieverApi).
|
||||
*
|
||||
* @return whether or not the Play Services SMS Listener was successfully registered.
|
||||
*/
|
||||
suspend fun registerSmsListener(context: Context): Boolean {
|
||||
Log.d(TAG, "Attempting to start verification code SMS retriever.")
|
||||
val started = withTimeoutOrNull(5.seconds.inWholeMilliseconds) {
|
||||
try {
|
||||
SmsRetriever.getClient(context).startSmsRetriever().await()
|
||||
Log.d(TAG, "Successfully started verification code SMS retriever.")
|
||||
return@withTimeoutOrNull true
|
||||
} catch (ex: Exception) {
|
||||
Log.w(TAG, "Could not start verification code SMS retriever due to exception.", ex)
|
||||
return@withTimeoutOrNull false
|
||||
}
|
||||
}
|
||||
|
||||
if (started == null) {
|
||||
Log.w(TAG, "Could not start verification code SMS retriever due to timeout.")
|
||||
}
|
||||
|
||||
return started == true
|
||||
}
|
||||
|
||||
enum class Mode(val isSmsRetrieverSupported: Boolean, val transport: PushServiceSocket.VerificationCodeTransport) {
|
||||
SMS_WITH_LISTENER(true, PushServiceSocket.VerificationCodeTransport.SMS),
|
||||
SMS_WITHOUT_LISTENER(false, PushServiceSocket.VerificationCodeTransport.SMS),
|
||||
PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE)
|
||||
}
|
||||
|
||||
private class PushTokenChallengeSubscriber {
|
||||
@@ -494,6 +534,7 @@ object RegistrationRepository {
|
||||
|
||||
@Subscribe
|
||||
fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) {
|
||||
Log.d(TAG, "Push challenge received!")
|
||||
challenge = pushChallengeEvent.challenge
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ data class RegistrationV2State(
|
||||
val captchaToken: String? = null,
|
||||
val nextSms: Long = 0L,
|
||||
val nextCall: Long = 0L,
|
||||
val smsListenerTimeout: Long = 0L,
|
||||
val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION,
|
||||
val networkError: Throwable? = null
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.internal.push.LockedException
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* ViewModel shared across all of registration.
|
||||
@@ -124,10 +125,12 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun updateFcmToken(context: Context): String? {
|
||||
Log.d(TAG, "Fetching FCM token…")
|
||||
val fcmToken = RegistrationRepository.getFcmToken(context)
|
||||
store.update {
|
||||
it.copy(fcmToken = fcmToken)
|
||||
}
|
||||
Log.d(TAG, "FCM token fetched.")
|
||||
return fcmToken
|
||||
}
|
||||
|
||||
@@ -147,8 +150,6 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO [regv2]: initialize Play Services sms retriever
|
||||
val mccMncProducer = MccMncProducer(context)
|
||||
val e164 = state.phoneNumber.toE164()
|
||||
if (hasRecoveryPassword() && matchesSavedE164(e164)) {
|
||||
// Re-registration when the local database is intact.
|
||||
@@ -187,14 +188,81 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val codeRequestResponse = RegistrationRepository.requestSmsCode(context, validSession.body.id, e164, password)
|
||||
requestSmsCodeInternal(context, validSession.body.id, e164)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSmsCode(context: Context) {
|
||||
val e164 = getCurrentE164()
|
||||
|
||||
if (e164 == null) {
|
||||
Log.w(TAG, "Phone number was null after confirmation.")
|
||||
onErrorOccurred()
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val validSession = getOrCreateValidSession(context) ?: return@launch
|
||||
requestSmsCodeInternal(context, validSession.body.id, e164)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestVerificationCall(context: Context) {
|
||||
val e164 = getCurrentE164()
|
||||
|
||||
if (e164 == null) {
|
||||
Log.w(TAG, "Phone number was null after confirmation.")
|
||||
onErrorOccurred()
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val validSession = getOrCreateValidSession(context) ?: return@launch
|
||||
Log.d(TAG, "Requesting voice call code…")
|
||||
val codeRequestResponse = RegistrationRepository.requestSmsCode(
|
||||
context = context,
|
||||
sessionId = validSession.body.id,
|
||||
e164 = e164,
|
||||
password = password,
|
||||
mode = RegistrationRepository.Mode.PHONE_CALL
|
||||
)
|
||||
Log.d(TAG, "Voice call code request submitted.")
|
||||
|
||||
handleSessionStateResult(context, codeRequestResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String) {
|
||||
var smsListenerReady = false
|
||||
Log.d(TAG, "Captcha token submitted.")
|
||||
if (store.value.smsListenerTimeout < System.currentTimeMillis()) {
|
||||
smsListenerReady = store.value.isFcmSupported && RegistrationRepository.registerSmsListener(context)
|
||||
|
||||
if (smsListenerReady) {
|
||||
val smsRetrieverTimeout = System.currentTimeMillis() + 5.minutes.inWholeMilliseconds
|
||||
Log.d(TAG, "Successfully started verification code SMS retriever, which will last until $smsRetrieverTimeout.")
|
||||
store.update { it.copy(smsListenerTimeout = smsRetrieverTimeout) }
|
||||
} else {
|
||||
Log.d(TAG, "Could not start verification code SMS retriever.")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Requesting SMS code…")
|
||||
val codeRequestResponse = RegistrationRepository.requestSmsCode(
|
||||
context = context,
|
||||
sessionId = sessionId,
|
||||
e164 = e164,
|
||||
password = password,
|
||||
mode = if (smsListenerReady) RegistrationRepository.Mode.SMS_WITH_LISTENER else RegistrationRepository.Mode.SMS_WITHOUT_LISTENER
|
||||
)
|
||||
Log.d(TAG, "SMS code request submitted.")
|
||||
|
||||
handleSessionStateResult(context, codeRequestResponse)
|
||||
}
|
||||
|
||||
private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? {
|
||||
val e164 = getCurrentE164() ?: throw IllegalStateException("E164 required to create session!")
|
||||
Log.d(TAG, "Validating/creating a registration session.")
|
||||
val mccMncProducer = MccMncProducer(context)
|
||||
|
||||
val existingSessionId = store.value.sessionId
|
||||
@@ -210,6 +278,7 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Registration session validated.")
|
||||
return metadata
|
||||
}
|
||||
is RegistrationSessionCreationResult.Success -> {
|
||||
@@ -222,27 +291,46 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Registration session created.")
|
||||
return metadata
|
||||
}
|
||||
is RegistrationSessionCheckResult.SessionNotFound -> Log.w(TAG, "This should be impossible to reach at this stage; it should have been handled in RegistrationRepository.", sessionResult.getCause())
|
||||
is RegistrationSessionCheckResult.UnknownError -> Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
|
||||
is RegistrationSessionCreationResult.MalformedRequest -> Log.i(TAG, "Malformed request error occurred while creating registration session.", sessionResult.getCause())
|
||||
is RegistrationSessionCreationResult.RateLimited -> Log.i(TAG, "Rate limit occurred while creating registration session.", sessionResult.getCause())
|
||||
is RegistrationSessionCreationResult.ServerUnableToParse -> Log.i(TAG, "Server unable to parse request for creating registration session.", sessionResult.getCause())
|
||||
is RegistrationSessionCreationResult.UnknownError -> Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
|
||||
is RegistrationSessionCheckResult.SessionNotFound -> {
|
||||
Log.w(TAG, "This should be impossible to reach at this stage; it should have been handled in RegistrationRepository.", sessionResult.getCause())
|
||||
handleGenericError(sessionResult.getCause())
|
||||
}
|
||||
is RegistrationSessionCheckResult.UnknownError -> {
|
||||
Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
|
||||
handleGenericError(sessionResult.getCause())
|
||||
}
|
||||
is RegistrationSessionCreationResult.MalformedRequest -> {
|
||||
Log.i(TAG, "Malformed request error occurred while creating registration session.", sessionResult.getCause())
|
||||
handleGenericError(sessionResult.getCause())
|
||||
}
|
||||
is RegistrationSessionCreationResult.RateLimited -> {
|
||||
Log.i(TAG, "Rate limit occurred while creating registration session.", sessionResult.getCause())
|
||||
handleGenericError(sessionResult.getCause())
|
||||
}
|
||||
is RegistrationSessionCreationResult.ServerUnableToParse -> {
|
||||
Log.i(TAG, "Server unable to parse request for creating registration session.", sessionResult.getCause())
|
||||
handleGenericError(sessionResult.getCause())
|
||||
}
|
||||
is RegistrationSessionCreationResult.UnknownError -> {
|
||||
Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
|
||||
handleGenericError(sessionResult.getCause())
|
||||
}
|
||||
}
|
||||
setInProgress(false)
|
||||
return null
|
||||
}
|
||||
|
||||
fun submitCaptchaToken(context: Context) {
|
||||
val e164 = getCurrentE164() ?: throw IllegalStateException("TODO")
|
||||
val sessionId = store.value.sessionId ?: throw IllegalStateException("TODO")
|
||||
val captchaToken = store.value.captchaToken ?: throw IllegalStateException("TODO")
|
||||
|
||||
viewModelScope.launch {
|
||||
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, sessionId, captchaToken)
|
||||
|
||||
val session = getOrCreateValidSession(context) ?: return@launch
|
||||
Log.d(TAG, "Submitting captcha token…")
|
||||
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, session.body.id, captchaToken)
|
||||
Log.d(TAG, "Captcha token submitted.")
|
||||
handleSessionStateResult(context, captchaSubmissionResult)
|
||||
}
|
||||
}
|
||||
@@ -258,6 +346,7 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
is Success -> {
|
||||
Log.d(TAG, "New registration session status received.")
|
||||
updateFcmToken(context)
|
||||
store.update {
|
||||
it.copy(
|
||||
@@ -273,10 +362,12 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
is AttemptsExhausted -> Log.w(TAG, "TODO")
|
||||
is ChallengeRequired -> store.update {
|
||||
// TODO [regv2] handle push challenge required
|
||||
Log.d(TAG, "[${sessionResult.challenges.joinToString()}] registration challenges received.")
|
||||
it.copy(
|
||||
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED
|
||||
)
|
||||
}
|
||||
|
||||
is ImpossibleNumber -> Log.w(TAG, "TODO")
|
||||
is NonNormalizedNumber -> Log.w(TAG, "TODO")
|
||||
is RateLimited -> Log.w(TAG, "TODO")
|
||||
@@ -447,6 +538,12 @@ class RegistrationV2ViewModel : ViewModel() {
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
}
|
||||
|
||||
fun clearNetworkError() {
|
||||
store.update {
|
||||
it.copy(networkError = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSavedE164(e164: String?): Boolean {
|
||||
return if (e164 == null) {
|
||||
false
|
||||
|
||||
@@ -58,6 +58,20 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
|
||||
sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it)
|
||||
}
|
||||
|
||||
binding.callMeCountDown.apply {
|
||||
setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in)
|
||||
setOnClickListener {
|
||||
sharedViewModel.requestVerificationCall(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
binding.resendSmsCountDown.apply {
|
||||
setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in)
|
||||
setOnClickListener {
|
||||
sharedViewModel.requestSmsCode(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
binding.keyboard.setOnKeyPressListener { key ->
|
||||
if (!autopilotCodeEntryActive) {
|
||||
if (key >= 0) {
|
||||
@@ -67,6 +81,11 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.uiState.observe(viewLifecycleOwner) {
|
||||
binding.resendSmsCountDown.startCountDownTo(it.nextSms)
|
||||
binding.callMeCountDown.startCountDownTo(it.nextCall)
|
||||
}
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
|
||||
@@ -243,12 +243,14 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
|
||||
}
|
||||
|
||||
private fun presentNetworkError(networkError: Throwable) {
|
||||
// TODO [regv2]: check specific errors with a when clause
|
||||
Log.i(TAG, "Unknown error during verification code request", networkError)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
|
||||
setPositiveButton(android.R.string.ok) { _, _ -> sharedViewModel.clearNetworkError() }
|
||||
setOnCancelListener { sharedViewModel.clearNetworkError() }
|
||||
setOnDismissListener { sharedViewModel.clearNetworkError() }
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRegistrationButtonClicked() {
|
||||
|
||||
Reference in New Issue
Block a user