diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt index 8d856d0860..5709e66581 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/crosssigning/CrossSigningService.kt @@ -61,6 +61,8 @@ interface CrossSigningService { fun canCrossSign(): Boolean + fun allPrivateKeysKnown(): Boolean + fun trustUser(otherUserId: String, callback: MatrixCallback) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt index 7c5f64182c..fdecfe202e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/crosssigning/DefaultCrossSigningService.kt @@ -507,6 +507,13 @@ internal class DefaultCrossSigningService @Inject constructor( && cryptoStore.getCrossSigningPrivateKeys()?.user != null } + override fun allPrivateKeysKnown(): Boolean { + return checkSelfTrust().isVerified() + && cryptoStore.getCrossSigningPrivateKeys()?.selfSigned != null + && cryptoStore.getCrossSigningPrivateKeys()?.user != null + && cryptoStore.getCrossSigningPrivateKeys()?.master != null + } + override fun trustUser(otherUserId: String, callback: MatrixCallback) { cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { Timber.d("## CrossSigning - Mark user $userId as trusted ") diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt index 6cf555b32d..2838a42169 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt @@ -73,6 +73,7 @@ import im.vector.riotx.features.terms.ReviewTermsActivity import im.vector.riotx.features.ui.UiStateRepository import im.vector.riotx.features.widgets.WidgetActivity import im.vector.riotx.features.widgets.permissions.RoomWidgetPermissionBottomSheet +import im.vector.riotx.features.workers.signout.SignOutBottomSheetDialogFragment @Component( dependencies = [ @@ -154,6 +155,7 @@ interface ScreenComponent { fun inject(bottomSheet: RoomWidgetPermissionBottomSheet) fun inject(bottomSheet: RoomWidgetsBottomSheet) fun inject(bottomSheet: CallControlsBottomSheet) + fun inject(bottomSheet: SignOutBottomSheetDialogFragment) /* ========================================================================================== * Others diff --git a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt index badfdd96c1..6ac6fa03da 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt @@ -36,7 +36,6 @@ import im.vector.riotx.features.reactions.EmojiChooserViewModel import im.vector.riotx.features.roomdirectory.RoomDirectorySharedActionViewModel import im.vector.riotx.features.roomprofile.RoomProfileSharedActionViewModel import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel -import im.vector.riotx.features.workers.signout.SignOutViewModel @Module interface ViewModelModule { @@ -51,11 +50,6 @@ interface ViewModelModule { * Below are bindings for the androidx view models (which extend ViewModel). Will be converted to MvRx ViewModel in the future. */ - @Binds - @IntoMap - @ViewModelKey(SignOutViewModel::class) - fun bindSignOutViewModel(viewModel: SignOutViewModel): ViewModel - @Binds @IntoMap @ViewModelKey(EmojiChooserViewModel::class) diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt index c28dcf12d3..7c1cae3644 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt @@ -16,9 +16,16 @@ package im.vector.riotx.core.extensions +import android.content.ActivityNotFoundException +import android.content.Intent import android.os.Parcelable import androidx.fragment.app.Fragment +import im.vector.riotx.R import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.core.utils.toast +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale fun VectorBaseFragment.addFragment(frameId: Int, fragment: Fragment) { parentFragmentManager.commitTransactionNow { add(frameId, fragment) } @@ -89,3 +96,29 @@ fun Fragment.getAllChildFragments(): List { // Define a missing constant const val POP_BACK_STACK_EXCLUSIVE = 0 + +fun Fragment.queryExportKeys(userId: String, requestCode: Int) { + // We need WRITE_EXTERNAL permission +// if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, +// this, +// PERMISSION_REQUEST_CODE_EXPORT_KEYS, +// R.string.permissions_rationale_msg_keys_backup_export)) { + // WRITE permissions are not needed + val timestamp = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).let { + it.format(Date()) + } + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "text/plain" + intent.putExtra( + Intent.EXTRA_TITLE, + "riot-megolm-export-$userId-$timestamp.txt" + ) + + try { + startActivityForResult(Intent.createChooser(intent, getString(R.string.keys_backup_setup_step1_manual_export)), requestCode) + } catch (activityNotFoundException: ActivityNotFoundException) { + activity?.toast(R.string.error_no_external_application_found) + } +// } +} diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt index 29b169ffd4..1ad6fb9090 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt @@ -65,3 +65,12 @@ fun Session.hasUnsavedKeys(): Boolean { return cryptoService().inboundGroupSessionsCount(false) > 0 && cryptoService().keysBackupService().state != KeysBackupState.ReadyToBackUp } + +fun Session.cannotLogoutSafely(): Boolean { + // has some encrypted chat + return hasUnsavedKeys() + // has local cross signing keys + || (cryptoService().crossSigningService().allPrivateKeysKnown() + // That are not backed up + && !sharedSecretStorageService.isRecoverySetup()) +} diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt index 817575d91a..d0cea6194b 100755 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/KeysBackupBanner.kt @@ -17,15 +17,14 @@ package im.vector.riotx.core.ui.views import android.content.Context -import androidx.preference.PreferenceManager import android.util.AttributeSet import android.view.View import android.view.ViewGroup -import android.widget.AbsListView import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.edit import androidx.core.view.isVisible +import androidx.preference.PreferenceManager import androidx.transition.TransitionManager import butterknife.BindView import butterknife.ButterKnife @@ -58,22 +57,12 @@ class KeysBackupBanner @JvmOverloads constructor( var delegate: Delegate? = null private var state: State = State.Initial - private var scrollState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE - set(value) { - field = value - - val pendingV = pendingVisibility - - if (pendingV != null) { - pendingVisibility = null - visibility = pendingV - } - } - - private var pendingVisibility: Int? = null - init { setupView() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(BANNER_SETUP_DO_NOT_SHOW_AGAIN, false) + putString(BANNER_RECOVER_DO_NOT_SHOW_FOR_VERSION, "") + } } /** @@ -91,7 +80,8 @@ class KeysBackupBanner @JvmOverloads constructor( state = newState hideAll() - + val parent = parent as ViewGroup + TransitionManager.beginDelayedTransition(parent) when (newState) { State.Initial -> renderInitial() State.Hidden -> renderHidden() @@ -102,22 +92,6 @@ class KeysBackupBanner @JvmOverloads constructor( } } - override fun setVisibility(visibility: Int) { - if (scrollState != AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { - // Wait for scroll state to be idle - pendingVisibility = visibility - return - } - - if (visibility != getVisibility()) { - // Schedule animation - val parent = parent as ViewGroup - TransitionManager.beginDelayedTransition(parent) - } - - super.setVisibility(visibility) - } - override fun onClick(v: View?) { when (state) { is State.Setup -> { @@ -166,6 +140,8 @@ class KeysBackupBanner @JvmOverloads constructor( ButterKnife.bind(this) setOnClickListener(this) + textView1.setOnClickListener(this) + textView2.setOnClickListener(this) } private fun renderInitial() { @@ -184,9 +160,9 @@ class KeysBackupBanner @JvmOverloads constructor( } else { isVisible = true - textView1.setText(R.string.keys_backup_banner_setup_line1) + textView1.setText(R.string.secure_backup_banner_setup_line1) textView2.isVisible = true - textView2.setText(R.string.keys_backup_banner_setup_line2) + textView2.setText(R.string.secure_backup_banner_setup_line2) close.isVisible = true } } @@ -218,10 +194,10 @@ class KeysBackupBanner @JvmOverloads constructor( } private fun renderBackingUp() { - // Do not render when backing up anymore - isVisible = false - - textView1.setText(R.string.keys_backup_banner_in_progress) + isVisible = true + textView1.setText(R.string.secure_backup_banner_setup_line1) + textView2.isVisible = true + textView2.setText(R.string.keys_backup_banner_in_progress) loading.isVisible = true } diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt index b9b75588f1..2467334f69 100644 --- a/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keys/KeysExporter.kt @@ -17,37 +17,34 @@ package im.vector.riotx.features.crypto.keys import android.content.Context -import android.os.Environment +import android.net.Uri import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.internal.extensions.foldToCallback import im.vector.matrix.android.internal.util.awaitCallback -import im.vector.riotx.core.files.addEntryToDownloadManager -import im.vector.riotx.core.files.writeToFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File class KeysExporter(private val session: Session) { /** * Export keys and return the file path with the callback */ - fun export(context: Context, password: String, callback: MatrixCallback) { + fun export(context: Context, password: String, uri: Uri, callback: MatrixCallback) { GlobalScope.launch(Dispatchers.Main) { runCatching { - val data = awaitCallback { session.cryptoService().exportRoomKeys(password, it) } withContext(Dispatchers.IO) { - val parentDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) - val file = File(parentDir, "riotx-keys-" + System.currentTimeMillis() + ".txt") - - writeToFile(data, file) - - addEntryToDownloadManager(context, file, "text/plain") - - file.absolutePath + val data = awaitCallback { session.cryptoService().exportRoomKeys(password, it) } + val os = context.contentResolver?.openOutputStream(uri) + if (os == null) { + false + } else { + os.write(data) + os.flush() + true + } } }.foldToCallback(callback) } diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt index c7d3da30ea..b99c0e4330 100644 --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt @@ -15,6 +15,8 @@ */ package im.vector.riotx.features.crypto.keysbackup.setup +import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import androidx.appcompat.app.AlertDialog @@ -132,36 +134,22 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { this, PERMISSION_REQUEST_CODE_EXPORT_KEYS, R.string.permissions_rationale_msg_keys_backup_export)) { - ExportKeysDialog().show(this, object : ExportKeysDialog.ExportKeyDialogListener { - override fun onPassphrase(passphrase: String) { - showWaitingView() + try { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "text/plain" + intent.putExtra(Intent.EXTRA_TITLE, "riot-megolm-export-${session.myUserId}-${System.currentTimeMillis()}.txt") - KeysExporter(session) - .export(this@KeysBackupSetupActivity, - passphrase, - object : MatrixCallback { - override fun onSuccess(data: String) { - hideWaitingView() - - AlertDialog.Builder(this@KeysBackupSetupActivity) - .setMessage(getString(R.string.encryption_export_saved_as, data)) - .setCancelable(false) - .setPositiveButton(R.string.ok) { _, _ -> - val resultIntent = Intent() - resultIntent.putExtra(MANUAL_EXPORT, true) - setResult(RESULT_OK, resultIntent) - finish() - } - .show() - } - - override fun onFailure(failure: Throwable) { - toast(failure.localizedMessage ?: getString(R.string.unexpected_error)) - hideWaitingView() - } - }) - } - }) + startActivityForResult( + Intent.createChooser( + intent, + getString(R.string.keys_backup_setup_step1_manual_export) + ), + REQUEST_CODE_SAVE_MEGOLM_EXPORT + ) + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(R.string.error_no_external_application_found) + } } } @@ -173,6 +161,47 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { } } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_CODE_SAVE_MEGOLM_EXPORT) { + val uri = data?.data + if (resultCode == Activity.RESULT_OK && uri != null) { + ExportKeysDialog().show(this, object : ExportKeysDialog.ExportKeyDialogListener { + override fun onPassphrase(passphrase: String) { + showWaitingView() + + KeysExporter(session) + .export(this@KeysBackupSetupActivity, + passphrase, + uri, + object : MatrixCallback { + override fun onSuccess(data: Boolean) { + if (data) { + toast(getString(R.string.encryption_exported_successfully)) + Intent().apply { + putExtra(MANUAL_EXPORT, true) + }.let { + setResult(Activity.RESULT_OK, it) + finish() + } + } + hideWaitingView() + } + + override fun onFailure(failure: Throwable) { + toast(failure.localizedMessage ?: getString(R.string.unexpected_error)) + hideWaitingView() + } + }) + } + }) + } else { + toast(getString(R.string.unexpected_error)) + hideWaitingView() + } + } + super.onActivityResult(requestCode, resultCode, data) + } + override fun onBackPressed() { if (viewModel.shouldPromptOnBack) { if (waitingView?.isVisible == true) { @@ -205,6 +234,7 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { const val KEYS_VERSION = "KEYS_VERSION" const val MANUAL_EXPORT = "MANUAL_EXPORT" const val EXTRA_SHOW_MANUAL_EXPORT = "SHOW_MANUAL_EXPORT" + const val REQUEST_CODE_SAVE_MEGOLM_EXPORT = 101 fun intent(context: Context, showManualExport: Boolean): Intent { val intent = Intent(context, KeysBackupSetupActivity::class.java) diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt index a3306677fe..40ea79eb6d 100644 --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupStep2Fragment.kt @@ -15,13 +15,13 @@ */ package im.vector.riotx.features.crypto.keysbackup.setup -import android.os.AsyncTask import android.os.Bundle import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.ImageView import androidx.lifecycle.Observer +import androidx.lifecycle.viewModelScope import androidx.transition.TransitionManager import butterknife.BindView import butterknife.OnClick @@ -33,6 +33,8 @@ import im.vector.riotx.core.extensions.showPassword import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.ui.views.PasswordStrengthBar import im.vector.riotx.features.settings.VectorLocale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment() { @@ -117,9 +119,9 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment() if (newValue.isEmpty()) { viewModel.passwordStrength.value = null } else { - AsyncTask.execute { + viewModel.viewModelScope.launch(Dispatchers.IO) { val strength = zxcvbn.measure(newValue) - activity?.runOnUiThread { + launch(Dispatchers.Main) { viewModel.passwordStrength.value = strength } } diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt index 6a3fadbcb3..9f68e09444 100644 --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapCrossSigningTask.kt @@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.securestorage.SsssKeySpec import im.vector.matrix.android.internal.crypto.crosssigning.toBase64NoPadding import im.vector.matrix.android.internal.crypto.keysbackup.model.MegolmBackupCreationInfo import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeysVersion +import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeysVersionResult import im.vector.matrix.android.internal.crypto.keysbackup.util.extractCurveKeyFromRecoveryKey import im.vector.matrix.android.internal.crypto.model.rest.UserPasswordAuth import im.vector.matrix.android.internal.util.awaitCallback @@ -84,8 +85,10 @@ class BootstrapCrossSigningTask @Inject constructor( override suspend fun execute(params: Params): BootstrapResult { val crossSigningService = session.cryptoService().crossSigningService() + Timber.d("## BootstrapCrossSigningTask: initXSOnly:${params.initOnlyCrossSigning} Starting...") // Ensure cross-signing is initialized. Due to migration it is maybe not always correctly initialized if (!crossSigningService.isCrossSigningInitialized()) { + Timber.d("## BootstrapCrossSigningTask: Cross signing not enabled, so initialize") params.progressListener?.onProgress( WaitingViewData( stringProvider.getString(R.string.bootstrap_crosssigning_progress_initializing), @@ -104,8 +107,9 @@ class BootstrapCrossSigningTask @Inject constructor( return handleInitializeXSigningError(failure) } } else { - // not sure how this can happen?? + Timber.d("## BootstrapCrossSigningTask: Cross signing already setup, go to 4S setup") if (params.initOnlyCrossSigning) { + // not sure how this can happen?? return handleInitializeXSigningError(IllegalArgumentException("Cross signing already setup")) } } @@ -119,6 +123,8 @@ class BootstrapCrossSigningTask @Inject constructor( stringProvider.getString(R.string.bootstrap_crosssigning_progress_pbkdf2), isIndeterminate = true) ) + + Timber.d("## BootstrapCrossSigningTask: Creating 4S key with pass: ${params.passphrase != null}") try { keyInfo = awaitCallback { params.passphrase?.let { passphrase -> @@ -141,6 +147,7 @@ class BootstrapCrossSigningTask @Inject constructor( } } } catch (failure: Failure) { + Timber.e("## BootstrapCrossSigningTask: Creating 4S - Failed to generate key <${failure.localizedMessage}>") return BootstrapResult.FailedToCreateSSSSKey(failure) } @@ -149,19 +156,24 @@ class BootstrapCrossSigningTask @Inject constructor( stringProvider.getString(R.string.bootstrap_crosssigning_progress_default_key), isIndeterminate = true) ) + + Timber.d("## BootstrapCrossSigningTask: Creating 4S - Set default key") try { awaitCallback { ssssService.setDefaultKey(keyInfo.keyId, it) } } catch (failure: Failure) { // Maybe we could just ignore this error? + Timber.e("## BootstrapCrossSigningTask: Creating 4S - Set default key error <${failure.localizedMessage}>") return BootstrapResult.FailedToSetDefaultSSSSKey(failure) } + Timber.d("## BootstrapCrossSigningTask: Creating 4S - gathering private keys") val xKeys = crossSigningService.getCrossSigningPrivateKeys() val mskPrivateKey = xKeys?.master ?: return BootstrapResult.MissingPrivateKey val sskPrivateKey = xKeys.selfSigned ?: return BootstrapResult.MissingPrivateKey val uskPrivateKey = xKeys.user ?: return BootstrapResult.MissingPrivateKey + Timber.d("## BootstrapCrossSigningTask: Creating 4S - gathering private keys success") try { params.progressListener?.onProgress( @@ -170,6 +182,7 @@ class BootstrapCrossSigningTask @Inject constructor( isIndeterminate = true ) ) + Timber.d("## BootstrapCrossSigningTask: Creating 4S - Storing MSK...") awaitCallback { ssssService.storeSecret( MASTER_KEY_SSSS_NAME, @@ -183,6 +196,7 @@ class BootstrapCrossSigningTask @Inject constructor( isIndeterminate = true ) ) + Timber.d("## BootstrapCrossSigningTask: Creating 4S - Storing USK...") awaitCallback { ssssService.storeSecret( USER_SIGNING_KEY_SSSS_NAME, @@ -196,6 +210,7 @@ class BootstrapCrossSigningTask @Inject constructor( stringProvider.getString(R.string.bootstrap_crosssigning_progress_save_ssk), isIndeterminate = true ) ) + Timber.d("## BootstrapCrossSigningTask: Creating 4S - Storing SSK...") awaitCallback { ssssService.storeSecret( SELF_SIGNING_KEY_SSSS_NAME, @@ -204,6 +219,7 @@ class BootstrapCrossSigningTask @Inject constructor( ) } } catch (failure: Failure) { + Timber.e("## BootstrapCrossSigningTask: Creating 4S - Failed to store keys <${failure.localizedMessage}>") // Maybe we could just ignore this error? return BootstrapResult.FailedToStorePrivateKeyInSSSS(failure) } @@ -215,7 +231,14 @@ class BootstrapCrossSigningTask @Inject constructor( ) ) try { - if (session.cryptoService().keysBackupService().keysBackupVersion == null) { + Timber.d("## BootstrapCrossSigningTask: Creating 4S - Checking megolm backup") + + // First ensure that in sync + val serverVersion = awaitCallback { + session.cryptoService().keysBackupService().getCurrentVersion(it) + } + if (serverVersion == null) { + Timber.d("## BootstrapCrossSigningTask: Creating 4S - Create megolm backup") val creationInfo = awaitCallback { session.cryptoService().keysBackupService().prepareKeysBackupVersion(null, null, it) } @@ -223,6 +246,7 @@ class BootstrapCrossSigningTask @Inject constructor( session.cryptoService().keysBackupService().createKeysBackupVersion(creationInfo, it) } // Save it for gossiping + Timber.d("## BootstrapCrossSigningTask: Creating 4S - Save megolm backup key for gossiping") session.cryptoService().keysBackupService().saveBackupRecoveryKey(creationInfo.recoveryKey, version = version.version) awaitCallback { @@ -239,6 +263,7 @@ class BootstrapCrossSigningTask @Inject constructor( Timber.e("## BootstrapCrossSigningTask: Failed to init keybackup") } + Timber.d("## BootstrapCrossSigningTask: initXSOnly:${params.initOnlyCrossSigning} Finished") return BootstrapResult.Success(keyInfo) } diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt index 3a95a575f4..22dcab217e 100644 --- a/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/crypto/recover/BootstrapSharedViewModel.kt @@ -406,7 +406,10 @@ class BootstrapSharedViewModel @AssistedInject constructor( setState { copy( recoveryKeyCreationInfo = bootstrapResult.keyInfo, - step = BootstrapStep.SaveRecoveryKey(false) + step = BootstrapStep.SaveRecoveryKey( + // If a passphrase was used, saving key is optional + state.passphrase != null + ) ) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt index 2723740996..5bed5b1f78 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt @@ -46,7 +46,8 @@ import im.vector.riotx.features.popup.PopupAlertManager import im.vector.riotx.features.popup.VerificationVectorAlert import im.vector.riotx.features.rageshake.VectorUncaughtExceptionHandler import im.vector.riotx.features.settings.VectorPreferences -import im.vector.riotx.features.workers.signout.SignOutViewModel +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewModel +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewState import im.vector.riotx.push.fcm.FcmHelper import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.activity_home.* @@ -60,13 +61,16 @@ data class HomeActivityArgs( val accountCreation: Boolean ) : Parcelable -class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDetectorSharedViewModel.Factory { +class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDetectorSharedViewModel.Factory, ServerBackupStatusViewModel.Factory { private lateinit var sharedActionViewModel: HomeSharedActionViewModel private val homeActivityViewModel: HomeActivityViewModel by viewModel() @Inject lateinit var viewModelFactory: HomeActivityViewModel.Factory + private val serverBackupStatusViewModel: ServerBackupStatusViewModel by viewModel() + @Inject lateinit var serverBackupviewModelFactory: ServerBackupStatusViewModel.Factory + @Inject lateinit var activeSessionHolder: ActiveSessionHolder @Inject lateinit var vectorUncaughtExceptionHandler: VectorUncaughtExceptionHandler @Inject lateinit var pushManager: PushersManager @@ -92,6 +96,10 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDet return unknownDeviceViewModelFactory.create(initialState) } + override fun create(initialState: ServerBackupStatusViewState): ServerBackupStatusViewModel { + return serverBackupviewModelFactory.create(initialState) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) FcmHelper.ensureFcmTokenIsRetrieved(this, pushManager, vectorPreferences.areNotificationEnabledForDevice()) @@ -234,7 +242,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable, UnknownDeviceDet } // Force remote backup state update to update the banner if needed - viewModelProvider.get(SignOutViewModel::class.java).refreshRemoteStateIfNeeded() + serverBackupStatusViewModel.refreshRemoteStateIfNeeded() } override fun configure(toolbar: Toolbar) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt index c92c28079f..f0fdc207f9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt @@ -27,7 +27,6 @@ import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.bottomnavigation.BottomNavigationItemView import com.google.android.material.bottomnavigation.BottomNavigationMenuView -import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState import im.vector.matrix.android.api.session.group.model.GroupSummary import im.vector.matrix.android.api.util.toMatrixItem import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo @@ -49,13 +48,10 @@ import im.vector.riotx.features.home.room.list.UnreadCounterBadgeView import im.vector.riotx.features.popup.PopupAlertManager import im.vector.riotx.features.popup.VerificationVectorAlert import im.vector.riotx.features.settings.VectorSettingsActivity.Companion.EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS -import im.vector.riotx.features.workers.signout.SignOutViewModel +import im.vector.riotx.features.workers.signout.BannerState +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewModel +import im.vector.riotx.features.workers.signout.ServerBackupStatusViewState import kotlinx.android.synthetic.main.fragment_home_detail.* -import kotlinx.android.synthetic.main.fragment_home_detail.activeCallPiP -import kotlinx.android.synthetic.main.fragment_home_detail.activeCallPiPWrap -import kotlinx.android.synthetic.main.fragment_home_detail.activeCallView -import kotlinx.android.synthetic.main.fragment_home_detail.syncStateView -import kotlinx.android.synthetic.main.fragment_room_detail.* import timber.log.Timber import javax.inject.Inject @@ -65,15 +61,17 @@ private const val INDEX_ROOMS = 2 class HomeDetailFragment @Inject constructor( val homeDetailViewModelFactory: HomeDetailViewModel.Factory, + private val serverBackupStatusViewModelFactory: ServerBackupStatusViewModel.Factory, private val avatarRenderer: AvatarRenderer, private val alertManager: PopupAlertManager, private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager -) : VectorBaseFragment(), KeysBackupBanner.Delegate, ActiveCallView.Callback { +) : VectorBaseFragment(), KeysBackupBanner.Delegate, ActiveCallView.Callback, ServerBackupStatusViewModel.Factory { private val unreadCounterBadgeViews = arrayListOf() private val viewModel: HomeDetailViewModel by fragmentViewModel() private val unknownDeviceDetectorSharedViewModel: UnknownDeviceDetectorSharedViewModel by activityViewModel() + private val serverBackupStatusViewModel: ServerBackupStatusViewModel by activityViewModel() private lateinit var sharedActionViewModel: HomeSharedActionViewModel private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel @@ -195,34 +193,14 @@ class HomeDetailFragment @Inject constructor( } private fun setupKeysBackupBanner() { - // Keys backup banner - // Use the SignOutViewModel, it observe the keys backup state and this is what we need here - val model = fragmentViewModelProvider.get(SignOutViewModel::class.java) - - model.keysBackupState.observe(viewLifecycleOwner, Observer { keysBackupState -> - when (keysBackupState) { - null -> - homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) - KeysBackupState.Disabled -> - homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(model.getNumberOfKeysToBackup()), false) - KeysBackupState.NotTrusted, - KeysBackupState.WrongBackUpVersion -> - // In this case, getCurrentBackupVersion() should not return "" - homeKeysBackupBanner.render(KeysBackupBanner.State.Recover(model.getCurrentBackupVersion()), false) - KeysBackupState.WillBackUp, - KeysBackupState.BackingUp -> - homeKeysBackupBanner.render(KeysBackupBanner.State.BackingUp, false) - KeysBackupState.ReadyToBackUp -> - if (model.canRestoreKeys()) { - homeKeysBackupBanner.render(KeysBackupBanner.State.Update(model.getCurrentBackupVersion()), false) - } else { - homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) - } - else -> - homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) + serverBackupStatusViewModel.subscribe(this) { + when (val banState = it.bannerState.invoke()) { + is BannerState.Setup -> homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(banState.numberOfKeys), false) + BannerState.BackingUp -> homeKeysBackupBanner.render(KeysBackupBanner.State.BackingUp, false) + null, + BannerState.Hidden -> homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) } - }) - + }.disposeOnDestroyView() homeKeysBackupBanner.delegate = this } @@ -331,4 +309,8 @@ class HomeDetailFragment @Inject constructor( } } } + + override fun create(initialState: ServerBackupStatusViewState): ServerBackupStatusViewModel { + return serverBackupStatusViewModelFactory.create(initialState) + } } diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt index ae73cb8dad..3c3e85d3f6 100644 --- a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt @@ -218,7 +218,14 @@ class DefaultNavigator @Inject constructor( } override fun openKeysBackupSetup(context: Context, showManualExport: Boolean) { - context.startActivity(KeysBackupSetupActivity.intent(context, showManualExport)) + // if cross signing is enabled we should propose full 4S + sessionHolder.getSafeActiveSession()?.let { session -> + if (session.cryptoService().crossSigningService().canCrossSign() && context is VectorBaseActivity) { + BootstrapBottomSheet.show(context.supportFragmentManager, false) + } else { + context.startActivity(KeysBackupSetupActivity.intent(context, showManualExport)) + } + } } override fun openKeysBackupManager(context: Context) { diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt index 2b9338ccc8..3c2acb1693 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt @@ -34,16 +34,16 @@ import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse import im.vector.riotx.R +import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.dialogs.ExportKeysDialog +import im.vector.riotx.core.extensions.queryExportKeys import im.vector.riotx.core.intent.ExternalIntentData import im.vector.riotx.core.intent.analyseIntent import im.vector.riotx.core.intent.getFilenameFromUri import im.vector.riotx.core.platform.SimpleTextWatcher import im.vector.riotx.core.preference.VectorPreference -import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_EXPORT_KEYS import im.vector.riotx.core.utils.allGranted -import im.vector.riotx.core.utils.checkPermissions import im.vector.riotx.core.utils.openFileSelection import im.vector.riotx.core.utils.toast import im.vector.riotx.features.crypto.keys.KeysExporter @@ -52,7 +52,8 @@ import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupManageActiv import javax.inject.Inject class VectorSettingsSecurityPrivacyFragment @Inject constructor( - private val vectorPreferences: VectorPreferences + private val vectorPreferences: VectorPreferences, + private val activeSessionHolder: ActiveSessionHolder ) : VectorSettingsBaseFragment() { override var titleRes = R.string.settings_security_and_privacy @@ -119,38 +120,69 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( } private fun refreshXSigningStatus() { - val xSigningIsEnableInAccount = session.cryptoService().crossSigningService().isCrossSigningInitialized() - val xSigningKeysAreTrusted = session.cryptoService().crossSigningService().checkUserTrust(session.myUserId).isVerified() - val xSigningKeyCanSign = session.cryptoService().crossSigningService().canCrossSign() + val crossSigningKeys = session.cryptoService().crossSigningService().getMyCrossSigningKeys() + val xSigningIsEnableInAccount = crossSigningKeys != null + val xSigningKeysAreTrusted = session.cryptoService().crossSigningService().checkUserTrust(session.myUserId).isVerified() + val xSigningKeyCanSign = session.cryptoService().crossSigningService().canCrossSign() - if (xSigningKeyCanSign) { - mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_trusted) - mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_complete) - } else if (xSigningKeysAreTrusted) { - mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_custom) - mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_trusted) - } else if (xSigningIsEnableInAccount) { - mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_black) - mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_not_trusted) - } else { - mCrossSigningStatePreference.setIcon(android.R.color.transparent) - mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_disabled) - } + if (xSigningKeyCanSign) { + mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_trusted) + mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_complete) + } else if (xSigningKeysAreTrusted) { + mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_custom) + mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_trusted) + } else if (xSigningIsEnableInAccount) { + mCrossSigningStatePreference.setIcon(R.drawable.ic_shield_black) + mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_not_trusted) + } else { + mCrossSigningStatePreference.setIcon(android.R.color.transparent) + mCrossSigningStatePreference.summary = getString(R.string.encryption_information_dg_xsigning_disabled) + } - mCrossSigningStatePreference.isVisible = true + mCrossSigningStatePreference.isVisible = true } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { if (allGranted(grantResults)) { if (requestCode == PERMISSION_REQUEST_CODE_EXPORT_KEYS) { - exportKeys() + queryExportKeys(activeSessionHolder.getSafeActiveSession()?.myUserId ?: "", REQUEST_CODE_SAVE_MEGOLM_EXPORT) } } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE_SAVE_MEGOLM_EXPORT) { + val uri = data?.data + if (resultCode == Activity.RESULT_OK && uri != null) { + activity?.let { activity -> + ExportKeysDialog().show(activity, object : ExportKeysDialog.ExportKeyDialogListener { + override fun onPassphrase(passphrase: String) { + displayLoadingView() + KeysExporter(session) + .export(requireContext(), + passphrase, + uri, + object : MatrixCallback { + override fun onSuccess(data: Boolean) { + if (data) { + requireActivity().toast(getString(R.string.encryption_exported_successfully)) + } else { + requireActivity().toast(getString(R.string.unexpected_error)) + } + hideLoadingView() + } + + override fun onFailure(failure: Throwable) { + onCommonDone(failure.localizedMessage) + } + }) + } + }) + } + } + } if (resultCode == Activity.RESULT_OK) { when (requestCode) { REQUEST_E2E_FILE_REQUEST_CODE -> importKeys(data) @@ -169,7 +201,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( } exportPref.onPreferenceClickListener = Preference.OnPreferenceClickListener { - exportKeys() + queryExportKeys(activeSessionHolder.getSafeActiveSession()?.myUserId ?: "", REQUEST_CODE_SAVE_MEGOLM_EXPORT) true } @@ -179,46 +211,6 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( } } - /** - * Manage the e2e keys export. - */ - private fun exportKeys() { - // We need WRITE_EXTERNAL permission - if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, - this, - PERMISSION_REQUEST_CODE_EXPORT_KEYS, - R.string.permissions_rationale_msg_keys_backup_export)) { - activity?.let { activity -> - ExportKeysDialog().show(activity, object : ExportKeysDialog.ExportKeyDialogListener { - override fun onPassphrase(passphrase: String) { - displayLoadingView() - - KeysExporter(session) - .export(requireContext(), - passphrase, - object : MatrixCallback { - override fun onSuccess(data: String) { - if (isAdded) { - hideLoadingView() - - AlertDialog.Builder(activity) - .setMessage(getString(R.string.encryption_export_saved_as, data)) - .setCancelable(false) - .setPositiveButton(R.string.ok, null) - .show() - } - } - - override fun onFailure(failure: Throwable) { - onCommonDone(failure.localizedMessage) - } - }) - } - }) - } - } - } - /** * Manage the e2e keys import. */ @@ -515,6 +507,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( companion object { private const val REQUEST_E2E_FILE_REQUEST_CODE = 123 + private const val REQUEST_CODE_SAVE_MEGOLM_EXPORT = 124 private const val PUSHER_PREFERENCE_KEY_BASE = "PUSHER_PREFERENCE_KEY_BASE" private const val DEVICES_PREFERENCE_KEY_BASE = "DEVICES_PREFERENCE_KEY_BASE" diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/ServerBackupStatusViewModel.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/ServerBackupStatusViewModel.kt new file mode 100644 index 0000000000..dca98c16b2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/ServerBackupStatusViewModel.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.workers.signout + +import androidx.lifecycle.MutableLiveData +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.MXCrossSigningInfo +import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupStateListener +import im.vector.matrix.android.api.util.Optional +import im.vector.matrix.android.internal.crypto.store.PrivateKeysInfo +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData +import im.vector.matrix.rx.rx +import im.vector.riotx.core.platform.EmptyAction +import im.vector.riotx.core.platform.EmptyViewEvents +import im.vector.riotx.core.platform.VectorViewModel +import io.reactivex.Observable +import io.reactivex.functions.Function4 +import io.reactivex.subjects.PublishSubject +import java.util.concurrent.TimeUnit + +data class ServerBackupStatusViewState( + val bannerState: Async = Uninitialized +) : MvRxState + +/** + * The state representing the view + * It can take one state at a time + */ +sealed class BannerState { + + object Hidden : BannerState() + + // Keys backup is not setup, numberOfKeys is the number of locally stored keys + data class Setup(val numberOfKeys: Int) : BannerState() + + // Keys are backing up + object BackingUp : BannerState() +} + +class ServerBackupStatusViewModel @AssistedInject constructor(@Assisted initialState: ServerBackupStatusViewState, + private val session: Session) + : VectorViewModel(initialState), KeysBackupStateListener { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: ServerBackupStatusViewState): ServerBackupStatusViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: ServerBackupStatusViewState): ServerBackupStatusViewModel? { + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") + } + } + + // Keys exported manually + val keysExportedToFile = MutableLiveData() + val keysBackupState = MutableLiveData() + + private val keyBackupPublishSubject: PublishSubject = PublishSubject.create() + + init { + session.cryptoService().keysBackupService().addListener(this) + + keysBackupState.value = session.cryptoService().keysBackupService().state + + Observable.combineLatest, Optional, KeysBackupState, Optional, BannerState>( + session.rx().liveAccountData(setOf(MASTER_KEY_SSSS_NAME, USER_SIGNING_KEY_SSSS_NAME, SELF_SIGNING_KEY_SSSS_NAME)), + session.rx().liveCrossSigningInfo(session.myUserId), + keyBackupPublishSubject, + session.rx().liveCrossSigningPrivateKeys(), + Function4 { _, crossSigningInfo, keyBackupState, pInfo -> + // first check if 4S is already setup + if (session.sharedSecretStorageService.isRecoverySetup()) { + // 4S is already setup sp we should not display anything + return@Function4 when (keyBackupState) { + KeysBackupState.BackingUp -> BannerState.BackingUp + else -> BannerState.Hidden + } + } + + // So recovery is not setup + // Check if cross signing is enabled and local secrets known + if (crossSigningInfo.getOrNull()?.isTrusted() == true + && pInfo.getOrNull()?.master != null + && pInfo.getOrNull()?.selfSigned != null + && pInfo.getOrNull()?.user != null + ) { + // So 4S is not setup and we have local secrets, + return@Function4 BannerState.Setup(numberOfKeys = getNumberOfKeysToBackup()) + } + + BannerState.Hidden + } + ) + .throttleLast(1000, TimeUnit.MILLISECONDS) // we don't want to flicker or catch transient states + .distinctUntilChanged() + .execute { async -> + copy( + bannerState = async + ) + } + + keyBackupPublishSubject.onNext(session.cryptoService().keysBackupService().state) + } + + /** + * Safe way to get the current KeysBackup version + */ + fun getCurrentBackupVersion(): String { + return session.cryptoService().keysBackupService().currentBackupVersion ?: "" + } + + /** + * Safe way to get the number of keys to backup + */ + fun getNumberOfKeysToBackup(): Int { + return session.cryptoService().inboundGroupSessionsCount(false) + } + + /** + * Safe way to tell if there are more keys on the server + */ + fun canRestoreKeys(): Boolean { + return session.cryptoService().keysBackupService().canRestoreKeys() + } + + override fun onCleared() { + super.onCleared() + session.cryptoService().keysBackupService().removeListener(this) + } + + override fun onStateChange(newState: KeysBackupState) { + keyBackupPublishSubject.onNext(session.cryptoService().keysBackupService().state) + keysBackupState.value = newState + } + + fun refreshRemoteStateIfNeeded() { + if (keysBackupState.value == KeysBackupState.Disabled) { + session.cryptoService().keysBackupService().checkAndStartKeysBackup() + } + } + + override fun handle(action: EmptyAction) {} +} diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt index e1ef7bc07b..16be661f06 100644 --- a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutBottomSheetDialogFragment.kt @@ -28,19 +28,27 @@ import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible -import androidx.lifecycle.Observer -import androidx.transition.TransitionManager import butterknife.BindView +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog +import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState import im.vector.riotx.R +import im.vector.riotx.core.di.ScreenComponent +import im.vector.riotx.core.dialogs.ExportKeysDialog +import im.vector.riotx.core.extensions.queryExportKeys import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment -import im.vector.riotx.core.utils.toast -import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupManageActivity import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupActivity +import im.vector.riotx.features.crypto.recover.BootstrapBottomSheet +import timber.log.Timber +import javax.inject.Inject -class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() { +// TODO this needs to be refactored to current standard and remove legacy +class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment(), SignoutCheckViewModel.Factory { @BindView(R.id.bottom_sheet_signout_warning_text) lateinit var sheetTitle: TextView @@ -48,14 +56,20 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() { @BindView(R.id.bottom_sheet_signout_backingup_status_group) lateinit var backingUpStatusGroup: ViewGroup - @BindView(R.id.keys_backup_setup) - lateinit var setupClickableView: View + @BindView(R.id.setupRecoveryButton) + lateinit var setupRecoveryButton: SignoutBottomSheetActionButton - @BindView(R.id.keys_backup_activate) - lateinit var activateClickableView: View + @BindView(R.id.setupMegolmBackupButton) + lateinit var setupMegolmBackupButton: SignoutBottomSheetActionButton - @BindView(R.id.keys_backup_dont_want) - lateinit var dontWantClickableView: View + @BindView(R.id.exportManuallyButton) + lateinit var exportManuallyButton: SignoutBottomSheetActionButton + + @BindView(R.id.exitAnywayButton) + lateinit var exitAnywayButton: SignoutBottomSheetActionButton + + @BindView(R.id.signOutButton) + lateinit var signOutButton: SignoutBottomSheetActionButton @BindView(R.id.bottom_sheet_signout_icon_progress_bar) lateinit var backupProgress: ProgressBar @@ -66,8 +80,8 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() { @BindView(R.id.bottom_sheet_backup_status_text) lateinit var backupStatusTex: TextView - @BindView(R.id.bottom_sheet_signout_button) - lateinit var signoutClickableView: View + @BindView(R.id.signoutExportingLoading) + lateinit var signoutExportingLoading: View @BindView(R.id.root_layout) lateinit var rootLayout: ViewGroup @@ -78,62 +92,44 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() { fun newInstance() = SignOutBottomSheetDialogFragment() private const val EXPORT_REQ = 0 + private const val QUERY_EXPORT_KEYS = 1 } init { isCancelable = true } - private lateinit var viewModel: SignOutViewModel + @Inject + lateinit var viewModelFactory: SignoutCheckViewModel.Factory + + override fun create(initialState: SignoutCheckViewState): SignoutCheckViewModel { + return viewModelFactory.create(initialState) + } + + private val viewModel: SignoutCheckViewModel by fragmentViewModel(SignoutCheckViewModel::class) + + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + override fun onResume() { + super.onResume() + viewModel.refreshRemoteStateIfNeeded() + } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) - viewModel = fragmentViewModelProvider.get(SignOutViewModel::class.java) - - setupClickableView.setOnClickListener { - context?.let { context -> - startActivityForResult(KeysBackupSetupActivity.intent(context, true), EXPORT_REQ) - } + setupRecoveryButton.action = { + BootstrapBottomSheet.show(parentFragmentManager, false) } - activateClickableView.setOnClickListener { - context?.let { context -> - startActivity(KeysBackupManageActivity.intent(context)) - } - } - - signoutClickableView.setOnClickListener { - this.onSignOut?.run() - } - - dontWantClickableView.setOnClickListener { _ -> + exitAnywayButton.action = { context?.let { AlertDialog.Builder(it) .setTitle(R.string.are_you_sure) .setMessage(R.string.sign_out_bottom_sheet_will_lose_secure_messages) - .setPositiveButton(R.string.backup) { _, _ -> - when (viewModel.keysBackupState.value) { - KeysBackupState.NotTrusted -> { - context?.let { context -> - startActivity(KeysBackupManageActivity.intent(context)) - } - } - KeysBackupState.Disabled -> { - context?.let { context -> - startActivityForResult(KeysBackupSetupActivity.intent(context, true), EXPORT_REQ) - } - } - KeysBackupState.BackingUp, - KeysBackupState.WillBackUp -> { - // keys are already backing up please wait - context?.toast(R.string.keys_backup_is_not_finished_please_wait) - } - else -> { - // nop - } - } - } + .setPositiveButton(R.string.backup, null) .setNegativeButton(R.string.action_sign_out) { _, _ -> onSignOut?.run() } @@ -141,71 +137,143 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() { } } - viewModel.keysExportedToFile.observe(viewLifecycleOwner, Observer { - val hasExportedToFile = it ?: false - if (hasExportedToFile) { - // We can allow to sign out - - sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple) - - signoutClickableView.isVisible = true - dontWantClickableView.isVisible = false - setupClickableView.isVisible = false - activateClickableView.isVisible = false - backingUpStatusGroup.isVisible = false + exportManuallyButton.action = { + withState(viewModel) { state -> + queryExportKeys(state.userId, QUERY_EXPORT_KEYS) } - }) + } - viewModel.keysBackupState.observe(viewLifecycleOwner, Observer { - if (viewModel.keysExportedToFile.value == true) { - // ignore this - return@Observer - } - TransitionManager.beginDelayedTransition(rootLayout) + setupMegolmBackupButton.action = { + startActivityForResult(KeysBackupSetupActivity.intent(requireContext(), true), EXPORT_REQ) + } + + viewModel.observeViewEvents { when (it) { - KeysBackupState.ReadyToBackUp -> { - signoutClickableView.isVisible = true - dontWantClickableView.isVisible = false - setupClickableView.isVisible = false - activateClickableView.isVisible = false - backingUpStatusGroup.isVisible = true + is SignoutCheckViewModel.ViewEvents.ExportKeys -> { + it.exporter + .export(requireContext(), + it.passphrase, + it.uri, + object : MatrixCallback { + override fun onSuccess(data: Boolean) { + if (data) { + viewModel.handle(SignoutCheckViewModel.Actions.KeySuccessfullyManuallyExported) + } else { + viewModel.handle(SignoutCheckViewModel.Actions.KeyExportFailed) + } + } + override fun onFailure(failure: Throwable) { + Timber.e("## Failed to export manually keys ${failure.localizedMessage}") + viewModel.handle(SignoutCheckViewModel.Actions.KeyExportFailed) + } + }) + } + } + } + } + + override fun invalidate() = withState(viewModel) { state -> + signoutExportingLoading.isVisible = false + if (state.crossSigningSetupAllKeysKnown && !state.backupIsSetup) { + sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_no_backup) + backingUpStatusGroup.isVisible = false + // we should show option to setup 4S + setupRecoveryButton.isVisible = true + setupMegolmBackupButton.isVisible = false + signOutButton.isVisible = false + // We let the option to ignore and quit + exportManuallyButton.isVisible = true + exitAnywayButton.isVisible = true + } else if (state.keysBackupState == KeysBackupState.Unknown || state.keysBackupState == KeysBackupState.Disabled) { + sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_no_backup) + backingUpStatusGroup.isVisible = false + // no key backup and cannot setup full 4S + // we propose to setup + // we should show option to setup 4S + setupRecoveryButton.isVisible = false + setupMegolmBackupButton.isVisible = true + signOutButton.isVisible = false + // We let the option to ignore and quit + exportManuallyButton.isVisible = true + exitAnywayButton.isVisible = true + } else { + // so keybackup is setup + // You should wait until all are uploaded + setupRecoveryButton.isVisible = false + + when (state.keysBackupState) { + KeysBackupState.ReadyToBackUp -> { + sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple) + + // Ok all keys are backedUp + backingUpStatusGroup.isVisible = true backupProgress.isVisible = false backupCompleteImage.isVisible = true backupStatusTex.text = getString(R.string.keys_backup_info_keys_all_backup_up) - sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple) + hideViews(setupMegolmBackupButton, exportManuallyButton, exitAnywayButton) + // You can signout + signOutButton.isVisible = true } - KeysBackupState.BackingUp, - KeysBackupState.WillBackUp -> { - backingUpStatusGroup.isVisible = true - sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_backing_up) - dontWantClickableView.isVisible = true - setupClickableView.isVisible = false - activateClickableView.isVisible = false + KeysBackupState.WillBackUp, + KeysBackupState.BackingUp -> { + sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_backing_up) + + // save in progress + backingUpStatusGroup.isVisible = true backupProgress.isVisible = true backupCompleteImage.isVisible = false backupStatusTex.text = getString(R.string.sign_out_bottom_sheet_backing_up_keys) + + hideViews(setupMegolmBackupButton, setupMegolmBackupButton, signOutButton, exportManuallyButton) + exitAnywayButton.isVisible = true } KeysBackupState.NotTrusted -> { - backingUpStatusGroup.isVisible = false - dontWantClickableView.isVisible = true - setupClickableView.isVisible = false - activateClickableView.isVisible = true sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_backup_not_active) + // It's not trusted and we know there are unsaved keys.. + backingUpStatusGroup.isVisible = false + + exportManuallyButton.isVisible = true + // option to enter pass/key + setupMegolmBackupButton.isVisible = true + exitAnywayButton.isVisible = true } else -> { - backingUpStatusGroup.isVisible = false - dontWantClickableView.isVisible = true - setupClickableView.isVisible = true - activateClickableView.isVisible = false - sheetTitle.text = getString(R.string.sign_out_bottom_sheet_warning_no_backup) + // mmm.. strange state + + exitAnywayButton.isVisible = true } } + } - // updateSignOutSection() - }) + // final call if keys have been exported + when (state.hasBeenExportedToFile) { + is Loading -> { + signoutExportingLoading.isVisible = true + hideViews(setupRecoveryButton, + setupMegolmBackupButton, + exportManuallyButton, + backingUpStatusGroup, + signOutButton) + exitAnywayButton.isVisible = true + } + is Success -> { + if (state.hasBeenExportedToFile.invoke()) { + sheetTitle.text = getString(R.string.action_sign_out_confirmation_simple) + hideViews(setupRecoveryButton, + setupMegolmBackupButton, + exportManuallyButton, + backingUpStatusGroup, + exitAnywayButton) + signOutButton.isVisible = true + } + } + else -> { + } + } + super.invalidate() } override fun getLayoutResId() = R.layout.bottom_sheet_logout_and_backup @@ -228,10 +296,26 @@ class SignOutBottomSheetDialogFragment : VectorBaseBottomSheetDialogFragment() { super.onActivityResult(requestCode, resultCode, data) if (resultCode == Activity.RESULT_OK) { - if (requestCode == EXPORT_REQ) { - val manualExportDone = data?.getBooleanExtra(KeysBackupSetupActivity.MANUAL_EXPORT, false) - viewModel.keysExportedToFile.value = manualExportDone + if (requestCode == QUERY_EXPORT_KEYS) { + val uri = data?.data + if (resultCode == Activity.RESULT_OK && uri != null) { + activity?.let { activity -> + ExportKeysDialog().show(activity, object : ExportKeysDialog.ExportKeyDialogListener { + override fun onPassphrase(passphrase: String) { + viewModel.handle(SignoutCheckViewModel.Actions.ExportKeys(passphrase, uri)) + } + }) + } + } + } else if (requestCode == EXPORT_REQ) { + if (data?.getBooleanExtra(KeysBackupSetupActivity.MANUAL_EXPORT, false) == true) { + viewModel.handle(SignoutCheckViewModel.Actions.KeySuccessfullyManuallyExported) + } } } } + + private fun hideViews(vararg views: View) { + views.forEach { it.isVisible = false } + } } diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt index e51fda2be5..e06a47d3d4 100644 --- a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutUiWorker.kt @@ -21,7 +21,7 @@ import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import im.vector.riotx.R import im.vector.riotx.core.di.ActiveSessionHolder -import im.vector.riotx.core.extensions.hasUnsavedKeys +import im.vector.riotx.core.extensions.cannotLogoutSafely import im.vector.riotx.core.extensions.vectorComponent import im.vector.riotx.features.MainActivity import im.vector.riotx.features.MainActivityArgs @@ -33,7 +33,7 @@ class SignOutUiWorker(private val activity: FragmentActivity) { fun perform(context: Context) { activeSessionHolder = context.vectorComponent().activeSessionHolder() val session = activeSessionHolder.getActiveSession() - if (session.hasUnsavedKeys()) { + if (session.cannotLogoutSafely()) { // The backup check on logout flow has to be displayed if there are keys in the store, and the keys backup state is not Ready val signOutDialog = SignOutBottomSheetDialogFragment.newInstance() signOutDialog.onSignOut = Runnable { diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutViewModel.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutViewModel.kt deleted file mode 100644 index 2f26fdf377..0000000000 --- a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignOutViewModel.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.riotx.features.workers.signout - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import im.vector.matrix.android.api.session.Session -import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState -import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupStateListener -import javax.inject.Inject - -class SignOutViewModel @Inject constructor(private val session: Session) : ViewModel(), KeysBackupStateListener { - // Keys exported manually - var keysExportedToFile = MutableLiveData() - - var keysBackupState = MutableLiveData() - - init { - session.cryptoService().keysBackupService().addListener(this) - - keysBackupState.value = session.cryptoService().keysBackupService().state - } - - /** - * Safe way to get the current KeysBackup version - */ - fun getCurrentBackupVersion(): String { - return session.cryptoService().keysBackupService().currentBackupVersion ?: "" - } - - /** - * Safe way to get the number of keys to backup - */ - fun getNumberOfKeysToBackup(): Int { - return session.cryptoService().inboundGroupSessionsCount(false) - } - - /** - * Safe way to tell if there are more keys on the server - */ - fun canRestoreKeys(): Boolean { - return session.cryptoService().keysBackupService().canRestoreKeys() - } - - override fun onCleared() { - super.onCleared() - - session.cryptoService().keysBackupService().removeListener(this) - } - - override fun onStateChange(newState: KeysBackupState) { - keysBackupState.value = newState - } - - fun refreshRemoteStateIfNeeded() { - if (keysBackupState.value == KeysBackupState.Disabled) { - session.cryptoService().keysBackupService().checkAndStartKeysBackup() - } - } -} diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutBottomSheetActionButton.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutBottomSheetActionButton.kt new file mode 100644 index 0000000000..cd5e4ed9da --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutBottomSheetActionButton.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.workers.signout + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import butterknife.BindView +import butterknife.ButterKnife +import im.vector.riotx.R +import im.vector.riotx.core.extensions.setTextOrHide +import im.vector.riotx.features.themes.ThemeUtils + +class SignoutBottomSheetActionButton @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + @BindView(R.id.actionTitleText) + lateinit var actionTextView: TextView + + @BindView(R.id.actionIconImageView) + lateinit var iconImageView: ImageView + + @BindView(R.id.signedOutActionClickable) + lateinit var clickableZone: View + + var action: (() -> Unit)? = null + + var title: String? = null + set(value) { + field = value + actionTextView.setTextOrHide(value) + } + + var leftIcon: Drawable? = null + set(value) { + field = value + if (value == null) { + iconImageView.isVisible = false + iconImageView.setImageDrawable(null) + } else { + iconImageView.isVisible = true + iconImageView.setImageDrawable(value) + } + } + + var tint: Int? = null + set(value) { + field = value + iconImageView.imageTintList = value?.let { ColorStateList.valueOf(value) } + } + + var textColor: Int? = null + set(value) { + field = value + textColor?.let { actionTextView.setTextColor(it) } + } + + init { + inflate(context, R.layout.item_signout_action, this) + ButterKnife.bind(this) + + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.SignoutBottomSheetActionButton, 0, 0) + title = typedArray.getString(R.styleable.SignoutBottomSheetActionButton_actionTitle) ?: "" + leftIcon = typedArray.getDrawable(R.styleable.SignoutBottomSheetActionButton_leftIcon) + tint = typedArray.getColor(R.styleable.SignoutBottomSheetActionButton_iconTint, ThemeUtils.getColor(context, android.R.attr.textColor)) + textColor = typedArray.getColor(R.styleable.SignoutBottomSheetActionButton_textColor, ThemeUtils.getColor(context, android.R.attr.textColor)) + + typedArray.recycle() + + clickableZone.setOnClickListener { + action?.invoke() + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutCheckViewModel.kt b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutCheckViewModel.kt new file mode 100644 index 0000000000..47da7d4edc --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/workers/signout/SignoutCheckViewModel.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.workers.signout + +import android.net.Uri +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupStateListener +import im.vector.matrix.rx.rx +import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.platform.VectorViewEvents +import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.core.platform.VectorViewModelAction +import im.vector.riotx.features.crypto.keys.KeysExporter + +data class SignoutCheckViewState( + val userId: String = "", + val backupIsSetup: Boolean = false, + val crossSigningSetupAllKeysKnown: Boolean = false, + val keysBackupState: KeysBackupState = KeysBackupState.Unknown, + val hasBeenExportedToFile: Async = Uninitialized +) : MvRxState + +class SignoutCheckViewModel @AssistedInject constructor(@Assisted initialState: SignoutCheckViewState, + private val session: Session) + : VectorViewModel(initialState), KeysBackupStateListener { + + sealed class Actions : VectorViewModelAction { + data class ExportKeys(val passphrase: String, val uri: Uri) : Actions() + object KeySuccessfullyManuallyExported : Actions() + object KeyExportFailed : Actions() + } + + sealed class ViewEvents : VectorViewEvents { + data class ExportKeys(val exporter: KeysExporter, val passphrase: String, val uri: Uri) : ViewEvents() + } + + @AssistedInject.Factory + interface Factory { + fun create(initialState: SignoutCheckViewState): SignoutCheckViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: SignoutCheckViewState): SignoutCheckViewModel? { + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") + } + } + + init { + session.cryptoService().keysBackupService().addListener(this) + session.cryptoService().keysBackupService().checkAndStartKeysBackup() + + val quad4SIsSetup = session.sharedSecretStorageService.isRecoverySetup() + val allKeysKnown = session.cryptoService().crossSigningService().allPrivateKeysKnown() + val backupState = session.cryptoService().keysBackupService().state + setState { + copy( + userId = session.myUserId, + crossSigningSetupAllKeysKnown = allKeysKnown, + backupIsSetup = quad4SIsSetup, + keysBackupState = backupState + ) + } + + session.rx().liveAccountData(setOf(MASTER_KEY_SSSS_NAME, USER_SIGNING_KEY_SSSS_NAME, SELF_SIGNING_KEY_SSSS_NAME)) + .map { + session.sharedSecretStorageService.isRecoverySetup() + } + .distinctUntilChanged() + .execute { + copy(backupIsSetup = it.invoke() == true) + } + } + + override fun onCleared() { + super.onCleared() + session.cryptoService().keysBackupService().removeListener(this) + } + + override fun onStateChange(newState: KeysBackupState) { + setState { + copy( + keysBackupState = newState + ) + } + } + + fun refreshRemoteStateIfNeeded() = withState { state -> + if (state.keysBackupState == KeysBackupState.Disabled) { + session.cryptoService().keysBackupService().checkAndStartKeysBackup() + } + } + + override fun handle(action: Actions) { + when (action) { + is Actions.ExportKeys -> { + setState { + copy(hasBeenExportedToFile = Loading()) + } + _viewEvents.post(ViewEvents.ExportKeys(KeysExporter(session), action.passphrase, action.uri)) + } + Actions.KeySuccessfullyManuallyExported -> { + setState { + copy(hasBeenExportedToFile = Success(true)) + } + } + Actions.KeyExportFailed -> { + setState { + copy(hasBeenExportedToFile = Uninitialized) + } + } + }.exhaustive + } +} diff --git a/vector/src/main/res/drawable/ic_secure_backup.xml b/vector/src/main/res/drawable/ic_secure_backup.xml new file mode 100644 index 0000000000..899bb8d2ae --- /dev/null +++ b/vector/src/main/res/drawable/ic_secure_backup.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml b/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml index feaa79e1dc..c6605dfc05 100644 --- a/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml +++ b/vector/src/main/res/layout/bottom_sheet_logout_and_backup.xml @@ -70,137 +70,60 @@ + android:layout_height="44dp" + android:gravity="center"> - - - - + android:layout_height="wrap_content" /> - - - - - - - + app:actionTitle="@string/secure_backup_setup" + app:iconTint="?riotx_text_primary" + app:leftIcon="@drawable/ic_secure_backup" + app:textColor="?riotx_text_secondary" /> - + app:actionTitle="@string/keys_backup_setup" + app:iconTint="?riotx_text_primary" + app:leftIcon="@drawable/backup_keys" + app:textColor="?riotx_text_secondary" /> - - - - - - + app:actionTitle="@string/keys_backup_setup_step1_manual_export" + app:iconTint="?riotx_text_primary" + app:leftIcon="@drawable/ic_download" + app:textColor="?riotx_text_secondary" /> - - - - + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_home_detail.xml b/vector/src/main/res/layout/fragment_home_detail.xml index f90422dff9..aa7a76cf16 100644 --- a/vector/src/main/res/layout/fragment_home_detail.xml +++ b/vector/src/main/res/layout/fragment_home_detail.xml @@ -59,6 +59,8 @@ android:layout_height="wrap_content" android:background="?riotx_keys_backup_banner_accent_color" android:minHeight="67dp" + android:visibility="gone" + tools:visibility="visible" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/syncStateView" /> diff --git a/vector/src/main/res/layout/item_signout_action.xml b/vector/src/main/res/layout/item_signout_action.xml new file mode 100644 index 0000000000..c5acc09e56 --- /dev/null +++ b/vector/src/main/res/layout/item_signout_action.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/view_keys_backup_banner.xml b/vector/src/main/res/layout/view_keys_backup_banner.xml index 87c92cf8b4..6c8fc2b5a1 100644 --- a/vector/src/main/res/layout/view_keys_backup_banner.xml +++ b/vector/src/main/res/layout/view_keys_backup_banner.xml @@ -10,11 +10,11 @@ + + + + + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index b57f0932aa..7cb839eba6 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -1052,6 +1052,7 @@ Export Please create a passphrase to encrypt the exported keys. You will need to enter the same passphrase to be able to import the keys. The E2E room keys have been saved to \'%s\'.\n\nWarning: this file may be deleted if the application is uninstalled. + Keys successfully exported Encrypted Messages Recovery Manage Key Backup @@ -1497,17 +1498,24 @@ Why choose Riot.im? New Key Backup A new secure message key backup has been detected.\n\nIf you didn’t set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings. It was me + Never lose encrypted messages Start using Key Backup + Secure Backup + Safeguard against losing access to encrypted messages & data + Never lose encrypted messages Use Key Backup New secure message keys Manage in Key Backup - Backing up keys… + Backing up your keys. This may take several minutes… + + + Set Up Secure Backup All keys backed up