Update registration for new restore flows.
This commit is contained in:
parent
aad2624bd5
commit
22c4e2d084
140 changed files with 8364 additions and 2679 deletions
|
@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
|||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
|
@ -104,7 +105,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
|||
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
)
|
||||
val remoteResult = RegistrationRepository.AccountRegistrationResult(
|
||||
val remoteResult = AccountRegistrationResult(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
pni = UUID.randomUUID().toString(),
|
||||
storageCapable = false,
|
||||
|
|
|
@ -838,6 +838,11 @@
|
|||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.olddevice.TransferAccountActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registration.ui.RegistrationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
|
@ -845,6 +850,13 @@
|
|||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registrationv3.ui.RegistrationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden|adjustResize"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".restore.RestoreActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
|
@ -977,7 +989,7 @@
|
|||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registration.ui.restore.RemoteRestoreActivity"
|
||||
<activity android:name=".registrationv3.ui.restore.RemoteRestoreActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
|
|
|
@ -191,11 +191,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||
}
|
||||
|
||||
private boolean userMustCreateSignalPin() {
|
||||
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().lastPinCreateFailed() && !SignalStore.svr().hasOptedOut();
|
||||
}
|
||||
|
||||
private boolean userHasSkippedOrForgottenPin() {
|
||||
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.svr().isPinForgottenOrSkipped();
|
||||
return !SignalStore.registration().isRegistrationComplete() &&
|
||||
!SignalStore.svr().hasOptedInWithAccess() &&
|
||||
!SignalStore.svr().lastPinCreateFailed() &&
|
||||
!SignalStore.svr().hasOptedOut();
|
||||
}
|
||||
|
||||
private boolean userMustSetProfileName() {
|
||||
|
@ -240,7 +239,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||
}
|
||||
|
||||
private Intent getTransferOrRestoreIntent() {
|
||||
Intent intent = RestoreActivity.getIntentForTransferOrRestore(this);
|
||||
Intent intent = RestoreActivity.getRestoreIntent(this);
|
||||
return getRoutedIntent(intent, MainActivity.clearTop(this));
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import org.greenrobot.eventbus.EventBus
|
|||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.LimitedWorker
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.forceForeignKeyConstraintsEnabled
|
||||
|
@ -644,7 +645,7 @@ object BackupRepository {
|
|||
|
||||
else -> Log.w(TAG, "Unrecognized frame")
|
||||
}
|
||||
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead(), totalLength))
|
||||
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead().bytes, totalLength.bytes))
|
||||
}
|
||||
|
||||
if (chatItemInserter.flush()) {
|
||||
|
@ -1176,7 +1177,7 @@ object BackupRepository {
|
|||
return if (SignalStore.backup.backupsInitialized) {
|
||||
getArchiveServiceAccessPair().runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
} else if (isPreRestoreDuringRegistration()) {
|
||||
Log.w(TAG, "Requesting/using auth credentials in pre-restore state")
|
||||
Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable())
|
||||
getArchiveServiceAccessPair()
|
||||
} else {
|
||||
val messageBackupKey = SignalStore.backup.messageBackupKey
|
||||
|
|
|
@ -5,18 +5,19 @@
|
|||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
class RestoreV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
|
||||
import org.signal.core.util.ByteSize
|
||||
|
||||
class RestoreV2Event(val type: Type, val count: ByteSize, val estimatedTotalCount: ByteSize) {
|
||||
enum class Type {
|
||||
PROGRESS_DOWNLOAD,
|
||||
PROGRESS_RESTORE,
|
||||
PROGRESS_MEDIA_RESTORE,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
fun getProgress(): Float {
|
||||
if (estimatedTotalCount == 0L) {
|
||||
if (estimatedTotalCount.inWholeBytes == 0L) {
|
||||
return 0f
|
||||
}
|
||||
return count.toFloat() / estimatedTotalCount.toFloat()
|
||||
return count.inWholeBytes.toFloat() / estimatedTotalCount.inWholeBytes.toFloat()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,98 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import java.io.InputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RemoteRestoreViewModel : ViewModel() {
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
private val _state: MutableState<ScreenState> = mutableStateOf(
|
||||
ScreenState(
|
||||
backupTier = SignalStore.backup.backupTier,
|
||||
backupTime = SignalStore.backup.lastBackupTime,
|
||||
importState = ImportState.NONE,
|
||||
restoreProgress = null
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<ScreenState> = _state
|
||||
|
||||
fun import(length: Long, inputStreamFactory: () -> InputStream) {
|
||||
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
|
||||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
|
||||
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
_state.value = _state.value.copy(importState = ImportState.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
|
||||
disposables += Single.fromCallable {
|
||||
AppDependencies
|
||||
.jobManager
|
||||
.startChain(BackupRestoreJob())
|
||||
.then(SyncArchivedMediaJob())
|
||||
.then(BackupRestoreMediaJob())
|
||||
.enqueueAndBlockUntilCompletion(120.seconds.inWholeMilliseconds)
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
_state.value = _state.value.copy(importState = ImportState.RESTORED)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRestoreProgress(restoreEvent: RestoreV2Event) {
|
||||
_state.value = _state.value.copy(restoreProgress = restoreEvent)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
data class ScreenState(
|
||||
val backupTier: MessageBackupTier?,
|
||||
val backupTime: Long,
|
||||
val importState: ImportState,
|
||||
val restoreProgress: RestoreV2Event?
|
||||
)
|
||||
|
||||
enum class ImportState(val inProgress: Boolean = false) {
|
||||
NONE,
|
||||
IN_PROGRESS(true),
|
||||
RESTORED
|
||||
}
|
||||
}
|
|
@ -69,10 +69,10 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
|||
|
||||
@Suppress("DEPRECATION")
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(if (state.hasPin) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin),
|
||||
title = DSLSettingsText.from(if (state.hasOptedInWithAccess) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
if (state.hasPin) {
|
||||
if (state.hasOptedInWithAccess) {
|
||||
startActivityForResult(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN)
|
||||
} else {
|
||||
startActivityForResult(CreateSvrPinActivity.getIntentForPinCreate(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN)
|
||||
|
@ -94,7 +94,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
|||
title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock),
|
||||
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin),
|
||||
isChecked = state.registrationLockEnabled,
|
||||
isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(),
|
||||
isEnabled = (state.hasOptedInWithAccess) && state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
setRegistrationLockEnabled(!state.registrationLockEnabled)
|
||||
}
|
||||
|
@ -125,7 +125,6 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
|||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
|
||||
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
|
||||
isEnabled = state.isDeprecatedOrUnregistered(),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.account
|
|||
|
||||
data class AccountSettingsState(
|
||||
val hasPin: Boolean,
|
||||
val hasOptedInWithAccess: Boolean,
|
||||
val pinRemindersEnabled: Boolean,
|
||||
val registrationLockEnabled: Boolean,
|
||||
val userUnregistered: Boolean,
|
||||
|
|
|
@ -19,7 +19,8 @@ class AccountSettingsViewModel : ViewModel() {
|
|||
private fun getCurrentState(): AccountSettingsState {
|
||||
return AccountSettingsState(
|
||||
hasPin = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut(),
|
||||
pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled(),
|
||||
hasOptedInWithAccess = SignalStore.svr.hasOptedInWithAccess(),
|
||||
pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled() && SignalStore.svr.hasPin(),
|
||||
registrationLockEnabled = SignalStore.svr.isRegistrationLockEnabled,
|
||||
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application),
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
|
|
|
@ -392,7 +392,7 @@ class ChangeNumberViewModel : ViewModel() {
|
|||
private suspend fun changeNumberWithRecoveryPassword(): Boolean {
|
||||
Log.v(TAG, "changeNumberWithRecoveryPassword()")
|
||||
SignalStore.svr.recoveryPassword?.let { recoveryPassword ->
|
||||
if (SignalStore.svr.hasPin()) {
|
||||
if (SignalStore.svr.hasOptedInWithAccess()) {
|
||||
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
|
||||
|
||||
if (result is ChangeNumberResult.Success) {
|
||||
|
|
|
@ -837,7 +837,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
|||
SignalStore.account.setRegistered(false)
|
||||
SignalStore.registration.clearRegistrationComplete()
|
||||
SignalStore.registration.clearHasUploadedProfile()
|
||||
SignalStore.registration.clearSkippedTransferOrRestore()
|
||||
SignalStore.registration.debugClearSkippedTransferOrRestore()
|
||||
Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
|
|
|
@ -63,13 +63,17 @@ fun DrawScope.drawQr(
|
|||
val deadzonePaddingPercent = 0.045f
|
||||
|
||||
// We want an even number of dots on either side of the deadzone
|
||||
val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
|
||||
if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
|
||||
candidateDeadzoneHeight
|
||||
} else {
|
||||
candidateDeadzoneHeight + 1
|
||||
}
|
||||
} / 2
|
||||
val deadzoneRadius: Int = if (data.canSupportIconOverlay) {
|
||||
(data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
|
||||
if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
|
||||
candidateDeadzoneHeight
|
||||
} else {
|
||||
candidateDeadzoneHeight + 1
|
||||
}
|
||||
} / 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
val cellWidthPx: Float = size.width / data.width
|
||||
val cornerRadius = CornerRadius(7f, 7f)
|
||||
|
@ -108,25 +112,27 @@ fun DrawScope.drawQr(
|
|||
}
|
||||
}
|
||||
|
||||
// Logo border
|
||||
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
|
||||
drawCircle(
|
||||
color = foregroundColor,
|
||||
radius = logoBorderRadiusPx,
|
||||
style = Stroke(width = cellWidthPx * 0.75f),
|
||||
center = this.center
|
||||
)
|
||||
|
||||
// Logo
|
||||
val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
|
||||
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
|
||||
if (logo != null) {
|
||||
drawImage(
|
||||
image = logo,
|
||||
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
|
||||
dstSize = IntSize(logoWidthPx, logoWidthPx),
|
||||
colorFilter = ColorFilter.tint(foregroundColor)
|
||||
if (data.canSupportIconOverlay) {
|
||||
// Logo border
|
||||
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
|
||||
drawCircle(
|
||||
color = foregroundColor,
|
||||
radius = logoBorderRadiusPx,
|
||||
style = Stroke(width = cellWidthPx * 0.75f),
|
||||
center = this.center
|
||||
)
|
||||
|
||||
// Logo
|
||||
val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
|
||||
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
|
||||
if (logo != null) {
|
||||
drawImage(
|
||||
image = logo,
|
||||
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
|
||||
dstSize = IntSize(logoWidthPx, logoWidthPx),
|
||||
colorFilter = ColorFilter.tint(foregroundColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,7 +141,7 @@ fun DrawScope.drawQr(
|
|||
private fun Preview() {
|
||||
Surface {
|
||||
QrCode(
|
||||
data = QrCodeData.forData("https://signal.org", 64),
|
||||
data = QrCodeData.forData("https://signal.org"),
|
||||
modifier = Modifier.size(350.dp)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
@ -170,13 +169,13 @@ private fun PreviewWithCodeShort() {
|
|||
Surface {
|
||||
Column {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42",
|
||||
usernameCopyable = false
|
||||
)
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "parker.42",
|
||||
usernameCopyable = true
|
||||
|
@ -193,14 +192,14 @@ private fun PreviewWithCodeLong() {
|
|||
Surface {
|
||||
Column {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "TheAmazingSpiderMan.42",
|
||||
usernameCopyable = false
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.org")),
|
||||
colorScheme = UsernameQrCodeColorScheme.Blue,
|
||||
username = "TheAmazingSpiderMan.42",
|
||||
usernameCopyable = true
|
||||
|
@ -249,7 +248,7 @@ private fun PreviewAllColorsP2() {
|
|||
@Composable
|
||||
private fun SampleCode(colorScheme: UsernameQrCodeColorScheme) {
|
||||
QrCodeBadge(
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf", 64)),
|
||||
data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf")),
|
||||
colorScheme = colorScheme,
|
||||
username = "parker.42"
|
||||
)
|
||||
|
|
|
@ -15,6 +15,7 @@ import java.util.BitSet
|
|||
class QrCodeData(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val canSupportIconOverlay: Boolean,
|
||||
private val bits: BitSet
|
||||
) {
|
||||
|
||||
|
@ -34,13 +35,17 @@ class QrCodeData(
|
|||
|
||||
/**
|
||||
* Converts the provided string data into a QR representation.
|
||||
*
|
||||
* @param supportIconOverlay indicates data can be rendered with the icon overlay. Rendering with an icon relies on more error correction
|
||||
* data in the QR which requires a denser rendering which is sometimes not easily scanned by our scanner. Set to false if data is expected to be
|
||||
* long to prevent scanning issues.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun forData(data: String, size: Int): QrCodeData {
|
||||
fun forData(data: String, supportIconOverlay: Boolean = true): QrCodeData {
|
||||
val qrCodeWriter = QRCodeWriter()
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q.toString())
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to if (supportIconOverlay) ErrorCorrectionLevel.Q.toString() else ErrorCorrectionLevel.L.toString())
|
||||
|
||||
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 64, 64, hints)
|
||||
val dimens = padded.enclosingRectangle
|
||||
val xStart = dimens[0]
|
||||
val yStart = dimens[1]
|
||||
|
@ -58,7 +63,7 @@ class QrCodeData(
|
|||
}
|
||||
}
|
||||
|
||||
return QrCodeData(width, height, bitSet)
|
||||
return QrCodeData(width, height, supportIconOverlay, bitSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
|||
|
||||
if (usernameLink != null) {
|
||||
disposable += Single
|
||||
.fromCallable { QrCodeData.forData(usernameLink.toLink(), 64) }
|
||||
.fromCallable { QrCodeData.forData(usernameLink.toLink()) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
|
|
|
@ -371,7 +371,7 @@ private fun MainScreenPreview() {
|
|||
activeTab = ActiveTab.Code,
|
||||
username = "PeterParker.42",
|
||||
usernameLinkState = UsernameLinkState.Present("https://signal.org"),
|
||||
qrCodeState = QrCodeState.Present(QrCodeData.forData("PeterParker.42", 64)),
|
||||
qrCodeState = QrCodeState.Present(QrCodeData.forData("PeterParker.42")),
|
||||
qrCodeColorScheme = UsernameQrCodeColorScheme.Orange
|
||||
)
|
||||
)
|
||||
|
|
|
@ -203,7 +203,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
|||
|
||||
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
|
||||
return Single.fromCallable {
|
||||
url.map { QrCodeData.forData(it, 64) }
|
||||
url.map { QrCodeData.forData(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -334,7 +334,7 @@ private fun previewState(): UsernameLinkSettingsState {
|
|||
activeTab = ActiveTab.Code,
|
||||
username = "parker.42",
|
||||
usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
|
||||
qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)),
|
||||
qrCodeState = QrCodeState.Present(QrCodeData.forData(link)),
|
||||
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,170 +0,0 @@
|
|||
package org.thoughtcrime.securesms.devicetransfer;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.devicetransfer.DeviceToDeviceTransferService;
|
||||
import org.signal.devicetransfer.TransferStatus;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Drives the UI for the actual device transfer progress. Shown after setup is complete
|
||||
* and the two devices are transferring.
|
||||
* <p>
|
||||
* Handles show progress and error state.
|
||||
*/
|
||||
public abstract class DeviceTransferFragment extends LoggingFragment {
|
||||
|
||||
private static final String TRANSFER_FINISHED_KEY = "transfer_finished";
|
||||
|
||||
private final OnBackPressed onBackPressed = new OnBackPressed();
|
||||
private final TransferModeListener transferModeListener = new TransferModeListener();
|
||||
|
||||
protected TextView title;
|
||||
protected View tryAgain;
|
||||
protected Button cancel;
|
||||
protected View progress;
|
||||
protected View alert;
|
||||
protected TextView status;
|
||||
protected boolean transferFinished;
|
||||
|
||||
public DeviceTransferFragment() {
|
||||
super(R.layout.fragment_device_transfer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (savedInstanceState != null) {
|
||||
transferFinished = savedInstanceState.getBoolean(TRANSFER_FINISHED_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
if (transferFinished) {
|
||||
navigateToTransferComplete();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putBoolean(TRANSFER_FINISHED_KEY, transferFinished);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
title = view.findViewById(R.id.device_transfer_fragment_title);
|
||||
tryAgain = view.findViewById(R.id.device_transfer_fragment_try_again);
|
||||
cancel = view.findViewById(R.id.device_transfer_fragment_cancel);
|
||||
progress = view.findViewById(R.id.device_transfer_fragment_progress);
|
||||
alert = view.findViewById(R.id.device_transfer_fragment_alert);
|
||||
status = view.findViewById(R.id.device_transfer_fragment_status);
|
||||
|
||||
cancel.setOnClickListener(v -> cancelActiveTransfer());
|
||||
tryAgain.setOnClickListener(v -> {
|
||||
EventBus.getDefault().unregister(transferModeListener);
|
||||
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
|
||||
navigateToRestartTransfer();
|
||||
});
|
||||
|
||||
EventBus.getDefault().register(transferModeListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
EventBus.getDefault().unregister(transferModeListener);
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
private void cancelActiveTransfer() {
|
||||
new MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.DeviceTransfer__stop_transfer)
|
||||
.setMessage(R.string.DeviceTransfer__all_transfer_progress_will_be_lost)
|
||||
.setPositiveButton(R.string.DeviceTransfer__stop_transfer, (d, w) -> {
|
||||
EventBus.getDefault().unregister(transferModeListener);
|
||||
DeviceToDeviceTransferService.stop(requireContext());
|
||||
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
|
||||
navigateAwayFromTransfer();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
protected void ignoreTransferStatusEvents() {
|
||||
EventBus.getDefault().unregister(transferModeListener);
|
||||
}
|
||||
|
||||
protected abstract void navigateToRestartTransfer();
|
||||
|
||||
protected abstract void navigateAwayFromTransfer();
|
||||
|
||||
protected abstract void navigateToTransferComplete();
|
||||
|
||||
private class TransferModeListener {
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(@NonNull TransferStatus event) {
|
||||
if (event.getTransferMode() != TransferStatus.TransferMode.SERVICE_CONNECTED) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void abort() {
|
||||
abort(R.string.DeviceTransfer__transfer_failed);
|
||||
}
|
||||
|
||||
protected void abort(@StringRes int errorMessage) {
|
||||
EventBus.getDefault().unregister(transferModeListener);
|
||||
DeviceToDeviceTransferService.stop(requireContext());
|
||||
|
||||
progress.setVisibility(View.GONE);
|
||||
alert.setVisibility(View.VISIBLE);
|
||||
tryAgain.setVisibility(View.VISIBLE);
|
||||
|
||||
title.setText(R.string.DeviceTransfer__unable_to_transfer);
|
||||
status.setText(errorMessage);
|
||||
cancel.setText(R.string.DeviceTransfer__cancel);
|
||||
cancel.setOnClickListener(v -> navigateAwayFromTransfer());
|
||||
|
||||
onBackPressed.isActiveTransfer = false;
|
||||
}
|
||||
|
||||
protected class OnBackPressed extends OnBackPressedCallback {
|
||||
|
||||
private boolean isActiveTransfer = true;
|
||||
|
||||
public OnBackPressed() {
|
||||
super(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
if (isActiveTransfer) {
|
||||
cancelActiveTransfer();
|
||||
} else {
|
||||
navigateAwayFromTransfer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.devicetransfer.moreoptions
|
||||
|
||||
/**
|
||||
* Allows component opening sheet to specify mode
|
||||
*/
|
||||
enum class MoreTransferOrRestoreOptionsMode {
|
||||
/**
|
||||
* Only display the option to log in without transferring. Selection
|
||||
* will be disabled.
|
||||
*/
|
||||
SKIP_ONLY,
|
||||
|
||||
/**
|
||||
* Display transfer/restore local/skip as well as a next and cancel button
|
||||
*/
|
||||
SELECTION
|
||||
}
|
|
@ -1,339 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.devicetransfer.moreoptions
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Lists a set of options the user can choose from for restoring backup or skipping restoration
|
||||
*/
|
||||
class MoreTransferOrRestoreOptionsSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
private val args by navArgs<MoreTransferOrRestoreOptionsSheetArgs>()
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
var selectedOption by remember {
|
||||
mutableStateOf<BackupRestorationType?>(null)
|
||||
}
|
||||
|
||||
MoreOptionsSheetContent(
|
||||
mode = args.mode,
|
||||
selectedOption = selectedOption,
|
||||
onOptionSelected = { selectedOption = it },
|
||||
onCancelClick = { findNavController().popBackStack() },
|
||||
onNextClick = {
|
||||
this.onNextClicked(selectedOption ?: BackupRestorationType.NONE)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onNextClicked(selectedOption: BackupRestorationType) {
|
||||
// TODO [message-requests] -- Launch next screen based off user choice
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MoreOptionsSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
MoreOptionsSheetContent(
|
||||
mode = MoreTransferOrRestoreOptionsMode.SKIP_ONLY,
|
||||
selectedOption = null,
|
||||
onOptionSelected = {},
|
||||
onCancelClick = {},
|
||||
onNextClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MoreOptionsSheetSelectableContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
MoreOptionsSheetContent(
|
||||
mode = MoreTransferOrRestoreOptionsMode.SELECTION,
|
||||
selectedOption = null,
|
||||
onOptionSelected = {},
|
||||
onCancelClick = {},
|
||||
onNextClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoreOptionsSheetContent(
|
||||
mode: MoreTransferOrRestoreOptionsMode,
|
||||
selectedOption: BackupRestorationType?,
|
||||
onOptionSelected: (BackupRestorationType) -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onNextClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.size(42.dp))
|
||||
|
||||
if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
|
||||
TransferFromAndroidDeviceOption(
|
||||
selectedOption = selectedOption,
|
||||
onOptionSelected = onOptionSelected
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
RestoreLocalBackupOption(
|
||||
selectedOption = selectedOption,
|
||||
onOptionSelected = onOptionSelected
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
}
|
||||
|
||||
LogInWithoutTransferringOption(
|
||||
selectedOption = selectedOption,
|
||||
onOptionSelected = when (mode) {
|
||||
MoreTransferOrRestoreOptionsMode.SKIP_ONLY -> { _ -> onNextClick() }
|
||||
MoreTransferOrRestoreOptionsMode.SELECTION -> onOptionSelected
|
||||
}
|
||||
)
|
||||
|
||||
if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 30.dp, bottom = 24.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onCancelClick
|
||||
) {
|
||||
Text(text = stringResource(id = android.R.string.cancel))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = selectedOption != null,
|
||||
onClick = onNextClick
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.RegistrationActivity_next))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(45.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun LogInWithoutTransferringOptionPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
LogInWithoutTransferringOption(
|
||||
selectedOption = null,
|
||||
onOptionSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogInWithoutTransferringOption(
|
||||
selectedOption: BackupRestorationType?,
|
||||
onOptionSelected: (BackupRestorationType) -> Unit
|
||||
) {
|
||||
Option(
|
||||
icon = {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 18.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
isSelected = selectedOption == BackupRestorationType.NONE,
|
||||
title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__log_in_without_transferring),
|
||||
subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__continue_without_transferring),
|
||||
onClick = { onOptionSelected(BackupRestorationType.NONE) }
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun TransferFromAndroidDeviceOptionPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
TransferFromAndroidDeviceOption(
|
||||
selectedOption = null,
|
||||
onOptionSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransferFromAndroidDeviceOption(
|
||||
selectedOption: BackupRestorationType?,
|
||||
onOptionSelected: (BackupRestorationType) -> Unit
|
||||
) {
|
||||
Option(
|
||||
icon = {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 18.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
isSelected = selectedOption == BackupRestorationType.DEVICE_TRANSFER,
|
||||
title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_from_android_device),
|
||||
subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_your_account_and_messages),
|
||||
onClick = { onOptionSelected(BackupRestorationType.DEVICE_TRANSFER) }
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RestoreLocalBackupOptionPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
RestoreLocalBackupOption(
|
||||
selectedOption = null,
|
||||
onOptionSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreLocalBackupOption(
|
||||
selectedOption: BackupRestorationType?,
|
||||
onOptionSelected: (BackupRestorationType) -> Unit
|
||||
) {
|
||||
Option(
|
||||
icon = {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 18.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
isSelected = selectedOption == BackupRestorationType.LOCAL_BACKUP,
|
||||
title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_local_backup),
|
||||
subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_your_messages),
|
||||
onClick = { onOptionSelected(BackupRestorationType.LOCAL_BACKUP) }
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun OptionPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Option(
|
||||
icon = {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 18.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_backup_light),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
isSelected = false,
|
||||
title = "Option Preview Title",
|
||||
subtitle = "Option Preview Subtitle",
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Option(
|
||||
icon: @Composable () -> Unit,
|
||||
isSelected: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.border(
|
||||
width = if (isSelected) 2.dp else 0.dp,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onClick() }
|
||||
.padding(vertical = 21.dp)
|
||||
) {
|
||||
icon()
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -73,6 +73,8 @@ final class NewDeviceServerTask implements ServerTask {
|
|||
|
||||
long end = System.currentTimeMillis();
|
||||
Log.i(TAG, "Receive took: " + (end - start));
|
||||
|
||||
EventBus.getDefault().post(new Status(0, Status.State.RESTORE_COMPLETE));
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.POSTING)
|
||||
|
@ -80,7 +82,7 @@ final class NewDeviceServerTask implements ServerTask {
|
|||
if (event.getType() == BackupEvent.Type.PROGRESS) {
|
||||
EventBus.getDefault().post(new Status(event.getCount(), Status.State.IN_PROGRESS));
|
||||
} else if (event.getType() == BackupEvent.Type.FINISHED) {
|
||||
EventBus.getDefault().post(new Status(event.getCount(), Status.State.SUCCESS));
|
||||
EventBus.getDefault().post(new Status(event.getCount(), Status.State.TRANSFER_COMPLETE));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -103,7 +105,8 @@ final class NewDeviceServerTask implements ServerTask {
|
|||
|
||||
public enum State {
|
||||
IN_PROGRESS,
|
||||
SUCCESS,
|
||||
TRANSFER_COMPLETE,
|
||||
RESTORE_COMPLETE,
|
||||
FAILURE_VERSION_DOWNGRADE,
|
||||
FAILURE_FOREIGN_KEY,
|
||||
FAILURE_UNKNOWN
|
||||
|
|
|
@ -6,11 +6,10 @@ import android.view.View;
|
|||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
|
||||
/**
|
||||
* Shown after the new device successfully completes receiving a backup from the old device.
|
||||
|
@ -23,8 +22,7 @@ public final class NewDeviceTransferCompleteFragment extends LoggingFragment {
|
|||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
view.findViewById(R.id.new_device_transfer_complete_fragment_continue_registration)
|
||||
.setOnClickListener(v -> SafeNavigation.safeNavigate(NavHostFragment.findNavController(this),
|
||||
R.id.action_newDeviceTransferComplete_to_enterPhoneNumberFragment));
|
||||
.setOnClickListener(v -> ((RestoreActivity) requireActivity()).onBackupCompletedSuccessfully());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
package org.thoughtcrime.securesms.devicetransfer.newdevice;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.devicetransfer.DeviceToDeviceTransferService;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
|
||||
|
||||
/**
|
||||
* Shows transfer progress on the new device. Most logic is in {@link DeviceTransferFragment}
|
||||
* and it delegates to this class for strings, navigation, and updating progress.
|
||||
*/
|
||||
public final class NewDeviceTransferFragment extends DeviceTransferFragment {
|
||||
|
||||
private final ServerTaskListener serverTaskListener = new ServerTaskListener();
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
EventBus.getDefault().register(serverTaskListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
EventBus.getDefault().unregister(serverTaskListener);
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void navigateToRestartTransfer() {
|
||||
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void navigateAwayFromTransfer() {
|
||||
EventBus.getDefault().unregister(serverTaskListener);
|
||||
requireActivity().finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void navigateToTransferComplete() {
|
||||
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete());
|
||||
}
|
||||
|
||||
private class ServerTaskListener {
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(@NonNull NewDeviceServerTask.Status event) {
|
||||
status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
|
||||
switch (event.getState()) {
|
||||
case IN_PROGRESS:
|
||||
break;
|
||||
case SUCCESS:
|
||||
transferFinished = true;
|
||||
DeviceToDeviceTransferService.stop(requireContext());
|
||||
SignalStore.registration().markRestoreCompleted();
|
||||
navigateToTransferComplete();
|
||||
break;
|
||||
case FAILURE_VERSION_DOWNGRADE:
|
||||
abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal);
|
||||
break;
|
||||
case FAILURE_FOREIGN_KEY:
|
||||
abort(R.string.NewDeviceTransfer__failure_foreign_key);
|
||||
break;
|
||||
case FAILURE_UNKNOWN:
|
||||
abort();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package org.thoughtcrime.securesms.devicetransfer.newdevice
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.devicetransfer.DeviceToDeviceTransferService
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity
|
||||
import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Shows transfer progress on the new device. Most logic is in [DeviceTransferFragment]
|
||||
* and it delegates to this class for strings, navigation, and updating progress.
|
||||
*/
|
||||
class NewDeviceTransferFragment : DeviceTransferFragment() {
|
||||
|
||||
private val viewModel: NewDeviceTransferViewModel by viewModels()
|
||||
private val serverTaskListener = ServerTaskListener()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
EventBus.getDefault().register(serverTaskListener)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
EventBus.getDefault().unregister(serverTaskListener)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun navigateToRestartTransfer() {
|
||||
findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions())
|
||||
}
|
||||
|
||||
override fun navigateAwayFromTransfer() {
|
||||
EventBus.getDefault().unregister(serverTaskListener)
|
||||
requireActivity().finish()
|
||||
}
|
||||
|
||||
override fun navigateToTransferComplete() {
|
||||
if (SignalStore.account.isRegistered) {
|
||||
(requireActivity() as RestoreActivity).onBackupCompletedSuccessfully()
|
||||
} else {
|
||||
findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreComplete() {
|
||||
ignoreTransferStatusEvents()
|
||||
DeviceToDeviceTransferService.stop(requireContext())
|
||||
|
||||
viewModel.onRestoreComplete(requireContext()) {
|
||||
transferFinished = true
|
||||
navigateToTransferComplete()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ServerTaskListener {
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEventMainThread(event: NewDeviceServerTask.Status) {
|
||||
status.text = getString(R.string.DeviceTransfer__d_messages_so_far, event.messageCount)
|
||||
|
||||
when (event.state) {
|
||||
NewDeviceServerTask.Status.State.IN_PROGRESS,
|
||||
NewDeviceServerTask.Status.State.TRANSFER_COMPLETE -> Unit
|
||||
|
||||
NewDeviceServerTask.Status.State.RESTORE_COMPLETE -> onRestoreComplete()
|
||||
NewDeviceServerTask.Status.State.FAILURE_VERSION_DOWNGRADE -> abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal)
|
||||
NewDeviceServerTask.Status.State.FAILURE_FOREIGN_KEY -> abort(R.string.NewDeviceTransfer__failure_foreign_key)
|
||||
NewDeviceServerTask.Status.State.FAILURE_UNKNOWN -> abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,7 +27,7 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra
|
|||
|
||||
@Override
|
||||
protected void navigateAwayFromTransfer() {
|
||||
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore);
|
||||
requireActivity().onNavigateUp();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -78,7 +78,7 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra
|
|||
|
||||
@Override
|
||||
protected void navigateWhenWifiDirectUnavailable() {
|
||||
SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore);
|
||||
requireActivity().onNavigateUp();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.devicetransfer.newdevice
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
|
||||
class NewDeviceTransferViewModel : ViewModel() {
|
||||
fun onRestoreComplete(context: Context, onComplete: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
SignalStore.registration.localRegistrationMetadata?.let { metadata ->
|
||||
RegistrationRepository.registerAccountLocally(context, metadata)
|
||||
SignalStore.registration.clearLocalRegistrationMetadata()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
}
|
||||
|
||||
SignalStore.registration.markRestoreCompleted()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
package org.thoughtcrime.securesms.devicetransfer.newdevice;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreBinding;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
|
||||
|
||||
/**
|
||||
* Simple jumping off menu to starts a device-to-device transfer or restore a backup.
|
||||
*/
|
||||
public final class TransferOrRestoreFragment extends LoggingFragment {
|
||||
|
||||
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
private FragmentTransferRestoreBinding binding;
|
||||
|
||||
public TransferOrRestoreFragment() {
|
||||
super(R.layout.fragment_transfer_restore);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
binding = FragmentTransferRestoreBinding.bind(view);
|
||||
|
||||
TransferOrRestoreViewModel viewModel = new ViewModelProvider(this).get(TransferOrRestoreViewModel.class);
|
||||
|
||||
binding.transferOrRestoreFragmentTransfer.setOnClickListener(v -> viewModel.onTransferFromAndroidDeviceSelected());
|
||||
binding.transferOrRestoreFragmentRestore.setOnClickListener(v -> viewModel.onRestoreFromLocalBackupSelected());
|
||||
binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener(v -> viewModel.onRestoreFromRemoteBackupSelected());
|
||||
binding.transferOrRestoreFragmentNext.setOnClickListener(v -> launchSelection(viewModel.getStateSnapshot()));
|
||||
binding.transferOrRestoreFragmentMoreOptions.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transferOrRestore_to_moreOptions));
|
||||
|
||||
int visibility = RemoteConfig.messageBackups() ? View.VISIBLE : View.GONE;
|
||||
binding.transferOrRestoreFragmentRestoreRemoteCard.setVisibility(visibility);
|
||||
binding.transferOrRestoreFragmentMoreOptions.setVisibility(visibility);
|
||||
|
||||
String description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_messages_from_your_old_android_device);
|
||||
String toBold = getString(R.string.TransferOrRestoreFragment__you_need_access_to_your_old_device);
|
||||
|
||||
binding.transferOrRestoreFragmentTransferDescription.setText(SpanUtil.boldSubstring(description, toBold));
|
||||
|
||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
lifecycleDisposable.add(viewModel.getState().subscribe(this::updateSelection));
|
||||
}
|
||||
|
||||
private void updateSelection(BackupRestorationType restorationType) {
|
||||
binding.transferOrRestoreFragmentTransferCard.setSelected(restorationType == BackupRestorationType.DEVICE_TRANSFER);
|
||||
binding.transferOrRestoreFragmentRestoreCard.setSelected(restorationType == BackupRestorationType.LOCAL_BACKUP);
|
||||
binding.transferOrRestoreFragmentRestoreRemoteCard.setSelected(restorationType == BackupRestorationType.REMOTE_BACKUP);
|
||||
}
|
||||
|
||||
private void launchSelection(BackupRestorationType restorationType) {
|
||||
switch (restorationType) {
|
||||
case DEVICE_TRANSFER -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_new_device_transfer_instructions);
|
||||
case LOCAL_BACKUP -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transfer_or_restore_to_local_restore);
|
||||
case REMOTE_BACKUP -> {}
|
||||
default -> throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.devicetransfer.newdevice
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
|
||||
/**
|
||||
* Maintains state of the TransferOrRestoreFragment
|
||||
*/
|
||||
class TransferOrRestoreViewModel : ViewModel() {
|
||||
|
||||
private val internalState = BehaviorProcessor.createDefault(BackupRestorationType.DEVICE_TRANSFER)
|
||||
|
||||
val state: Flowable<BackupRestorationType> = internalState.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread())
|
||||
val stateSnapshot: BackupRestorationType get() = internalState.value!!
|
||||
|
||||
fun onTransferFromAndroidDeviceSelected() {
|
||||
internalState.onNext(BackupRestorationType.DEVICE_TRANSFER)
|
||||
}
|
||||
|
||||
fun onRestoreFromLocalBackupSelected() {
|
||||
internalState.onNext(BackupRestorationType.LOCAL_BACKUP)
|
||||
}
|
||||
|
||||
fun onRestoreFromRemoteBackupSelected() {
|
||||
internalState.onNext(BackupRestorationType.REMOTE_BACKUP)
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ import org.greenrobot.eventbus.ThreadMode;
|
|||
import org.signal.devicetransfer.DeviceToDeviceTransferService;
|
||||
import org.signal.devicetransfer.TransferStatus;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment;
|
||||
import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment;
|
||||
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
|
@ -66,16 +66,16 @@ public final class OldDeviceTransferFragment extends DeviceTransferFragment {
|
|||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(@NonNull OldDeviceClientTask.Status event) {
|
||||
if (event.isDone()) {
|
||||
transferFinished = true;
|
||||
setTransferFinished(true);
|
||||
ignoreTransferStatusEvents();
|
||||
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
|
||||
DeviceToDeviceTransferService.stop(requireContext());
|
||||
SafeNavigation.safeNavigate(NavHostFragment.findNavController(OldDeviceTransferFragment.this), R.id.action_oldDeviceTransfer_to_oldDeviceTransferComplete);
|
||||
} else {
|
||||
if (event.getEstimatedMessageCount() == 0) {
|
||||
status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
|
||||
getStatus().setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
|
||||
} else {
|
||||
status.setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage())));
|
||||
getStatus().setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,8 @@ object SignalSymbols {
|
|||
enum class Glyph(val unicode: Char) {
|
||||
CHECKMARK('\u2713'),
|
||||
CHEVRON_RIGHT('\uE025'),
|
||||
PERSON_CIRCLE('\uE05E')
|
||||
PERSON_CIRCLE('\uE05E'),
|
||||
LOCK('\uE041')
|
||||
}
|
||||
|
||||
enum class Weight {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
@ -75,7 +76,7 @@ class BackupRestoreJob private constructor(parameters: Parameters) : BaseJob(par
|
|||
progress = progress.toFloat() / total.toFloat(),
|
||||
indeterminate = false
|
||||
)
|
||||
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress, total))
|
||||
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress.bytes, total.bytes))
|
||||
}
|
||||
|
||||
override fun shouldCancel() = isCanceled
|
||||
|
|
|
@ -103,8 +103,8 @@ public class RefreshAttributesJob extends BaseJob {
|
|||
String deviceName = SignalStore.account().getDeviceName();
|
||||
byte[] encryptedDeviceName = (deviceName == null) ? null : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), SignalStore.account().getAciIdentityKey());
|
||||
|
||||
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasPin() && !svrValues.hasOptedOut());
|
||||
Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() +
|
||||
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasOptedInWithAccess() && !svrValues.hasOptedOut());
|
||||
Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", access? " + svrValues.hasOptedInWithAccess() +
|
||||
"\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) +
|
||||
"\n Phone number discoverable : " + phoneNumberDiscoverable +
|
||||
"\n Device Name : " + (encryptedDeviceName != null) +
|
||||
|
|
|
@ -105,7 +105,7 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
return;
|
||||
}
|
||||
|
||||
if (SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) {
|
||||
if (SignalStore.svr().hasOptedInWithAccess() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) {
|
||||
Log.i(TAG, "Registered with PIN but haven't completed storage sync yet.");
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas
|
|||
|
||||
@JvmStatic
|
||||
fun enqueueIfNecessary() {
|
||||
if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered) {
|
||||
if (SignalStore.svr.hasOptedInWithAccess() && SignalStore.account.isRegistered) {
|
||||
val lastTimestamp = SignalStore.svr.lastRefreshAuthTimestamp
|
||||
if (lastTimestamp + FREQUENCY.inWholeMilliseconds < System.currentTimeMillis() || lastTimestamp > System.currentTimeMillis()) {
|
||||
AppDependencies.jobManager.add(RefreshSvrCredentialsJob())
|
||||
|
|
|
@ -139,7 +139,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
|
||||
@Throws(IOException::class, RetryLaterException::class, UntrustedIdentityException::class)
|
||||
override fun onRun() {
|
||||
if (!SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) {
|
||||
if (!SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) {
|
||||
Log.i(TAG, "Doesn't have a PIN. Skipping.")
|
||||
return
|
||||
}
|
||||
|
|
|
@ -154,7 +154,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
|||
}
|
||||
|
||||
/**
|
||||
* When uploading a backup, we store the progress state here so that I can remain across app restarts.
|
||||
* When uploading a backup, we store the progress state here so that it can remain across app restarts.
|
||||
*/
|
||||
var archiveUploadState: ArchiveUploadProgressState? by protoValue(KEY_ARCHIVE_UPLOAD_STATE, ArchiveUploadProgressState.ADAPTER)
|
||||
|
||||
|
|
|
@ -243,7 +243,8 @@ class PaymentsValues internal constructor(store: KeyValueStore) : SignalStoreVal
|
|||
fun showUpdatePinInfoCard(): Boolean {
|
||||
return if (userHasLargeBalance() &&
|
||||
SignalStore.svr.hasPin() &&
|
||||
!SignalStore.svr.hasOptedOut() && SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC
|
||||
!SignalStore.svr.hasOptedOut() &&
|
||||
SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC
|
||||
) {
|
||||
store.getBoolean(SHOW_CASHING_OUT_INFO_CARD, true)
|
||||
} else {
|
||||
|
|
|
@ -96,7 +96,7 @@ public final class RegistrationValues extends SignalStoreValues {
|
|||
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true);
|
||||
}
|
||||
|
||||
public void clearSkippedTransferOrRestore() {
|
||||
public void debugClearSkippedTransferOrRestore() {
|
||||
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false);
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
private const val SVR2_AUTH_TOKENS = "kbs.kbs_auth_tokens"
|
||||
private const val SVR_LAST_AUTH_REFRESH_TIMESTAMP = "kbs.kbs_auth_tokens.last_refresh_timestamp"
|
||||
private const val SVR3_AUTH_TOKENS = "kbs.svr3_auth_tokens"
|
||||
private const val RESTORED_VIA_ACCOUNT_ENTROPY_KEY = "kbs.restore_via_account_entropy_pool"
|
||||
}
|
||||
|
||||
public override fun onFirstEverAppLaunch() = Unit
|
||||
|
@ -52,14 +53,22 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
}
|
||||
|
||||
@Synchronized
|
||||
fun setMasterKey(masterKey: MasterKey, pin: String) {
|
||||
store.beginWrite()
|
||||
.putBlob(MASTER_KEY, masterKey.serialize())
|
||||
.putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
|
||||
.putString(PIN, pin)
|
||||
.putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
|
||||
.putBoolean(OPTED_OUT, false)
|
||||
.commit()
|
||||
fun setMasterKey(masterKey: MasterKey, pin: String?) {
|
||||
store.beginWrite().apply {
|
||||
putBlob(MASTER_KEY, masterKey.serialize())
|
||||
putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
|
||||
putBoolean(OPTED_OUT, false)
|
||||
|
||||
if (pin != null) {
|
||||
putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
|
||||
putString(PIN, pin)
|
||||
remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
|
||||
} else {
|
||||
putBoolean(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, true)
|
||||
remove(LOCK_LOCAL_PIN_HASH)
|
||||
remove(PIN)
|
||||
}
|
||||
}.commit()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
@ -85,9 +94,9 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
return getLong(LAST_CREATE_FAILED_TIMESTAMP, -1) > 0
|
||||
}
|
||||
|
||||
/** Returns the Master Key, lazily creating one if needed. */
|
||||
@get:Synchronized
|
||||
val masterKey: MasterKey
|
||||
/** Returns the Master Key, lazily creating one if needed. */
|
||||
get() {
|
||||
val blob = store.getBlob(MASTER_KEY, null)
|
||||
if (blob != null) {
|
||||
|
@ -123,7 +132,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
val recoveryPassword: String?
|
||||
get() {
|
||||
val masterKey = rawMasterKey
|
||||
return if (masterKey != null && hasPin()) {
|
||||
return if (masterKey != null && hasOptedInWithAccess()) {
|
||||
masterKey.deriveRegistrationRecoveryPassword()
|
||||
} else {
|
||||
null
|
||||
|
@ -136,11 +145,19 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
@get:Synchronized
|
||||
val localPinHash: String? by stringValue(LOCK_LOCAL_PIN_HASH, null)
|
||||
|
||||
@Synchronized
|
||||
fun hasOptedInWithAccess(): Boolean {
|
||||
return hasPin() || restoredViaAccountEntropyPool
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasPin(): Boolean {
|
||||
return localPinHash != null
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val restoredViaAccountEntropyPool by booleanValue(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, false)
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isPinForgottenOrSkipped: Boolean by booleanValue(PIN_FORGOTTEN_OR_SKIPPED, false)
|
||||
|
@ -229,6 +246,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
.putBlob(MASTER_KEY, MasterKey.createNew(SecureRandom()).serialize())
|
||||
.remove(LOCK_LOCAL_PIN_HASH)
|
||||
.remove(PIN)
|
||||
.remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
|
||||
.putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
|
||||
.commit()
|
||||
}
|
||||
|
|
|
@ -101,7 +101,7 @@ public abstract class BaseSvrPinFragment<ViewModel extends BaseSvrPinViewModel>
|
|||
@Override
|
||||
public void onPrepareOptionsMenu(@NonNull Menu menu) {
|
||||
if (SignalStore.svr().isRegistrationLockEnabled() ||
|
||||
SignalStore.svr().hasPin() ||
|
||||
SignalStore.svr().hasOptedInWithAccess() ||
|
||||
SignalStore.svr().hasOptedOut())
|
||||
{
|
||||
menu.clear();
|
||||
|
|
|
@ -115,7 +115,7 @@ public final class SvrSplashFragment extends Fragment {
|
|||
private void onCreatePin() {
|
||||
SvrSplashFragmentDirections.ActionCreateKbsPin action = SvrSplashFragmentDirections.actionCreateKbsPin();
|
||||
|
||||
action.setIsPinChange(SignalStore.svr().hasPin());
|
||||
action.setIsPinChange(SignalStore.svr().hasOptedInWithAccess());
|
||||
|
||||
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), action);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ public class LogSectionPin implements LogSection {
|
|||
.append("Next Reminder Interval: ").append(SignalStore.pin().getCurrentInterval()).append("\n")
|
||||
.append("Reglock: ").append(SignalStore.svr().isRegistrationLockEnabled()).append("\n")
|
||||
.append("Signal PIN: ").append(SignalStore.svr().hasPin()).append("\n")
|
||||
.append("Restored via AEP: ").append(SignalStore.svr().getRestoredViaAccountEntropyPool()).append("\n")
|
||||
.append("Opted Out: ").append(SignalStore.svr().hasOptedOut()).append("\n")
|
||||
.append("Last Creation Failed: ").append(SignalStore.svr().lastPinCreateFailed()).append("\n")
|
||||
.append("Needs Account Restore: ").append(SignalStore.storageService().needsAccountRestore()).append("\n")
|
||||
|
|
|
@ -3,9 +3,10 @@ package org.thoughtcrime.securesms.mediasend.v2.capture
|
|||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
sealed class MediaCaptureEvent {
|
||||
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent()
|
||||
data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent()
|
||||
object DeviceLinkScannedFromQrCode : MediaCaptureEvent()
|
||||
object MediaCaptureRenderFailed : MediaCaptureEvent()
|
||||
sealed interface MediaCaptureEvent {
|
||||
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent
|
||||
data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent
|
||||
data object DeviceLinkScannedFromQrCode : MediaCaptureEvent
|
||||
data object MediaCaptureRenderFailed : MediaCaptureEvent
|
||||
data class ReregistrationScannedFromQrCode(val data: String) : MediaCaptureEvent
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
|
|||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.registrationv3.olddevice.TransferAccountActivity
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
@ -67,6 +68,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
|
|||
Log.w(TAG, "Failed to render captured media.")
|
||||
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
is MediaCaptureEvent.MediaCaptureRendered -> {
|
||||
if (isFirst()) {
|
||||
sharedViewModel.addCameraFirstCapture(event.media)
|
||||
|
@ -76,6 +78,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
|
|||
|
||||
navigator.goToReview(findNavController())
|
||||
}
|
||||
|
||||
is MediaCaptureEvent.UsernameScannedFromQrCode -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(getString(R.string.MediaCaptureFragment_username_dialog_title, event.username))
|
||||
|
@ -87,6 +90,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
|
|||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
is MediaCaptureEvent.DeviceLinkScannedFromQrCode -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.MediaCaptureFragment_device_link_dialog_title)
|
||||
|
@ -98,6 +102,11 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
|
|||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
is MediaCaptureEvent.ReregistrationScannedFromQrCode -> {
|
||||
startActivity(TransferAccountActivity.intent(requireContext(), event.data))
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,10 +11,11 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
|||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.QrScanResult
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import java.io.FileDescriptor
|
||||
import java.util.Optional
|
||||
|
@ -71,6 +72,15 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
|
|||
.subscribe { data ->
|
||||
internalEvents.onNext(MediaCaptureEvent.DeviceLinkScannedFromQrCode)
|
||||
}
|
||||
|
||||
if (SignalStore.account.isRegistered) {
|
||||
disposables += qrData
|
||||
.throttleFirst(5, TimeUnit.SECONDS)
|
||||
.filter { it.startsWith("sgnl://rereg") && QuickRegistrationRepository.isValidReRegistrationQr(it) }
|
||||
.subscribe { data ->
|
||||
internalEvents.onNext(MediaCaptureEvent.ReregistrationScannedFromQrCode(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
|
|
@ -45,7 +45,7 @@ class PinsForAllSchedule implements MegaphoneSchedule {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (SignalStore.svr().hasPin()) {
|
||||
if (SignalStore.svr().hasOptedInWithAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -62,6 +62,6 @@ class PinsForAllSchedule implements MegaphoneSchedule {
|
|||
|
||||
private static boolean pinCreationFailedDuringRegistration() {
|
||||
return SignalStore.registration().pinWasRequiredAtRegistration() &&
|
||||
!SignalStore.svr().hasPin();
|
||||
!SignalStore.svr().hasOptedInWithAccess();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ public final class PinOptOutMigration extends MigrationJob {
|
|||
|
||||
@Override
|
||||
void performMigration() {
|
||||
if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasPin()) {
|
||||
if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasOptedInWithAccess()) {
|
||||
Log.w(TAG, "Discovered a legacy opt-out user! Resetting the state.");
|
||||
|
||||
SignalStore.svr().optOut();
|
||||
|
|
|
@ -240,7 +240,7 @@ public class PinRestoreEntryFragment extends LoggingFragment {
|
|||
Activity activity = requireActivity();
|
||||
|
||||
if (RemoteConfig.messageBackups() && !SignalStore.registration().hasCompletedRestore()) {
|
||||
final Intent transferOrRestore = RestoreActivity.getIntentForTransferOrRestore(activity);
|
||||
final Intent transferOrRestore = RestoreActivity.getRestoreIntent(activity);
|
||||
transferOrRestore.putExtra(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, MainActivity.clearTop(requireContext()));
|
||||
startActivity(transferOrRestore);
|
||||
} else if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) {
|
||||
|
|
|
@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.pin
|
|||
import android.app.backup.BackupManager
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.annotation.WorkerThread
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
|
@ -163,10 +164,13 @@ object SvrRepository {
|
|||
Log.i(TAG, "[restoreMasterKeyPostRegistration] Successfully restored master key. $implementation", true)
|
||||
stopwatch.split("restore")
|
||||
|
||||
SignalStore.registration.localRegistrationMetadata?.let { metadata ->
|
||||
SignalStore.registration.localRegistrationMetadata = metadata.copy(masterKey = response.masterKey.serialize().toByteString(), pin = userPin)
|
||||
}
|
||||
|
||||
SignalStore.svr.setMasterKey(response.masterKey, userPin)
|
||||
SignalStore.svr.isRegistrationLockEnabled = false
|
||||
SignalStore.pin.resetPinReminders()
|
||||
SignalStore.svr.isPinForgottenOrSkipped = false
|
||||
SignalStore.pin.keyboardType = pinKeyboardType
|
||||
SignalStore.storageService.setNeedsAccountRestore(false)
|
||||
|
||||
|
@ -264,7 +268,6 @@ object SvrRepository {
|
|||
Log.i(TAG, "[setPin] Success!", true)
|
||||
|
||||
SignalStore.svr.setMasterKey(masterKey, userPin)
|
||||
SignalStore.svr.isPinForgottenOrSkipped = false
|
||||
responses
|
||||
.filterIsInstance<BackupResponse.Success>()
|
||||
.forEach {
|
||||
|
@ -321,6 +324,9 @@ object SvrRepository {
|
|||
SignalStore.pin.resetPinReminders()
|
||||
|
||||
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
|
||||
} else if (masterKey != null) {
|
||||
Log.i(TAG, "[onRegistrationComplete] ReRegistered with key without pin")
|
||||
SignalStore.svr.setMasterKey(masterKey, null)
|
||||
} else if (hasPinToRestore) {
|
||||
Log.i(TAG, "[onRegistrationComplete] Has a PIN to restore.", true)
|
||||
SignalStore.svr.clearRegistrationLockAndPin()
|
||||
|
@ -342,7 +348,6 @@ object SvrRepository {
|
|||
operationLock.withLock {
|
||||
SignalStore.svr.clearRegistrationLockAndPin()
|
||||
SignalStore.storageService.setNeedsAccountRestore(false)
|
||||
SignalStore.svr.isPinForgottenOrSkipped = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -364,7 +369,7 @@ object SvrRepository {
|
|||
@Throws(IOException::class)
|
||||
fun enableRegistrationLockForUserWithPin() {
|
||||
operationLock.withLock {
|
||||
check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" }
|
||||
check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" }
|
||||
|
||||
Log.i(TAG, "[enableRegistrationLockForUserWithPin] Enabling registration lock.", true)
|
||||
AppDependencies.signalServiceAccountManager.enableRegistrationLock(SignalStore.svr.masterKey)
|
||||
|
@ -378,7 +383,7 @@ object SvrRepository {
|
|||
@Throws(IOException::class)
|
||||
fun disableRegistrationLockForUserWithPin() {
|
||||
operationLock.withLock {
|
||||
check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" }
|
||||
check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" }
|
||||
|
||||
Log.i(TAG, "[disableRegistrationLockForUserWithPin] Disabling registration lock.", true)
|
||||
AppDependencies.signalServiceAccountManager.disableRegistrationLock()
|
||||
|
@ -408,7 +413,7 @@ object SvrRepository {
|
|||
false
|
||||
}
|
||||
|
||||
if (newToken && SignalStore.svr.hasPin()) {
|
||||
if (newToken && SignalStore.svr.hasOptedInWithAccess()) {
|
||||
BackupManager(AppDependencies.application).dataChanged()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
@ -469,7 +474,7 @@ object SvrRepository {
|
|||
private val hasNoRegistrationLock: Boolean
|
||||
get() {
|
||||
return !SignalStore.svr.isRegistrationLockEnabled &&
|
||||
!SignalStore.svr.hasPin() &&
|
||||
!SignalStore.svr.hasOptedInWithAccess() &&
|
||||
!SignalStore.svr.hasOptedOut()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.data
|
||||
|
||||
import org.whispersystems.signalservice.api.account.PreKeyCollection
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
|
||||
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
|
||||
)
|
|
@ -17,7 +17,7 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection
|
|||
* and combines them into a proto-backed class [LocalRegistrationMetadata] so they can be serialized & stored.
|
||||
*/
|
||||
object LocalRegistrationMetadataUtil {
|
||||
fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata {
|
||||
fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata {
|
||||
return LocalRegistrationMetadata.Builder().apply {
|
||||
aciIdentityKeyPair = localAciIdentityKeyPair.serialize().toByteString()
|
||||
aciSignedPreKey = remoteResult.aciPreKeyCollection.signedPreKey.serialize().toByteString()
|
||||
|
|
|
@ -622,15 +622,4 @@ object RegistrationRepository {
|
|||
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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
package org.thoughtcrime.securesms.registration.data.network
|
||||
|
||||
import org.thoughtcrime.securesms.pin.SvrWrongPinException
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
|
||||
|
@ -23,7 +23,7 @@ import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
|||
*/
|
||||
sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause) {
|
||||
companion object {
|
||||
fun from(networkResult: NetworkResult<RegistrationRepository.AccountRegistrationResult>): RegisterAccountResult {
|
||||
fun from(networkResult: NetworkResult<AccountRegistrationResult>): RegisterAccountResult {
|
||||
return when (networkResult) {
|
||||
is NetworkResult.Success -> Success(networkResult.result)
|
||||
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
|
||||
|
@ -55,7 +55,7 @@ sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause
|
|||
}
|
||||
}
|
||||
}
|
||||
class Success(val accountRegistrationResult: RegistrationRepository.AccountRegistrationResult) : RegisterAccountResult(null)
|
||||
class Success(val accountRegistrationResult: AccountRegistrationResult) : RegisterAccountResult(null)
|
||||
class IncorrectRecoveryPassword(cause: Throwable) : RegisterAccountResult(cause)
|
||||
class AuthorizationFailed(cause: Throwable) : RegisterAccountResult(cause)
|
||||
class MalformedRequest(cause: Throwable) : RegisterAccountResult(cause)
|
||||
|
|
|
@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
|
|||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
|
@ -119,7 +119,7 @@ class RegistrationActivity : BaseActivity() {
|
|||
|
||||
@JvmStatic
|
||||
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
|
||||
return Intent(context, RegistrationActivity::class.java).apply {
|
||||
return Intent(context, getRegistrationClass()).apply {
|
||||
putExtra(RE_REGISTRATION_EXTRA, false)
|
||||
setData(originalIntent.data)
|
||||
}
|
||||
|
@ -127,9 +127,13 @@ class RegistrationActivity : BaseActivity() {
|
|||
|
||||
@JvmStatic
|
||||
fun newIntentForReRegistration(context: Context): Intent {
|
||||
return Intent(context, RegistrationActivity::class.java).apply {
|
||||
return Intent(context, getRegistrationClass()).apply {
|
||||
putExtra(RE_REGISTRATION_EXTRA, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRegistrationClass(): Class<*> {
|
||||
return if (RemoteConfig.restoreAfterRegistration) org.thoughtcrime.securesms.registrationv3.ui.RegistrationActivity::class.java else RegistrationActivity::class.java
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
|||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.pin.SvrRepository
|
||||
import org.thoughtcrime.securesms.pin.SvrWrongPinException
|
||||
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
|
@ -827,7 +828,7 @@ class RegistrationViewModel : ViewModel() {
|
|||
handleRegistrationResult(context, registrationData, registrationResponse, false)
|
||||
}
|
||||
|
||||
private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean) {
|
||||
private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) {
|
||||
Log.v(TAG, "onSuccessfulRegistration()")
|
||||
val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled)
|
||||
RegistrationRepository.registerAccountLocally(context, metadata)
|
||||
|
|
|
@ -104,7 +104,7 @@ class GrantPermissionsFragment : ComposeFragment() {
|
|||
when (welcomeAction) {
|
||||
WelcomeAction.CONTINUE -> findNavController().safeNavigate(GrantPermissionsFragmentDirections.actionEnterPhoneNumber())
|
||||
WelcomeAction.RESTORE_BACKUP -> {
|
||||
val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity())
|
||||
val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity())
|
||||
launchRestoreActivity.launch(restoreIntent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,380 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.restore
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.RemoteRestoreViewModel
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.restore.transferorrestore.TransferOrRestoreMoreOptionsDialog
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import java.util.Locale
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
class RemoteRestoreActivity : BaseActivity() {
|
||||
companion object {
|
||||
fun getIntent(context: Context): Intent {
|
||||
return Intent(context, RemoteRestoreActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel: RemoteRestoreViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val state by viewModel.state
|
||||
SignalTheme {
|
||||
Surface {
|
||||
RestoreFromBackupContent(
|
||||
features = getFeatureList(state.backupTier),
|
||||
onRestoreBackupClick = {
|
||||
viewModel.restore()
|
||||
},
|
||||
onCancelClick = {
|
||||
finish()
|
||||
},
|
||||
onMoreOptionsClick = {
|
||||
TransferOrRestoreMoreOptionsDialog.show(fragmentManager = supportFragmentManager, skipOnly = false)
|
||||
},
|
||||
state.backupTier,
|
||||
state.backupTime,
|
||||
state.backupTier != MessageBackupTier.PAID
|
||||
)
|
||||
if (state.importState == RemoteRestoreViewModel.ImportState.RESTORED) {
|
||||
SideEffect {
|
||||
SignalStore.registration.markRestoreCompleted()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
AppDependencies.jobManager.add(ProfileUploadJob())
|
||||
startActivity(MainActivity.clearTop(this))
|
||||
}
|
||||
} else if (state.importState == RemoteRestoreViewModel.ImportState.IN_PROGRESS) {
|
||||
ProgressDialog(state.restoreProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(restoreEvent: RestoreV2Event) {
|
||||
viewModel.updateRestoreProgress(restoreEvent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getFeatureList(tier: MessageBackupTier?): ImmutableList<MessageBackupsTypeFeature> {
|
||||
return when (tier) {
|
||||
null -> persistentListOf()
|
||||
MessageBackupTier.PAID -> {
|
||||
persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageBackupTier.FREE -> {
|
||||
persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog that *just* shows a spinner. Useful for short actions where you need to
|
||||
* let the user know that some action is completing.
|
||||
*/
|
||||
@Composable
|
||||
fun ProgressDialog(restoreProgress: RestoreV2Event?) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {},
|
||||
dismissButton = {},
|
||||
text = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.wrapContentSize()
|
||||
) {
|
||||
if (restoreProgress == null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(top = 55.dp, bottom = 16.dp)
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
progress = restoreProgress.getProgress(),
|
||||
modifier = Modifier
|
||||
.padding(top = 55.dp, bottom = 16.dp)
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val progressText = when (restoreProgress?.type) {
|
||||
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
|
||||
RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
|
||||
else -> stringResource(id = R.string.RemoteRestoreActivity__restoring)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = progressText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
if (restoreProgress != null) {
|
||||
val progressBytes = Util.getPrettyFileSize(restoreProgress.count)
|
||||
val totalBytes = Util.getPrettyFileSize(restoreProgress.estimatedTotalCount)
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(212.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ProgressDialogPreview() {
|
||||
Previews.Preview {
|
||||
ProgressDialog(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, 10, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RestoreFromBackupContentPreview() {
|
||||
Previews.Preview {
|
||||
RestoreFromBackupContent(
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Your last 30 days of media"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
|
||||
label = "All of your text messages"
|
||||
)
|
||||
),
|
||||
onRestoreBackupClick = {},
|
||||
onCancelClick = {},
|
||||
onMoreOptionsClick = {},
|
||||
MessageBackupTier.PAID,
|
||||
System.currentTimeMillis(),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreFromBackupContent(
|
||||
features: ImmutableList<MessageBackupsTypeFeature>,
|
||||
onRestoreBackupClick: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onMoreOptionsClick: () -> Unit,
|
||||
tier: MessageBackupTier?,
|
||||
lastBackupTime: Long,
|
||||
cancelable: Boolean
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
.padding(top = 40.dp, bottom = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
val yourLastBackupText = buildAnnotatedString {
|
||||
append(
|
||||
stringResource(
|
||||
id = R.string.RemoteRestoreActivity__backup_created_at,
|
||||
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), lastBackupTime),
|
||||
DateUtils.getOnlyTimeString(LocalContext.current, lastBackupTime)
|
||||
)
|
||||
|
||||
)
|
||||
append(" ")
|
||||
if (tier != MessageBackupTier.PAID) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = yourLastBackupText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 28.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(top = 20.dp, bottom = 18.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
features.forEach {
|
||||
MessageBackupsTypeFeatureRow(
|
||||
messageBackupsTypeFeature = it,
|
||||
iconTint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onRestoreBackupClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup)
|
||||
)
|
||||
}
|
||||
|
||||
if (cancelable) {
|
||||
TextButton(
|
||||
onClick = onCancelClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = android.R.string.cancel)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = onMoreOptionsClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.TransferOrRestoreFragment__more_options)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreFromServer() {
|
||||
viewModel.restore()
|
||||
}
|
||||
|
||||
private fun continueRegistration() {
|
||||
if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) {
|
||||
val main = MainActivity.clearTop(this)
|
||||
val profile = CreateProfileActivity.getIntentForUserProfile(this)
|
||||
profile.putExtra("next_intent", main)
|
||||
startActivity(profile)
|
||||
} else {
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
AppDependencies.jobManager.add(ProfileUploadJob())
|
||||
startActivity(MainActivity.clearTop(this))
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StateLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
|
@ -83,7 +83,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome)
|
|||
} else {
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
|
||||
|
||||
val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity())
|
||||
val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity())
|
||||
launchRestoreActivity.launch(restoreIntent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.jobs.StorageSyncJob;
|
|||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
|
||||
public final class RegistrationUtil {
|
||||
|
||||
|
@ -29,7 +30,8 @@ public final class RegistrationUtil {
|
|||
if (!SignalStore.registration().isRegistrationComplete() &&
|
||||
SignalStore.account().isRegistered() &&
|
||||
!Recipient.self().getProfileName().isEmpty() &&
|
||||
(SignalStore.svr().hasPin() || SignalStore.svr().hasOptedOut()))
|
||||
(SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) &&
|
||||
(RemoteConfig.restoreAfterRegistration() && (SignalStore.registration().hasSkippedTransferOrRestore() || SignalStore.registration().hasCompletedRestore())))
|
||||
{
|
||||
Log.i(TAG, "Marking registration completed.", new Throwable());
|
||||
SignalStore.registration().setRegistrationComplete();
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.data
|
||||
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.Base64.decode
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.registration.proto.RegistrationProvisionMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Helpers for quickly re-registering on a new device with the old device.
|
||||
*/
|
||||
object QuickRegistrationRepository {
|
||||
private val TAG = Log.tag(QuickRegistrationRepository::class)
|
||||
|
||||
private const val REREG_URI_HOST = "rereg"
|
||||
|
||||
fun isValidReRegistrationQr(data: String): Boolean {
|
||||
val uri = Uri.parse(data)
|
||||
|
||||
if (!uri.isHierarchical) {
|
||||
return false
|
||||
}
|
||||
|
||||
val ephemeralId: String? = uri.getQueryParameter("uuid")
|
||||
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
|
||||
return uri.host == REREG_URI_HOST && ephemeralId.isNotNullOrBlank() && publicKeyEncoded.isNotNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send registration provisioning message to new device.
|
||||
*/
|
||||
fun transferAccount(reRegisterUri: String): TransferAccountResult {
|
||||
if (!isValidReRegistrationQr(reRegisterUri)) {
|
||||
Log.w(TAG, "Invalid quick re-register qr data")
|
||||
return TransferAccountResult.FAILED
|
||||
}
|
||||
|
||||
val uri = Uri.parse(reRegisterUri)
|
||||
|
||||
try {
|
||||
val ephemeralId: String? = uri.getQueryParameter("uuid")
|
||||
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
|
||||
val publicKey = Curve.decodePoint(publicKeyEncoded?.let { decode(it) }, 0)
|
||||
|
||||
if (ephemeralId == null || publicKeyEncoded == null) {
|
||||
Log.w(TAG, "Invalid link data hasId: ${ephemeralId != null} hasKey: ${publicKeyEncoded != null}")
|
||||
return TransferAccountResult.FAILED
|
||||
}
|
||||
|
||||
val pin = SignalStore.svr.pin ?: run {
|
||||
Log.w(TAG, "No pin")
|
||||
return TransferAccountResult.FAILED
|
||||
}
|
||||
|
||||
AppDependencies
|
||||
.signalServiceAccountManager
|
||||
.registrationApi
|
||||
.sendReRegisterDeviceProvisioningMessage(
|
||||
ephemeralId,
|
||||
publicKey,
|
||||
RegistrationProvisionMessage(
|
||||
e164 = SignalStore.account.requireE164(),
|
||||
aci = SignalStore.account.requireAci().toByteString(),
|
||||
accountEntropyPool = Hex.toStringCondensed(SignalStore.svr.masterKey.serialize()),
|
||||
pin = pin,
|
||||
platform = RegistrationProvisionMessage.Platform.ANDROID,
|
||||
backupTimestampMs = SignalStore.backup.lastBackupTime.coerceAtLeast(0L),
|
||||
tier = when (SignalStore.backup.backupTier) {
|
||||
MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID
|
||||
MessageBackupTier.FREE,
|
||||
null -> RegistrationProvisionMessage.Tier.FREE
|
||||
}
|
||||
)
|
||||
)
|
||||
.successOrThrow()
|
||||
|
||||
Log.i(TAG, "Re-registration provisioning message sent")
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Exception re-registering new device", e)
|
||||
return TransferAccountResult.FAILED
|
||||
} catch (e: InvalidKeyException) {
|
||||
Log.w(TAG, "Exception re-registering new device", e)
|
||||
return TransferAccountResult.FAILED
|
||||
}
|
||||
|
||||
return TransferAccountResult.SUCCESS
|
||||
}
|
||||
|
||||
enum class TransferAccountResult {
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
}
|
|
@ -0,0 +1,623 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.data
|
||||
|
||||
import android.app.backup.BackupManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.google.android.gms.auth.api.phone.SmsRetriever
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
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.database.model.databaseprotos.LocalRegistrationMetadata
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
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.Svr3Migration
|
||||
import org.thoughtcrime.securesms.pin.SvrRepository
|
||||
import org.thoughtcrime.securesms.pin.SvrWrongPinException
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.push.AccountManagerFactory
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciIdentityKeyPair
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciPreKeyCollection
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniIdentityKeyPair
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniPreKeyCollection
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
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.SvrNoDataException
|
||||
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.kbs.PinHashUtil
|
||||
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.api.svr.Svr3Credentials
|
||||
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 org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Locale
|
||||
import java.util.Optional
|
||||
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, 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.
|
||||
*/
|
||||
@JvmStatic
|
||||
suspend fun registerAccountLocally(context: Context, data: LocalRegistrationMetadata) =
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.v(TAG, "registerAccountLocally()")
|
||||
val aciIdentityKeyPair = data.getAciIdentityKeyPair()
|
||||
val pniIdentityKeyPair = data.getPniIdentityKeyPair()
|
||||
SignalStore.account.restoreAciIdentityKeyFromBackup(aciIdentityKeyPair.publicKey.serialize(), aciIdentityKeyPair.privateKey.serialize())
|
||||
SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize())
|
||||
|
||||
val aciPreKeyCollection = data.getAciPreKeyCollection()
|
||||
val pniPreKeyCollection = data.getPniPreKeyCollection()
|
||||
val aci: ACI = ACI.parseOrThrow(data.aci)
|
||||
val pni: PNI = PNI.parseOrThrow(data.pni)
|
||||
val hasPin: Boolean = data.hasPin
|
||||
|
||||
SignalStore.account.setAci(aci)
|
||||
SignalStore.account.setPni(pni)
|
||||
|
||||
AppDependencies.resetProtocolStores()
|
||||
|
||||
AppDependencies.protocolStore.aci().sessions().archiveAllSessions()
|
||||
AppDependencies.protocolStore.pni().sessions().archiveAllSessions()
|
||||
SenderKeyUtil.clearAllState()
|
||||
|
||||
val aciProtocolStore = AppDependencies.protocolStore.aci()
|
||||
val aciMetadataStore = SignalStore.account.aciPreKeys
|
||||
|
||||
val pniProtocolStore = AppDependencies.protocolStore.pni()
|
||||
val pniMetadataStore = SignalStore.account.pniPreKeys
|
||||
|
||||
storeSignedAndLastResortPreKeys(aciProtocolStore, aciMetadataStore, aciPreKeyCollection)
|
||||
storeSignedAndLastResortPreKeys(pniProtocolStore, pniMetadataStore, pniPreKeyCollection)
|
||||
|
||||
val recipientTable = SignalDatabase.recipients
|
||||
val selfId = Recipient.trustedPush(aci, pni, data.e164).id
|
||||
|
||||
recipientTable.setProfileSharing(selfId, true)
|
||||
recipientTable.markRegisteredOrThrow(selfId, aci)
|
||||
recipientTable.linkIdsForSelf(aci, pni, data.e164)
|
||||
recipientTable.setProfileKey(selfId, ProfileKey(data.profileKey.toByteArray()))
|
||||
|
||||
AppDependencies.recipientCache.clearSelf()
|
||||
|
||||
SignalStore.account.setE164(data.e164)
|
||||
SignalStore.account.fcmToken = data.fcmToken
|
||||
SignalStore.account.fcmEnabled = data.fcmEnabled
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
saveOwnIdentityKey(selfId, aci, aciProtocolStore, now)
|
||||
saveOwnIdentityKey(selfId, pni, pniProtocolStore, now)
|
||||
|
||||
SignalStore.account.setServicePassword(data.servicePassword)
|
||||
SignalStore.account.setRegistered(true)
|
||||
TextSecurePreferences.setPromptedPushRegistration(context, true)
|
||||
TextSecurePreferences.setUnauthorizedReceived(context, false)
|
||||
NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
|
||||
|
||||
val masterKey = if (data.masterKey != null) MasterKey(data.masterKey.toByteArray()) else null
|
||||
SvrRepository.onRegistrationComplete(masterKey, data.pin, hasPin, data.reglockEnabled)
|
||||
|
||||
AppDependencies.resetNetwork()
|
||||
AppDependencies.incomingMessageObserver
|
||||
PreKeysSyncJob.enqueue()
|
||||
|
||||
val jobManager = AppDependencies.jobManager
|
||||
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()
|
||||
}
|
||||
|
||||
fun canUseLocalRecoveryPassword(): Boolean {
|
||||
val recoveryPassword = SignalStore.svr.recoveryPassword
|
||||
val pinHash = SignalStore.svr.localPinHash
|
||||
return recoveryPassword != null && pinHash != null
|
||||
}
|
||||
|
||||
fun doesPinMatchLocalHash(pin: String): Boolean {
|
||||
val pinHash = SignalStore.svr.localPinHash ?: throw IllegalStateException("Local PIN hash is not present!")
|
||||
return PinHashUtil.verifyLocalPinHash(pinHash, pin)
|
||||
}
|
||||
|
||||
suspend fun fetchMasterKeyFromSvrRemote(pin: String, svr2Credentials: AuthCredentials?, svr3Credentials: Svr3Credentials?): MasterKey =
|
||||
withContext(Dispatchers.IO) {
|
||||
val credentialSet = SvrAuthCredentialSet(svr2Credentials = svr2Credentials, svr3Credentials = svr3Credentials)
|
||||
val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin)
|
||||
return@withContext masterKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a session ID.
|
||||
*/
|
||||
private 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a new registration session on the service.
|
||||
*/
|
||||
suspend fun createSession(context: Context, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionCreationResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.d(TAG, "About to create a registration session…")
|
||||
val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
|
||||
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.registration.sessionId = result.getMetadata().body.id
|
||||
SignalStore.registration.sessionE164 = e164
|
||||
}
|
||||
|
||||
return@withContext result
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an existing session, if its ID is provided. If the session is expired/invalid, or none is provided, it will attempt to initiate a new session.
|
||||
*/
|
||||
suspend fun createOrValidateSession(context: Context, sessionId: String?, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionResult {
|
||||
val savedSessionId = if (sessionId == null && e164 == SignalStore.registration.sessionE164) {
|
||||
SignalStore.registration.sessionId
|
||||
} else {
|
||||
sessionId
|
||||
}
|
||||
|
||||
if (savedSessionId != null) {
|
||||
Log.d(TAG, "Validating existing registration session.")
|
||||
val sessionValidationResult = validateSession(context, savedSessionId, e164, password)
|
||||
when (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
|
||||
}
|
||||
|
||||
is RegistrationSessionCheckResult.SessionNotFound -> {
|
||||
Log.i(TAG, "Current session is invalid or has expired. Must create new one.")
|
||||
// fall through to creation
|
||||
}
|
||||
}
|
||||
}
|
||||
return createSession(context, e164, password, mcc, mnc)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: E164VerificationMode): VerificationCodeRequestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
|
||||
|
||||
val codeRequestResult = api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported, mode.transport)
|
||||
|
||||
return@withContext VerificationCodeRequestResult.from(codeRequestResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the user-entered verification code to the service.
|
||||
*/
|
||||
suspend fun submitVerificationCode(context: Context, sessionId: String, registrationData: RegistrationData): VerificationCodeRequestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
|
||||
val result = api.verifyAccount(sessionId = sessionId, verificationCode = registrationData.code)
|
||||
return@withContext VerificationCodeRequestResult.from(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the solved captcha token to the service.
|
||||
*/
|
||||
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)
|
||||
return@withContext VerificationCodeRequestResult.from(captchaSubmissionResult)
|
||||
}
|
||||
|
||||
suspend fun requestAndVerifyPushToken(context: Context, sessionId: String, e164: String, password: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val fcmToken = getFcmToken(context)
|
||||
val accountManager = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
|
||||
val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, sessionId, Optional.ofNullable(fcmToken), PUSH_REQUEST_TIMEOUT).orElse(null)
|
||||
val pushSubmissionResult = accountManager.registrationApi.submitPushChallengeToken(sessionId = sessionId, pushChallengeToken = pushChallenge)
|
||||
return@withContext VerificationCodeRequestResult.from(pushSubmissionResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the necessary assets as a verified account so that the user can actually use the service.
|
||||
*/
|
||||
suspend fun registerAccount(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String? = null, masterKeyProducer: MasterKeyProducer? = null): RegisterAccountResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.v(TAG, "registerAccount()")
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
|
||||
|
||||
val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
|
||||
val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
|
||||
|
||||
val masterKey: MasterKey?
|
||||
try {
|
||||
masterKey = masterKeyProducer?.produceMasterKey()
|
||||
} catch (e: SvrNoDataException) {
|
||||
return@withContext RegisterAccountResult.SvrNoData(e)
|
||||
} catch (e: SvrWrongPinException) {
|
||||
return@withContext RegisterAccountResult.SvrWrongPin(e)
|
||||
} catch (e: IOException) {
|
||||
return@withContext RegisterAccountResult.UnknownError(e)
|
||||
}
|
||||
|
||||
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 = generateSignedAndLastResortPreKeys(aciIdentity, SignalStore.account.aciPreKeys)
|
||||
val pniPreKeyCollection = generateSignedAndLastResortPreKeys(pniIdentity, SignalStore.account.pniPreKeys)
|
||||
|
||||
val result: NetworkResult<AccountRegistrationResult> = api.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, aciPreKeyCollection, pniPreKeyCollection, registrationData.fcmToken, true)
|
||||
.map { accountRegistrationResponse: VerifyAccountResponse ->
|
||||
AccountRegistrationResult(
|
||||
uuid = accountRegistrationResponse.uuid,
|
||||
pni = accountRegistrationResponse.pni,
|
||||
storageCapable = accountRegistrationResponse.storageCapable,
|
||||
number = accountRegistrationResponse.number,
|
||||
masterKey = masterKey,
|
||||
pin = pin,
|
||||
aciPreKeyCollection = aciPreKeyCollection,
|
||||
pniPreKeyCollection = pniPreKeyCollection
|
||||
)
|
||||
}
|
||||
|
||||
return@withContext RegisterAccountResult.from(result)
|
||||
}
|
||||
|
||||
private suspend fun createSessionAndBlockForPushChallenge(accountManager: RegistrationApi, fcmToken: String, mcc: String?, mnc: String?): NetworkResult<RegistrationSessionMetadataResponse> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// TODO [regv2]: do not use event bus nor latch
|
||||
val subscriber = PushTokenChallengeSubscriber()
|
||||
val eventBus = EventBus.getDefault()
|
||||
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
|
||||
}
|
||||
|
||||
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.result.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. Continuing with session created without one.")
|
||||
return@withContext sessionCreationResponse
|
||||
} catch (ex: Exception) {
|
||||
Log.w(TAG, "Exception caught, but the earlier try block should have caught it?", ex)
|
||||
return@withContext NetworkResult.ApplicationError<RegistrationSessionMetadataResponse>(ex)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun deriveTimestamp(headers: RegistrationSessionMetadataHeaders, deltaSeconds: Int?): Long {
|
||||
if (deltaSeconds == null) {
|
||||
return 0L
|
||||
}
|
||||
|
||||
val timestamp: Long = headers.timestamp
|
||||
return timestamp + deltaSeconds.seconds.inWholeMilliseconds
|
||||
}
|
||||
|
||||
suspend fun hasValidSvrAuthCredentials(context: Context, e164: String, password: String): BackupAuthCheckResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
|
||||
|
||||
val svr3Result = SignalStore.svr.svr3AuthTokens
|
||||
?.takeIf { Svr3Migration.shouldReadFromSvr3 }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.toSvrCredentials()
|
||||
?.let { authTokens ->
|
||||
api
|
||||
.validateSvr3AuthCredential(e164, authTokens)
|
||||
.runIfSuccessful {
|
||||
val removedInvalidTokens = SignalStore.svr.removeSvr3AuthTokens(it.invalid)
|
||||
if (removedInvalidTokens) {
|
||||
BackupManager(context).dataChanged()
|
||||
}
|
||||
}
|
||||
.let { BackupAuthCheckResult.fromV3(it) }
|
||||
}
|
||||
|
||||
if (svr3Result is BackupAuthCheckResult.SuccessWithCredentials) {
|
||||
Log.d(TAG, "Found valid SVR3 credentials.")
|
||||
return@withContext svr3Result
|
||||
}
|
||||
|
||||
Log.d(TAG, "No valid SVR3 credentials, looking for SVR2.")
|
||||
|
||||
return@withContext SignalStore.svr.svr2AuthTokens
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.toSvrCredentials()
|
||||
?.let { authTokens ->
|
||||
api
|
||||
.validateSvr2AuthCredential(e164, authTokens)
|
||||
.runIfSuccessful {
|
||||
val removedInvalidTokens = SignalStore.svr.removeSvr2AuthTokens(it.invalid)
|
||||
if (removedInvalidTokens) {
|
||||
BackupManager(context).dataChanged()
|
||||
}
|
||||
}
|
||||
.let { BackupAuthCheckResult.fromV2(it) }
|
||||
} ?: BackupAuthCheckResult.SuccessWithoutCredentials()
|
||||
}
|
||||
|
||||
/** Converts the basic-auth creds we have locally into username:password pairs that are suitable for handing off to the service. */
|
||||
private fun List<String?>.toSvrCredentials(): List<String> {
|
||||
return this
|
||||
.asSequence()
|
||||
.filterNotNull()
|
||||
.take(10)
|
||||
.map { it.replace("Basic ", "").trim() }
|
||||
.mapNotNull {
|
||||
try {
|
||||
Base64.decode(it)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Encountered error trying to decode a token!", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
.map { String(it, StandardCharsets.ISO_8859_1) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun generateSignedAndLastResortPreKeys(identity: IdentityKeyPair, metadataStore: PreKeyMetadataStore): PreKeyCollection {
|
||||
val signedPreKey = PreKeyUtil.generateSignedPreKey(metadataStore.nextSignedPreKeyId, identity.privateKey)
|
||||
val lastResortKyberPreKey = PreKeyUtil.generateLastResortKyberPreKey(metadataStore.nextKyberPreKeyId, identity.privateKey)
|
||||
|
||||
return PreKeyCollection(
|
||||
identity.publicKey,
|
||||
signedPreKey,
|
||||
lastResortKyberPreKey
|
||||
)
|
||||
}
|
||||
|
||||
fun isMissingProfileData(): Boolean {
|
||||
return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
|
||||
}
|
||||
|
||||
fun interface MasterKeyProducer {
|
||||
@Throws(IOException::class, SvrWrongPinException::class, SvrNoDataException::class)
|
||||
fun produceMasterKey(): MasterKey
|
||||
}
|
||||
|
||||
enum class E164VerificationMode(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 {
|
||||
var challenge: String? = null
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
@Subscribe
|
||||
fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) {
|
||||
Log.d(TAG, "Push challenge received!")
|
||||
challenge = pushChallengeEvent.challenge
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,346 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.olddevice
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.horizontalGutters
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
|
||||
import org.thoughtcrime.securesms.BiometricDeviceLockContract
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Launched after scanning QR code from new device to start the transfer/reregistration process from
|
||||
* old phone to new phone.
|
||||
*/
|
||||
class TransferAccountActivity : PassphraseRequiredActivity() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TransferAccountActivity::class)
|
||||
|
||||
private const val KEY_URI = "URI"
|
||||
|
||||
// TODO [backups] Put actual learn more url
|
||||
const val LEARN_MORE_URL = "https://signal.org#"
|
||||
|
||||
fun intent(context: Context, uri: String): Intent {
|
||||
return Intent(context, TransferAccountActivity::class.java).apply {
|
||||
putExtra(KEY_URI, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val theme: DynamicTheme = DynamicNoActionBarTheme()
|
||||
|
||||
private val viewModel: TransferAccountViewModel by viewModel {
|
||||
TransferAccountViewModel(intent.getStringExtra(KEY_URI)!!)
|
||||
}
|
||||
|
||||
private lateinit var biometricAuth: BiometricDeviceAuthentication
|
||||
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
theme.onCreate(this)
|
||||
|
||||
if (!SignalStore.account.isRegistered) {
|
||||
finish()
|
||||
}
|
||||
|
||||
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
|
||||
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
|
||||
Log.i(TAG, "Device authentication succeeded via contract")
|
||||
transferAccount()
|
||||
}
|
||||
}
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
|
||||
.setTitle(getString(R.string.TransferAccount_unlock_to_transfer))
|
||||
.setConfirmationRequired(true)
|
||||
.build()
|
||||
|
||||
biometricAuth = BiometricDeviceAuthentication(
|
||||
BiometricManager.from(this),
|
||||
BiometricPrompt(this, BiometricAuthenticationListener()),
|
||||
promptInfo
|
||||
)
|
||||
|
||||
setContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
SignalTheme {
|
||||
TransferToNewDevice(
|
||||
state = state,
|
||||
onTransferAccount = this::authenticate,
|
||||
clearReRegisterResult = viewModel::clearReRegisterResult,
|
||||
onBackClicked = { finish() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
biometricAuth.cancelAuthentication()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
theme.onResume(this)
|
||||
}
|
||||
|
||||
private fun authenticate() {
|
||||
val canAuthenticate = biometricAuth.authenticate(this, true) {
|
||||
biometricDeviceLockLauncher.launch(getString(R.string.TransferAccount_unlock_to_transfer))
|
||||
}
|
||||
|
||||
if (!canAuthenticate) {
|
||||
Log.w(TAG, "Device authentication not available")
|
||||
transferAccount()
|
||||
}
|
||||
}
|
||||
|
||||
private fun transferAccount() {
|
||||
Log.d(TAG, "transferAccount()")
|
||||
|
||||
viewModel.transferAccount()
|
||||
}
|
||||
|
||||
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
|
||||
Log.w(TAG, "Device authentication error: $errorCode")
|
||||
onAuthenticationFailed()
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
Log.i(TAG, "Device authentication succeeded")
|
||||
transferAccount()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
Log.w(TAG, "Device authentication failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TransferToNewDevice(
|
||||
state: TransferAccountViewModel.TransferAccountState,
|
||||
onTransferAccount: () -> Unit = {},
|
||||
clearReRegisterResult: () -> Unit = {},
|
||||
onBackClicked: () -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBarContent(onBackClicked = onBackClicked) }
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_transfer_phones),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 28.dp)
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
val learnMore = stringResource(id = R.string.TransferAccount_learn_more)
|
||||
val fullString = stringResource(id = R.string.TransferAccount_body, learnMore)
|
||||
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, TransferAccountActivity.LEARN_MORE_URL)
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = spanned,
|
||||
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state.inProgress,
|
||||
contentAlignment = Alignment.Center
|
||||
) { inProgress ->
|
||||
if (inProgress) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onTransferAccount,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.TransferAccount_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.LOCK)
|
||||
append(" ")
|
||||
append(stringResource(id = R.string.TransferAccount_messages_e2e))
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
|
||||
)
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
when (state.reRegisterResult) {
|
||||
QuickRegistrationRepository.TransferAccountResult.SUCCESS -> {
|
||||
ModalBottomSheet(
|
||||
dragHandle = null,
|
||||
onDismissRequest = clearReRegisterResult,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
ContinueOnOtherDevice()
|
||||
}
|
||||
}
|
||||
|
||||
QuickRegistrationRepository.TransferAccountResult.FAILED -> {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = Dialogs.NoTitle,
|
||||
body = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service),
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
onConfirm = clearReRegisterResult,
|
||||
onDismiss = clearReRegisterResult
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun TransferToNewDevicePreview() {
|
||||
Previews.Preview {
|
||||
TransferToNewDevice(state = TransferAccountViewModel.TransferAccountState("sgnl://rereg"))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopAppBarContent(onBackClicked: () -> Unit) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(text = stringResource(R.string.TransferAccount_title))
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_x_24),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown after successfully sending provisioning message to new device.
|
||||
*/
|
||||
@Composable
|
||||
fun ContinueOnOtherDevice() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(bottom = 54.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.height(26.dp))
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_other_device),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device_details),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
CircularProgressIndicator(modifier = Modifier.size(44.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ContinueOnOtherDevicePreview() {
|
||||
Previews.Preview {
|
||||
ContinueOnOtherDevice()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.olddevice
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
|
||||
|
||||
class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
|
||||
|
||||
private val store: MutableStateFlow<TransferAccountState> = MutableStateFlow(TransferAccountState(reRegisterUri))
|
||||
|
||||
val state: StateFlow<TransferAccountState> = store
|
||||
|
||||
fun transferAccount() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
store.update { it.copy(inProgress = true) }
|
||||
val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri)
|
||||
store.update { it.copy(reRegisterResult = result, inProgress = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearReRegisterResult() {
|
||||
store.update { it.copy(reRegisterResult = null) }
|
||||
}
|
||||
|
||||
data class TransferAccountState(
|
||||
val reRegisterUri: String,
|
||||
val inProgress: Boolean = false,
|
||||
val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null
|
||||
)
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.pin.PinRestoreActivity
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
/**
|
||||
* Activity to hold the entire registration process.
|
||||
*/
|
||||
class RegistrationActivity : BaseActivity() {
|
||||
|
||||
private val TAG = Log.tag(RegistrationActivity::class.java)
|
||||
|
||||
private val dynamicTheme = DynamicNoActionBarTheme()
|
||||
val sharedViewModel: RegistrationViewModel by viewModels()
|
||||
|
||||
private var smsRetrieverReceiver: SmsRetrieverReceiver? = null
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(SmsRetrieverObserver())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
dynamicTheme.onCreate(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_registration_navigation_v3)
|
||||
|
||||
sharedViewModel.isReregister = intent.getBooleanExtra(RE_REGISTRATION_EXTRA, false)
|
||||
|
||||
sharedViewModel.checkpoint.observe(this) {
|
||||
if (it >= RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE) {
|
||||
handleSuccessfulVerify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dynamicTheme.onResume(this)
|
||||
}
|
||||
|
||||
private fun handleSuccessfulVerify() {
|
||||
if (SignalStore.misc.hasLinkedDevices) {
|
||||
SignalStore.misc.shouldShowLinkedDevicesReminder = sharedViewModel.isReregister
|
||||
}
|
||||
|
||||
if (SignalStore.storageService.needsAccountRestore()) {
|
||||
Log.i(TAG, "Performing pin restore.")
|
||||
startActivity(Intent(this, PinRestoreActivity::class.java))
|
||||
finish()
|
||||
} else {
|
||||
val isProfileNameEmpty = Recipient.self().profileName.isEmpty
|
||||
val isAvatarEmpty = !AvatarHelper.hasAvatar(this, Recipient.self().id)
|
||||
val needsProfile = isProfileNameEmpty || isAvatarEmpty
|
||||
val needsPin = !SignalStore.svr.hasOptedInWithAccess()
|
||||
|
||||
Log.i(TAG, "Pin restore flow not required. Profile name empty: $isProfileNameEmpty | Profile avatar empty: $isAvatarEmpty | Needs PIN: $needsPin")
|
||||
|
||||
if (!needsProfile && !needsPin) {
|
||||
sharedViewModel.completeRegistration()
|
||||
}
|
||||
sharedViewModel.setInProgress(false)
|
||||
|
||||
val startIntent = MainActivity.clearTop(this)
|
||||
|
||||
val nextIntent: Intent? = when {
|
||||
needsPin -> CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity)
|
||||
!SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
|
||||
needsProfile -> CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity)
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (nextIntent != null) {
|
||||
startIntent.putExtra("next_intent", nextIntent)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Launching ${startIntent.component} with next_intent: ${nextIntent?.component}")
|
||||
startActivity(startIntent)
|
||||
finish()
|
||||
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SmsRetrieverObserver : DefaultLifecycleObserver {
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
smsRetrieverReceiver = SmsRetrieverReceiver(application)
|
||||
smsRetrieverReceiver?.registerReceiver()
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
smsRetrieverReceiver?.unregisterReceiver()
|
||||
smsRetrieverReceiver = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RE_REGISTRATION_EXTRA: String = "re_registration"
|
||||
|
||||
@JvmStatic
|
||||
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
|
||||
return Intent(context, RegistrationActivity::class.java).apply {
|
||||
putExtra(RE_REGISTRATION_EXTRA, false)
|
||||
setData(originalIntent.data)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun newIntentForReRegistration(context: Context): Intent {
|
||||
return Intent(context, RegistrationActivity::class.java).apply {
|
||||
putExtra(RE_REGISTRATION_EXTRA, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui
|
||||
|
||||
/**
|
||||
* 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_RESTORED_OR_SKIPPED,
|
||||
PUSH_NETWORK_AUDITED,
|
||||
PHONE_NUMBER_CONFIRMED,
|
||||
PIN_CONFIRMED,
|
||||
CHALLENGE_RECEIVED,
|
||||
CHALLENGE_COMPLETED,
|
||||
VERIFICATION_CODE_REQUESTED,
|
||||
VERIFICATION_CODE_ENTERED,
|
||||
PIN_ENTERED,
|
||||
VERIFICATION_CODE_VALIDATED,
|
||||
SERVICE_REGISTRATION_COMPLETED,
|
||||
LOCAL_REGISTRATION_COMPLETE
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui
|
||||
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import com.google.i18n.phonenumbers.Phonenumber
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.whispersystems.signalservice.api.svr.Svr3Credentials
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
|
||||
/**
|
||||
* State holder shared across all of registration.
|
||||
*/
|
||||
data class RegistrationState(
|
||||
val sessionId: String? = null,
|
||||
val enteredCode: String = "",
|
||||
val phoneNumber: Phonenumber.PhoneNumber? = fetchExistingE164FromValues(),
|
||||
val inProgress: Boolean = false,
|
||||
val isReRegister: Boolean = false,
|
||||
val recoveryPassword: String? = null,
|
||||
val canSkipSms: Boolean = false,
|
||||
val svr2AuthCredentials: AuthCredentials? = null,
|
||||
val svr3AuthCredentials: Svr3Credentials? = null,
|
||||
val svrTriesRemaining: Int = 10,
|
||||
val incorrectCodeAttempts: Int = 0,
|
||||
val isRegistrationLockEnabled: Boolean = false,
|
||||
val lockedTimeRemaining: Long = 0L,
|
||||
val userSkippedReregistration: Boolean = false,
|
||||
val isFcmSupported: Boolean = false,
|
||||
val isAllowedToRequestCode: Boolean = false,
|
||||
val fcmToken: String? = null,
|
||||
val challengesRequested: List<Challenge> = emptyList(),
|
||||
val challengesPresented: Set<Challenge> = emptySet(),
|
||||
val captchaToken: String? = null,
|
||||
val allowedToRequestCode: Boolean = false,
|
||||
val nextSmsTimestamp: Long = 0L,
|
||||
val nextCallTimestamp: Long = 0L,
|
||||
val nextVerificationAttempt: Long = 0L,
|
||||
val verified: Boolean = false,
|
||||
val smsListenerTimeout: Long = 0L,
|
||||
val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION,
|
||||
val networkError: Throwable? = null,
|
||||
val sessionCreationError: RegistrationSessionResult? = null,
|
||||
val sessionStateError: VerificationCodeRequestResult? = null,
|
||||
val registerAccountError: RegisterAccountResult? = null
|
||||
) {
|
||||
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationState::class)
|
||||
|
||||
private fun fetchExistingE164FromValues(): Phonenumber.PhoneNumber? {
|
||||
val existingE164 = SignalStore.registration.sessionE164
|
||||
if (existingE164 != null) {
|
||||
try {
|
||||
return PhoneNumberUtil.getInstance().parse(existingE164, null)
|
||||
} catch (ex: NumberParseException) {
|
||||
Log.w(TAG, "Could not parse stored E164.", ex)
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,996 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import com.google.i18n.phonenumbers.Phonenumber
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.pin.SvrRepository
|
||||
import org.thoughtcrime.securesms.pin.SvrWrongPinException
|
||||
import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
|
||||
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AlreadyVerified
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AttemptsExhausted
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ChallengeRequired
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ExternalServiceFailure
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ImpossibleNumber
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MalformedRequest
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MustRetry
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NoSuchSession
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NonNormalizedNumber
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RateLimited
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RegistrationLocked
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.Success
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError
|
||||
import org.thoughtcrime.securesms.registration.ui.toE164
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
|
||||
import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* ViewModel shared across all of registration.
|
||||
*/
|
||||
class RegistrationViewModel : ViewModel() {
|
||||
|
||||
private val store = MutableStateFlow(RegistrationState())
|
||||
private val password = Util.getSecret(18)
|
||||
|
||||
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
|
||||
Log.w(TAG, "CoroutineExceptionHandler invoked.", exception)
|
||||
store.update {
|
||||
it.copy(
|
||||
networkError = exception,
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<RegistrationState> = store
|
||||
|
||||
val uiState = store.asLiveData()
|
||||
|
||||
val checkpoint = store.map { it.registrationCheckpoint }.asLiveData()
|
||||
|
||||
val lockedTimeRemaining = store.map { it.lockedTimeRemaining }.asLiveData()
|
||||
|
||||
val incorrectCodeAttempts = store.map { it.incorrectCodeAttempts }.asLiveData()
|
||||
|
||||
val svrTriesRemaining: Int
|
||||
get() = store.value.svrTriesRemaining
|
||||
|
||||
var isReregister: Boolean
|
||||
get() = store.value.isReRegister
|
||||
set(value) {
|
||||
store.update {
|
||||
it.copy(isReRegister = value)
|
||||
}
|
||||
}
|
||||
|
||||
val phoneNumber: Phonenumber.PhoneNumber?
|
||||
get() = store.value.phoneNumber
|
||||
|
||||
fun maybePrefillE164(context: Context) {
|
||||
Log.v(TAG, "maybePrefillE164()")
|
||||
if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) {
|
||||
val localNumber = Util.getDeviceNumber(context).getOrNull()
|
||||
|
||||
if (localNumber != null) {
|
||||
Log.v(TAG, "Phone number detected.")
|
||||
setPhoneNumber(localNumber)
|
||||
} else {
|
||||
Log.i(TAG, "Could not read phone number.")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "No phone permission.")
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
sessionId = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCaptchaResponse(token: String) {
|
||||
store.update {
|
||||
it.copy(
|
||||
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_COMPLETED,
|
||||
captchaToken = token
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sessionCreationErrorShown() {
|
||||
store.update {
|
||||
it.copy(sessionCreationError = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun sessionStateErrorShown() {
|
||||
store.update {
|
||||
it.copy(sessionStateError = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun registerAccountErrorShown() {
|
||||
store.update {
|
||||
it.copy(registerAccountError = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun incrementIncorrectCodeAttempts() {
|
||||
store.update {
|
||||
it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun addPresentedChallenge(challenge: Challenge) {
|
||||
store.update {
|
||||
it.copy(challengesPresented = it.challengesPresented.plus(challenge))
|
||||
}
|
||||
}
|
||||
|
||||
fun removePresentedChallenge(challenge: Challenge) {
|
||||
store.update {
|
||||
it.copy(challengesPresented = it.challengesPresented.minus(challenge))
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchFcmToken(context: Context) {
|
||||
viewModelScope.launch(context = coroutineExceptionHandler) {
|
||||
val fcmToken = RegistrationRepository.getFcmToken(context)
|
||||
store.update {
|
||||
it.copy(registrationCheckpoint = RegistrationCheckpoint.PUSH_NETWORK_AUDITED, isFcmSupported = true, fcmToken = fcmToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun onBackupSuccessfullyRestored() {
|
||||
val recoveryPassword = SignalStore.svr.recoveryPassword
|
||||
store.update {
|
||||
it.copy(registrationCheckpoint = RegistrationCheckpoint.BACKUP_RESTORED_OR_SKIPPED, recoveryPassword = SignalStore.svr.recoveryPassword, canSkipSms = recoveryPassword != null, isReRegister = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun onUserConfirmedPhoneNumber(context: Context) {
|
||||
setRegistrationCheckpoint(RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED)
|
||||
val state = store.value
|
||||
|
||||
val e164 = state.phoneNumber?.toE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") }
|
||||
|
||||
if (!state.userSkippedReregistration) {
|
||||
if (hasRecoveryPassword() && matchesSavedE164(e164)) {
|
||||
// Re-registration when the local database is intact.
|
||||
Log.d(TAG, "Has recovery password, and therefore can skip SMS verification.")
|
||||
store.update {
|
||||
it.copy(
|
||||
canSkipSms = true,
|
||||
isReRegister = true,
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
if (!state.userSkippedReregistration) {
|
||||
val svrCredentialsResult: BackupAuthCheckResult = RegistrationRepository.hasValidSvrAuthCredentials(context, e164, password)
|
||||
|
||||
when (svrCredentialsResult) {
|
||||
is BackupAuthCheckResult.UnknownError -> {
|
||||
handleGenericError(svrCredentialsResult.getCause())
|
||||
return@launch
|
||||
}
|
||||
|
||||
is BackupAuthCheckResult.SuccessWithCredentials -> {
|
||||
Log.d(TAG, "Found local valid SVR auth credentials.")
|
||||
store.update {
|
||||
it.copy(
|
||||
isReRegister = true,
|
||||
canSkipSms = true,
|
||||
svr2AuthCredentials = svrCredentialsResult.svr2Credentials,
|
||||
svr3AuthCredentials = svrCredentialsResult.svr3Credentials,
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
is BackupAuthCheckResult.SuccessWithoutCredentials -> {
|
||||
Log.d(TAG, "No local SVR auth credentials could be found and/or validated.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for confirming the entered E164.") }
|
||||
|
||||
if (validSession.body.verified) {
|
||||
Log.i(TAG, "Session is already verified, registering account.")
|
||||
registerVerifiedSession(context, validSession.body.id)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!validSession.body.allowedToRequestCode) {
|
||||
if (System.currentTimeMillis() > (validSession.body.nextVerificationAttempt ?: Int.MAX_VALUE)) {
|
||||
store.update {
|
||||
it.copy(registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED)
|
||||
}
|
||||
} else {
|
||||
val challenges = validSession.body.requestedInformation
|
||||
Log.i(TAG, "Not allowed to request code! Remaining challenges: ${challenges.joinToString()}")
|
||||
handleSessionStateResult(context, ChallengeRequired(Challenge.parse(validSession.body.requestedInformation)))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
requestSmsCodeInternal(context, validSession.body.id, e164)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSmsCode(context: Context) {
|
||||
val e164 = getCurrentE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") }
|
||||
|
||||
viewModelScope.launch {
|
||||
val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for requesting an SMS code.") }
|
||||
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 bail { Log.i(TAG, "Could not create valid session for requesting a verification call.") }
|
||||
Log.d(TAG, "Requesting voice call code…")
|
||||
val codeRequestResponse = RegistrationRepository.requestSmsCode(
|
||||
context = context,
|
||||
sessionId = validSession.body.id,
|
||||
e164 = e164,
|
||||
password = password,
|
||||
mode = RegistrationRepository.E164VerificationMode.PHONE_CALL
|
||||
)
|
||||
Log.d(TAG, "Voice code request network call completed.")
|
||||
|
||||
handleSessionStateResult(context, codeRequestResponse)
|
||||
if (codeRequestResponse is Success) {
|
||||
Log.d(TAG, "Voice code request was successful.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String) {
|
||||
var smsListenerReady = false
|
||||
Log.d(TAG, "Initializing SMS listener.")
|
||||
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 transportMode = if (smsListenerReady) RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER else RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER
|
||||
val codeRequestResponse = RegistrationRepository.requestSmsCode(
|
||||
context = context,
|
||||
sessionId = sessionId,
|
||||
e164 = e164,
|
||||
password = password,
|
||||
mode = transportMode
|
||||
)
|
||||
Log.d(TAG, "SMS code request network call completed.")
|
||||
|
||||
if (codeRequestResponse is AlreadyVerified) {
|
||||
Log.d(TAG, "Got session was already verified when requesting SMS code.")
|
||||
registerVerifiedSession(context, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
handleSessionStateResult(context, codeRequestResponse)
|
||||
|
||||
if (codeRequestResponse is Success) {
|
||||
Log.d(TAG, "SMS code request was successful.")
|
||||
store.update {
|
||||
it.copy(
|
||||
registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? {
|
||||
Log.v(TAG, "getOrCreateValidSession()")
|
||||
val e164 = getCurrentE164() ?: throw IllegalStateException("E164 required to create session!")
|
||||
val mccMncProducer = MccMncProducer(context)
|
||||
|
||||
val existingSessionId = store.value.sessionId
|
||||
return getOrCreateValidSession(
|
||||
context = context,
|
||||
existingSessionId = existingSessionId,
|
||||
e164 = e164,
|
||||
password = password,
|
||||
mcc = mccMncProducer.mcc,
|
||||
mnc = mccMncProducer.mnc,
|
||||
successListener = { networkResult ->
|
||||
store.update {
|
||||
it.copy(
|
||||
sessionId = networkResult.body.id,
|
||||
nextSmsTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextSms),
|
||||
nextCallTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextCall),
|
||||
nextVerificationAttempt = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextVerificationAttempt),
|
||||
allowedToRequestCode = networkResult.body.allowedToRequestCode,
|
||||
challengesRequested = Challenge.parse(networkResult.body.requestedInformation),
|
||||
verified = networkResult.body.verified,
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
},
|
||||
errorHandler = { error ->
|
||||
Log.d(TAG, "Setting ${error::class.simpleName} as session creation error.")
|
||||
store.update {
|
||||
it.copy(
|
||||
sessionCreationError = error,
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun submitCaptchaToken(context: Context) {
|
||||
val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
|
||||
val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!")
|
||||
|
||||
store.update {
|
||||
it.copy(captchaToken = null)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a captcha token.") }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestAndSubmitPushToken(context: Context) {
|
||||
Log.v(TAG, "validatePushToken()")
|
||||
|
||||
addPresentedChallenge(Challenge.PUSH)
|
||||
|
||||
val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
|
||||
|
||||
viewModelScope.launch {
|
||||
Log.d(TAG, "Getting session in order to perform push token verification…")
|
||||
val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a push challenge token.") }
|
||||
|
||||
if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.PUSH)) {
|
||||
Log.d(TAG, "Push submission no longer necessary, bailing.")
|
||||
store.update {
|
||||
it.copy(
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
return@launch bail { Log.i(TAG, "Push challenge token no longer needed, bailing.") }
|
||||
}
|
||||
|
||||
Log.d(TAG, "Requesting push challenge token…")
|
||||
val pushSubmissionResult = RegistrationRepository.requestAndVerifyPushToken(context, session.body.id, e164, password)
|
||||
Log.d(TAG, "Push challenge token submitted.")
|
||||
handleSessionStateResult(context, pushSubmissionResult)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the request was successful and execution should continue
|
||||
*/
|
||||
private suspend fun handleSessionStateResult(context: Context, sessionResult: VerificationCodeRequestResult): Boolean {
|
||||
Log.v(TAG, "handleSessionStateResult()")
|
||||
when (sessionResult) {
|
||||
is UnknownError -> {
|
||||
handleGenericError(sessionResult.getCause())
|
||||
}
|
||||
|
||||
is Success -> {
|
||||
Log.d(TAG, "New registration session status received.")
|
||||
updateFcmToken(context)
|
||||
store.update {
|
||||
it.copy(
|
||||
sessionId = sessionResult.sessionId,
|
||||
nextSmsTimestamp = sessionResult.nextSmsTimestamp,
|
||||
nextCallTimestamp = sessionResult.nextCallTimestamp,
|
||||
isAllowedToRequestCode = sessionResult.allowedToRequestCode,
|
||||
challengesRequested = emptyList(),
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
is ChallengeRequired -> {
|
||||
Log.d(TAG, "[${sessionResult.challenges.joinToString()}] registration challenges received.")
|
||||
store.update {
|
||||
it.copy(
|
||||
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED,
|
||||
challengesRequested = sessionResult.challenges,
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
is AttemptsExhausted -> Log.i(TAG, "Received AttemptsExhausted.", sessionResult.getCause())
|
||||
|
||||
is ImpossibleNumber -> Log.i(TAG, "Received ImpossibleNumber.", sessionResult.getCause())
|
||||
|
||||
is NonNormalizedNumber -> Log.i(TAG, "Received NonNormalizedNumber.", sessionResult.getCause())
|
||||
|
||||
is RateLimited -> Log.i(TAG, "Received RateLimited.", sessionResult.getCause())
|
||||
|
||||
is ExternalServiceFailure -> Log.i(TAG, "Received ExternalServiceFailure.", sessionResult.getCause())
|
||||
|
||||
is InvalidTransportModeFailure -> Log.i(TAG, "Received InvalidTransportModeFailure.", sessionResult.getCause())
|
||||
|
||||
is MalformedRequest -> Log.i(TAG, "Received MalformedRequest.", sessionResult.getCause())
|
||||
|
||||
is MustRetry -> Log.i(TAG, "Received MustRetry.", sessionResult.getCause())
|
||||
|
||||
is TokenNotAccepted -> Log.i(TAG, "Received TokenNotAccepted.", sessionResult.getCause())
|
||||
|
||||
is RegistrationLocked -> {
|
||||
store.update {
|
||||
it.copy(lockedTimeRemaining = sessionResult.timeRemaining)
|
||||
}
|
||||
Log.i(TAG, "Received RegistrationLocked.", sessionResult.getCause())
|
||||
}
|
||||
|
||||
is NoSuchSession -> Log.i(TAG, "Received NoSuchSession.", sessionResult.getCause())
|
||||
|
||||
is AlreadyVerified -> Log.i(TAG, "Received AlreadyVerified", sessionResult.getCause())
|
||||
}
|
||||
setInProgress(false)
|
||||
store.update {
|
||||
it.copy(
|
||||
sessionStateError = sessionResult
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the request was successful and execution should continue
|
||||
*/
|
||||
private suspend fun handleRegistrationResult(context: Context, registrationData: RegistrationData, registrationResult: RegisterAccountResult, reglockEnabled: Boolean): Boolean {
|
||||
Log.v(TAG, "handleRegistrationResult()")
|
||||
when (registrationResult) {
|
||||
is RegisterAccountResult.Success -> {
|
||||
Log.i(TAG, "Register account result: Success! Registration lock: $reglockEnabled")
|
||||
store.update {
|
||||
it.copy(
|
||||
registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED
|
||||
)
|
||||
}
|
||||
onSuccessfulRegistration(context, registrationData, registrationResult.accountRegistrationResult, reglockEnabled)
|
||||
return true
|
||||
}
|
||||
|
||||
is RegisterAccountResult.IncorrectRecoveryPassword -> {
|
||||
Log.i(TAG, "Registration recovery password was incorrect, falling back to SMS verification.", registrationResult.getCause())
|
||||
setUserSkippedReRegisterFlow(true)
|
||||
}
|
||||
|
||||
is RegisterAccountResult.RegistrationLocked -> {
|
||||
Log.i(TAG, "Account is registration locked!", registrationResult.getCause())
|
||||
}
|
||||
|
||||
is RegisterAccountResult.SvrWrongPin -> {
|
||||
Log.i(TAG, "Received wrong SVR PIN response! ${registrationResult.triesRemaining} tries remaining.")
|
||||
updateSvrTriesRemaining(registrationResult.triesRemaining)
|
||||
}
|
||||
|
||||
is RegisterAccountResult.SvrNoData,
|
||||
is RegisterAccountResult.AttemptsExhausted,
|
||||
is RegisterAccountResult.RateLimited,
|
||||
is RegisterAccountResult.AuthorizationFailed,
|
||||
is RegisterAccountResult.MalformedRequest,
|
||||
is RegisterAccountResult.ValidationError,
|
||||
is RegisterAccountResult.UnknownError -> Log.i(TAG, "Received error when trying to register!", registrationResult.getCause())
|
||||
}
|
||||
setInProgress(false)
|
||||
store.update {
|
||||
it.copy(
|
||||
registerAccountError = registrationResult
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun handleGenericError(cause: Throwable) {
|
||||
Log.w(TAG, "Encountered unknown error!", cause)
|
||||
store.update {
|
||||
it.copy(inProgress = false, networkError = cause)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setRecoveryPassword(recoveryPassword: String?) {
|
||||
store.update {
|
||||
it.copy(recoveryPassword = recoveryPassword)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSvrTriesRemaining(remainingTries: Int) {
|
||||
store.update {
|
||||
it.copy(svrTriesRemaining = remainingTries)
|
||||
}
|
||||
}
|
||||
|
||||
fun setUserSkippedReRegisterFlow(value: Boolean) {
|
||||
store.update {
|
||||
it.copy(userSkippedReregistration = value, canSkipSms = !value)
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyReRegisterWithPin(context: Context, pin: String, wrongPinHandler: () -> Unit) {
|
||||
setInProgress(true)
|
||||
|
||||
// Local recovery password
|
||||
if (RegistrationRepository.canUseLocalRecoveryPassword()) {
|
||||
if (RegistrationRepository.doesPinMatchLocalHash(pin)) {
|
||||
Log.d(TAG, "Found recovery password, attempting to re-register.")
|
||||
viewModelScope.launch(context = coroutineExceptionHandler) {
|
||||
val masterKey = SignalStore.svr.masterKey
|
||||
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
|
||||
verifyReRegisterInternal(context, pin, masterKey)
|
||||
setInProgress(false)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Entered PIN did not match local PIN hash.")
|
||||
wrongPinHandler()
|
||||
setInProgress(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// remote recovery password
|
||||
val svr2Credentials = store.value.svr2AuthCredentials
|
||||
val svr3Credentials = store.value.svr3AuthCredentials
|
||||
|
||||
if (svr2Credentials != null || svr3Credentials != null) {
|
||||
Log.d(TAG, "Found SVR auth credentials, fetching recovery password from SVR (svr2: ${svr2Credentials != null}, svr3: ${svr3Credentials != null}).")
|
||||
viewModelScope.launch(context = coroutineExceptionHandler) {
|
||||
try {
|
||||
val masterKey = RegistrationRepository.fetchMasterKeyFromSvrRemote(pin, svr2Credentials, svr3Credentials)
|
||||
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
|
||||
updateSvrTriesRemaining(10)
|
||||
verifyReRegisterInternal(context, pin, masterKey)
|
||||
} catch (rejectedPin: SvrWrongPinException) {
|
||||
Log.w(TAG, "Submitted PIN was rejected by SVR.", rejectedPin)
|
||||
updateSvrTriesRemaining(rejectedPin.triesRemaining)
|
||||
wrongPinHandler()
|
||||
} catch (noData: SvrNoDataException) {
|
||||
Log.w(TAG, "SVR has no data for these credentials. Aborting skip SMS flow.", noData)
|
||||
updateSvrTriesRemaining(0)
|
||||
setUserSkippedReRegisterFlow(true)
|
||||
}
|
||||
setInProgress(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Log.w(TAG, "Could not get credentials to skip SMS registration, aborting!")
|
||||
store.update {
|
||||
it.copy(canSkipSms = false, inProgress = false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun verifyReRegisterInternal(context: Context, pin: String?, masterKey: MasterKey) {
|
||||
Log.v(TAG, "verifyReRegisterInternal(hasPin=${pin != null})")
|
||||
updateFcmToken(context)
|
||||
|
||||
val registrationData = getRegistrationData()
|
||||
|
||||
val resultAndRegLockStatus = registerAccountInternal(context, null, registrationData, pin, masterKey)
|
||||
val result = resultAndRegLockStatus.first
|
||||
val reglockEnabled = resultAndRegLockStatus.second
|
||||
|
||||
handleRegistrationResult(context, registrationData, result, reglockEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a [Pair] containing the server response and a boolean signifying whether the current account is registration locked.
|
||||
*/
|
||||
private suspend fun registerAccountInternal(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String?, masterKey: MasterKey): Pair<RegisterAccountResult, Boolean> {
|
||||
Log.v(TAG, "registerAccountInternal()")
|
||||
var registrationResult: RegisterAccountResult = RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin)
|
||||
|
||||
// Check if reg lock is enabled
|
||||
if (registrationResult !is RegisterAccountResult.RegistrationLocked) {
|
||||
if (registrationResult is RegisterAccountResult.Success) {
|
||||
registrationResult = RegisterAccountResult.Success(registrationResult.accountRegistrationResult.copy(masterKey = masterKey))
|
||||
}
|
||||
|
||||
Log.i(TAG, "Received a non-registration lock response to registration. Assuming registration lock as DISABLED")
|
||||
return Pair(registrationResult, false)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Received a registration lock response when trying to register an account. Retrying with master key.")
|
||||
store.update {
|
||||
it.copy(
|
||||
svr2AuthCredentials = registrationResult.svr2Credentials,
|
||||
svr3AuthCredentials = registrationResult.svr3Credentials
|
||||
)
|
||||
}
|
||||
|
||||
return Pair(RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin) { masterKey }, true)
|
||||
}
|
||||
|
||||
fun verifyCodeWithoutRegistrationLock(context: Context, code: String) {
|
||||
Log.v(TAG, "verifyCodeWithoutRegistrationLock()")
|
||||
store.update {
|
||||
it.copy(
|
||||
inProgress = true,
|
||||
enteredCode = code,
|
||||
registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_ENTERED
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch(context = coroutineExceptionHandler) {
|
||||
verifyCodeInternal(
|
||||
context = context,
|
||||
registrationLocked = false,
|
||||
pin = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyCodeAndRegisterAccountWithRegistrationLock(context: Context, pin: String) {
|
||||
Log.v(TAG, "verifyCodeAndRegisterAccountWithRegistrationLock()")
|
||||
store.update {
|
||||
it.copy(
|
||||
inProgress = true,
|
||||
registrationCheckpoint = RegistrationCheckpoint.PIN_ENTERED
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
verifyCodeInternal(
|
||||
context = context,
|
||||
registrationLocked = true,
|
||||
pin = pin
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun verifyCodeInternal(context: Context, registrationLocked: Boolean, pin: String?) {
|
||||
Log.d(TAG, "Getting valid session in order to submit verification code.")
|
||||
|
||||
if (registrationLocked && pin.isNullOrBlank()) {
|
||||
throw IllegalStateException("Must have PIN to register with registration lock!")
|
||||
}
|
||||
|
||||
var reglock = registrationLocked
|
||||
|
||||
val sessionId = getOrCreateValidSession(context)?.body?.id ?: return
|
||||
val registrationData = getRegistrationData()
|
||||
|
||||
Log.d(TAG, "Submitting verification code…")
|
||||
|
||||
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
|
||||
|
||||
val submissionSuccessful = verificationResponse is Success
|
||||
val alreadyVerified = verificationResponse is AlreadyVerified
|
||||
|
||||
Log.d(TAG, "Verification code submission network call completed. Submission successful? $submissionSuccessful Account already verified? $alreadyVerified")
|
||||
|
||||
if (!submissionSuccessful && !alreadyVerified) {
|
||||
handleSessionStateResult(context, verificationResponse)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Submitting registration…")
|
||||
|
||||
var result: RegisterAccountResult? = null
|
||||
var state = store.value
|
||||
|
||||
if (!reglock) {
|
||||
Log.d(TAG, "Registration lock not enabled, attempting to register account without master key producer.")
|
||||
result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin)
|
||||
}
|
||||
|
||||
if (result is RegisterAccountResult.RegistrationLocked) {
|
||||
Log.d(TAG, "Registration lock response received.")
|
||||
val timeRemaining = result.timeRemaining
|
||||
store.update {
|
||||
it.copy(lockedTimeRemaining = timeRemaining)
|
||||
}
|
||||
reglock = true
|
||||
if (pin == null && SignalStore.svr.registrationLockToken != null) {
|
||||
Log.d(TAG, "Retrying registration with stored credentials.")
|
||||
result = RegistrationRepository.registerAccount(context, sessionId, registrationData, SignalStore.svr.pin) { SignalStore.svr.masterKey }
|
||||
} else if (result.svr2Credentials != null || result.svr3Credentials != null) {
|
||||
Log.d(TAG, "Retrying registration with received credentials (svr2: ${result.svr2Credentials != null}, svr3: ${result.svr3Credentials != null}).")
|
||||
val svr2Credentials = result.svr2Credentials
|
||||
val svr3Credentials = result.svr3Credentials
|
||||
state = store.updateAndGet {
|
||||
it.copy(svr2AuthCredentials = svr2Credentials, svr3AuthCredentials = svr3Credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reglock && pin.isNotNullOrBlank()) {
|
||||
Log.d(TAG, "Registration lock enabled, attempting to register account restore master key from SVR (svr2: ${state.svr2AuthCredentials != null}, svr3: ${state.svr3AuthCredentials != null})")
|
||||
result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin) {
|
||||
SvrRepository.restoreMasterKeyPreRegistration(
|
||||
credentials = SvrAuthCredentialSet(
|
||||
svr2Credentials = state.svr2AuthCredentials,
|
||||
svr3Credentials = state.svr3AuthCredentials
|
||||
),
|
||||
userPin = pin
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
handleRegistrationResult(context, registrationData, result, reglock)
|
||||
} else {
|
||||
Log.w(TAG, "No registration response received!")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerVerifiedSession(context: Context, sessionId: String) {
|
||||
Log.v(TAG, "registerVerifiedSession()")
|
||||
val registrationData = getRegistrationData()
|
||||
val registrationResponse: RegisterAccountResult = RegistrationRepository.registerAccount(context, sessionId, registrationData)
|
||||
handleRegistrationResult(context, registrationData, registrationResponse, false)
|
||||
}
|
||||
|
||||
private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) = withContext(Dispatchers.IO) {
|
||||
Log.v(TAG, "onSuccessfulRegistration()")
|
||||
val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled)
|
||||
SignalStore.registration.localRegistrationMetadata = metadata
|
||||
RegistrationRepository.registerAccountLocally(context, metadata)
|
||||
|
||||
if (!remoteResult.storageCapable && !SignalStore.registration.hasCompletedRestore()) {
|
||||
// Not being storage capable is a high signal that account is new and there's no data to restore
|
||||
SignalStore.registration.markSkippedTransferOrRestore()
|
||||
}
|
||||
|
||||
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
|
||||
SignalStore.onboarding.clearAll()
|
||||
}
|
||||
|
||||
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
|
||||
val stopwatch = Stopwatch("post-reg-storage-service")
|
||||
|
||||
AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN)
|
||||
stopwatch.split("account-restore")
|
||||
|
||||
AppDependencies.jobManager
|
||||
.startChain(StorageSyncJob())
|
||||
.then(ReclaimUsernameAndLinkJob())
|
||||
.enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10))
|
||||
stopwatch.split("storage-sync")
|
||||
|
||||
BackupRepository.restoreBackupTier(SignalStore.account.requireAci())
|
||||
stopwatch.split("backup-tier")
|
||||
|
||||
stopwatch.stop(TAG)
|
||||
}
|
||||
|
||||
refreshRemoteConfig()
|
||||
|
||||
store.update {
|
||||
it.copy(
|
||||
registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE,
|
||||
inProgress = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun completeRegistration() {
|
||||
AppDependencies.jobManager.startChain(ProfileUploadJob()).then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob())).enqueue()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
}
|
||||
|
||||
fun networkErrorShown() {
|
||||
store.update {
|
||||
it.copy(networkError = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSavedE164(e164: String?): Boolean {
|
||||
return if (e164 == null) {
|
||||
false
|
||||
} else {
|
||||
e164 == SignalStore.account.e164
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRecoveryPassword(): Boolean {
|
||||
return store.value.recoveryPassword != null
|
||||
}
|
||||
|
||||
private fun getCurrentE164(): String? {
|
||||
return store.value.phoneNumber?.toE164()
|
||||
}
|
||||
|
||||
private suspend fun getRegistrationData(): RegistrationData {
|
||||
val currentState = store.value
|
||||
val code = currentState.enteredCode
|
||||
val e164: String = currentState.phoneNumber?.toE164() ?: throw IllegalStateException("Can't construct registration data without E164!")
|
||||
val recoveryPassword = if (currentState.sessionId == null && hasRecoveryPassword()) store.value.recoveryPassword!! else null
|
||||
return RegistrationData(code, e164, password, RegistrationRepository.getRegistrationId(), RegistrationRepository.getProfileKey(e164), currentState.fcmToken, RegistrationRepository.getPniRegistrationId(), recoveryPassword)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for early returns in order to end the in-progress visual state, as well as print a log message explaining what happened.
|
||||
*
|
||||
* @param logMessage Logging code is wrapped in lambda so that our automated tools detect the various [Log] calls with their accompanying messages.
|
||||
*/
|
||||
private fun bail(logMessage: () -> Unit) {
|
||||
logMessage()
|
||||
setInProgress(false)
|
||||
}
|
||||
|
||||
fun registerWithBackupKey(context: Context, backupKey: String, e164: String?, pin: String?) {
|
||||
setInProgress(true)
|
||||
|
||||
viewModelScope.launch(context = coroutineExceptionHandler) {
|
||||
if (e164 != null) {
|
||||
setPhoneNumber(PhoneNumberUtil.getInstance().parse(e164, null))
|
||||
}
|
||||
|
||||
// TODO [backups] use new data and not master key
|
||||
val masterKey = MasterKey(Hex.fromStringCondensed(backupKey))
|
||||
SignalStore.svr.setMasterKey(masterKey, pin)
|
||||
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
|
||||
verifyReRegisterInternal(context = context, pin = pin, masterKey = masterKey)
|
||||
|
||||
setInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationViewModel::class.java)
|
||||
|
||||
private suspend fun refreshRemoteConfig() = withContext(Dispatchers.IO) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
RemoteConfig.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)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getOrCreateValidSession(
|
||||
context: Context,
|
||||
existingSessionId: String?,
|
||||
e164: String,
|
||||
password: String,
|
||||
mcc: String?,
|
||||
mnc: String?,
|
||||
successListener: (RegistrationSessionMetadataResponse) -> Unit,
|
||||
errorHandler: (RegistrationSessionResult) -> Unit
|
||||
): RegistrationSessionMetadataResponse? {
|
||||
Log.d(TAG, "Validating/creating a registration session.")
|
||||
val sessionResult: RegistrationSessionResult = RegistrationRepository.createOrValidateSession(context, existingSessionId, e164, password, mcc, mnc)
|
||||
when (sessionResult) {
|
||||
is RegistrationSessionCheckResult.Success -> {
|
||||
val metadata = sessionResult.getMetadata()
|
||||
successListener(metadata)
|
||||
Log.d(TAG, "Registration session validated.")
|
||||
return metadata
|
||||
}
|
||||
|
||||
is RegistrationSessionCreationResult.Success -> {
|
||||
val metadata = sessionResult.getMetadata()
|
||||
successListener(metadata)
|
||||
Log.d(TAG, "Registration session created.")
|
||||
return metadata
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.d(TAG, "Handling error during session creation.")
|
||||
errorHandler(sessionResult)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.accountlocked
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Screen educating the user that they need to wait some number of days to register.
|
||||
*/
|
||||
class AccountLockedFragment : LoggingFragment(R.layout.account_locked_fragment) {
|
||||
private val viewModel by activityViewModels<RegistrationViewModel>()
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title))
|
||||
|
||||
val description = view.findViewById<TextView>(R.id.account_locked_description)
|
||||
|
||||
viewModel.lockedTimeRemaining.observe(
|
||||
viewLifecycleOwner
|
||||
) { t: Long? -> description.text = getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t!!)) }
|
||||
|
||||
view.findViewById<View>(R.id.account_locked_next).setOnClickListener { v: View? -> onNext() }
|
||||
view.findViewById<View>(R.id.account_locked_learn_more).setOnClickListener { v: View? -> learnMore() }
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
onNext()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun learnMore() {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)))
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
fun onNext() {
|
||||
requireActivity().finish()
|
||||
}
|
||||
|
||||
private fun durationToDays(duration: Long): Long {
|
||||
return if (duration != 0L) getLockoutDays(duration).toLong() else 7
|
||||
}
|
||||
|
||||
private fun getLockoutDays(timeRemainingMs: Long): Int {
|
||||
return timeRemainingMs.milliseconds.inWholeDays.toInt() + 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.captcha
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.addCallback
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaBinding
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationConstants
|
||||
|
||||
abstract class CaptchaFragment : LoggingFragment(R.layout.fragment_registration_captcha) {
|
||||
|
||||
private val binding: FragmentRegistrationCaptchaBinding by ViewBinderDelegate(FragmentRegistrationCaptchaBinding::bind)
|
||||
|
||||
private val backListener = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
handleUserExit()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.registrationCaptchaWebView.settings.javaScriptEnabled = true
|
||||
binding.registrationCaptchaWebView.clearCache(true)
|
||||
|
||||
binding.registrationCaptchaWebView.webViewClient = object : WebViewClient() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
if (url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) {
|
||||
val token = url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length)
|
||||
handleCaptchaToken(token)
|
||||
backListener.isEnabled = false
|
||||
findNavController().navigateUp()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
|
||||
handleUserExit()
|
||||
}
|
||||
binding.registrationCaptchaWebView.loadUrl(BuildConfig.SIGNAL_CAPTCHA_URL)
|
||||
}
|
||||
|
||||
abstract fun handleCaptchaToken(token: String)
|
||||
|
||||
abstract fun handleUserExit()
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.captcha
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
|
||||
/**
|
||||
* Screen that displays a captcha as part of the registration flow.
|
||||
* This subclass plugs in [RegistrationViewModel] to the shared super class.
|
||||
*
|
||||
* @see CaptchaFragment
|
||||
*/
|
||||
class RegistrationCaptchaFragment : CaptchaFragment() {
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
sharedViewModel.addPresentedChallenge(Challenge.CAPTCHA)
|
||||
}
|
||||
|
||||
override fun handleCaptchaToken(token: String) {
|
||||
sharedViewModel.setCaptchaResponse(token)
|
||||
}
|
||||
|
||||
override fun handleUserExit() {
|
||||
sharedViewModel.removePresentedChallenge(Challenge.CAPTCHA)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,300 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.entercode
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
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.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeBinding
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
|
||||
import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener
|
||||
import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* The final screen of account registration, where the user enters their verification code.
|
||||
*/
|
||||
class EnterCodeFragment : LoggingFragment(R.layout.fragment_registration_enter_code) {
|
||||
|
||||
companion object {
|
||||
private const val BOTTOM_SHEET_TAG = "support_bottom_sheet"
|
||||
}
|
||||
|
||||
private val TAG = Log.tag(EnterCodeFragment::class.java)
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val fragmentViewModel by viewModels<EnterCodeViewModel>()
|
||||
private val bottomSheet = ContactSupportBottomSheetFragment()
|
||||
private val binding: FragmentRegistrationEnterCodeBinding by ViewBinderDelegate(FragmentRegistrationEnterCodeBinding::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.havingTroubleButton.setOnClickListener {
|
||||
bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
|
||||
}
|
||||
|
||||
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) {
|
||||
binding.code.append(key)
|
||||
} else {
|
||||
binding.code.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.incorrectCodeAttempts.observe(viewLifecycleOwner) { attempts: Int ->
|
||||
if (attempts >= 3) {
|
||||
binding.havingTroubleButton.visible = true
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.uiState.observe(viewLifecycleOwner) {
|
||||
it.sessionStateError?.let { error ->
|
||||
handleSessionErrorResponse(error)
|
||||
sharedViewModel.sessionStateErrorShown()
|
||||
}
|
||||
|
||||
it.registerAccountError?.let { error ->
|
||||
handleRegistrationErrorResponse(error)
|
||||
sharedViewModel.registerAccountErrorShown()
|
||||
}
|
||||
|
||||
binding.resendSmsCountDown.startCountDownTo(it.nextSmsTimestamp)
|
||||
binding.callMeCountDown.startCountDownTo(it.nextCallTimestamp)
|
||||
if (it.inProgress) {
|
||||
binding.keyboard.displayProgress()
|
||||
} else {
|
||||
binding.keyboard.displayKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
fragmentViewModel.uiState.observe(viewLifecycleOwner) {
|
||||
if (it.resetRequiredAfterFailure) {
|
||||
binding.callMeCountDown.visibility = View.VISIBLE
|
||||
binding.resendSmsCountDown.visibility = View.VISIBLE
|
||||
binding.wrongNumber.visibility = View.VISIBLE
|
||||
binding.code.clear()
|
||||
binding.keyboard.displayKeyboard()
|
||||
fragmentViewModel.allViewsResetCompleted()
|
||||
} else if (it.showKeyboard) {
|
||||
binding.keyboard.displayKeyboard()
|
||||
fragmentViewModel.keyboardShown()
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
sharedViewModel.phoneNumber?.let {
|
||||
val formatted = PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
|
||||
binding.verificationSubheader.text = requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionErrorResponse(result: VerificationCodeRequestResult) {
|
||||
when (result) {
|
||||
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
|
||||
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
|
||||
is VerificationCodeRequestResult.AttemptsExhausted -> presentAccountLocked()
|
||||
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
|
||||
else -> presentGenericError(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
|
||||
when (result) {
|
||||
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
|
||||
is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
|
||||
is RegisterAccountResult.AuthorizationFailed -> presentIncorrectCodeDialog()
|
||||
is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
|
||||
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
|
||||
|
||||
else -> presentGenericError(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentAccountLocked() {
|
||||
binding.keyboard.displayLocked().addListener(
|
||||
object : AssertedSuccessListener<Boolean>() {
|
||||
override fun onSuccess(result: Boolean?) {
|
||||
findNavController().safeNavigate(EnterCodeFragmentDirections.actionAccountLocked())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentRegistrationLocked(timeRemaining: Long) {
|
||||
binding.keyboard.displayLocked().addListener(
|
||||
object : AssertedSuccessListener<Boolean>() {
|
||||
override fun onSuccess(result: Boolean?) {
|
||||
findNavController().safeNavigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentRateLimitedDialog() {
|
||||
binding.keyboard.displayFailure().addListener(
|
||||
object : AssertedSuccessListener<Boolean?>() {
|
||||
override fun onSuccess(result: Boolean?) {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
|
||||
fragmentViewModel.resetAllViews()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentIncorrectCodeDialog() {
|
||||
sharedViewModel.incrementIncorrectCodeAttempts()
|
||||
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show()
|
||||
|
||||
binding.keyboard.displayFailure().addListener(object : AssertedSuccessListener<Boolean?>() {
|
||||
override fun onSuccess(result: Boolean?) {
|
||||
fragmentViewModel.resetAllViews()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun presentGenericError(requestResult: RegistrationResult) {
|
||||
binding.keyboard.displayFailure().addListener(
|
||||
object : AssertedSuccessListener<Boolean>() {
|
||||
override fun onSuccess(result: Boolean?) {
|
||||
Log.w(TAG, "Encountered unexpected error!", requestResult.getCause())
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
null?.let<String, MaterialAlertDialogBuilder> {
|
||||
setTitle(it)
|
||||
}
|
||||
setMessage(getString(R.string.RegistrationActivity_error_connecting_to_service))
|
||||
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.showKeyboard() }
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PUSH_NETWORK_AUDITED)
|
||||
NavHostFragment.findNavController(this).popBackStack()
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onVerificationCodeReceived(event: ReceivedSmsEvent) {
|
||||
Log.i(TAG, "Received verification code via EventBus.")
|
||||
binding.code.clear()
|
||||
|
||||
if (event.code.isBlank() || event.code.length != ReceivedSmsEvent.CODE_LENGTH) {
|
||||
Log.i(TAG, "Received invalid code of length ${event.code.length}. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
val finalIndex = ReceivedSmsEvent.CODE_LENGTH - 1
|
||||
autopilotCodeEntryActive = true
|
||||
try {
|
||||
event.code
|
||||
.map { it.digitToInt() }
|
||||
.forEachIndexed { i, digit ->
|
||||
binding.code.postDelayed({
|
||||
binding.code.append(digit)
|
||||
if (i == finalIndex) {
|
||||
autopilotCodeEntryActive = false
|
||||
}
|
||||
}, i * 200L)
|
||||
}
|
||||
Log.i(TAG, "Finished auto-filling code.")
|
||||
} catch (notADigit: IllegalArgumentException) {
|
||||
Log.w(TAG, "Failed to convert code into digits.", notADigit)
|
||||
autopilotCodeEntryActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback {
|
||||
override fun onNoCellSignalPresent() {
|
||||
if (isAdded) {
|
||||
bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCellSignalPresent() {
|
||||
if (bottomSheet.isResumed) {
|
||||
bottomSheet.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.entercode
|
||||
|
||||
data class EnterCodeState(val resetRequiredAfterFailure: Boolean = false, val showKeyboard: Boolean = false)
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.entercode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class EnterCodeViewModel : ViewModel() {
|
||||
private val store = MutableStateFlow(EnterCodeState())
|
||||
val uiState = store.asLiveData()
|
||||
|
||||
fun resetAllViews() {
|
||||
store.update { it.copy(resetRequiredAfterFailure = true) }
|
||||
}
|
||||
|
||||
fun allViewsResetCompleted() {
|
||||
store.update {
|
||||
it.copy(
|
||||
resetRequiredAfterFailure = false,
|
||||
showKeyboard = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showKeyboard() {
|
||||
store.update { it.copy(showKeyboard = true) }
|
||||
}
|
||||
|
||||
fun keyboardShown() {
|
||||
store.update { it.copy(showKeyboard = false) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.permissions
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.welcome.WelcomeUserSelection
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
|
||||
/**
|
||||
* Screen in account registration that provides rationales for the suggested runtime permissions.
|
||||
*/
|
||||
@RequiresApi(23)
|
||||
class GrantPermissionsFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GrantPermissionsFragment::class.java)
|
||||
|
||||
const val REQUEST_KEY = "GrantPermissionsFragment"
|
||||
}
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val args by navArgs<GrantPermissionsFragmentArgs>()
|
||||
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
::onPermissionsGranted
|
||||
)
|
||||
|
||||
private val welcomeUserSelection: WelcomeUserSelection by lazy { args.welcomeUserSelection }
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
GrantPermissionsScreen(
|
||||
deviceBuildVersion = Build.VERSION.SDK_INT,
|
||||
isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current),
|
||||
onNextClicked = this::launchPermissionRequests,
|
||||
onNotNowClicked = this::proceedToNextScreen
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchPermissionRequests() {
|
||||
val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
|
||||
|
||||
val neededPermissions = WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).filterNot {
|
||||
ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (neededPermissions.isEmpty()) {
|
||||
proceedToNextScreen()
|
||||
} else {
|
||||
requestPermissionLauncher.launch(neededPermissions.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPermissionsGranted(permissions: Map<String, Boolean>) {
|
||||
permissions.forEach {
|
||||
Log.d(TAG, "${it.key} = ${it.value}")
|
||||
}
|
||||
sharedViewModel.maybePrefillE164(requireContext())
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
|
||||
proceedToNextScreen()
|
||||
}
|
||||
|
||||
private fun proceedToNextScreen() {
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to welcomeUserSelection))
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.permissions
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
|
||||
|
||||
/**
|
||||
* Layout that explains permissions rationale to the user.
|
||||
*/
|
||||
@Composable
|
||||
fun GrantPermissionsScreen(
|
||||
deviceBuildVersion: Int,
|
||||
isBackupSelectionRequired: Boolean,
|
||||
onNextClicked: () -> Unit = {},
|
||||
onNotNowClicked: () -> Unit = {}
|
||||
) {
|
||||
RegistrationScreen(
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
|
||||
bottomContent = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = onNotNowClicked) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClicked
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (deviceBuildVersion >= 33) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun GrantPermissionsScreenPreview() {
|
||||
Previews.Preview {
|
||||
GrantPermissionsScreen(
|
||||
deviceBuildVersion = 33,
|
||||
isBackupSelectionRequired = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun PermissionRowPreview() {
|
||||
Previews.Preview {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,654 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.TextWatcher
|
||||
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 androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
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.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
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.FragmentRegistrationEnterPhoneNumberBinding
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
|
||||
import org.thoughtcrime.securesms.registration.ui.toE164
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Dialogs
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
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
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Screen in registration where the user enters their phone number.
|
||||
*/
|
||||
class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number) {
|
||||
|
||||
private val TAG = Log.tag(EnterPhoneNumberFragment::class.java)
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val fragmentViewModel by viewModels<EnterPhoneNumberViewModel>()
|
||||
private val args by navArgs<EnterPhoneNumberFragmentArgs>()
|
||||
private val binding: FragmentRegistrationEnterPhoneNumberBinding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberBinding::bind)
|
||||
|
||||
private val enterPhoneNumberMode: EnterPhoneNumberMode by lazy { args.enterPhoneNumberMode }
|
||||
private var processedResumeMode: Boolean = false
|
||||
|
||||
private val skipToNextScreen: DialogInterface.OnClickListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> moveToVerificationEntryScreen() }
|
||||
|
||||
private lateinit var spinnerAdapter: ArrayAdapter<CountryPrefix>
|
||||
private lateinit var phoneNumberInputLayout: TextInputEditText
|
||||
private lateinit var spinnerView: MaterialAutoCompleteTextView
|
||||
|
||||
private var currentPhoneNumberFormatter: TextWatcher? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setDebugLogSubmitMultiTapView(binding.verifyHeader)
|
||||
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<CountryPrefix>(
|
||||
requireContext(),
|
||||
R.layout.registration_country_code_dropdown_item,
|
||||
fragmentViewModel.supportedCountryPrefixes
|
||||
)
|
||||
binding.registerButton.setOnClickListener { onRegistrationButtonClicked() }
|
||||
|
||||
binding.toolbar.title = ""
|
||||
val activity = requireActivity() as AppCompatActivity
|
||||
activity.setSupportActionBar(binding.toolbar)
|
||||
|
||||
requireActivity().addMenuProvider(UseProxyMenuProvider(), viewLifecycleOwner)
|
||||
|
||||
sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
|
||||
presentRegisterButton(sharedState)
|
||||
presentProgressBar(sharedState.inProgress, sharedState.isReRegister)
|
||||
|
||||
sharedState.networkError?.let {
|
||||
presentNetworkError(it)
|
||||
sharedViewModel.networkErrorShown()
|
||||
}
|
||||
|
||||
sharedState.sessionCreationError?.let {
|
||||
handleSessionCreationError(it)
|
||||
sharedViewModel.sessionCreationErrorShown()
|
||||
}
|
||||
|
||||
sharedState.sessionStateError?.let {
|
||||
handleSessionStateError(it)
|
||||
sharedViewModel.sessionStateErrorShown()
|
||||
}
|
||||
|
||||
sharedState.registerAccountError?.let {
|
||||
handleRegistrationErrorResponse(it)
|
||||
sharedViewModel.registerAccountErrorShown()
|
||||
}
|
||||
|
||||
if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) {
|
||||
sharedViewModel.submitCaptchaToken(requireContext())
|
||||
} else if (sharedState.challengesRemaining.isNotEmpty()) {
|
||||
handleChallenges(sharedState.challengesRemaining)
|
||||
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
|
||||
moveToEnterPinScreen()
|
||||
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
|
||||
moveToVerificationEntryScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
|
||||
|
||||
fragmentState.phoneNumberFormatter?.let {
|
||||
bindPhoneNumberFormatter(it)
|
||||
phoneNumberInputLayout.requestFocus()
|
||||
}
|
||||
|
||||
if (fragmentViewModel.isEnteredNumberPossible(fragmentState)) {
|
||||
sharedViewModel.setPhoneNumber(fragmentViewModel.parsePhoneNumber(fragmentState))
|
||||
} else {
|
||||
sharedViewModel.setPhoneNumber(null)
|
||||
}
|
||||
|
||||
if (fragmentState.error != EnterPhoneNumberState.Error.NONE) {
|
||||
presentLocalError(fragmentState)
|
||||
}
|
||||
}
|
||||
|
||||
initializeInputFields()
|
||||
|
||||
val existingPhoneNumber = sharedViewModel.phoneNumber
|
||||
if (existingPhoneNumber != null) {
|
||||
fragmentViewModel.restoreState(existingPhoneNumber)
|
||||
spinnerView.setText(existingPhoneNumber.countryCode.toString())
|
||||
fragmentViewModel.formatter?.let {
|
||||
bindPhoneNumberFormatter(it)
|
||||
}
|
||||
phoneNumberInputLayout.setText(existingPhoneNumber.nationalNumber.toString())
|
||||
} else {
|
||||
spinnerView.setText(fragmentViewModel.countryPrefix().toString())
|
||||
}
|
||||
|
||||
if (enterPhoneNumberMode == EnterPhoneNumberMode.RESTART_AFTER_COLLECTION && (savedInstanceState == null && !processedResumeMode)) {
|
||||
processedResumeMode = true
|
||||
startNormalRegistration()
|
||||
} else {
|
||||
ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPhoneNumberFormatter(formatter: TextWatcher) {
|
||||
if (formatter != currentPhoneNumberFormatter) {
|
||||
currentPhoneNumberFormatter?.let { oldWatcher ->
|
||||
Log.d(TAG, "Removing current phone number formatter in fragment")
|
||||
phoneNumberInputLayout.removeTextChangedListener(oldWatcher)
|
||||
}
|
||||
phoneNumberInputLayout.addTextChangedListener(formatter)
|
||||
currentPhoneNumberFormatter = formatter
|
||||
Log.d(TAG, "Updated phone number formatter in fragment")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChallenges(remainingChallenges: List<Challenge>) {
|
||||
when (remainingChallenges.first()) {
|
||||
Challenge.CAPTCHA -> moveToCaptcha()
|
||||
Challenge.PUSH -> performPushChallenge()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performPushChallenge() {
|
||||
sharedViewModel.requestAndSubmitPushToken(requireContext())
|
||||
}
|
||||
|
||||
private fun initializeInputFields() {
|
||||
binding.countryCode.editText?.addTextChangedListener { s ->
|
||||
val sanitized = s.toString().filter { c -> c.isDigit() }
|
||||
if (sanitized.isNotNullOrBlank()) {
|
||||
val countryCode: Int = sanitized.toInt()
|
||||
fragmentViewModel.setCountry(countryCode)
|
||||
}
|
||||
}
|
||||
|
||||
phoneNumberInputLayout.addTextChangedListener {
|
||||
fragmentViewModel.setPhoneNumber(it?.toString())
|
||||
}
|
||||
|
||||
val scrollView = binding.scrollView
|
||||
val registerButton = binding.registerButton
|
||||
phoneNumberInputLayout.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
|
||||
if (hasFocus) {
|
||||
scrollView.postDelayed({
|
||||
scrollView.smoothScrollTo(0, 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(afterTextChanged = ::onCountryDropDownChanged)
|
||||
}
|
||||
|
||||
private fun onCountryDropDownChanged(s: Editable?) {
|
||||
if (s.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (s[0] != '+') {
|
||||
s.insert(0, "+")
|
||||
}
|
||||
|
||||
fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let {
|
||||
fragmentViewModel.setCountry(it.digits)
|
||||
val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0
|
||||
phoneNumberInputLayout.setSelection(numberLength, numberLength)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentRegisterButton(sharedState: RegistrationState) {
|
||||
binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isPossibleNumber(sharedState.phoneNumber)
|
||||
if (sharedState.inProgress) {
|
||||
binding.registerButton.setSpinning()
|
||||
} else {
|
||||
binding.registerButton.cancelSpinning()
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentLocalError(state: EnterPhoneNumberState) {
|
||||
when (state.error) {
|
||||
EnterPhoneNumberState.Error.NONE -> Unit
|
||||
|
||||
EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER -> {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setTitle(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()
|
||||
}
|
||||
}
|
||||
|
||||
EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING -> {
|
||||
handlePromptForNoPlayServices()
|
||||
}
|
||||
|
||||
EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE -> {
|
||||
GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0)?.show()
|
||||
}
|
||||
|
||||
EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT -> {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setTitle(R.string.RegistrationActivity_play_services_error)
|
||||
setMessage(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable)
|
||||
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
|
||||
setOnCancelListener { fragmentViewModel.clearError() }
|
||||
setOnDismissListener { fragmentViewModel.clearError() }
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentNetworkError(networkError: Throwable) {
|
||||
Log.i(TAG, "Unknown error during verification code request", networkError)
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
|
||||
setPositiveButton(android.R.string.ok, null)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionCreationError(result: RegistrationSessionResult) {
|
||||
if (!result.isSuccess()) {
|
||||
Log.i(TAG, "Handling error response of ${result.javaClass.name}", result.getCause())
|
||||
}
|
||||
when (result) {
|
||||
is RegistrationSessionCheckResult.Success,
|
||||
is RegistrationSessionCreationResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
|
||||
|
||||
is RegistrationSessionCreationResult.AttemptsExhausted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
|
||||
is RegistrationSessionCreationResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
|
||||
|
||||
is RegistrationSessionCreationResult.RateLimited -> {
|
||||
Log.i(TAG, "Session creation rate limited! Next attempt: ${result.timeRemaining.milliseconds}")
|
||||
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString()))
|
||||
}
|
||||
|
||||
is RegistrationSessionCreationResult.ServerUnableToParse -> presentGenericError(result)
|
||||
is RegistrationSessionCheckResult.SessionNotFound -> presentGenericError(result)
|
||||
is RegistrationSessionCheckResult.UnknownError,
|
||||
is RegistrationSessionCreationResult.UnknownError -> presentGenericError(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionStateError(result: VerificationCodeRequestResult) {
|
||||
if (!result.isSuccess()) {
|
||||
Log.i(TAG, "Handling error response.", result.getCause())
|
||||
}
|
||||
when (result) {
|
||||
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
|
||||
is VerificationCodeRequestResult.AttemptsExhausted -> presentRateLimitedDialog()
|
||||
is VerificationCodeRequestResult.ChallengeRequired -> handleChallenges(result.challenges)
|
||||
is VerificationCodeRequestResult.ExternalServiceFailure -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
|
||||
is VerificationCodeRequestResult.ImpossibleNumber -> {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setMessage(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164()))
|
||||
setPositiveButton(android.R.string.ok, null)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.InvalidTransportModeFailure -> {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setMessage(R.string.RegistrationActivity_we_couldnt_send_you_a_verification_code)
|
||||
setPositiveButton(R.string.RegistrationActivity_voice_call) { _, _ ->
|
||||
sharedViewModel.requestVerificationCall(requireContext())
|
||||
}
|
||||
setNegativeButton(R.string.RegistrationActivity_cancel, null)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
|
||||
is VerificationCodeRequestResult.MustRetry -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
|
||||
is VerificationCodeRequestResult.NonNormalizedNumber -> handleNonNormalizedNumberError(result.originalNumber, result.normalizedNumber, fragmentViewModel.e164VerificationMode)
|
||||
is VerificationCodeRequestResult.RateLimited -> {
|
||||
Log.i(TAG, "Code request rate limited! Next attempt: ${result.timeRemaining.milliseconds}")
|
||||
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString()))
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.TokenNotAccepted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human)) { _, _ -> moveToCaptcha() }
|
||||
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
|
||||
is VerificationCodeRequestResult.AlreadyVerified -> presentGenericError(result)
|
||||
is VerificationCodeRequestResult.NoSuchSession -> presentGenericError(result)
|
||||
is VerificationCodeRequestResult.UnknownError -> presentGenericError(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentGenericError(result: RegistrationResult) {
|
||||
Log.i(TAG, "Received unhandled response: ${result.javaClass.name}", result.getCause())
|
||||
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service))
|
||||
}
|
||||
|
||||
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
|
||||
when (result) {
|
||||
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
|
||||
is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
|
||||
is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
|
||||
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
|
||||
is RegisterAccountResult.SvrNoData -> presentAccountLocked()
|
||||
else -> presentGenericError(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentRegistrationLocked(timeRemaining: Long) {
|
||||
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberRegistrationLock(timeRemaining))
|
||||
}
|
||||
|
||||
private fun presentRateLimitedDialog() {
|
||||
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
|
||||
}
|
||||
|
||||
private fun presentAccountLocked() {
|
||||
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberAccountLocked())
|
||||
}
|
||||
|
||||
private fun moveToCaptcha() {
|
||||
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha())
|
||||
}
|
||||
|
||||
private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setMessage(message)
|
||||
setPositiveButton(android.R.string.ok, positiveButtonListener)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNonNormalizedNumberError(originalNumber: String, normalizedNumber: String, mode: RegistrationRepository.E164VerificationMode) {
|
||||
try {
|
||||
val phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setTitle(R.string.RegistrationActivity_non_standard_number_format)
|
||||
setMessage(getString(R.string.RegistrationActivity_the_number_you_entered_appears_to_be_a_non_standard, originalNumber, normalizedNumber))
|
||||
setNegativeButton(android.R.string.no) { d: DialogInterface, i: Int -> d.dismiss() }
|
||||
setNeutralButton(R.string.RegistrationActivity_contact_signal_support) { dialogInterface, _ ->
|
||||
val subject = getString(R.string.RegistrationActivity_signal_android_phone_number_format)
|
||||
val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.RegistrationActivity_signal_android_phone_number_format, null, null)
|
||||
|
||||
CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body)
|
||||
dialogInterface.dismiss()
|
||||
}
|
||||
setPositiveButton(R.string.yes) { dialogInterface, _ ->
|
||||
spinnerView.setText(phoneNumber.countryCode.toString())
|
||||
phoneNumberInputLayout.setText(phoneNumber.nationalNumber.toString())
|
||||
when (mode) {
|
||||
RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER,
|
||||
RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER -> sharedViewModel.requestSmsCode(requireContext())
|
||||
|
||||
RegistrationRepository.E164VerificationMode.PHONE_CALL -> sharedViewModel.requestVerificationCall(requireContext())
|
||||
}
|
||||
dialogInterface.dismiss()
|
||||
}
|
||||
show()
|
||||
}
|
||||
} catch (e: NumberParseException) {
|
||||
Log.w(TAG, "Failed to parse number!", e)
|
||||
|
||||
Dialogs.showAlertDialog(
|
||||
requireContext(),
|
||||
getString(R.string.RegistrationActivity_invalid_number),
|
||||
getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRegistrationButtonClicked() {
|
||||
when (enterPhoneNumberMode) {
|
||||
EnterPhoneNumberMode.NORMAL,
|
||||
EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
|
||||
|
||||
EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNormalRegistration() {
|
||||
ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout)
|
||||
sharedViewModel.setInProgress(true)
|
||||
val hasFcm = validateFcmStatus(requireContext())
|
||||
if (hasFcm) {
|
||||
sharedViewModel.uiState.observe(viewLifecycleOwner, FcmTokenRetrievedObserver())
|
||||
sharedViewModel.fetchFcmToken(requireContext())
|
||||
} else {
|
||||
sharedViewModel.uiState.value?.let { value ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (value.phoneNumber == null) {
|
||||
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
|
||||
sharedViewModel.setInProgress(false)
|
||||
} else if (now < value.nextSmsTimestamp) {
|
||||
moveToVerificationEntryScreen()
|
||||
} else {
|
||||
presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFcmTokenRetrieved(value: RegistrationState) {
|
||||
if (value.phoneNumber == null) {
|
||||
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
|
||||
sharedViewModel.setInProgress(false)
|
||||
} else {
|
||||
presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = false)
|
||||
}
|
||||
}
|
||||
|
||||
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(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
|
||||
return false
|
||||
}
|
||||
|
||||
PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE -> {
|
||||
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE)
|
||||
return false
|
||||
}
|
||||
|
||||
PlayServicesUtil.PlayServicesStatus.TRANSIENT_ERROR -> {
|
||||
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT)
|
||||
return false
|
||||
}
|
||||
|
||||
null -> {
|
||||
Log.w(TAG, "Null result received from PlayServicesUtil, marking Play Services as missing.")
|
||||
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConfirmNumberDialogCanceled() {
|
||||
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, missingFcmConsentRequired: 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.")
|
||||
if (missingFcmConsentRequired) {
|
||||
handlePromptForNoPlayServices()
|
||||
} else {
|
||||
sharedViewModel.onUserConfirmedPhoneNumber(requireContext())
|
||||
}
|
||||
}
|
||||
setNegativeButton(R.string.RegistrationActivity_edit_number) { _, _ -> handleConfirmNumberDialogCanceled() }
|
||||
setOnCancelListener { _ -> handleConfirmNumberDialogCanceled() }
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun handlePromptForNoPlayServices() {
|
||||
val context = activity
|
||||
|
||||
if (context != null) {
|
||||
Log.d(TAG, "Device does not have Play Services, showing consent dialog.")
|
||||
MaterialAlertDialogBuilder(context).apply {
|
||||
setTitle(R.string.RegistrationActivity_missing_google_play_services)
|
||||
setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services)
|
||||
setPositiveButton(R.string.RegistrationActivity_i_understand) { _, _ ->
|
||||
Log.d(TAG, "User confirmed number.")
|
||||
sharedViewModel.onUserConfirmedPhoneNumber(AppDependencies.application)
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setOnCancelListener { fragmentViewModel.clearError() }
|
||||
setOnDismissListener { fragmentViewModel.clearError() }
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveToEnterPinScreen() {
|
||||
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionReRegisterWithPinFragment())
|
||||
sharedViewModel.setInProgress(false)
|
||||
}
|
||||
|
||||
private fun moveToVerificationEntryScreen() {
|
||||
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode())
|
||||
sharedViewModel.setInProgress(false)
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback<RegistrationState>(sharedViewModel.uiState) {
|
||||
override fun onValue(value: RegistrationState): 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@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy())
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
|
||||
|
||||
/**
|
||||
* Enter phone number mode to determine if verification is needed or just e164 input is necessary.
|
||||
*/
|
||||
enum class EnterPhoneNumberMode {
|
||||
/** Normal registration start, collect number to verify */
|
||||
NORMAL,
|
||||
|
||||
/** User pre-selected restore/transfer flow, collect number to re-register and restore with */
|
||||
COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE,
|
||||
|
||||
/** User reversed decision on restore and needs to resume normal re-register but automatically start verify */
|
||||
RESTART_AFTER_COLLECTION
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
|
||||
|
||||
import android.text.TextWatcher
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
|
||||
/**
|
||||
* State holder for the phone number entry screen, including phone number and Play Services errors.
|
||||
*/
|
||||
data class EnterPhoneNumberState(
|
||||
val countryPrefixIndex: Int = 0,
|
||||
val phoneNumber: String = "",
|
||||
val phoneNumberFormatter: TextWatcher? = null,
|
||||
val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER,
|
||||
val error: Error = Error.NONE
|
||||
) {
|
||||
enum class Error {
|
||||
NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
|
||||
|
||||
import android.telephony.PhoneNumberFormattingTextWatcher
|
||||
import android.text.TextWatcher
|
||||
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.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.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
|
||||
/**
|
||||
* ViewModel for the phone number entry screen.
|
||||
*/
|
||||
class EnterPhoneNumberViewModel : ViewModel() {
|
||||
|
||||
private val TAG = Log.tag(EnterPhoneNumberViewModel::class.java)
|
||||
|
||||
private val store = MutableStateFlow(EnterPhoneNumberState())
|
||||
val uiState = store.asLiveData()
|
||||
|
||||
val formatter: TextWatcher?
|
||||
get() = store.value.phoneNumberFormatter
|
||||
|
||||
val phoneNumber: PhoneNumber?
|
||||
get() = try {
|
||||
parsePhoneNumber(store.value)
|
||||
} catch (ex: NumberParseException) {
|
||||
Log.w(TAG, "Could not parse phone number in current state.", ex)
|
||||
null
|
||||
}
|
||||
|
||||
val supportedCountryPrefixes: List<CountryPrefix> = PhoneNumberUtil.getInstance().supportedCallingCodes
|
||||
.map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
|
||||
.sortedBy { it.digits }
|
||||
|
||||
var e164VerificationMode: RegistrationRepository.E164VerificationMode
|
||||
get() = store.value.mode
|
||||
set(value) = store.update {
|
||||
it.copy(mode = value)
|
||||
}
|
||||
|
||||
fun countryPrefix(): CountryPrefix {
|
||||
return supportedCountryPrefixes[store.value.countryPrefixIndex]
|
||||
}
|
||||
|
||||
fun setPhoneNumber(phoneNumber: String?) {
|
||||
store.update { it.copy(phoneNumber = phoneNumber ?: "") }
|
||||
}
|
||||
|
||||
fun setCountry(digits: Int) {
|
||||
val matchingIndex = countryCodeToAdapterIndex(digits)
|
||||
if (matchingIndex == -1) {
|
||||
Log.d(TAG, "Invalid country code specified $digits")
|
||||
return
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(countryPrefixIndex = matchingIndex)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
val regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(digits)
|
||||
val textWatcher = PhoneNumberFormattingTextWatcher(regionCode)
|
||||
|
||||
store.update {
|
||||
Log.d(TAG, "Updating phone number formatter in state")
|
||||
it.copy(phoneNumberFormatter = textWatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parsePhoneNumber(state: EnterPhoneNumberState): PhoneNumber {
|
||||
return PhoneNumberUtil.getInstance().parse(state.phoneNumber, supportedCountryPrefixes[state.countryPrefixIndex].regionCode)
|
||||
}
|
||||
|
||||
fun isEnteredNumberPossible(state: EnterPhoneNumberState): Boolean {
|
||||
return try {
|
||||
PhoneNumberUtil.getInstance().isPossibleNumber(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(EnterPhoneNumberState.Error.NONE)
|
||||
}
|
||||
|
||||
fun setError(error: EnterPhoneNumberState.Error) {
|
||||
store.update {
|
||||
it.copy(error = error)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,304 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.registrationlock
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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.FragmentRegistrationLockBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RegistrationLockFragment : LoggingFragment(R.layout.fragment_registration_lock) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationLockFragment::class.java)
|
||||
}
|
||||
|
||||
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
|
||||
|
||||
private val viewModel by activityViewModels<RegistrationViewModel>()
|
||||
|
||||
private var timeRemaining: Long = 0
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
|
||||
|
||||
val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
timeRemaining = args.getTimeRemaining()
|
||||
|
||||
binding.kbsLockForgotPin.visibility = View.GONE
|
||||
binding.kbsLockForgotPin.setOnClickListener { handleForgottenPin(timeRemaining) }
|
||||
|
||||
binding.kbsLockPinInput.setImeOptions(EditorInfo.IME_ACTION_DONE)
|
||||
binding.kbsLockPinInput.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
ViewUtil.hideKeyboard(requireContext(), v!!)
|
||||
handlePinEntry()
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
enableAndFocusPinEntry()
|
||||
|
||||
binding.kbsLockPinConfirm.setOnClickListener {
|
||||
ViewUtil.hideKeyboard(requireContext(), binding.kbsLockPinInput)
|
||||
handlePinEntry()
|
||||
}
|
||||
|
||||
binding.kbsLockKeyboardToggle.setOnClickListener {
|
||||
val keyboardType: PinKeyboardType = getPinEntryKeyboardType()
|
||||
updateKeyboard(keyboardType.other)
|
||||
binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
|
||||
}
|
||||
|
||||
val keyboardType: PinKeyboardType = getPinEntryKeyboardType().getOther()
|
||||
binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
|
||||
|
||||
viewModel.lockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t }
|
||||
|
||||
val triesRemaining: Int = viewModel.svrTriesRemaining
|
||||
|
||||
if (triesRemaining <= 3) {
|
||||
val daysRemaining = getLockoutDays(timeRemaining)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
|
||||
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
|
||||
.show()
|
||||
}
|
||||
|
||||
if (triesRemaining < 5) {
|
||||
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining)
|
||||
}
|
||||
|
||||
viewModel.uiState.observe(viewLifecycleOwner) {
|
||||
if (it.inProgress) {
|
||||
binding.kbsLockPinConfirm.setSpinning()
|
||||
} else {
|
||||
binding.kbsLockPinConfirm.cancelSpinning()
|
||||
}
|
||||
|
||||
it.sessionStateError?.let { error ->
|
||||
handleSessionErrorResponse(error)
|
||||
viewModel.sessionStateErrorShown()
|
||||
}
|
||||
|
||||
it.registerAccountError?.let { error ->
|
||||
handleRegistrationErrorResponse(error)
|
||||
viewModel.registerAccountErrorShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinEntry() {
|
||||
binding.kbsLockPinInput.setEnabled(false)
|
||||
|
||||
val pin: String = binding.kbsLockPinInput.getText().toString()
|
||||
|
||||
val trimmedLength = pin.replace(" ", "").length
|
||||
if (trimmedLength == 0) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
|
||||
enableAndFocusPinEntry()
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedLength < SvrConstants.MINIMUM_PIN_LENGTH) {
|
||||
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
|
||||
enableAndFocusPinEntry()
|
||||
return
|
||||
}
|
||||
|
||||
SignalStore.pin.keyboardType = getPinEntryKeyboardType()
|
||||
|
||||
binding.kbsLockPinConfirm.setSpinning()
|
||||
|
||||
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(requireContext(), pin)
|
||||
}
|
||||
|
||||
private fun handleSessionErrorResponse(requestResult: VerificationCodeRequestResult) {
|
||||
when (requestResult) {
|
||||
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
|
||||
is VerificationCodeRequestResult.RateLimited -> onRateLimited()
|
||||
is VerificationCodeRequestResult.AttemptsExhausted -> {
|
||||
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
|
||||
}
|
||||
|
||||
is VerificationCodeRequestResult.RegistrationLocked -> {
|
||||
Log.i(TAG, "Registration locked response to verify account!")
|
||||
binding.kbsLockPinConfirm.cancelSpinning()
|
||||
enableAndFocusPinEntry()
|
||||
Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unable to verify code with registration lock", requestResult.getCause())
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
|
||||
when (result) {
|
||||
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
|
||||
is RegisterAccountResult.RateLimited -> onRateLimited()
|
||||
is RegisterAccountResult.AttemptsExhausted -> {
|
||||
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
|
||||
}
|
||||
|
||||
is RegisterAccountResult.RegistrationLocked -> {
|
||||
Log.i(TAG, "Registration locked response to register account!")
|
||||
binding.kbsLockPinConfirm.cancelSpinning()
|
||||
enableAndFocusPinEntry()
|
||||
Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
is RegisterAccountResult.SvrWrongPin -> onIncorrectKbsRegistrationLockPin(result.triesRemaining)
|
||||
is RegisterAccountResult.SvrNoData -> {
|
||||
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unable to register account with registration lock", result.getCause())
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) {
|
||||
binding.kbsLockPinConfirm.cancelSpinning()
|
||||
binding.kbsLockPinInput.getText().clear()
|
||||
enableAndFocusPinEntry()
|
||||
|
||||
if (svrTriesRemaining == 0) {
|
||||
Log.w(TAG, "Account locked. User out of attempts on KBS.")
|
||||
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
|
||||
return
|
||||
}
|
||||
|
||||
if (svrTriesRemaining == 3) {
|
||||
val daysRemaining = getLockoutDays(timeRemaining)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
|
||||
.setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
if (svrTriesRemaining > 5) {
|
||||
binding.kbsLockPinInputLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again)
|
||||
} else {
|
||||
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining)
|
||||
binding.kbsLockForgotPin.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRateLimited() {
|
||||
binding.kbsLockPinConfirm.cancelSpinning()
|
||||
enableAndFocusPinEntry()
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun onError() {
|
||||
binding.kbsLockPinConfirm.cancelSpinning()
|
||||
enableAndFocusPinEntry()
|
||||
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun handleForgottenPin(timeRemainingMs: Long) {
|
||||
val lockoutDays = getLockoutDays(timeRemainingMs)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
|
||||
.setMessage(requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getLockoutDays(timeRemainingMs: Long): Int {
|
||||
return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1
|
||||
}
|
||||
|
||||
private fun getTriesRemainingDialogMessage(triesRemaining: Int, daysRemaining: Int): String {
|
||||
val resources = requireContext().resources
|
||||
val tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining)
|
||||
val days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining)
|
||||
|
||||
return "$tries $days"
|
||||
}
|
||||
|
||||
private fun enableAndFocusPinEntry() {
|
||||
binding.kbsLockPinInput.setEnabled(true)
|
||||
binding.kbsLockPinInput.setFocusable(true)
|
||||
ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput)
|
||||
}
|
||||
|
||||
private fun getPinEntryKeyboardType(): PinKeyboardType {
|
||||
val isNumeric = (binding.kbsLockPinInput.inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER
|
||||
|
||||
return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC
|
||||
}
|
||||
|
||||
private fun updateKeyboard(keyboard: PinKeyboardType) {
|
||||
val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
|
||||
|
||||
binding.kbsLockPinInput.setInputType(
|
||||
if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
)
|
||||
|
||||
binding.kbsLockPinInput.getText().clear()
|
||||
}
|
||||
|
||||
private fun sendEmailToSupport() {
|
||||
val subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin
|
||||
|
||||
val body = SupportEmailUtil.generateSupportEmailBody(
|
||||
requireContext(),
|
||||
subject,
|
||||
null,
|
||||
null
|
||||
)
|
||||
CommunicationActions.openEmail(
|
||||
requireContext(),
|
||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||
getString(subject),
|
||||
body
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,287 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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.FragmentRegistrationPinRestoreEntryV2Binding
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class ReRegisterWithPinFragment : LoggingFragment(R.layout.fragment_registration_pin_restore_entry_v2) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(ReRegisterWithPinFragment::class.java)
|
||||
}
|
||||
|
||||
private val registrationViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val reRegisterViewModel by viewModels<ReRegisterWithPinViewModel>()
|
||||
|
||||
private val binding: FragmentRegistrationPinRestoreEntryV2Binding by ViewBinderDelegate(FragmentRegistrationPinRestoreEntryV2Binding::bind)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.pinRestorePinTitle)
|
||||
binding.pinRestorePinDescription.setText(R.string.RegistrationLockFragment__enter_the_pin_you_created_for_your_account)
|
||||
|
||||
binding.pinRestoreForgotPin.visibility = View.GONE
|
||||
binding.pinRestoreForgotPin.setOnClickListener { onNeedHelpClicked() }
|
||||
|
||||
binding.pinRestoreSkipButton.setOnClickListener { onSkipClicked() }
|
||||
|
||||
binding.pinRestorePinInput.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
binding.pinRestorePinInput.setOnEditorActionListener { v, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
ViewUtil.hideKeyboard(requireContext(), v!!)
|
||||
handlePinEntry()
|
||||
return@setOnEditorActionListener true
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
enableAndFocusPinEntry()
|
||||
|
||||
binding.pinRestorePinContinue.setOnClickListener {
|
||||
handlePinEntry()
|
||||
}
|
||||
|
||||
binding.pinRestoreKeyboardToggle.setOnClickListener {
|
||||
val currentKeyboardType: PinKeyboardType = getPinEntryKeyboardType()
|
||||
updateKeyboard(currentKeyboardType.other)
|
||||
binding.pinRestoreKeyboardToggle.setIconResource(currentKeyboardType.iconResource)
|
||||
}
|
||||
|
||||
binding.pinRestoreKeyboardToggle.setIconResource(getPinEntryKeyboardType().other.iconResource)
|
||||
|
||||
registrationViewModel.uiState.observe(viewLifecycleOwner, ::updateViewState)
|
||||
}
|
||||
|
||||
private fun updateViewState(state: RegistrationState) {
|
||||
if (state.networkError != null) {
|
||||
genericErrorDialog()
|
||||
registrationViewModel.networkErrorShown()
|
||||
} else if (!state.canSkipSms) {
|
||||
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
|
||||
} else if (state.isRegistrationLockEnabled && state.svrTriesRemaining == 0) {
|
||||
Log.w(TAG, "Unable to continue skip flow, KBS is locked")
|
||||
onAccountLocked()
|
||||
} else {
|
||||
presentProgress(state.inProgress)
|
||||
presentTriesRemaining(state.svrTriesRemaining)
|
||||
}
|
||||
|
||||
state.registerAccountError?.let { error ->
|
||||
registrationErrorHandler(error)
|
||||
registrationViewModel.registerAccountErrorShown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentProgress(inProgress: Boolean) {
|
||||
if (inProgress) {
|
||||
ViewUtil.hideKeyboard(requireContext(), binding.pinRestorePinInput)
|
||||
binding.pinRestorePinInput.isEnabled = false
|
||||
binding.pinRestorePinContinue.setSpinning()
|
||||
} else {
|
||||
binding.pinRestorePinInput.isEnabled = true
|
||||
binding.pinRestorePinContinue.cancelSpinning()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinEntry() {
|
||||
val pin: String? = binding.pinRestorePinInput.text?.toString()
|
||||
|
||||
if (pin.isNullOrBlank()) {
|
||||
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
|
||||
enableAndFocusPinEntry()
|
||||
return
|
||||
}
|
||||
|
||||
if (pin.trim().length < SvrConstants.MINIMUM_PIN_LENGTH) {
|
||||
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
|
||||
enableAndFocusPinEntry()
|
||||
return
|
||||
}
|
||||
|
||||
registrationViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PIN_CONFIRMED)
|
||||
|
||||
registrationViewModel.verifyReRegisterWithPin(
|
||||
context = requireContext(),
|
||||
pin = pin,
|
||||
wrongPinHandler = {
|
||||
reRegisterViewModel.markIncorrectGuess()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun presentTriesRemaining(triesRemaining: Int) {
|
||||
if (reRegisterViewModel.hasIncorrectGuess) {
|
||||
if (triesRemaining == 1 && !reRegisterViewModel.isLocalVerification) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
|
||||
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
if (triesRemaining > 5) {
|
||||
binding.pinRestorePinInputLabel.setText(R.string.PinRestoreEntryFragment_incorrect_pin)
|
||||
} else {
|
||||
binding.pinRestorePinInputLabel.text = resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining)
|
||||
}
|
||||
binding.pinRestoreForgotPin.visibility = View.VISIBLE
|
||||
} else {
|
||||
if (triesRemaining == 1) {
|
||||
binding.pinRestoreForgotPin.visibility = View.VISIBLE
|
||||
if (!reRegisterViewModel.isLocalVerification) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (triesRemaining == 0) {
|
||||
Log.w(TAG, "Account locked. User out of attempts on KBS.")
|
||||
onAccountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAccountLocked() {
|
||||
Log.d(TAG, "Showing Incorrect PIN dialog. Is local verification: ${reRegisterViewModel.isLocalVerification}")
|
||||
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_out_of_guesses_local else R.string.PinRestoreLockedFragment_youve_run_out_of_pin_guesses
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
|
||||
.setMessage(message)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ReRegisterWithPinFragment_send_sms_code) { _, _ -> onSkipPinEntry() }
|
||||
.setNegativeButton(R.string.AccountLockedFragment__learn_more) { _, _ -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PinRestoreLockedFragment_learn_more_url)) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun enableAndFocusPinEntry() {
|
||||
binding.pinRestorePinInput.isEnabled = true
|
||||
binding.pinRestorePinInput.isFocusable = true
|
||||
ViewUtil.focusAndShowKeyboard(binding.pinRestorePinInput)
|
||||
}
|
||||
|
||||
private fun getPinEntryKeyboardType(): PinKeyboardType {
|
||||
val isNumeric = binding.pinRestorePinInput.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_CLASS_NUMBER
|
||||
return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC
|
||||
}
|
||||
|
||||
private fun updateKeyboard(keyboard: PinKeyboardType) {
|
||||
val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
|
||||
binding.pinRestorePinInput.inputType = if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
binding.pinRestorePinInput.text?.clear()
|
||||
}
|
||||
|
||||
private fun onNeedHelpClicked() {
|
||||
Log.i(TAG, "User clicked need help dialog.")
|
||||
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_need_help_local else R.string.PinRestoreEntryFragment_your_pin_is_a_d_digit_code
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.PinRestoreEntryFragment_need_help)
|
||||
.setMessage(getString(message, SvrConstants.MINIMUM_PIN_LENGTH))
|
||||
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
|
||||
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ ->
|
||||
val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.ReRegisterWithPinFragment_support_email_subject, null, null)
|
||||
|
||||
CommunicationActions.openEmail(
|
||||
requireContext(),
|
||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||
getString(R.string.ReRegisterWithPinFragment_support_email_subject),
|
||||
body
|
||||
)
|
||||
}
|
||||
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun onSkipClicked() {
|
||||
Log.i(TAG, "User clicked the skip PIN button.")
|
||||
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_skip_local else R.string.PinRestoreEntryFragment_if_you_cant_remember_your_pin
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.PinRestoreEntryFragment_skip_pin_entry)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
|
||||
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun onSkipPinEntry() {
|
||||
Log.d(TAG, "User skipping PIN entry.")
|
||||
registrationViewModel.setUserSkippedReRegisterFlow(true)
|
||||
}
|
||||
|
||||
private fun presentRateLimitedDialog() {
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setTitle(R.string.RegistrationActivity_too_many_attempts)
|
||||
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
|
||||
setPositiveButton(android.R.string.ok, null)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun genericErrorDialog() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.RegistrationActivity_error_connecting_to_service)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun registrationErrorHandler(result: RegisterAccountResult) {
|
||||
when (result) {
|
||||
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
|
||||
is RegisterAccountResult.AuthorizationFailed,
|
||||
is RegisterAccountResult.MalformedRequest,
|
||||
is RegisterAccountResult.UnknownError,
|
||||
is RegisterAccountResult.ValidationError,
|
||||
is RegisterAccountResult.RegistrationLocked -> {
|
||||
Log.i(TAG, "Registration failed.", result.getCause())
|
||||
genericErrorDialog()
|
||||
}
|
||||
|
||||
is RegisterAccountResult.IncorrectRecoveryPassword -> {
|
||||
registrationViewModel.setUserSkippedReRegisterFlow(true)
|
||||
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
|
||||
}
|
||||
|
||||
is RegisterAccountResult.AttemptsExhausted,
|
||||
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
|
||||
|
||||
is RegisterAccountResult.SvrNoData -> onAccountLocked()
|
||||
is RegisterAccountResult.SvrWrongPin -> {
|
||||
reRegisterViewModel.markIncorrectGuess()
|
||||
reRegisterViewModel.markAsRemoteVerification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
|
||||
|
||||
data class ReRegisterWithPinState(
|
||||
val isLocalVerification: Boolean = false,
|
||||
val hasIncorrectGuess: Boolean = false,
|
||||
val localPinMatches: Boolean = false
|
||||
)
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class ReRegisterWithPinViewModel : ViewModel() {
|
||||
companion object {
|
||||
private val TAG = Log.tag(ReRegisterWithPinViewModel::class.java)
|
||||
}
|
||||
|
||||
private val store = MutableStateFlow(ReRegisterWithPinState())
|
||||
|
||||
val isLocalVerification: Boolean
|
||||
get() = store.value.isLocalVerification
|
||||
val hasIncorrectGuess: Boolean
|
||||
get() = store.value.hasIncorrectGuess
|
||||
|
||||
fun markAsRemoteVerification() {
|
||||
store.update {
|
||||
it.copy(isLocalVerification = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun markIncorrectGuess() {
|
||||
store.update {
|
||||
it.copy(hasIncorrectGuess = true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
/**
|
||||
* Visual formatter for backup keys.
|
||||
*
|
||||
* @param length max length of key
|
||||
* @param chunkSize character count per group
|
||||
*/
|
||||
class BackupKeyVisualTransformation(private val length: Int, private val chunkSize: Int) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
var output = ""
|
||||
for (i in text.take(length).indices) {
|
||||
output += text[i]
|
||||
if (i % chunkSize == chunkSize - 1) {
|
||||
output += " "
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
text = AnnotatedString(output),
|
||||
offsetMapping = BackupKeyVisualTransformation(chunkSize)
|
||||
)
|
||||
}
|
||||
|
||||
private class BackupKeyVisualTransformation(private val chunkSize: Int) : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
return offset + (offset / chunkSize)
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
return offset - (offset / chunkSize)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,299 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyViewModel.EnterBackupKeyState
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Enter backup key screen for manual Signal Backups restore flow.
|
||||
*/
|
||||
class EnterBackupKeyFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private const val LEARN_MORE_URL = "https://signal.org" // TODO [backups] but really
|
||||
}
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val viewModel by viewModels<EnterBackupKeyViewModel>()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
val sharedState by sharedViewModel.state.collectAsState()
|
||||
|
||||
EnterBackupKeyScreen(
|
||||
state = state,
|
||||
sharedState = sharedState,
|
||||
onBackupKeyChanged = viewModel::updateBackupKey,
|
||||
onNextClicked = {
|
||||
sharedViewModel.registerWithBackupKey(
|
||||
context = requireContext(),
|
||||
backupKey = state.backupKey,
|
||||
e164 = null,
|
||||
pin = null
|
||||
)
|
||||
},
|
||||
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
|
||||
onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EnterBackupKeyScreen(
|
||||
state: EnterBackupKeyState,
|
||||
sharedState: RegistrationState,
|
||||
onBackupKeyChanged: (String) -> Unit = {},
|
||||
onNextClicked: () -> Unit = {},
|
||||
onLearnMore: () -> Unit = {},
|
||||
onSkip: () -> Unit = {}
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
RegistrationScreen(
|
||||
title = stringResource(R.string.EnterBackupKey_title),
|
||||
subtitle = stringResource(R.string.EnterBackupKey_subtitle),
|
||||
bottomContent = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(
|
||||
enabled = !sharedState.inProgress,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
sheetState.show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.backupKeyValid && !sharedState.inProgress,
|
||||
onClick = onNextClicked
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RegistrationActivity_next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val visualTransform = remember(state.length, state.chunkLength) { BackupKeyVisualTransformation(length = state.length, chunkSize = state.chunkLength) }
|
||||
|
||||
TextField(
|
||||
value = state.backupKey,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
|
||||
},
|
||||
onValueChange = onBackupKeyChanged,
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { if (state.backupKeyValid) onNextClicked() }
|
||||
),
|
||||
minLines = 4,
|
||||
visualTransformation = visualTransform,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
if (sheetState.isVisible) {
|
||||
ModalBottomSheet(
|
||||
dragHandle = null,
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
) {
|
||||
NoBackupKeyBottomSheet(
|
||||
onLearnMore = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
onLearnMore()
|
||||
},
|
||||
onSkip = onSkip
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun EnterBackupKeyScreenPreview() {
|
||||
Previews.Preview {
|
||||
EnterBackupKeyScreen(
|
||||
state = EnterBackupKeyState(backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", length = 64, chunkLength = 4),
|
||||
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoBackupKeyBottomSheet(
|
||||
onLearnMore: () -> Unit = {},
|
||||
onSkip: () -> Unit = {}
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_key_24),
|
||||
tint = BackupsIconColors.Success.foreground,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 18.dp, bottom = 16.dp)
|
||||
.size(88.dp)
|
||||
.background(
|
||||
color = BackupsIconColors.Success.background,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.EnterBackupKey_no_backup_key),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onLearnMore
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.EnterBackupKey_learn_more)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSkip
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun NoBackupKeyBottomSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
NoBackupKeyBottomSheet()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.signal.core.util.Hex
|
||||
import java.io.IOException
|
||||
|
||||
class EnterBackupKeyViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
// TODO [backups] Set actual valid characters for key input
|
||||
private val VALID_CHARACTERS = setOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
|
||||
}
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
EnterBackupKeyState(
|
||||
backupKey = "",
|
||||
length = 64,
|
||||
chunkLength = 4
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<EnterBackupKeyState> = _state
|
||||
|
||||
fun updateBackupKey(key: String) {
|
||||
_state.update {
|
||||
val newKey = key.removeIllegalCharacters().take(length)
|
||||
copy(backupKey = newKey, backupKeyValid = validate(length, newKey))
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(length: Int, backupKey: String): Boolean {
|
||||
if (backupKey.length != length) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO [backups] Actually validate key with requirements instead of just hex
|
||||
Hex.fromStringCondensed(backupKey)
|
||||
} catch (e: IOException) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun String.removeIllegalCharacters(): String {
|
||||
return filter { VALID_CHARACTERS.contains(it) }
|
||||
}
|
||||
|
||||
private inline fun <T> MutableState<T>.update(update: T.() -> T) {
|
||||
this.value = this.value.update()
|
||||
}
|
||||
|
||||
data class EnterBackupKeyState(
|
||||
val backupKey: String = "",
|
||||
val backupKeyValid: Boolean = false,
|
||||
val length: Int,
|
||||
val chunkLength: Int
|
||||
)
|
||||
}
|
|
@ -0,0 +1,382 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.bytes
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Restore backup from remote source.
|
||||
*/
|
||||
class RemoteRestoreActivity : BaseActivity() {
|
||||
companion object {
|
||||
fun getIntent(context: Context): Intent {
|
||||
return Intent(context, RemoteRestoreActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel: RemoteRestoreViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
lifecycleScope.launch {
|
||||
val restored = viewModel
|
||||
.state
|
||||
.map { it.importState }
|
||||
.filterIsInstance<RemoteRestoreViewModel.ImportState.Restored>()
|
||||
.firstOrNull()
|
||||
|
||||
if (restored != null) {
|
||||
continueRegistration(restored.missingProfileData)
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
val state: RemoteRestoreViewModel.ScreenState by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
SignalTheme {
|
||||
Surface {
|
||||
RestoreFromBackupContent(
|
||||
state = state,
|
||||
onRestoreBackupClick = { viewModel.restore() },
|
||||
onCancelClick = { finish() },
|
||||
onErrorDialogDismiss = { viewModel.clearError() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(restoreEvent: RestoreV2Event) {
|
||||
viewModel.updateRestoreProgress(restoreEvent)
|
||||
}
|
||||
|
||||
private fun continueRegistration(missingProfileData: Boolean) {
|
||||
val main = MainActivity.clearTop(this)
|
||||
|
||||
if (missingProfileData) {
|
||||
val profile = CreateProfileActivity.getIntentForUserProfile(this)
|
||||
profile.putExtra("next_intent", main)
|
||||
startActivity(profile)
|
||||
} else {
|
||||
startActivity(main)
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreFromBackupContent(
|
||||
state: RemoteRestoreViewModel.ScreenState,
|
||||
onRestoreBackupClick: () -> Unit = {},
|
||||
onCancelClick: () -> Unit = {},
|
||||
onErrorDialogDismiss: () -> Unit = {}
|
||||
) {
|
||||
val subtitle = buildAnnotatedString {
|
||||
append(
|
||||
stringResource(
|
||||
id = R.string.RemoteRestoreActivity__backup_created_at,
|
||||
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime),
|
||||
DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime)
|
||||
)
|
||||
)
|
||||
append(" ")
|
||||
if (state.backupTier != MessageBackupTier.PAID) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RegistrationScreen(
|
||||
title = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
|
||||
subtitle = if (state.isLoaded()) subtitle else null,
|
||||
bottomContent = {
|
||||
Column {
|
||||
if (state.isLoaded()) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onRestoreBackupClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup))
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onCancelClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
when (state.loadState) {
|
||||
RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> {
|
||||
Dialogs.IndeterminateProgressDialog(
|
||||
message = stringResource(R.string.RemoteRestoreActivity__fetching_backup_details)
|
||||
)
|
||||
}
|
||||
|
||||
RemoteRestoreViewModel.ScreenState.LoadState.LOADED -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(top = 20.dp, bottom = 18.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
getFeatures(state.backupTier).forEach {
|
||||
MessageBackupsTypeFeatureRow(
|
||||
messageBackupsTypeFeature = it,
|
||||
iconTint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> {
|
||||
RestoreFailedDialog(onDismiss = onCancelClick)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.importState) {
|
||||
RemoteRestoreViewModel.ImportState.None -> Unit
|
||||
RemoteRestoreViewModel.ImportState.InProgress -> RestoreProgressDialog(state.restoreProgress)
|
||||
is RemoteRestoreViewModel.ImportState.Restored -> Unit
|
||||
RemoteRestoreViewModel.ImportState.Failed -> RestoreFailedDialog(onDismiss = onErrorDialogDismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreFromBackupContentPreview() {
|
||||
Previews.Preview {
|
||||
RestoreFromBackupContent(
|
||||
state = RemoteRestoreViewModel.ScreenState(
|
||||
backupTier = MessageBackupTier.PAID,
|
||||
backupTime = System.currentTimeMillis(),
|
||||
importState = RemoteRestoreViewModel.ImportState.None,
|
||||
restoreProgress = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreFromBackupContentLoadingPreview() {
|
||||
Previews.Preview {
|
||||
RestoreFromBackupContent(
|
||||
state = RemoteRestoreViewModel.ScreenState(
|
||||
importState = RemoteRestoreViewModel.ImportState.None,
|
||||
restoreProgress = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getFeatures(tier: MessageBackupTier?): ImmutableList<MessageBackupsTypeFeature> {
|
||||
return when (tier) {
|
||||
null -> persistentListOf()
|
||||
MessageBackupTier.PAID -> {
|
||||
persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
MessageBackupTier.FREE -> {
|
||||
persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30)
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
|
||||
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog that *just* shows a spinner. Useful for short actions where you need to
|
||||
* let the user know that some action is completing.
|
||||
*/
|
||||
@Composable
|
||||
private fun RestoreProgressDialog(restoreProgress: RestoreV2Event?) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {},
|
||||
dismissButton = {},
|
||||
text = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.wrapContentSize()
|
||||
) {
|
||||
if (restoreProgress == null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(top = 55.dp, bottom = 16.dp)
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
progress = { restoreProgress.getProgress() },
|
||||
modifier = Modifier
|
||||
.padding(top = 55.dp, bottom = 16.dp)
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val progressText = when (restoreProgress?.type) {
|
||||
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
|
||||
RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
|
||||
else -> stringResource(id = R.string.RemoteRestoreActivity__restoring)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = progressText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
if (restoreProgress != null) {
|
||||
val progressBytes = restoreProgress.count.toUnitString(maxPlaces = 2)
|
||||
val totalBytes = restoreProgress.estimatedTotalCount.toUnitString(maxPlaces = 2)
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(212.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ProgressDialogPreview() {
|
||||
Previews.Preview {
|
||||
RestoreProgressDialog(
|
||||
RestoreV2Event(
|
||||
type = RestoreV2Event.Type.PROGRESS_RESTORE,
|
||||
count = 1234.bytes,
|
||||
estimatedTotalCount = 10240.bytes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RestoreFailedDialog(
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = "Restore Failed", // TODO [backups] Remote restore error placeholder copy
|
||||
body = "Unable to restore from backup. Please try again.", // TODO [backups] Placeholder copy
|
||||
confirm = stringResource(android.R.string.ok),
|
||||
onConfirm = onDismiss,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreFailedDialogPreview() {
|
||||
Previews.Preview {
|
||||
RestoreFailedDialog()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
|
||||
class RemoteRestoreViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RemoteRestoreViewModel::class)
|
||||
}
|
||||
|
||||
private val store: MutableStateFlow<ScreenState> = MutableStateFlow(
|
||||
ScreenState(
|
||||
backupTier = SignalStore.backup.backupTier,
|
||||
backupTime = SignalStore.backup.lastBackupTime
|
||||
)
|
||||
)
|
||||
|
||||
val state: StateFlow<ScreenState> = store.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val restored = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) != null
|
||||
store.update {
|
||||
if (restored) {
|
||||
it.copy(
|
||||
loadState = ScreenState.LoadState.LOADED,
|
||||
backupTier = SignalStore.backup.backupTier,
|
||||
backupTime = SignalStore.backup.lastBackupTime
|
||||
)
|
||||
} else {
|
||||
it.copy(
|
||||
loadState = ScreenState.LoadState.FAILURE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
viewModelScope.launch {
|
||||
store.update { it.copy(importState = ImportState.InProgress) }
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val jobStateFlow = callbackFlow {
|
||||
val listener = JobTracker.JobListener { _, jobState ->
|
||||
trySend(jobState)
|
||||
}
|
||||
|
||||
AppDependencies
|
||||
.jobManager
|
||||
.startChain(BackupRestoreJob())
|
||||
.then(SyncArchivedMediaJob())
|
||||
.then(BackupRestoreMediaJob())
|
||||
.enqueue(listener)
|
||||
|
||||
awaitClose {
|
||||
AppDependencies.jobManager.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
jobStateFlow.collect { state ->
|
||||
when (state) {
|
||||
JobTracker.JobState.SUCCESS -> {
|
||||
Log.i(TAG, "Restore successful")
|
||||
SignalStore.registration.markRestoreCompleted()
|
||||
|
||||
if (!RegistrationRepository.isMissingProfileData()) {
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
AppDependencies.jobManager.add(ProfileUploadJob())
|
||||
}
|
||||
|
||||
store.update { it.copy(importState = ImportState.Restored(RegistrationRepository.isMissingProfileData())) }
|
||||
}
|
||||
|
||||
JobTracker.JobState.PENDING,
|
||||
JobTracker.JobState.RUNNING -> {
|
||||
Log.i(TAG, "Restore job states updated: $state")
|
||||
}
|
||||
|
||||
JobTracker.JobState.FAILURE,
|
||||
JobTracker.JobState.IGNORED -> {
|
||||
Log.w(TAG, "Restore failed with $state")
|
||||
|
||||
store.update { it.copy(importState = ImportState.Failed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRestoreProgress(restoreEvent: RestoreV2Event) {
|
||||
store.update { it.copy(restoreProgress = restoreEvent) }
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
SignalStore.registration.markSkippedTransferOrRestore()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
store.update { it.copy(importState = ImportState.None, restoreProgress = null) }
|
||||
}
|
||||
|
||||
data class ScreenState(
|
||||
val backupTier: MessageBackupTier? = null,
|
||||
val backupTime: Long = -1,
|
||||
val importState: ImportState = ImportState.None,
|
||||
val restoreProgress: RestoreV2Event? = null,
|
||||
val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING
|
||||
) {
|
||||
|
||||
fun isLoaded(): Boolean {
|
||||
return loadState == LoadState.LOADED
|
||||
}
|
||||
|
||||
fun isLoading(): Boolean {
|
||||
return loadState == LoadState.LOADING
|
||||
}
|
||||
|
||||
enum class LoadState {
|
||||
LOADING, LOADED, FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ImportState {
|
||||
data object None : ImportState
|
||||
data object InProgress : ImportState
|
||||
data class Restored(val missingProfileData: Boolean) : ImportState
|
||||
data object Failed : ImportState
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Restore methods for various spots in restore flow.
|
||||
*/
|
||||
enum class RestoreMethod(val iconRes: Int, val titleRes: Int, val subtitleRes: Int) {
|
||||
FROM_SIGNAL_BACKUPS(
|
||||
iconRes = R.drawable.symbol_signal_backups_24,
|
||||
titleRes = R.string.SelectRestoreMethodFragment__from_signal_backups,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan
|
||||
),
|
||||
FROM_LOCAL_BACKUP_V1(
|
||||
iconRes = R.drawable.symbol_file_24,
|
||||
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_file,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
|
||||
),
|
||||
FROM_LOCAL_BACKUP_V2(
|
||||
iconRes = R.drawable.symbol_folder_24,
|
||||
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_folder,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
|
||||
),
|
||||
FROM_OLD_DEVICE(
|
||||
iconRes = R.drawable.symbol_transfer_24,
|
||||
titleRes = R.string.SelectRestoreMethodFragment__from_your_old_phone,
|
||||
subtitleRes = R.string.SelectRestoreMethodFragment__transfer_directly_from_old
|
||||
)
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Renders row-ux used commonly through the restore flows.
|
||||
*/
|
||||
@Composable
|
||||
fun RestoreRow(
|
||||
icon: Painter,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onRowClick: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(SignalTheme.colors.colorSurface2)
|
||||
.clickable(enabled = true, onClick = onRowClick)
|
||||
.padding(horizontal = 20.dp, vertical = 22.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreMethodRowPreview() {
|
||||
Previews.Preview {
|
||||
RestoreRow(
|
||||
icon = painterResource(R.drawable.symbol_backup_24),
|
||||
title = stringResource(R.string.SelectRestoreMethodFragment__from_signal_backups),
|
||||
subtitle = stringResource(R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,350 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.horizontalGutters
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
|
||||
|
||||
/**
|
||||
* Show QR code on new device to allow registration and restore via old device.
|
||||
*/
|
||||
class RestoreViaQrFragment : ComposeFragment() {
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val viewModel: RestoreViaQrViewModel by viewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
viewModel
|
||||
.state
|
||||
.mapNotNull { it.provisioningMessage }
|
||||
.distinctUntilChanged()
|
||||
.collect { message ->
|
||||
sharedViewModel.registerWithBackupKey(requireContext(), message.accountEntropyPool, message.e164, message.pin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
sharedViewModel
|
||||
.state
|
||||
.map { it.registerAccountError }
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
sharedViewModel.registerAccountErrorShown()
|
||||
viewModel.handleRegistrationFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
RestoreViaQrScreen(
|
||||
state = state,
|
||||
onRetryQrCode = viewModel::restart,
|
||||
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
|
||||
onCancel = { findNavController().popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RestoreViaQrScreen(
|
||||
state: RestoreViaQrViewModel.RestoreViaQrState,
|
||||
onRetryQrCode: () -> Unit = {},
|
||||
onRegistrationErrorDismiss: () -> Unit = {},
|
||||
onCancel: () -> Unit = {}
|
||||
) {
|
||||
RegistrationScreen(
|
||||
title = stringResource(R.string.RestoreViaQr_title),
|
||||
subtitle = null,
|
||||
bottomContent = {
|
||||
TextButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(space = 48.dp, alignment = Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(space = 48.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(160.dp, 320.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(SignalTheme.colors.colorSurface5)
|
||||
.padding(40.dp)
|
||||
) {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = state.qrState,
|
||||
contentAlignment = Alignment.Center,
|
||||
label = "qr-code-progress"
|
||||
) { qrState ->
|
||||
when (qrState) {
|
||||
is RestoreViaQrViewModel.QrState.Loaded -> {
|
||||
QrCode(
|
||||
data = qrState.qrData,
|
||||
foregroundColor = Color(0xFF2449C0),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
)
|
||||
}
|
||||
|
||||
RestoreViaQrViewModel.QrState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
is RestoreViaQrViewModel.QrState.Scanned,
|
||||
RestoreViaQrViewModel.QrState.Failed -> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val text = if (state.qrState is RestoreViaQrViewModel.QrState.Scanned) {
|
||||
stringResource(R.string.RestoreViaQr_qr_code_scanned)
|
||||
} else {
|
||||
stringResource(R.string.RestoreViaQr_qr_code_error)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Buttons.Small(
|
||||
onClick = onRetryQrCode
|
||||
) {
|
||||
Text(text = stringResource(R.string.RestoreViaQr_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(alignment = Alignment.CenterVertically)
|
||||
.widthIn(160.dp, 320.dp)
|
||||
) {
|
||||
InstructionRow(
|
||||
icon = painterResource(R.drawable.symbol_phone_24),
|
||||
instruction = stringResource(R.string.RestoreViaQr_instruction_1)
|
||||
)
|
||||
|
||||
InstructionRow(
|
||||
icon = painterResource(R.drawable.symbol_camera_24),
|
||||
instruction = stringResource(R.string.RestoreViaQr_instruction_2)
|
||||
)
|
||||
|
||||
InstructionRow(
|
||||
icon = painterResource(R.drawable.symbol_qrcode_24),
|
||||
instruction = stringResource(R.string.RestoreViaQr_instruction_3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isRegistering) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
} else if (state.showRegistrationError) {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.RegistrationActivity_error_connecting_to_service),
|
||||
onDismiss = onRegistrationErrorDismiss,
|
||||
dismiss = stringResource(android.R.string.ok)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrViewModel.RestoreViaQrState(
|
||||
qrState = RestoreViaQrViewModel.QrState.Loaded(
|
||||
QrCodeData.forData("sgnl://rereg?uuid=asdfasdfasdfasdfasdfasdf&pub_key=asdfasdfasdfSDFSsdfsdfSDFSDffd", false)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenLoadingPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Loading)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenFailurePreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenScannedPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Scanned)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenRegisteringPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreViaQrScreenRegistrationFailedPreview() {
|
||||
Previews.Preview {
|
||||
RestoreViaQrScreen(
|
||||
state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = false, showRegistrationError = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstructionRow(
|
||||
icon: Painter,
|
||||
instruction: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(
|
||||
text = instruction,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun InstructionRowPreview() {
|
||||
Previews.Preview {
|
||||
InstructionRow(
|
||||
icon = painterResource(R.drawable.symbol_phone_24),
|
||||
instruction = "Instruction!"
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.registration.proto.RegistrationProvisionMessage
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.whispersystems.signalservice.api.registration.ProvisioningSocket
|
||||
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
|
||||
import java.io.Closeable
|
||||
|
||||
class RestoreViaQrViewModel : ViewModel() {
|
||||
|
||||
private val store: MutableStateFlow<RestoreViaQrState> = MutableStateFlow(RestoreViaQrState())
|
||||
|
||||
val state: StateFlow<RestoreViaQrState> = store
|
||||
|
||||
private var socketHandle: Closeable
|
||||
|
||||
init {
|
||||
socketHandle = start()
|
||||
}
|
||||
|
||||
fun restart() {
|
||||
socketHandle.close()
|
||||
socketHandle = start()
|
||||
}
|
||||
|
||||
fun handleRegistrationFailure() {
|
||||
store.update {
|
||||
if (it.isRegistering) {
|
||||
it.copy(
|
||||
isRegistering = false,
|
||||
provisioningMessage = null,
|
||||
showRegistrationError = true
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRegistrationError() {
|
||||
store.update { it.copy(showRegistrationError = false) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
socketHandle.close()
|
||||
}
|
||||
|
||||
private fun start(): Closeable {
|
||||
store.update { it.copy(qrState = QrState.Loading) }
|
||||
|
||||
return ProvisioningSocket.start(
|
||||
identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(),
|
||||
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
|
||||
handler = CoroutineExceptionHandler { _, _ -> store.update { it.copy(qrState = QrState.Failed) } }
|
||||
) { socket ->
|
||||
val url = socket.getProvisioningUrl()
|
||||
store.update { it.copy(qrState = QrState.Loaded(qrData = QrCodeData.forData(data = url, supportIconOverlay = false))) }
|
||||
|
||||
val result = socket.getRegistrationProvisioningMessage()
|
||||
if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) {
|
||||
store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) }
|
||||
} else {
|
||||
store.update { it.copy(showProvisioningError = true, qrState = QrState.Scanned) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RestoreViaQrState(
|
||||
val isRegistering: Boolean = false,
|
||||
val qrState: QrState = QrState.Loading,
|
||||
val provisioningMessage: RegistrationProvisionMessage? = null,
|
||||
val showProvisioningError: Boolean = false,
|
||||
val showRegistrationError: Boolean = false
|
||||
)
|
||||
|
||||
sealed interface QrState {
|
||||
data object Loading : QrState
|
||||
data class Loaded(val qrData: QrCodeData) : QrState
|
||||
data object Failed : QrState
|
||||
data object Scanned : QrState
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Provide options to select restore/transfer operation and flow during manual registration.
|
||||
*/
|
||||
class SelectManualRestoreMethodFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SelectManualRestoreMethodFragment::class)
|
||||
}
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
|
||||
private val launchRestoreActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
when (val resultCode = result.resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
sharedViewModel.onBackupSuccessfullyRestored()
|
||||
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
Log.w(TAG, "Backup restoration canceled.")
|
||||
}
|
||||
else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
SelectRestoreMethodScreen(
|
||||
restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1),
|
||||
onRestoreMethodClicked = this::startRestoreMethod,
|
||||
onSkip = { findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL)) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun startRestoreMethod(method: RestoreMethod) {
|
||||
when (method) {
|
||||
RestoreMethod.FROM_SIGNAL_BACKUPS -> findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
|
||||
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
|
||||
RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
|
||||
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.restore
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
|
||||
|
||||
/**
|
||||
* Screen showing various restore methods available during quick and manual re-registration.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectRestoreMethodScreen(
|
||||
restoreMethods: List<RestoreMethod>,
|
||||
onRestoreMethodClicked: (RestoreMethod) -> Unit = {},
|
||||
onSkip: () -> Unit = {}
|
||||
) {
|
||||
RegistrationScreen(
|
||||
title = stringResource(id = R.string.SelectRestoreMethodFragment__restore_or_transfer_account),
|
||||
subtitle = stringResource(id = R.string.SelectRestoreMethodFragment__get_your_signal_account),
|
||||
bottomContent = {
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Text(text = stringResource(R.string.registration_activity__skip))
|
||||
}
|
||||
}
|
||||
) {
|
||||
for (method in restoreMethods) {
|
||||
RestoreRow(
|
||||
icon = painterResource(method.iconRes),
|
||||
title = stringResource(method.titleRes),
|
||||
subtitle = stringResource(method.subtitleRes),
|
||||
onRowClick = { onRestoreMethodClicked(method) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun SelectRestoreMethodScreenPreview() {
|
||||
SignalTheme {
|
||||
SelectRestoreMethodScreen(listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.shared
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.horizontalGutters
|
||||
|
||||
/**
|
||||
* A base framework for rendering the various v3 registration screens.
|
||||
*/
|
||||
@Composable
|
||||
fun RegistrationScreen(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
bottomContent: @Composable (BoxScope.() -> Unit),
|
||||
mainContent: @Composable () -> Unit
|
||||
) {
|
||||
RegistrationScreen(title, AnnotatedString(subtitle), bottomContent, mainContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* A base framework for rendering the various v3 registration screens.
|
||||
*/
|
||||
@Composable
|
||||
fun RegistrationScreen(
|
||||
title: String,
|
||||
subtitle: AnnotatedString?,
|
||||
bottomContent: @Composable (BoxScope.() -> Unit),
|
||||
mainContent: @Composable () -> Unit
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.weight(weight = 1f, fill = false)
|
||||
.padding(top = 40.dp, bottom = 16.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
)
|
||||
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
mainContent()
|
||||
}
|
||||
|
||||
Surface(
|
||||
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 24.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
bottomContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RegistrationScreenPreview() {
|
||||
Previews.Preview {
|
||||
RegistrationScreen(
|
||||
title = "Title",
|
||||
subtitle = "Subtitle",
|
||||
bottomContent = {
|
||||
TextButton(onClick = {}) {
|
||||
Text("Bottom Button")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Main content")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.welcome
|
||||
|
||||
import android.content.DialogInterface
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
|
||||
/**
|
||||
* Restore flow starting bottom sheet that allows user to progress through quick restore or manual restore flows
|
||||
* from the Welcome screen.
|
||||
*/
|
||||
class RestoreWelcomeBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
private var result: WelcomeUserSelection = WelcomeUserSelection.CONTINUE
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "RestoreWelcomeBottomSheet"
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
Sheet(
|
||||
onHasOldPhone = {
|
||||
result = WelcomeUserSelection.RESTORE_WITH_OLD_PHONE
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onNoPhone = {
|
||||
result = WelcomeUserSelection.RESTORE_WITH_NO_PHONE
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to result))
|
||||
|
||||
super.onDismiss(dialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Sheet(
|
||||
onHasOldPhone: () -> Unit = {},
|
||||
onNoPhone: () -> Unit = {}
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(bottom = 54.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
RestoreActionRow(
|
||||
icon = painterResource(R.drawable.symbol_qrcode_24),
|
||||
title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
|
||||
subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr),
|
||||
onRowClick = onHasOldPhone
|
||||
)
|
||||
|
||||
RestoreActionRow(
|
||||
icon = painterResource(R.drawable.symbol_no_phone_44),
|
||||
title = stringResource(R.string.WelcomeFragment_restore_action_i_dont_have_my_old_phone),
|
||||
subtitle = stringResource(R.string.WelcomeFragment_restore_action_reinstalling),
|
||||
onRowClick = onNoPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@SignalPreview
|
||||
private fun SheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Sheet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RestoreActionRow(
|
||||
icon: Painter,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onRowClick: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.clickable(enabled = true, onClick = onRowClick)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(44.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun RestoreActionRowPreview() {
|
||||
Previews.Preview {
|
||||
RestoreActionRow(
|
||||
icon = painterResource(R.drawable.symbol_qrcode_24),
|
||||
title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
|
||||
subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.welcome
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
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.FragmentRegistrationWelcomeV3Binding
|
||||
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.registrationv3.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.permissions.GrantPermissionsFragment
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* First screen that is displayed on the very first app launch.
|
||||
*/
|
||||
class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v3) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(WelcomeFragment::class.java)
|
||||
private const val TERMS_AND_CONDITIONS_URL = "https://signal.org/legal"
|
||||
}
|
||||
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
private val binding: FragmentRegistrationWelcomeV3Binding by ViewBinderDelegate(FragmentRegistrationWelcomeV3Binding::bind)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setDebugLogSubmitMultiTapView(binding.image)
|
||||
setDebugLogSubmitMultiTapView(binding.title)
|
||||
|
||||
binding.welcomeContinueButton.setOnClickListener { onContinueClicked() }
|
||||
binding.welcomeTermsButton.setOnClickListener { onTermsClicked() }
|
||||
binding.welcomeTransferOrRestore.setOnClickListener { onRestoreOrTransferClicked() }
|
||||
|
||||
childFragmentManager.setFragmentResultListener(RestoreWelcomeBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
|
||||
if (requestKey == RestoreWelcomeBottomSheet.REQUEST_KEY) {
|
||||
when (val userSelection = bundle.getSerializableCompat(RestoreWelcomeBottomSheet.REQUEST_KEY, WelcomeUserSelection::class.java)) {
|
||||
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
|
||||
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> afterRestoreOrTransferClicked(userSelection)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Permissions.isRuntimePermissionsRequired()) {
|
||||
parentFragmentManager.setFragmentResultListener(GrantPermissionsFragment.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
|
||||
if (requestKey == GrantPermissionsFragment.REQUEST_KEY) {
|
||||
when (val userSelection = bundle.getSerializableCompat(GrantPermissionsFragment.REQUEST_KEY, WelcomeUserSelection::class.java)) {
|
||||
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
|
||||
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> navigateToNextScreenViaRestore(userSelection)
|
||||
WelcomeUserSelection.CONTINUE -> navigateToNextScreenViaContinue()
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onContinueClicked() {
|
||||
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
|
||||
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(WelcomeUserSelection.CONTINUE))
|
||||
} else {
|
||||
navigateToNextScreenViaContinue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToNextScreenViaContinue() {
|
||||
sharedViewModel.maybePrefillE164(requireContext())
|
||||
findNavController().safeNavigate(WelcomeFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
|
||||
}
|
||||
|
||||
private fun onTermsClicked() {
|
||||
CommunicationActions.openBrowserLink(requireContext(), TERMS_AND_CONDITIONS_URL)
|
||||
}
|
||||
|
||||
private fun onRestoreOrTransferClicked() {
|
||||
RestoreWelcomeBottomSheet().show(childFragmentManager, null)
|
||||
}
|
||||
|
||||
private fun afterRestoreOrTransferClicked(userSelection: WelcomeUserSelection) {
|
||||
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
|
||||
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(userSelection))
|
||||
} else {
|
||||
navigateToNextScreenViaRestore(userSelection)
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToNextScreenViaRestore(userSelection: WelcomeUserSelection) {
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
|
||||
|
||||
when (userSelection) {
|
||||
WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException()
|
||||
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
|
||||
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAllPermissions(): Boolean {
|
||||
val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
|
||||
return WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).all { ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.welcome
|
||||
|
||||
/**
|
||||
* User options available to start registration flow.
|
||||
*/
|
||||
enum class WelcomeUserSelection {
|
||||
CONTINUE, RESTORE_WITH_OLD_PHONE, RESTORE_WITH_NO_PHONE
|
||||
}
|
|
@ -8,16 +8,20 @@ package org.thoughtcrime.securesms.restore
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
|
||||
import org.thoughtcrime.securesms.RestoreDirections
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Activity to hold the restore from backup flow.
|
||||
|
@ -27,6 +31,8 @@ class RestoreActivity : BaseActivity() {
|
|||
private val dynamicTheme = DynamicNoActionBarTheme()
|
||||
private val sharedViewModel: RestoreViewModel by viewModels()
|
||||
|
||||
private lateinit var navController: NavController
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
dynamicTheme.onCreate(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -34,16 +40,42 @@ class RestoreActivity : BaseActivity() {
|
|||
setResult(RESULT_CANCELED)
|
||||
|
||||
setContentView(R.layout.activity_restore)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val fragment: NavHostFragment = NavHostFragment.create(R.navigation.restore)
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.nav_host_fragment, fragment)
|
||||
.commitNow()
|
||||
|
||||
navController = fragment.navController
|
||||
} else {
|
||||
val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
navController = fragment.navController
|
||||
}
|
||||
|
||||
intent.getParcelableExtraCompat(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, Intent::class.java)?.let {
|
||||
sharedViewModel.setNextIntent(it)
|
||||
}
|
||||
|
||||
val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.NONE.value))
|
||||
val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.LEGACY_LANDING.value))
|
||||
|
||||
when (navTarget) {
|
||||
NavTarget.LOCAL_RESTORE -> findNavController(R.id.nav_host_fragment).navigate(R.id.choose_local_backup_fragment)
|
||||
NavTarget.TRANSFER -> findNavController(R.id.nav_host_fragment).navigate(R.id.newDeviceTransferInstructions)
|
||||
NavTarget.NEW_LANDING -> navController.safeNavigate(RestoreDirections.goDirectlyToNewLanding())
|
||||
NavTarget.LOCAL_RESTORE -> navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup())
|
||||
NavTarget.TRANSFER -> navController.safeNavigate(RestoreDirections.goDirectlyToDeviceTransfer())
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
onNavigateUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -51,21 +83,38 @@ class RestoreActivity : BaseActivity() {
|
|||
dynamicTheme.onResume(this)
|
||||
}
|
||||
|
||||
fun finishActivitySuccessfully() {
|
||||
override fun onNavigateUp(): Boolean {
|
||||
return if (!Navigation.findNavController(this, R.id.nav_host_fragment).popBackStack()) {
|
||||
finish()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackupCompletedSuccessfully() {
|
||||
sharedViewModel.getNextIntent()?.let {
|
||||
Log.d(TAG, "Launching ${it.component}", Throwable())
|
||||
startActivity(it)
|
||||
}
|
||||
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = Log.tag(RestoreActivity::class)
|
||||
|
||||
enum class NavTarget(val value: Int) {
|
||||
NONE(0),
|
||||
TRANSFER(1),
|
||||
LOCAL_RESTORE(2);
|
||||
LEGACY_LANDING(0),
|
||||
NEW_LANDING(1),
|
||||
TRANSFER(2),
|
||||
LOCAL_RESTORE(3);
|
||||
|
||||
companion object {
|
||||
fun deserialize(value: Int): NavTarget {
|
||||
return entries.firstOrNull { it.value == value } ?: NONE
|
||||
return entries.firstOrNull { it.value == value } ?: LEGACY_LANDING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -73,26 +122,26 @@ class RestoreActivity : BaseActivity() {
|
|||
private const val EXTRA_NAV_TARGET = "nav_target"
|
||||
|
||||
@JvmStatic
|
||||
fun getIntentForTransfer(context: Context): Intent {
|
||||
fun getDeviceTransferIntent(context: Context): Intent {
|
||||
return Intent(context, RestoreActivity::class.java).apply {
|
||||
putExtra(EXTRA_NAV_TARGET, NavTarget.TRANSFER.value)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getIntentForLocalRestore(context: Context): Intent {
|
||||
fun getLocalRestoreIntent(context: Context): Intent {
|
||||
return Intent(context, RestoreActivity::class.java).apply {
|
||||
putExtra(EXTRA_NAV_TARGET, NavTarget.LOCAL_RESTORE.value)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getIntentForTransferOrRestore(context: Context): Intent {
|
||||
val tier = SignalStore.backup.backupTier
|
||||
if (tier == MessageBackupTier.PAID) {
|
||||
return Intent(context, RemoteRestoreActivity::class.java)
|
||||
fun getRestoreIntent(context: Context): Intent {
|
||||
return Intent(context, RestoreActivity::class.java).apply {
|
||||
if (RemoteConfig.restoreAfterRegistration) {
|
||||
putExtra(EXTRA_NAV_TARGET, NavTarget.NEW_LANDING.value)
|
||||
}
|
||||
}
|
||||
return Intent(context, RestoreActivity::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue