diff --git a/CHANGES.md b/CHANGES.md index 605735ffb4..3b0fa6a2a7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,8 @@ -Changes in RiotX 0.16.0 (2020-XX-XX) +Changes in RiotX 0.17.0 (2020-XX-XX) =================================================== Features ✨: + - Secured Shared Storage Support (#984, #936) - Polls and Bot Buttons (MSC 2192 matrix-org/matrix-doc#2192) - It's now possible to select several rooms (with a possible mix of clear/encrypted rooms) when sharing elements to RiotX (#1010) - Media preview: media are previewed before being sent to a room (#1010) @@ -9,19 +10,16 @@ Features ✨: - Sending image: image are sent to rooms with a reduced size. It's still possible to send original image file (#1010) Improvements 🙌: - - Show confirmation dialog before deleting a message (#967) - - Open room member profile from reactions list and read receipts list (#875) + - Bugfix 🐛: - - Fix crash by removing all notifications after clearing cache (#878) - - Fix issue with verification when other client declares it can only show QR code (#988) + - Account creation: wrongly hints that an email can be used to create an account (#941) Translations 🗣: - -SDK API changes 🔞: - - Javadoc improved for PushersService - - PushersService.pushers() has been renamed to PushersService.getPushers() +SDK API changes ⚠️: + - Build 🧱: - @@ -29,6 +27,25 @@ Build 🧱: Other changes: - +Changes in RiotX 0.16.0 (2020-02-14) +=================================================== + +Features ✨: + - Polls and Bot Buttons (MSC 2192 matrix-org/matrix-doc#2192) + +Improvements 🙌: + - Show confirmation dialog before deleting a message (#967, #1003) + - Open room member profile from reactions list and read receipts list (#875) + +Bugfix 🐛: + - Fix crash by removing all notifications after clearing cache (#878) + - Fix issue with verification when other client declares it can only show QR code (#988) + - Fix too errors in the code (1941862499c9ec5268cc80882512ced379cafcfd, a250a895fe0a4acf08c671e03434edcd29ccd84f) + +SDK API changes ⚠️: + - Javadoc improved for PushersService + - PushersService.pushers() has been renamed to PushersService.getPushers() + Changes in RiotX 0.15.0 (2020-02-10) =================================================== @@ -400,7 +417,7 @@ Bugfix 🐛: Translations 🗣: - -SDK API changes 🔞: +SDK API changes ⚠️: - Build 🧱: @@ -408,4 +425,3 @@ Build 🧱: Other changes: - - diff --git a/build.gradle b/build.gradle index 7f80eaf5fe..74a62f0d17 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,8 @@ allprojects { includeGroupByRegex "com\\.github\\.Zhuinden" // And ucrop includeGroupByRegex "com\\.github\\.yalantis" + // JsonViewer + includeGroupByRegex 'com\\.github\\.BillCarsonFr' } } maven { diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt index 2cd2bf2dd3..0417504cb7 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt @@ -31,6 +31,7 @@ import im.vector.matrix.android.api.util.JsonDict import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.api.util.toOptional import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent import io.reactivex.Observable import io.reactivex.Single @@ -121,6 +122,13 @@ class RxSession(private val session: Session) { session.getCrossSigningService().getUserCrossSigningKeys(userId).toOptional() } } + + fun liveAccountData(filter: List): Observable> { + return session.getLiveAccountDataEvents(filter).asObservable() + .startWithCallable { + session.getAccountDataEvents(filter) + } + } } fun Session.rx(): RxSession { diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/ssss/QuadSTests.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/ssss/QuadSTests.kt new file mode 100644 index 0000000000..f0e2161d4c --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/ssss/QuadSTests.kt @@ -0,0 +1,438 @@ +/* + * 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.matrix.android.internal.crypto.ssss + +import android.util.Base64 +import androidx.lifecycle.Observer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import im.vector.matrix.android.InstrumentedTest +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.securestorage.Curve25519AesSha2KeySpec +import im.vector.matrix.android.api.session.securestorage.EncryptedSecretContent +import im.vector.matrix.android.api.session.securestorage.KeySigner +import im.vector.matrix.android.api.session.securestorage.SsssKeyCreationInfo +import im.vector.matrix.android.api.session.securestorage.SecretStorageKeyContent +import im.vector.matrix.android.api.util.Optional +import im.vector.matrix.android.common.CommonTestHelper +import im.vector.matrix.android.common.CryptoTestHelper +import im.vector.matrix.android.common.SessionTestParams +import im.vector.matrix.android.common.TestConstants +import im.vector.matrix.android.common.TestMatrixCallback +import im.vector.matrix.android.internal.crypto.SSSS_ALGORITHM_CURVE25519_AES_SHA2 +import im.vector.matrix.android.internal.crypto.crosssigning.toBase64NoPadding +import im.vector.matrix.android.internal.crypto.secrets.DefaultSharedSecretStorageService +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert +import org.junit.Assert.fail +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters +import java.util.concurrent.CountDownLatch + +@RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class QuadSTests : InstrumentedTest { + + private val mTestHelper = CommonTestHelper(context()) + private val mCryptoTestHelper = CryptoTestHelper(mTestHelper) + + @Test + fun test_Generate4SKey() { + val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(true)) + + val aliceLatch = CountDownLatch(1) + + val quadS = aliceSession.sharedSecretStorageService + + val emptyKeySigner = object : KeySigner { + override fun sign(canonicalJson: String): Map>? { + return null + } + } + + var recoveryKey: String? = null + + val TEST_KEY_ID = "my.test.Key" + + quadS.generateKey(TEST_KEY_ID, "Test Key", emptyKeySigner, + object : MatrixCallback { + override fun onSuccess(data: SsssKeyCreationInfo) { + recoveryKey = data.recoveryKey + aliceLatch.countDown() + } + + override fun onFailure(failure: Throwable) { + Assert.fail("onFailure " + failure.localizedMessage) + aliceLatch.countDown() + } + }) + + mTestHelper.await(aliceLatch) + + // Assert Account data is updated + val accountDataLock = CountDownLatch(1) + var accountData: UserAccountDataEvent? = null + + val liveAccountData = runBlocking(Dispatchers.Main) { + aliceSession.getLiveAccountDataEvent("m.secret_storage.key.$TEST_KEY_ID") + } + val accountDataObserver = Observer?> { t -> + if (t?.getOrNull()?.type == "m.secret_storage.key.$TEST_KEY_ID") { + accountData = t.getOrNull() + accountDataLock.countDown() + } + } + GlobalScope.launch(Dispatchers.Main) { liveAccountData.observeForever(accountDataObserver) } + + mTestHelper.await(accountDataLock) + + Assert.assertNotNull("Key should be stored in account data", accountData) + val parsed = SecretStorageKeyContent.fromJson(accountData!!.content) + Assert.assertNotNull("Key Content cannot be parsed", parsed) + Assert.assertEquals("Unexpected Algorithm", SSSS_ALGORITHM_CURVE25519_AES_SHA2, parsed!!.algorithm) + Assert.assertEquals("Unexpected key name", "Test Key", parsed.name) + Assert.assertNull("Key was not generated from passphrase", parsed.passphrase) + Assert.assertNotNull("Pubkey should be defined", parsed.publicKey) + + val privateKeySpec = Curve25519AesSha2KeySpec.fromRecoveryKey(recoveryKey!!) + DefaultSharedSecretStorageService.withOlmDecryption { olmPkDecryption -> + val pubKey = olmPkDecryption.setPrivateKey(privateKeySpec!!.privateKey) + Assert.assertEquals("Unexpected Public Key", pubKey, parsed.publicKey) + } + + // Set as default key + quadS.setDefaultKey(TEST_KEY_ID, object : MatrixCallback {}) + + var defaultKeyAccountData: UserAccountDataEvent? = null + val defaultDataLock = CountDownLatch(1) + + val liveDefAccountData = runBlocking(Dispatchers.Main) { + aliceSession.getLiveAccountDataEvent(DefaultSharedSecretStorageService.DEFAULT_KEY_ID) + } + val accountDefDataObserver = Observer?> { t -> + if (t?.getOrNull()?.type == DefaultSharedSecretStorageService.DEFAULT_KEY_ID) { + defaultKeyAccountData = t.getOrNull()!! + defaultDataLock.countDown() + } + } + GlobalScope.launch(Dispatchers.Main) { liveDefAccountData.observeForever(accountDefDataObserver) } + + mTestHelper.await(defaultDataLock) + + Assert.assertNotNull(defaultKeyAccountData?.content) + Assert.assertEquals("Unexpected default key ${defaultKeyAccountData?.content}", TEST_KEY_ID, defaultKeyAccountData?.content?.get("key")) + + mTestHelper.signout(aliceSession) + } + + @Test + fun test_StoreSecret() { + val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(true)) + val keyId = "My.Key" + val info = generatedSecret(aliceSession, keyId, true) + + // Store a secret + + val storeCountDownLatch = CountDownLatch(1) + val clearSecret = Base64.encodeToString("42".toByteArray(), Base64.NO_PADDING or Base64.NO_WRAP) + aliceSession.sharedSecretStorageService.storeSecret( + "secret.of.life", + clearSecret, + null, // default key + TestMatrixCallback(storeCountDownLatch) + ) + + val secretAccountData = assertAccountData(aliceSession, "secret.of.life") + + val encryptedContent = secretAccountData.content.get("encrypted") as? Map<*, *> + Assert.assertNotNull("Element should be encrypted", encryptedContent) + Assert.assertNotNull("Secret should be encrypted with default key", encryptedContent?.get(keyId)) + + val secret = EncryptedSecretContent.fromJson(encryptedContent?.get(keyId)) + Assert.assertNotNull(secret?.ciphertext) + Assert.assertNotNull(secret?.mac) + Assert.assertNotNull(secret?.ephemeral) + + // Try to decrypt?? + + val keySpec = Curve25519AesSha2KeySpec.fromRecoveryKey(info.recoveryKey) + + var decryptedSecret: String? = null + + val decryptCountDownLatch = CountDownLatch(1) + aliceSession.sharedSecretStorageService.getSecret("secret.of.life", + null, // default key + keySpec!!, + object : MatrixCallback { + override fun onFailure(failure: Throwable) { + fail("Fail to decrypt -> " + failure.localizedMessage) + decryptCountDownLatch.countDown() + } + + override fun onSuccess(data: String) { + decryptedSecret = data + decryptCountDownLatch.countDown() + } + } + ) + mTestHelper.await(decryptCountDownLatch) + + Assert.assertEquals("Secret mismatch", clearSecret, decryptedSecret) + mTestHelper.signout(aliceSession) + } + + @Test + fun test_SetDefaultLocalEcho() { + val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(true)) + + val quadS = aliceSession.sharedSecretStorageService + + val emptyKeySigner = object : KeySigner { + override fun sign(canonicalJson: String): Map>? { + return null + } + } + + val TEST_KEY_ID = "my.test.Key" + + val countDownLatch = CountDownLatch(1) + quadS.generateKey(TEST_KEY_ID, "Test Key", emptyKeySigner, + TestMatrixCallback(countDownLatch)) + + mTestHelper.await(countDownLatch) + + // Test that we don't need to wait for an account data sync to access directly the keyid from DB + val defaultLatch = CountDownLatch(1) + quadS.setDefaultKey(TEST_KEY_ID, TestMatrixCallback(defaultLatch)) + mTestHelper.await(defaultLatch) + + mTestHelper.signout(aliceSession) + } + + @Test + fun test_StoreSecretWithMultipleKey() { + val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(true)) + val keyId1 = "Key.1" + val key1Info = generatedSecret(aliceSession, keyId1, true) + val keyId2 = "Key2" + val key2Info = generatedSecret(aliceSession, keyId2, true) + + val mySecretText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit" + + val storeLatch = CountDownLatch(1) + aliceSession.sharedSecretStorageService.storeSecret( + "my.secret", + mySecretText.toByteArray().toBase64NoPadding(), + listOf(keyId1, keyId2), + TestMatrixCallback(storeLatch) + ) + mTestHelper.await(storeLatch) + + val accountDataEvent = aliceSession.getAccountDataEvent("my.secret") + val encryptedContent = accountDataEvent?.content?.get("encrypted") as? Map<*, *> + + Assert.assertEquals("Content should contains two encryptions", 2, encryptedContent?.keys?.size ?: 0) + + Assert.assertNotNull(encryptedContent?.get(keyId1)) + Assert.assertNotNull(encryptedContent?.get(keyId2)) + + // Assert that can decrypt with both keys + val decryptCountDownLatch = CountDownLatch(2) + aliceSession.sharedSecretStorageService.getSecret("my.secret", + keyId1, + Curve25519AesSha2KeySpec.fromRecoveryKey(key1Info.recoveryKey)!!, + TestMatrixCallback(decryptCountDownLatch) + ) + + aliceSession.sharedSecretStorageService.getSecret("my.secret", + keyId2, + Curve25519AesSha2KeySpec.fromRecoveryKey(key2Info.recoveryKey)!!, + TestMatrixCallback(decryptCountDownLatch) + ) + + mTestHelper.await(decryptCountDownLatch) + + mTestHelper.signout(aliceSession) + } + + @Test + fun test_GetSecretWithBadPassphrase() { + val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(true)) + val keyId1 = "Key.1" + val passphrase = "The good pass phrase" + val key1Info = generatedSecretFromPassphrase(aliceSession, passphrase, keyId1, true) + + val mySecretText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit" + + val storeLatch = CountDownLatch(1) + aliceSession.sharedSecretStorageService.storeSecret( + "my.secret", + mySecretText.toByteArray().toBase64NoPadding(), + listOf(keyId1), + TestMatrixCallback(storeLatch) + ) + mTestHelper.await(storeLatch) + + val decryptCountDownLatch = CountDownLatch(2) + aliceSession.sharedSecretStorageService.getSecret("my.secret", + keyId1, + Curve25519AesSha2KeySpec.fromPassphrase( + "A bad passphrase", + key1Info.content?.passphrase?.salt ?: "", + key1Info.content?.passphrase?.iterations ?: 0, + null), + object : MatrixCallback { + override fun onSuccess(data: String) { + decryptCountDownLatch.countDown() + fail("Should not be able to decrypt") + } + + override fun onFailure(failure: Throwable) { + Assert.assertTrue(true) + decryptCountDownLatch.countDown() + } + } + ) + + // Now try with correct key + aliceSession.sharedSecretStorageService.getSecret("my.secret", + keyId1, + Curve25519AesSha2KeySpec.fromPassphrase( + passphrase, + key1Info.content?.passphrase?.salt ?: "", + key1Info.content?.passphrase?.iterations ?: 0, + null), + TestMatrixCallback(decryptCountDownLatch) + ) + + mTestHelper.await(decryptCountDownLatch) + + mTestHelper.signout(aliceSession) + } + + private fun assertAccountData(session: Session, type: String): UserAccountDataEvent { + val accountDataLock = CountDownLatch(1) + var accountData: UserAccountDataEvent? = null + + val liveAccountData = runBlocking(Dispatchers.Main) { + session.getLiveAccountDataEvent(type) + } + val accountDataObserver = Observer?> { t -> + if (t?.getOrNull()?.type == type) { + accountData = t.getOrNull() + accountDataLock.countDown() + } + } + GlobalScope.launch(Dispatchers.Main) { liveAccountData.observeForever(accountDataObserver) } + mTestHelper.await(accountDataLock) + + Assert.assertNotNull("Account Data type:$type should be found", accountData) + + return accountData!! + } + + private fun generatedSecret(session: Session, keyId: String, asDefault: Boolean = true): SsssKeyCreationInfo { + val quadS = session.sharedSecretStorageService + + val emptyKeySigner = object : KeySigner { + override fun sign(canonicalJson: String): Map>? { + return null + } + } + + var creationInfo: SsssKeyCreationInfo? = null + + val generateLatch = CountDownLatch(1) + + quadS.generateKey(keyId, keyId, emptyKeySigner, + object : MatrixCallback { + override fun onSuccess(data: SsssKeyCreationInfo) { + creationInfo = data + generateLatch.countDown() + } + + override fun onFailure(failure: Throwable) { + Assert.fail("onFailure " + failure.localizedMessage) + generateLatch.countDown() + } + }) + + mTestHelper.await(generateLatch) + + Assert.assertNotNull(creationInfo) + + assertAccountData(session, "m.secret_storage.key.$keyId") + if (asDefault) { + val setDefaultLatch = CountDownLatch(1) + quadS.setDefaultKey(keyId, TestMatrixCallback(setDefaultLatch)) + mTestHelper.await(setDefaultLatch) + assertAccountData(session, DefaultSharedSecretStorageService.DEFAULT_KEY_ID) + } + + return creationInfo!! + } + + private fun generatedSecretFromPassphrase(session: Session, passphrase: String, keyId: String, asDefault: Boolean = true): SsssKeyCreationInfo { + val quadS = session.sharedSecretStorageService + + val emptyKeySigner = object : KeySigner { + override fun sign(canonicalJson: String): Map>? { + return null + } + } + + var creationInfo: SsssKeyCreationInfo? = null + + val generateLatch = CountDownLatch(1) + + quadS.generateKeyWithPassphrase(keyId, keyId, + passphrase, + emptyKeySigner, + null, + object : MatrixCallback { + override fun onSuccess(data: SsssKeyCreationInfo) { + creationInfo = data + generateLatch.countDown() + } + + override fun onFailure(failure: Throwable) { + Assert.fail("onFailure " + failure.localizedMessage) + generateLatch.countDown() + } + }) + + mTestHelper.await(generateLatch) + + Assert.assertNotNull(creationInfo) + + assertAccountData(session, "m.secret_storage.key.$keyId") + if (asDefault) { + val setDefaultLatch = CountDownLatch(1) + quadS.setDefaultKey(keyId, TestMatrixCallback(setDefaultLatch)) + mTestHelper.await(setDefaultLatch) + assertAccountData(session, DefaultSharedSecretStorageService.DEFAULT_KEY_ID) + } + + return creationInfo!! + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt index 5bd219247c..4167131c68 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.LiveData import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.api.failure.GlobalError import im.vector.matrix.android.api.pushrules.PushRuleService +import im.vector.matrix.android.api.session.accountdata.AccountDataService import im.vector.matrix.android.api.session.cache.CacheService import im.vector.matrix.android.api.session.content.ContentUploadStateTracker import im.vector.matrix.android.api.session.content.ContentUrlResolver @@ -33,6 +34,7 @@ import im.vector.matrix.android.api.session.pushers.PushersService import im.vector.matrix.android.api.session.room.RoomDirectoryService import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.api.session.securestorage.SecureStorageService +import im.vector.matrix.android.api.session.securestorage.SharedSecretStorageService import im.vector.matrix.android.api.session.signout.SignOutService import im.vector.matrix.android.api.session.sync.FilterService import im.vector.matrix.android.api.session.sync.SyncState @@ -57,7 +59,8 @@ interface Session : PushersService, InitialSyncProgressService, HomeServerCapabilitiesService, - SecureStorageService { + SecureStorageService, + AccountDataService { /** * The params associated to the session @@ -159,4 +162,6 @@ interface Session : */ fun onGlobalError(globalError: GlobalError) } + + val sharedSecretStorageService: SharedSecretStorageService } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/accountdata/AccountDataService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/accountdata/AccountDataService.kt new file mode 100644 index 0000000000..7af7fea214 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/accountdata/AccountDataService.kt @@ -0,0 +1,36 @@ +/* + * 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.matrix.android.api.session.accountdata + +import androidx.lifecycle.LiveData +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.events.model.Content +import im.vector.matrix.android.api.util.Optional +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent + +interface AccountDataService { + + fun getAccountDataEvent(type: String): UserAccountDataEvent? + + fun getLiveAccountDataEvent(type: String): LiveData> + + fun getAccountDataEvents(filterType: List): List + + fun getLiveAccountDataEvents(filterType: List): LiveData> + + fun updateAccountData(type: String, content: Content, callback: MatrixCallback? = null) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt index fb94d61c0b..d131960893 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt @@ -157,6 +157,11 @@ data class Event( */ fun isRedacted() = unsignedData?.redactedEvent != null + /** + * Tells if the event is redacted by the user himself. + */ + fun isRedactedBySameUser() = senderId == unsignedData?.redactedEvent?.senderId + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt index 8878930de0..9a3107a8ca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt @@ -66,6 +66,9 @@ object EventType { const val ROOM_KEY_REQUEST = "m.room_key_request" const val FORWARDED_ROOM_KEY = "m.forwarded_room_key" + const val REQUEST_SECRET = "m.secret.request" + const val SEND_SECRET = "m.secret.send" + // Interactive key verification const val KEY_VERIFICATION_START = "m.key.verification.start" const val KEY_VERIFICATION_ACCEPT = "m.key.verification.accept" diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/EncryptedSecretContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/EncryptedSecretContent.kt new file mode 100644 index 0000000000..4c8b51c668 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/EncryptedSecretContent.kt @@ -0,0 +1,47 @@ +/* + * 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.matrix.android.api.session.securestorage + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.internal.di.MoshiProvider + +/** + * The account_data will have an encrypted property that is a map from key ID to an object. + * The algorithm from the m.secret_storage.key.[key ID] data for the given key defines how the other properties are interpreted, + * though it's expected that most encryption schemes would have ciphertext and mac properties, + * where the ciphertext property is the unpadded base64-encoded ciphertext, and the mac is used to ensure the integrity of the data. + */ +@JsonClass(generateAdapter = true) +data class EncryptedSecretContent( + /** unpadded base64-encoded ciphertext */ + @Json(name = "ciphertext") val ciphertext: String? = null, + @Json(name = "mac") val mac: String? = null, + @Json(name = "ephemeral") val ephemeral: String? = null +) { + companion object { + /** + * Facility method to convert from object which must be comprised of maps, lists, + * strings, numbers, booleans and nulls. + */ + fun fromJson(obj: Any?): EncryptedSecretContent? { + return MoshiProvider.providesMoshi() + .adapter(EncryptedSecretContent::class.java) + .fromJsonValue(obj) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/KeyInfoResult.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/KeyInfoResult.kt new file mode 100644 index 0000000000..940f5298ef --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/KeyInfoResult.kt @@ -0,0 +1,24 @@ +/* + * 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.matrix.android.api.session.securestorage + +sealed class KeyInfoResult { + data class Success(val keyInfo: KeyInfo) : KeyInfoResult() + data class Error(val error: SharedSecretStorageError) : KeyInfoResult() + + fun isSuccess(): Boolean = this is Success +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/KeySigner.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/KeySigner.kt new file mode 100644 index 0000000000..2cd7a74f31 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/KeySigner.kt @@ -0,0 +1,21 @@ +/* + * 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.matrix.android.api.session.securestorage + +interface KeySigner { + fun sign(canonicalJson: String): Map>? +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SecretStorageKeyContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SecretStorageKeyContent.kt new file mode 100644 index 0000000000..02c3e96658 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SecretStorageKeyContent.kt @@ -0,0 +1,100 @@ +/* + * 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.matrix.android.api.session.securestorage + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.internal.di.MoshiProvider +import im.vector.matrix.android.internal.util.JsonCanonicalizer + +/** + * + * The contents of the account data for the key will include an algorithm property, which indicates the encryption algorithm used, as well as a name property, + * which is a human-readable name. + * The contents will be signed as signed JSON using the user's master cross-signing key. Other properties depend on the encryption algorithm. + * + * + * "content": { + * "algorithm": "m.secret_storage.v1.curve25519-aes-sha2", + * "passphrase": { + * "algorithm": "m.pbkdf2", + * "iterations": 500000, + * "salt": "IrswcMWnYieBALCAOMBw9k93xSzlc2su" + * }, + * "pubkey": "qql1q3IvBbwMU97zLnyh9HYW5x/zqTy5eoK1n+9fm1Y", + * "signatures": { + * "@valere35:matrix.org": { + * "ed25519:nOUQYiH9L8uKp5JajqiQyv+Loa3+lsdil7UBverz/Ko": "QtePmwfUL7+SHYRJT/HaTgF7gUFog1E/wtUCt0qc5aB8N+Sz5iCOvQ0KtaFHQ5SJzsBlYH8k7ejoBc0RcnU7BA" + * } + * } + * } + */ + +data class KeyInfo( + val id: String, + val content: SecretStorageKeyContent +) + +@JsonClass(generateAdapter = true) +data class SecretStorageKeyContent( + /** Currently support m.secret_storage.v1.curve25519-aes-sha2 */ + @Json(name = "algorithm") val algorithm: String? = null, + @Json(name = "name") val name: String? = null, + @Json(name = "passphrase") val passphrase: SSSSPassphrase? = null, + @Json(name = "pubkey") val publicKey: String? = null, + @Json(name = "signatures") + var signatures: Map>? = null +) { + + private fun signalableJSONDictionary(): Map { + val map = HashMap() + algorithm?.let { map["algorithm"] = it } + name?.let { map["name"] = it } + publicKey?.let { map["pubkey"] = it } + passphrase?.let { ssspp -> + map["passphrase"] = mapOf( + "algorithm" to ssspp.algorithm, + "iterations" to ssspp.salt, + "salt" to ssspp.salt + ) + } + return map + } + + fun canonicalSignable(): String { + return JsonCanonicalizer.getCanonicalJson(Map::class.java, signalableJSONDictionary()) + } + + companion object { + /** + * Facility method to convert from object which must be comprised of maps, lists, + * strings, numbers, booleans and nulls. + */ + fun fromJson(obj: Any?): SecretStorageKeyContent? { + return MoshiProvider.providesMoshi() + .adapter(SecretStorageKeyContent::class.java) + .fromJsonValue(obj) + } + } +} + +@JsonClass(generateAdapter = true) +data class SSSSPassphrase( + @Json(name = "algorithm") val algorithm: String?, + @Json(name = "iterations") val iterations: Int, + @Json(name = "salt") val salt: String? +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageError.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageError.kt new file mode 100644 index 0000000000..f882375e5c --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageError.kt @@ -0,0 +1,32 @@ +/* + * 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.matrix.android.api.session.securestorage + +sealed class SharedSecretStorageError(message: String?) : Throwable(message) { + + data class UnknownSecret(val secretName: String) : SharedSecretStorageError("Unknown Secret $secretName") + data class UnknownKey(val keyId: String) : SharedSecretStorageError("Unknown key $keyId") + data class UnknownAlgorithm(val keyId: String) : SharedSecretStorageError("Unknown algorithm $keyId") + data class UnsupportedAlgorithm(val algorithm: String) : SharedSecretStorageError("Unknown algorithm $algorithm") + data class SecretNotEncrypted(val secretName: String) : SharedSecretStorageError("Missing content for secret $secretName") + data class SecretNotEncryptedWithKey(val secretName: String, val keyId: String) + : SharedSecretStorageError("Missing content for secret $secretName with key $keyId") + + object BadKeyFormat : SharedSecretStorageError("Bad Key Format") + object ParsingError : SharedSecretStorageError("parsing Error") + data class OtherError(val reason: Throwable) : SharedSecretStorageError(reason.localizedMessage) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageService.kt new file mode 100644 index 0000000000..02ccc11026 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SharedSecretStorageService.kt @@ -0,0 +1,112 @@ +/* + * Copyright 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.matrix.android.api.session.securestorage + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.listeners.ProgressListener + +/** + * Some features may require clients to store encrypted data on the server so that it can be shared securely between clients. + * Clients may also wish to securely send such data directly to each other. + * For example, key backups (MSC1219) can store the decryption key for the backups on the server, or cross-signing (MSC1756) can store the signing keys. + * + * https://github.com/matrix-org/matrix-doc/pull/1946 + * + */ + +interface SharedSecretStorageService { + + /** + * Generates a SSSS key for encrypting secrets. + * Use the SsssKeyCreationInfo object returned by the callback to get more information about the created key (recovery key ...) + * + * @param keyId the ID of the key + * @param keyName a human readable name + * @param keySigner Used to add a signature to the key (client should check key signature before storing secret) + * + * @param callback Get key creation info + */ + fun generateKey(keyId: String, + keyName: String, + keySigner: KeySigner, + callback: MatrixCallback) + + /** + * Generates a SSSS key using the given passphrase. + * Use the SsssKeyCreationInfo object returned by the callback to get more information about the created key (recovery key, salt, iteration ...) + * + * @param keyId the ID of the key + * @param keyName human readable key name + * @param passphrase The passphrase used to generate the key + * @param keySigner Used to add a signature to the key (client should check key signature before retrieving secret) + * @param progressListener The derivation of the passphrase may take long depending on the device, use this to report progress + * + * @param callback Get key creation info + */ + fun generateKeyWithPassphrase(keyId: String, + keyName: String, + passphrase: String, + keySigner: KeySigner, + progressListener: ProgressListener?, + callback: MatrixCallback) + + fun getKey(keyId: String): KeyInfoResult + + /** + * A key can be marked as the "default" key by setting the user's account_data with event type m.secret_storage.default_key + * to an object that has the ID of the key as its key property. + * The default key will be used to encrypt all secrets that the user would expect to be available on all their clients. + * Unless the user specifies otherwise, clients will try to use the default key to decrypt secrets. + */ + fun getDefaultKey(): KeyInfoResult + + fun setDefaultKey(keyId: String, callback: MatrixCallback) + + /** + * Check whether we have a key with a given ID. + * + * @param keyId The ID of the key to check + * @return Whether we have the key. + */ + fun hasKey(keyId: String): Boolean + + /** + * Store an encrypted secret on the server + * Clients MUST ensure that the key is trusted before using it to encrypt secrets. + * + * @param name The name of the secret + * @param secret The secret contents. + * @param keys The list of (ID,privateKey) of the keys to use to encrypt the secret. + */ + fun storeSecret(name: String, secretBase64: String, keys: List?, callback: MatrixCallback) + + /** + * Use this call to determine which SSSSKeySpec to use for requesting secret + */ + fun getAlgorithmsForSecret(name: String): List + + /** + * Get an encrypted secret from the shared storage + * + * @param name The name of the secret + * @param keyId The id of the key that should be used to decrypt (null for default key) + * @param secretKey the secret key to use (@see #Curve25519AesSha2KeySpec) + * + */ + @Throws + fun getSecret(name: String, keyId: String?, secretKey: SSSSKeySpec, callback: MatrixCallback) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SsssKeyCreationInfo.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SsssKeyCreationInfo.kt new file mode 100644 index 0000000000..1d5522b8bf --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SsssKeyCreationInfo.kt @@ -0,0 +1,23 @@ +/* + * 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.matrix.android.api.session.securestorage + +data class SsssKeyCreationInfo( + val keyId: String = "", + var content: SecretStorageKeyContent?, + val recoveryKey: String = "" +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SsssKeySpec.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SsssKeySpec.kt new file mode 100644 index 0000000000..9e61f7f8ff --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/securestorage/SsssKeySpec.kt @@ -0,0 +1,66 @@ +/* + * 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.matrix.android.api.session.securestorage + +import im.vector.matrix.android.api.listeners.ProgressListener +import im.vector.matrix.android.internal.crypto.keysbackup.deriveKey +import im.vector.matrix.android.internal.crypto.keysbackup.util.extractCurveKeyFromRecoveryKey + +/** Tag class */ +interface SSSSKeySpec + +data class Curve25519AesSha2KeySpec( + val privateKey: ByteArray +) : SSSSKeySpec { + + companion object { + + fun fromPassphrase(passphrase: String, salt: String, iterations: Int, progressListener: ProgressListener?): Curve25519AesSha2KeySpec { + return Curve25519AesSha2KeySpec( + privateKey = deriveKey( + passphrase, + salt, + iterations, + progressListener + ) + ) + } + + fun fromRecoveryKey(recoveryKey: String): Curve25519AesSha2KeySpec? { + return extractCurveKeyFromRecoveryKey(recoveryKey)?.let { + Curve25519AesSha2KeySpec( + privateKey = it + ) + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Curve25519AesSha2KeySpec + + if (!privateKey.contentEquals(other.privateKey)) return false + + return true + } + + override fun hashCode(): Int { + return privateKey.contentHashCode() + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoConstants.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoConstants.kt index a3b0a567fe..fee81a853d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoConstants.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoConstants.kt @@ -31,6 +31,11 @@ const val MXCRYPTO_ALGORITHM_MEGOLM = "m.megolm.v1.aes-sha2" */ const val MXCRYPTO_ALGORITHM_MEGOLM_BACKUP = "m.megolm_backup.v1.curve25519-aes-sha2" +/** + * Secured Shared Storage algorithm constant + */ +const val SSSS_ALGORITHM_CURVE25519_AES_SHA2 = "m.secret_storage.v1.curve25519-aes-sha2" + // TODO Refacto: use this constants everywhere const val ed25519 = "ed25519" const val curve25519 = "curve25519" 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 920e5e9e4d..7fc3c0a549 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 @@ -637,9 +637,9 @@ internal class DefaultCrossSigningService @Inject constructor( // In this case it will change my MSK trust, and should then re-trigger a check of all other user trust setUserKeysAsTrusted(otherUserId, checkSelfTrust().isVerified()) } - - eventBus.post(CryptoToSessionUserTrustChange(userIds)) } + + eventBus.post(CryptoToSessionUserTrustChange(userIds)) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt index 7906005046..595b55a7a6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt @@ -40,8 +40,28 @@ import im.vector.matrix.android.internal.crypto.keysbackup.model.KeysBackupVersi import im.vector.matrix.android.internal.crypto.keysbackup.model.KeysBackupVersionTrustSignature import im.vector.matrix.android.internal.crypto.keysbackup.model.MegolmBackupAuthData import im.vector.matrix.android.internal.crypto.keysbackup.model.MegolmBackupCreationInfo -import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.* -import im.vector.matrix.android.internal.crypto.keysbackup.tasks.* +import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.BackupKeysResult +import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.CreateKeysBackupVersionBody +import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeyBackupData +import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.KeysBackupData +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.model.rest.RoomKeysBackupData +import im.vector.matrix.android.internal.crypto.keysbackup.model.rest.UpdateKeysBackupVersionBody +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.CreateKeysBackupVersionTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.DeleteBackupTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.DeleteRoomSessionDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.DeleteRoomSessionsDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.DeleteSessionsDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.GetKeysBackupLastVersionTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.GetKeysBackupVersionTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.GetRoomSessionDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.GetRoomSessionsDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.GetSessionsDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.StoreRoomSessionDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.StoreRoomSessionsDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.StoreSessionsDataTask +import im.vector.matrix.android.internal.crypto.keysbackup.tasks.UpdateKeysBackupVersionTask import im.vector.matrix.android.internal.crypto.keysbackup.util.computeRecoveryKey import im.vector.matrix.android.internal.crypto.keysbackup.util.extractCurveKeyFromRecoveryKey import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackupPassword.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackupPassword.kt index 344ba61277..2429c1e658 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackupPassword.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackupPassword.kt @@ -83,10 +83,10 @@ fun retrievePrivateKeyWithPassword(password: String, * @return a private key. */ @WorkerThread -private fun deriveKey(password: String, - salt: String, - iterations: Int, - progressListener: ProgressListener?): ByteArray { +fun deriveKey(password: String, + salt: String, + iterations: Int, + progressListener: ProgressListener?): ByteArray { // Note: copied and adapted from MXMegolmExportEncryption val t0 = System.currentTimeMillis() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/secrets/DefaultSharedSecretStorageService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/secrets/DefaultSharedSecretStorageService.kt new file mode 100644 index 0000000000..f741021e6c --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/secrets/DefaultSharedSecretStorageService.kt @@ -0,0 +1,358 @@ +/* + * 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.matrix.android.internal.crypto.secrets + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.listeners.ProgressListener +import im.vector.matrix.android.api.session.accountdata.AccountDataService +import im.vector.matrix.android.api.session.events.model.toContent +import im.vector.matrix.android.api.session.securestorage.Curve25519AesSha2KeySpec +import im.vector.matrix.android.api.session.securestorage.EncryptedSecretContent +import im.vector.matrix.android.api.session.securestorage.KeyInfo +import im.vector.matrix.android.api.session.securestorage.KeyInfoResult +import im.vector.matrix.android.api.session.securestorage.KeySigner +import im.vector.matrix.android.api.session.securestorage.SsssKeyCreationInfo +import im.vector.matrix.android.api.session.securestorage.SSSSKeySpec +import im.vector.matrix.android.api.session.securestorage.SSSSPassphrase +import im.vector.matrix.android.api.session.securestorage.SecretStorageKeyContent +import im.vector.matrix.android.api.session.securestorage.SharedSecretStorageError +import im.vector.matrix.android.api.session.securestorage.SharedSecretStorageService +import im.vector.matrix.android.internal.crypto.SSSS_ALGORITHM_CURVE25519_AES_SHA2 +import im.vector.matrix.android.internal.crypto.keysbackup.generatePrivateKeyWithPassword +import im.vector.matrix.android.internal.crypto.keysbackup.util.computeRecoveryKey +import im.vector.matrix.android.internal.extensions.foldToCallback +import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.matrix.olm.OlmPkDecryption +import org.matrix.olm.OlmPkEncryption +import org.matrix.olm.OlmPkMessage +import javax.inject.Inject + +internal class DefaultSharedSecretStorageService @Inject constructor( + private val accountDataService: AccountDataService, + private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val cryptoCoroutineScope: CoroutineScope +) : SharedSecretStorageService { + + override fun generateKey(keyId: String, + keyName: String, + keySigner: KeySigner, + callback: MatrixCallback) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { + val pkDecryption = OlmPkDecryption() + val pubKey: String + val privateKey: ByteArray + try { + pubKey = pkDecryption.generateKey() + privateKey = pkDecryption.privateKey() + } catch (failure: Throwable) { + return@launch Unit.also { + callback.onFailure(failure) + } + } finally { + pkDecryption.releaseDecryption() + } + + val storageKeyContent = SecretStorageKeyContent( + name = keyName, + algorithm = SSSS_ALGORITHM_CURVE25519_AES_SHA2, + passphrase = null, + publicKey = pubKey + ) + + val signedContent = keySigner.sign(storageKeyContent.canonicalSignable())?.let { + storageKeyContent.copy( + signatures = it + ) + } ?: storageKeyContent + + accountDataService.updateAccountData( + "$KEY_ID_BASE.$keyId", + signedContent.toContent(), + object : MatrixCallback { + override fun onFailure(failure: Throwable) { + callback.onFailure(failure) + } + + override fun onSuccess(data: Unit) { + callback.onSuccess(SsssKeyCreationInfo( + keyId = keyId, + content = storageKeyContent, + recoveryKey = computeRecoveryKey(privateKey) + )) + } + } + ) + } + } + + override fun generateKeyWithPassphrase(keyId: String, + keyName: String, + passphrase: String, + keySigner: KeySigner, + progressListener: ProgressListener?, + callback: MatrixCallback) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { + val privatePart = generatePrivateKeyWithPassword(passphrase, progressListener) + + val pkDecryption = OlmPkDecryption() + val pubKey: String + try { + pubKey = pkDecryption.setPrivateKey(privatePart.privateKey) + } catch (failure: Throwable) { + return@launch Unit.also { + callback.onFailure(failure) + } + } finally { + pkDecryption.releaseDecryption() + } + + val storageKeyContent = SecretStorageKeyContent( + algorithm = SSSS_ALGORITHM_CURVE25519_AES_SHA2, + passphrase = SSSSPassphrase(algorithm = "m.pbkdf2", iterations = privatePart.iterations, salt = privatePart.salt), + publicKey = pubKey + ) + + val signedContent = keySigner.sign(storageKeyContent.canonicalSignable())?.let { + storageKeyContent.copy( + signatures = it + ) + } ?: storageKeyContent + + accountDataService.updateAccountData( + "$KEY_ID_BASE.$keyId", + signedContent.toContent(), + object : MatrixCallback { + override fun onFailure(failure: Throwable) { + callback.onFailure(failure) + } + + override fun onSuccess(data: Unit) { + callback.onSuccess(SsssKeyCreationInfo( + keyId = keyId, + content = storageKeyContent, + recoveryKey = computeRecoveryKey(privatePart.privateKey) + )) + } + } + ) + } + } + + override fun hasKey(keyId: String): Boolean { + return accountDataService.getAccountDataEvent("$KEY_ID_BASE.$keyId") != null + } + + override fun getKey(keyId: String): KeyInfoResult { + val accountData = accountDataService.getAccountDataEvent("$KEY_ID_BASE.$keyId") + ?: return KeyInfoResult.Error(SharedSecretStorageError.UnknownKey(keyId)) + return SecretStorageKeyContent.fromJson(accountData.content)?.let { + KeyInfoResult.Success( + KeyInfo(id = keyId, content = it) + ) + } ?: KeyInfoResult.Error(SharedSecretStorageError.UnknownAlgorithm(keyId)) + } + + override fun setDefaultKey(keyId: String, callback: MatrixCallback) { + val existingKey = getKey(keyId) + if (existingKey is KeyInfoResult.Success) { + accountDataService.updateAccountData(DEFAULT_KEY_ID, + mapOf("key" to keyId), + callback + ) + } else { + callback.onFailure(SharedSecretStorageError.UnknownKey(keyId)) + } + } + + override fun getDefaultKey(): KeyInfoResult { + val accountData = accountDataService.getAccountDataEvent(DEFAULT_KEY_ID) + ?: return KeyInfoResult.Error(SharedSecretStorageError.UnknownKey(DEFAULT_KEY_ID)) + val keyId = accountData.content["key"] as? String + ?: return KeyInfoResult.Error(SharedSecretStorageError.UnknownKey(DEFAULT_KEY_ID)) + return getKey(keyId) + } + + override fun storeSecret(name: String, secretBase64: String, keys: List?, callback: MatrixCallback) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { + val encryptedContents = HashMap() + try { + if (keys == null || keys.isEmpty()) { + // use default key + val key = getDefaultKey() + when (key) { + is KeyInfoResult.Success -> { + if (key.keyInfo.content.algorithm == SSSS_ALGORITHM_CURVE25519_AES_SHA2) { + withOlmEncryption { olmEncrypt -> + olmEncrypt.setRecipientKey(key.keyInfo.content.publicKey) + val encryptedResult = olmEncrypt.encrypt(secretBase64) + encryptedContents[key.keyInfo.id] = EncryptedSecretContent( + ciphertext = encryptedResult.mCipherText, + ephemeral = encryptedResult.mEphemeralKey, + mac = encryptedResult.mMac + ) + } + } else { + // Unknown algorithm + callback.onFailure(SharedSecretStorageError.UnknownAlgorithm(key.keyInfo.content.algorithm ?: "")) + return@launch + } + } + is KeyInfoResult.Error -> { + callback.onFailure(key.error) + return@launch + } + } + } else { + keys.forEach { + val keyId = it + // encrypt the content + val key = getKey(keyId) + when (key) { + is KeyInfoResult.Success -> { + if (key.keyInfo.content.algorithm == SSSS_ALGORITHM_CURVE25519_AES_SHA2) { + withOlmEncryption { olmEncrypt -> + olmEncrypt.setRecipientKey(key.keyInfo.content.publicKey) + val encryptedResult = olmEncrypt.encrypt(secretBase64) + encryptedContents[keyId] = EncryptedSecretContent( + ciphertext = encryptedResult.mCipherText, + ephemeral = encryptedResult.mEphemeralKey, + mac = encryptedResult.mMac + ) + } + } else { + // Unknown algorithm + callback.onFailure(SharedSecretStorageError.UnknownAlgorithm(key.keyInfo.content.algorithm ?: "")) + return@launch + } + } + is KeyInfoResult.Error -> { + callback.onFailure(key.error) + return@launch + } + } + } + } + + accountDataService.updateAccountData( + type = name, + content = mapOf( + "encrypted" to encryptedContents + ), + callback = callback + ) + } catch (failure: Throwable) { + callback.onFailure(failure) + } + } + + // Add default key + } + + override fun getAlgorithmsForSecret(name: String): List { + val accountData = accountDataService.getAccountDataEvent(name) + ?: return listOf(KeyInfoResult.Error(SharedSecretStorageError.UnknownSecret(name))) + val encryptedContent = accountData.content[ENCRYPTED] as? Map<*, *> + ?: return listOf(KeyInfoResult.Error(SharedSecretStorageError.SecretNotEncrypted(name))) + + val results = ArrayList() + encryptedContent.keys.forEach { + (it as? String)?.let { keyId -> + results.add(getKey(keyId)) + } + } + return results + } + + override fun getSecret(name: String, keyId: String?, secretKey: SSSSKeySpec, callback: MatrixCallback) { + val accountData = accountDataService.getAccountDataEvent(name) ?: return Unit.also { + callback.onFailure(SharedSecretStorageError.UnknownSecret(name)) + } + val encryptedContent = accountData.content[ENCRYPTED] as? Map<*, *> ?: return Unit.also { + callback.onFailure(SharedSecretStorageError.SecretNotEncrypted(name)) + } + val key = keyId?.let { getKey(it) } as? KeyInfoResult.Success ?: getDefaultKey() as? KeyInfoResult.Success ?: return Unit.also { + callback.onFailure(SharedSecretStorageError.UnknownKey(name)) + } + + val encryptedForKey = encryptedContent[key.keyInfo.id] ?: return Unit.also { + callback.onFailure(SharedSecretStorageError.SecretNotEncryptedWithKey(name, key.keyInfo.id)) + } + + val secretContent = EncryptedSecretContent.fromJson(encryptedForKey) + ?: return Unit.also { + callback.onFailure(SharedSecretStorageError.ParsingError) + } + + val algorithm = key.keyInfo.content + if (SSSS_ALGORITHM_CURVE25519_AES_SHA2 == algorithm.algorithm) { + val keySpec = secretKey as? Curve25519AesSha2KeySpec ?: return Unit.also { + callback.onFailure(SharedSecretStorageError.BadKeyFormat) + } + cryptoCoroutineScope.launch(coroutineDispatchers.main) { + kotlin.runCatching { + // decryt from recovery key + val keyBytes = keySpec.privateKey + val decryption = OlmPkDecryption() + try { + decryption.setPrivateKey(keyBytes) + decryption.decrypt(OlmPkMessage().apply { + mCipherText = secretContent.ciphertext + mEphemeralKey = secretContent.ephemeral + mMac = secretContent.mac + }) + } catch (failure: Throwable) { + throw failure + } finally { + decryption.releaseDecryption() + } + }.foldToCallback(callback) + } + } else { + callback.onFailure(SharedSecretStorageError.UnsupportedAlgorithm(algorithm.algorithm ?: "")) + } + } + + companion object { + const val KEY_ID_BASE = "m.secret_storage.key" + const val ENCRYPTED = "encrypted" + const val DEFAULT_KEY_ID = "m.secret_storage.default_key" + + fun withOlmEncryption(block: (OlmPkEncryption) -> Unit) { + val olmPkEncryption = OlmPkEncryption() + try { + block(olmPkEncryption) + } catch (failure: Throwable) { + throw failure + } finally { + olmPkEncryption.releaseEncryption() + } + } + + fun withOlmDecryption(block: (OlmPkDecryption) -> Unit) { + val olmPkDecryption = OlmPkDecryption() + try { + block(olmPkDecryption) + } catch (failure: Throwable) { + throw failure + } finally { + olmPkDecryption.releaseDecryption() + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt index 74768f8797..081a6a5152 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt @@ -53,6 +53,7 @@ import io.realm.annotations.RealmModule DraftEntity::class, HomeServerCapabilitiesEntity::class, RoomMemberSummaryEntity::class, - CurrentStateEventEntity::class + CurrentStateEventEntity::class, + UserAccountDataEntity::class ]) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/UserAccountDataEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/UserAccountDataEntity.kt new file mode 100644 index 0000000000..90f73381dc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/UserAccountDataEntity.kt @@ -0,0 +1,34 @@ +/* + * 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.matrix.android.internal.database.model + +import io.realm.RealmObject +import io.realm.annotations.Index + +/** + * Clients can store custom config data for their account on their HomeServer. + * This account data will be synced between different devices and can persist across installations on a particular device. + * Users may only view the account data for their own account. + * The account_data may be either global or scoped to a particular rooms. + */ +internal open class UserAccountDataEntity( + @Index var type: String? = null, + var contentStr: String? = null +) : RealmObject() { + + companion object +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt index c19c686329..cd4e9abbc1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt @@ -23,7 +23,7 @@ import im.vector.matrix.android.internal.network.parsing.UriMoshiAdapter import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataBreadcrumbs import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataDirectMessages -import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataFallback +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataIgnoredUsers import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataPushRules @@ -31,7 +31,7 @@ object MoshiProvider { private val moshi: Moshi = Moshi.Builder() .add(UriMoshiAdapter()) - .add(RuntimeJsonAdapterFactory.of(UserAccountData::class.java, "type", UserAccountDataFallback::class.java) + .add(RuntimeJsonAdapterFactory.of(UserAccountData::class.java, "type", UserAccountDataEvent::class.java) .registerSubtype(UserAccountDataDirectMessages::class.java, UserAccountData.TYPE_DIRECT_MESSAGES) .registerSubtype(UserAccountDataIgnoredUsers::class.java, UserAccountData.TYPE_IGNORED_USER_LIST) .registerSubtype(UserAccountDataPushRules::class.java, UserAccountData.TYPE_PUSH_RULES) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt index 77cd3685d7..46264ceb85 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt @@ -25,6 +25,7 @@ import im.vector.matrix.android.api.failure.GlobalError import im.vector.matrix.android.api.pushrules.PushRuleService import im.vector.matrix.android.api.session.InitialSyncProgressService import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.accountdata.AccountDataService import im.vector.matrix.android.api.session.cache.CacheService import im.vector.matrix.android.api.session.content.ContentUploadStateTracker import im.vector.matrix.android.api.session.content.ContentUrlResolver @@ -37,6 +38,7 @@ import im.vector.matrix.android.api.session.pushers.PushersService import im.vector.matrix.android.api.session.room.RoomDirectoryService import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.api.session.securestorage.SecureStorageService +import im.vector.matrix.android.api.session.securestorage.SharedSecretStorageService import im.vector.matrix.android.api.session.signout.SignOutService import im.vector.matrix.android.api.session.sync.FilterService import im.vector.matrix.android.api.session.sync.SyncState @@ -91,6 +93,8 @@ internal class DefaultSession @Inject constructor( private val contentUploadProgressTracker: ContentUploadStateTracker, private val initialSyncProgressService: Lazy, private val homeServerCapabilitiesService: Lazy, + private val accountDataService: Lazy, + private val _sharedSecretStorageService: Lazy, private val shieldTrustUpdater: ShieldTrustUpdater) : Session, RoomService by roomService.get(), @@ -106,7 +110,11 @@ internal class DefaultSession @Inject constructor( InitialSyncProgressService by initialSyncProgressService.get(), SecureStorageService by secureStorageService.get(), HomeServerCapabilitiesService by homeServerCapabilitiesService.get(), - ProfileService by profileService.get() { + ProfileService by profileService.get(), + AccountDataService by accountDataService.get() { + + override val sharedSecretStorageService: SharedSecretStorageService + get() = _sharedSecretStorageService.get() private var isOpen = false diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index 969a968a91..7352b79073 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -32,8 +32,11 @@ import im.vector.matrix.android.api.auth.data.sessionId import im.vector.matrix.android.api.crypto.MXCryptoConfig import im.vector.matrix.android.api.session.InitialSyncProgressService import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.accountdata.AccountDataService import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService import im.vector.matrix.android.api.session.securestorage.SecureStorageService +import im.vector.matrix.android.api.session.securestorage.SharedSecretStorageService +import im.vector.matrix.android.internal.crypto.secrets.DefaultSharedSecretStorageService import im.vector.matrix.android.internal.crypto.verification.VerificationMessageLiveObserver import im.vector.matrix.android.internal.database.LiveEntityObserver import im.vector.matrix.android.internal.database.SessionRealmConfigurationFactory @@ -61,6 +64,7 @@ import im.vector.matrix.android.internal.session.room.create.RoomCreateEventLive import im.vector.matrix.android.internal.session.room.prune.EventsPruner import im.vector.matrix.android.internal.session.room.tombstone.RoomTombstoneEventLiveObserver import im.vector.matrix.android.internal.session.securestorage.DefaultSecureStorageService +import im.vector.matrix.android.internal.session.user.accountdata.DefaultAccountDataService import im.vector.matrix.android.internal.util.md5 import io.realm.RealmConfiguration import okhttp3.OkHttpClient @@ -263,4 +267,10 @@ internal abstract class SessionModule { @Binds abstract fun bindHomeServerCapabilitiesService(homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService): HomeServerCapabilitiesService + + @Binds + abstract fun bindAccountDataService(accountDataService: DefaultAccountDataService): AccountDataService + + @Binds + abstract fun bindSharedSecretStorageService(service: DefaultSharedSecretStorageService): SharedSecretStorageService } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/UserAccountDataSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/UserAccountDataSyncHandler.kt index f76c2ff448..c530578538 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/UserAccountDataSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/UserAccountDataSyncHandler.kt @@ -19,9 +19,12 @@ package im.vector.matrix.android.internal.session.sync import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.pushrules.RuleScope import im.vector.matrix.android.api.pushrules.RuleSetKey +import im.vector.matrix.android.api.session.events.model.Content +import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.RoomMemberContent import im.vector.matrix.android.api.session.room.model.RoomSummary +import im.vector.matrix.android.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.mapper.PushRulesMapper import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.BreadcrumbsEntity @@ -29,15 +32,18 @@ import im.vector.matrix.android.internal.database.model.IgnoredUserEntity import im.vector.matrix.android.internal.database.model.PushRulesEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields +import im.vector.matrix.android.internal.database.model.UserAccountDataEntity +import im.vector.matrix.android.internal.database.model.UserAccountDataEntityFields import im.vector.matrix.android.internal.database.query.getDirectRooms import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.session.room.membership.RoomMemberHelper import im.vector.matrix.android.internal.session.sync.model.InvitedRoomSync +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataBreadcrumbs import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataDirectMessages -import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataFallback import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataIgnoredUsers import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataPushRules import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataSync @@ -45,6 +51,7 @@ import im.vector.matrix.android.internal.session.user.accountdata.DirectChatsHel import im.vector.matrix.android.internal.session.user.accountdata.UpdateUserAccountDataTask import io.realm.Realm import io.realm.RealmList +import io.realm.kotlin.where import timber.log.Timber import javax.inject.Inject @@ -56,21 +63,23 @@ internal class UserAccountDataSyncHandler @Inject constructor( fun handle(realm: Realm, accountData: UserAccountDataSync?) { accountData?.list?.forEach { - when (it) { - is UserAccountDataDirectMessages -> handleDirectChatRooms(realm, it) - is UserAccountDataPushRules -> handlePushRules(realm, it) - is UserAccountDataIgnoredUsers -> handleIgnoredUsers(realm, it) - is UserAccountDataBreadcrumbs -> handleBreadcrumbs(realm, it) - is UserAccountDataFallback -> Timber.d("Receive account data of unhandled type ${it.type}") - else -> error("Missing code here!") + // Generic handling, just save in base + handleGenericAccountData(realm, it.type, it.content) + + // Didn't want to break too much thing, so i re-serialize to jsonString before reparsing + // TODO would be better to have a mapper? + val toJson = MoshiProvider.providesMoshi().adapter(Event::class.java).toJson(it) + val model = toJson?.let { json -> + MoshiProvider.providesMoshi().adapter(UserAccountData::class.java).fromJson(json) + } + // Specific parsing + when (model) { + is UserAccountDataDirectMessages -> handleDirectChatRooms(realm, model) + is UserAccountDataPushRules -> handlePushRules(realm, model) + is UserAccountDataIgnoredUsers -> handleIgnoredUsers(realm, model) + is UserAccountDataBreadcrumbs -> handleBreadcrumbs(realm, model) } } - - // TODO Store all account data, app can be interested of it - // accountData?.list?.forEach { - // it.toString() - // MoshiProvider.providesMoshi() - // } } // If we get some direct chat invites, we synchronize the user account data including those. @@ -200,4 +209,19 @@ internal class UserAccountDataSyncHandler @Inject constructor( ?.breadcrumbsIndex = index } } + + fun handleGenericAccountData(realm: Realm, type: String, content: Content?) { + val existing = realm.where() + .equalTo(UserAccountDataEntityFields.TYPE, type) + .findFirst() + if (existing != null) { + // Update current value + existing.contentStr = ContentMapper.map(content) + } else { + realm.createObject(UserAccountDataEntity::class.java).let { accountDataEntity -> + accountDataEntity.type = type + accountDataEntity.contentStr = ContentMapper.map(content) + } + } + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt index accc9c900f..3ec6c3c7eb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt @@ -18,7 +18,7 @@ package im.vector.matrix.android.internal.session.sync.model.accountdata import com.squareup.moshi.Json -internal abstract class UserAccountData { +abstract class UserAccountData { @Json(name = "type") abstract val type: String diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataFallback.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataEvent.kt similarity index 95% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataFallback.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataEvent.kt index a8b8235d37..a4ba0fc91a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataFallback.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataEvent.kt @@ -20,7 +20,7 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) -internal data class UserAccountDataFallback( +data class UserAccountDataEvent( @Json(name = "type") override val type: String, @Json(name = "content") val content: Map ) : UserAccountData() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataSync.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataSync.kt index c7f8bfa4c2..8acac86e1a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataSync.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataSync.kt @@ -18,8 +18,9 @@ package im.vector.matrix.android.internal.session.sync.model.accountdata import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.session.events.model.Event @JsonClass(generateAdapter = true) internal data class UserAccountDataSync( - @Json(name = "events") val list: List = emptyList() + @Json(name = "events") val list: List = emptyList() ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/DefaultAccountDataService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/DefaultAccountDataService.kt new file mode 100644 index 0000000000..b40c75992a --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/DefaultAccountDataService.kt @@ -0,0 +1,114 @@ +/* + * 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.matrix.android.internal.session.user.accountdata + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.accountdata.AccountDataService +import im.vector.matrix.android.api.session.events.model.Content +import im.vector.matrix.android.api.util.JSON_DICT_PARAMETERIZED_TYPE +import im.vector.matrix.android.api.util.Optional +import im.vector.matrix.android.api.util.toOptional +import im.vector.matrix.android.internal.database.model.UserAccountDataEntity +import im.vector.matrix.android.internal.database.model.UserAccountDataEntityFields +import im.vector.matrix.android.internal.di.MoshiProvider +import im.vector.matrix.android.internal.di.SessionId +import im.vector.matrix.android.internal.session.sync.UserAccountDataSyncHandler +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent +import im.vector.matrix.android.internal.task.TaskExecutor +import im.vector.matrix.android.internal.task.configureWith +import javax.inject.Inject + +internal class DefaultAccountDataService @Inject constructor( + private val monarchy: Monarchy, + @SessionId private val sessionId: String, + private val updateUserAccountDataTask: UpdateUserAccountDataTask, + private val userAccountDataSyncHandler: UserAccountDataSyncHandler, + private val taskExecutor: TaskExecutor +) : AccountDataService { + + private val moshi = MoshiProvider.providesMoshi() + private val adapter = moshi.adapter>(JSON_DICT_PARAMETERIZED_TYPE) + + override fun getAccountDataEvent(type: String): UserAccountDataEvent? { + return getAccountDataEvents(listOf(type)).firstOrNull() + } + + override fun getLiveAccountDataEvent(type: String): LiveData> { + return Transformations.map(getLiveAccountDataEvents(listOf(type))) { + it.firstOrNull()?.toOptional() + } + } + + override fun getAccountDataEvents(filterType: List): List { + return monarchy.fetchAllCopiedSync { realm -> + realm.where(UserAccountDataEntity::class.java) + .apply { + if (filterType.isNotEmpty()) { + `in`(UserAccountDataEntityFields.TYPE, filterType.toTypedArray()) + } + } + }?.mapNotNull { entity -> + entity.type?.let { type -> + UserAccountDataEvent( + type = type, + content = entity.contentStr?.let { adapter.fromJson(it) } ?: emptyMap() + ) + } + } ?: emptyList() + } + + override fun getLiveAccountDataEvents(filterType: List): LiveData> { + return monarchy.findAllMappedWithChanges({ realm -> + realm.where(UserAccountDataEntity::class.java) + .apply { + if (filterType.isNotEmpty()) { + `in`(UserAccountDataEntityFields.TYPE, filterType.toTypedArray()) + } + } + }, { entity -> + UserAccountDataEvent( + type = entity.type ?: "", + content = entity.contentStr?.let { adapter.fromJson(it) } ?: emptyMap() + ) + }) + } + + override fun updateAccountData(type: String, content: Content, callback: MatrixCallback?) { + updateUserAccountDataTask.configureWith(UpdateUserAccountDataTask.AnyParams( + type = type, + any = content + )) { + this.retryCount = 5 + this.callback = object : MatrixCallback { + override fun onSuccess(data: Unit) { + monarchy.runTransactionSync { realm -> + userAccountDataSyncHandler.handleGenericAccountData(realm, type, content) + } + callback?.onSuccess(data) + } + + override fun onFailure(failure: Throwable) { + callback?.onFailure(failure) + } + } + } + .executeBy(taskExecutor) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt index 068ce4777a..beb3a0fcc0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt @@ -49,6 +49,14 @@ internal interface UpdateUserAccountDataTask : Task @@ -384,6 +385,9 @@ SOFTWARE.
Copyright 2017, Yalantis +
  • + BillCarsonFr/JsonViewer +
  •  Apache License
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    index 5c680167d0..d7e89a62f6 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    @@ -73,6 +73,7 @@ import im.vector.riotx.features.settings.VectorSettingsPreferencesFragment
     import im.vector.riotx.features.settings.VectorSettingsSecurityPrivacyFragment
     import im.vector.riotx.features.settings.crosssigning.CrossSigningSettingsFragment
     import im.vector.riotx.features.settings.devices.VectorSettingsDevicesFragment
    +import im.vector.riotx.features.settings.devtools.AccountDataFragment
     import im.vector.riotx.features.settings.ignored.VectorSettingsIgnoredUsersFragment
     import im.vector.riotx.features.settings.push.PushGatewaysFragment
     import im.vector.riotx.features.share.IncomingShareFragment
    @@ -360,4 +361,9 @@ interface FragmentModule {
         @IntoMap
         @FragmentKey(IncomingShareFragment::class)
         fun bindIncomingShareFragment(fragment: IncomingShareFragment): Fragment
    +
    +    @Binds
    +    @IntoMap
    +    @FragmentKey(AccountDataFragment::class)
    +    fun bindAccountDataFragment(fragment: AccountDataFragment): Fragment
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/utils/UserColor.kt b/vector/src/main/java/im/vector/riotx/core/utils/UserColor.kt
    index 1f8308cd5c..15c4ce8a15 100644
    --- a/vector/src/main/java/im/vector/riotx/core/utils/UserColor.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/utils/UserColor.kt
    @@ -18,6 +18,8 @@ package im.vector.riotx.core.utils
     
     import androidx.annotation.ColorRes
     import im.vector.riotx.R
    +import im.vector.riotx.core.resources.ColorProvider
    +import org.billcarsonfr.jsonviewer.JSonViewerStyleProvider
     import kotlin.math.abs
     
     @ColorRes
    @@ -37,3 +39,14 @@ fun getColorFromUserId(userId: String?): Int {
             else -> R.color.riotx_username_1
         }
     }
    +
    +fun jsonViewerStyler(colorProvider: ColorProvider): JSonViewerStyleProvider {
    +    return JSonViewerStyleProvider(
    +            keyColor = colorProvider.getColor(R.color.riotx_accent),
    +            secondaryColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary),
    +            stringColor = colorProvider.getColorFromAttribute(R.attr.vctr_notice_text_color),
    +            baseColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_primary),
    +            booleanColor = colorProvider.getColorFromAttribute(R.attr.vctr_notice_text_color),
    +            numberColor = colorProvider.getColorFromAttribute(R.attr.vctr_notice_text_color)
    +    )
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    index ff72b004f9..9885e40eef 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    @@ -27,12 +27,10 @@ import android.os.Bundle
     import android.os.Parcelable
     import android.text.Spannable
     import android.view.HapticFeedbackConstants
    -import android.view.LayoutInflater
     import android.view.Menu
     import android.view.MenuItem
     import android.view.View
     import android.view.Window
    -import android.widget.TextView
     import android.widget.Toast
     import androidx.annotation.DrawableRes
     import androidx.annotation.StringRes
    @@ -96,6 +94,7 @@ import im.vector.riotx.core.extensions.showKeyboard
     import im.vector.riotx.core.files.addEntryToDownloadManager
     import im.vector.riotx.core.glide.GlideApp
     import im.vector.riotx.core.platform.VectorBaseFragment
    +import im.vector.riotx.core.resources.ColorProvider
     import im.vector.riotx.core.ui.views.JumpToReadMarkerView
     import im.vector.riotx.core.ui.views.NotificationAreaView
     import im.vector.riotx.core.utils.Debouncer
    @@ -110,6 +109,7 @@ import im.vector.riotx.core.utils.checkPermissions
     import im.vector.riotx.core.utils.copyToClipboard
     import im.vector.riotx.core.utils.createUIHandler
     import im.vector.riotx.core.utils.getColorFromUserId
    +import im.vector.riotx.core.utils.jsonViewerStyler
     import im.vector.riotx.core.utils.openUrlInExternalBrowser
     import im.vector.riotx.core.utils.shareMedia
     import im.vector.riotx.core.utils.toast
    @@ -157,6 +157,7 @@ import kotlinx.android.parcel.Parcelize
     import kotlinx.android.synthetic.main.fragment_room_detail.*
     import kotlinx.android.synthetic.main.merge_composer_layout.view.*
     import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
    +import org.billcarsonfr.jsonviewer.JSonViewerDialog
     import org.commonmark.parser.Parser
     import timber.log.Timber
     import java.io.File
    @@ -181,8 +182,8 @@ class RoomDetailFragment @Inject constructor(
             private val notificationDrawerManager: NotificationDrawerManager,
             val roomDetailViewModelFactory: RoomDetailViewModel.Factory,
             private val eventHtmlRenderer: EventHtmlRenderer,
    -        private val vectorPreferences: VectorPreferences
    -) :
    +        private val vectorPreferences: VectorPreferences,
    +        private val colorProvider: ColorProvider) :
             VectorBaseFragment(),
             TimelineEventController.Callback,
             VectorInviteView.Callback,
    @@ -801,12 +802,15 @@ class RoomDetailFragment @Inject constructor(
                     .show()
         }
     
    -    private fun promptReasonToRedactEvent(eventId: String) {
    +    private fun promptConfirmationToRedactEvent(action: EventSharedAction.Redact) {
             val layout = requireActivity().layoutInflater.inflate(R.layout.dialog_delete_event, null)
             val reasonCheckBox = layout.findViewById(R.id.deleteEventReasonCheck)
             val reasonTextInputLayout = layout.findViewById(R.id.deleteEventReasonTextInputLayout)
             val reasonInput = layout.findViewById(R.id.deleteEventReasonInput)
     
    +        reasonCheckBox.isVisible = action.askForReason
    +        reasonTextInputLayout.isVisible = action.askForReason
    +
             reasonCheckBox.setOnCheckedChangeListener { _, isChecked -> reasonTextInputLayout.isEnabled = isChecked }
     
             AlertDialog.Builder(requireActivity())
    @@ -814,9 +818,10 @@ class RoomDetailFragment @Inject constructor(
                     .setView(layout)
                     .setPositiveButton(R.string.remove) { _, _ ->
                         val reason = reasonInput.text.toString()
    -                            .takeIf { reasonCheckBox.isChecked }
    +                            .takeIf { action.askForReason }
    +                            ?.takeIf { reasonCheckBox.isChecked }
                                 ?.takeIf { it.isNotBlank() }
    -                    roomDetailViewModel.handle(RoomDetailAction.RedactAction(eventId, reason))
    +                    roomDetailViewModel.handle(RoomDetailAction.RedactAction(action.eventId, reason))
                     }
                     .setNegativeButton(R.string.cancel, null)
                     .show()
    @@ -1134,7 +1139,7 @@ class RoomDetailFragment @Inject constructor(
                     showSnackWithMessage(getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)
                 }
                 is EventSharedAction.Redact                     -> {
    -                promptReasonToRedactEvent(action.eventId)
    +                promptConfirmationToRedactEvent(action)
                 }
                 is EventSharedAction.Share                      -> {
                     // TODO current data communication is too limited
    @@ -1168,26 +1173,18 @@ class RoomDetailFragment @Inject constructor(
                     onEditedDecorationClicked(action.messageInformationData)
                 }
                 is EventSharedAction.ViewSource                 -> {
    -                val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
    -                view.findViewById(R.id.event_content_text_view)?.let {
    -                    it.text = action.content
    -                }
    -
    -                AlertDialog.Builder(requireActivity())
    -                        .setView(view)
    -                        .setPositiveButton(R.string.ok, null)
    -                        .show()
    +                JSonViewerDialog.newInstance(
    +                        action.content,
    +                        -1,
    +                        jsonViewerStyler(colorProvider)
    +                ).show(childFragmentManager, "JSON_VIEWER")
                 }
                 is EventSharedAction.ViewDecryptedSource        -> {
    -                val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
    -                view.findViewById(R.id.event_content_text_view)?.let {
    -                    it.text = action.content
    -                }
    -
    -                AlertDialog.Builder(requireActivity())
    -                        .setView(view)
    -                        .setPositiveButton(R.string.ok, null)
    -                        .show()
    +                JSonViewerDialog.newInstance(
    +                        action.content,
    +                        -1,
    +                        jsonViewerStyler(colorProvider)
    +                ).show(childFragmentManager, "JSON_VIEWER")
                 }
                 is EventSharedAction.QuickReact                 -> {
                     // eventId,ClickedOn,Add
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt
    index 8a8766c3ef..cba89d8481 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt
    @@ -55,7 +55,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int,
         data class Remove(val eventId: String) :
                 EventSharedAction(R.string.remove, R.drawable.ic_trash, true)
     
    -    data class Redact(val eventId: String) :
    +    data class Redact(val eventId: String, val askForReason: Boolean) :
                 EventSharedAction(R.string.message_action_item_redact, R.drawable.ic_delete, true)
     
         data class Cancel(val eventId: String) :
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
    index 4b130e2103..a36215007d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
    @@ -168,6 +168,10 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
         }
     
         private fun computeMessageBody(timelineEvent: TimelineEvent): CharSequence {
    +        if (timelineEvent.root.isRedacted()) {
    +            return getRedactionReason(timelineEvent)
    +        }
    +
             return when (timelineEvent.root.getClearType()) {
                 EventType.MESSAGE,
                 EventType.STICKER     -> {
    @@ -200,6 +204,31 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
             } ?: ""
         }
     
    +    private fun getRedactionReason(timelineEvent: TimelineEvent): String {
    +            return (timelineEvent
    +                    .root
    +                    .unsignedData
    +                    ?.redactedEvent
    +                    ?.content
    +                    ?.get("reason") as? String)
    +                    ?.takeIf { it.isNotBlank() }
    +                    .let { reason ->
    +                        if (reason == null) {
    +                            if (timelineEvent.root.isRedactedBySameUser()) {
    +                                stringProvider.getString(R.string.event_redacted_by_user_reason)
    +                            } else {
    +                                stringProvider.getString(R.string.event_redacted_by_admin_reason)
    +                            }
    +                        } else {
    +                            if (timelineEvent.root.isRedactedBySameUser()) {
    +                                stringProvider.getString(R.string.event_redacted_by_user_reason_with_reason, reason)
    +                            } else {
    +                                stringProvider.getString(R.string.event_redacted_by_admin_reason_with_reason, reason)
    +                            }
    +                        }
    +                    }
    +    }
    +
         private fun actionsForEvent(timelineEvent: TimelineEvent): List {
             val messageContent: MessageContent? = timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
                     ?: timelineEvent.root.getClearContent().toModel()
    @@ -227,7 +256,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
                         }
     
                         if (canRedact(timelineEvent, session.myUserId)) {
    -                        add(EventSharedAction.Redact(eventId))
    +                        add(EventSharedAction.Redact(eventId, askForReason = informationData.senderId != session.myUserId))
                         }
     
                         if (canCopy(msgType)) {
    diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt
    index 93b1b1b525..3e45eeb406 100644
    --- a/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt
    @@ -40,7 +40,7 @@ import javax.inject.Inject
     
     /**
      * In this screen, in signin mode:
    - * - the user is asked for login and password to sign in to a homeserver.
    + * - the user is asked for login (or email) and password to sign in to a homeserver.
      * - He also can reset his password
      * In signup mode:
      * - the user is asked for login and password
    @@ -97,6 +97,12 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() {
                 SignMode.SignIn  -> R.string.login_connect_to
             }
     
    +        loginFieldTil.hint = getString(when (state.signMode) {
    +            SignMode.Unknown -> error("developer error")
    +            SignMode.SignUp  -> R.string.login_signup_username_hint
    +            SignMode.SignIn  -> R.string.login_signin_username_hint
    +        })
    +
             when (state.serverType) {
                 ServerType.MatrixOrg -> {
                     loginServerIcon.isVisible = true
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataEpoxyController.kt
    new file mode 100644
    index 0000000000..c8a09bfb64
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataEpoxyController.kt
    @@ -0,0 +1,79 @@
    +/*
    + * 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.settings.devtools
    +
    +import android.view.View
    +import com.airbnb.epoxy.TypedEpoxyController
    +import com.airbnb.mvrx.Fail
    +import com.airbnb.mvrx.Loading
    +import com.airbnb.mvrx.Success
    +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData
    +import im.vector.riotx.R
    +import im.vector.riotx.core.epoxy.loadingItem
    +import im.vector.riotx.core.resources.StringProvider
    +import im.vector.riotx.core.ui.list.genericFooterItem
    +import im.vector.riotx.core.ui.list.genericItemWithValue
    +import im.vector.riotx.core.utils.DebouncedClickListener
    +import javax.inject.Inject
    +
    +class AccountDataEpoxyController @Inject constructor(
    +        private val stringProvider: StringProvider
    +) : TypedEpoxyController() {
    +
    +    interface InteractionListener {
    +        fun didTap(data: UserAccountData)
    +    }
    +
    +    var interactionListener: InteractionListener? = null
    +
    +    override fun buildModels(data: AccountDataViewState?) {
    +        if (data == null) return
    +        when (data.accountData) {
    +            is Loading -> {
    +                loadingItem {
    +                    id("loading")
    +                    loadingText(stringProvider.getString(R.string.loading))
    +                }
    +            }
    +            is Fail    -> {
    +                genericFooterItem {
    +                    id("fail")
    +                    text(data.accountData.error.localizedMessage)
    +                }
    +            }
    +            is Success -> {
    +                val dataList = data.accountData.invoke()
    +                if (dataList.isEmpty()) {
    +                    genericFooterItem {
    +                        id("noResults")
    +                        text(stringProvider.getString(R.string.no_result_placeholder))
    +                    }
    +                } else {
    +                    dataList.forEach { accountData ->
    +                        genericItemWithValue {
    +                            id(accountData.type)
    +                            title(accountData.type)
    +                            itemClickAction(DebouncedClickListener(View.OnClickListener {
    +                                interactionListener?.didTap(accountData)
    +                            }))
    +                        }
    +                    }
    +                }
    +            }
    +        }
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataFragment.kt
    new file mode 100644
    index 0000000000..7a57a03deb
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataFragment.kt
    @@ -0,0 +1,79 @@
    +/*
    + * 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.settings.devtools
    +
    +import android.os.Bundle
    +import android.view.View
    +import com.airbnb.mvrx.fragmentViewModel
    +import com.airbnb.mvrx.withState
    +import im.vector.matrix.android.internal.di.MoshiProvider
    +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountData
    +import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent
    +import im.vector.riotx.R
    +import im.vector.riotx.core.extensions.cleanup
    +import im.vector.riotx.core.extensions.configureWith
    +import im.vector.riotx.core.platform.VectorBaseActivity
    +import im.vector.riotx.core.platform.VectorBaseFragment
    +import im.vector.riotx.core.resources.ColorProvider
    +import im.vector.riotx.core.utils.jsonViewerStyler
    +import kotlinx.android.synthetic.main.fragment_generic_recycler.*
    +import org.billcarsonfr.jsonviewer.JSonViewerDialog
    +import javax.inject.Inject
    +
    +class AccountDataFragment @Inject constructor(
    +        val viewModelFactory: AccountDataViewModel.Factory,
    +        private val epoxyController: AccountDataEpoxyController,
    +        private val colorProvider: ColorProvider
    +) : VectorBaseFragment(), AccountDataEpoxyController.InteractionListener {
    +
    +    override fun getLayoutResId() = R.layout.fragment_generic_recycler
    +
    +    private val viewModel: AccountDataViewModel by fragmentViewModel(AccountDataViewModel::class)
    +
    +    override fun onResume() {
    +        super.onResume()
    +        (activity as? VectorBaseActivity)?.supportActionBar?.setTitle(R.string.settings_account_data)
    +    }
    +
    +    override fun invalidate() = withState(viewModel) { state ->
    +        epoxyController.setData(state)
    +    }
    +
    +    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    +        super.onViewCreated(view, savedInstanceState)
    +        recyclerView.configureWith(epoxyController, showDivider = true)
    +        epoxyController.interactionListener = this
    +    }
    +
    +    override fun onDestroyView() {
    +        super.onDestroyView()
    +        recyclerView.cleanup()
    +        epoxyController.interactionListener = null
    +    }
    +
    +    override fun didTap(data: UserAccountData) {
    +        val fb = data as? UserAccountDataEvent ?: return
    +        val jsonString = MoshiProvider.providesMoshi()
    +                .adapter(UserAccountDataEvent::class.java)
    +                .toJson(fb)
    +        JSonViewerDialog.newInstance(
    +                jsonString,
    +                -1, // open All
    +                jsonViewerStyler(colorProvider)
    +        ).show(childFragmentManager, "JSON_VIEWER")
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataViewModel.kt b/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataViewModel.kt
    new file mode 100644
    index 0000000000..b0b23a62d1
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/settings/devtools/AccountDataViewModel.kt
    @@ -0,0 +1,64 @@
    +/*
    + * 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.settings.devtools
    +
    +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.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
    +
    +data class AccountDataViewState(
    +        val accountData: Async> = Uninitialized
    +) : MvRxState
    +
    +class AccountDataViewModel @AssistedInject constructor(@Assisted initialState: AccountDataViewState,
    +                                                       private val session: Session)
    +    : VectorViewModel(initialState) {
    +
    +    init {
    +        session.rx().liveAccountData(emptyList())
    +                .execute {
    +                    copy(accountData = it)
    +                }
    +    }
    +
    +    override fun handle(action: EmptyAction) {}
    +
    +    @AssistedInject.Factory
    +    interface Factory {
    +        fun create(initialState: AccountDataViewState): AccountDataViewModel
    +    }
    +
    +    companion object : MvRxViewModelFactory {
    +
    +        @JvmStatic
    +        override fun create(viewModelContext: ViewModelContext, state: AccountDataViewState): AccountDataViewModel? {
    +            val fragment: AccountDataFragment = (viewModelContext as FragmentViewModelContext).fragment()
    +            return fragment.viewModelFactory.create(state)
    +        }
    +    }
    +}
    diff --git a/vector/src/main/res/layout/dialog_delete_event.xml b/vector/src/main/res/layout/dialog_delete_event.xml
    index 8ca7a25113..08b0131f6a 100644
    --- a/vector/src/main/res/layout/dialog_delete_event.xml
    +++ b/vector/src/main/res/layout/dialog_delete_event.xml
    @@ -43,8 +43,7 @@
             
    +            android:layout_height="wrap_content" />
     
         
     
    diff --git a/vector/src/main/res/layout/fragment_login.xml b/vector/src/main/res/layout/fragment_login.xml
    index fb445dfb89..3f2443440e 100644
    --- a/vector/src/main/res/layout/fragment_login.xml
    +++ b/vector/src/main/res/layout/fragment_login.xml
    @@ -50,8 +50,8 @@
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
                     android:layout_marginTop="32dp"
    -                android:hint="@string/login_signup_username_hint"
    -                app:errorEnabled="true">
    +                app:errorEnabled="true"
    +                tools:hint="@string/login_signin_username_hint">
     
                     Телефонния номер изглежда невалиден. Моля проверете го
     
         Регистрация в %1$s
    -    Потребителско име или имейл
    +    Потребителско име или имейл
         Парола
         Напред
         Това потребителско име е заето
    diff --git a/vector/src/main/res/values-de/strings.xml b/vector/src/main/res/values-de/strings.xml
    index fe059320d3..6c3ad4e84e 100644
    --- a/vector/src/main/res/values-de/strings.xml
    +++ b/vector/src/main/res/values-de/strings.xml
    @@ -1941,7 +1941,7 @@ Wenn du diese neue Wiederherstellungsmethode nicht eingerichtet hast, kann ein A
         Wir haben einen Code an %1$s gesendet. Gib diesen unten ein um dich zu verifizieren.
         Code eingeben
         Erneut senden
    -    Benutzername oder E-Mail-Adresse
    +    Benutzername oder E-Mail-Adresse
         Passwort
         Dieser Benutzername ist bereits belegt
         Dein Benutzerkonto ist noch nicht erstellt.
    diff --git a/vector/src/main/res/values-eu/strings.xml b/vector/src/main/res/values-eu/strings.xml
    index 91432ac66e..86fb1e0e12 100644
    --- a/vector/src/main/res/values-eu/strings.xml
    +++ b/vector/src/main/res/values-eu/strings.xml
    @@ -1909,7 +1909,7 @@ Abisua: Fitxategi hau ezabatu daiteke aplikazioa desinstalatzen bada.
         Telefono zenbakia baliogabea dirudi. Egiaztatu ezazu
     
         Erregistratu %1$s zerbitzarian
    -    Erabiltzaile-izena edo e-maila
    +    Erabiltzaile-izena edo e-maila
         Pasahitza
         Hurrengoa
         Erabiltzaile-izen hori hartuta dago
    diff --git a/vector/src/main/res/values-fi/strings.xml b/vector/src/main/res/values-fi/strings.xml
    index 4966a5ea6a..fef747d546 100644
    --- a/vector/src/main/res/values-fi/strings.xml
    +++ b/vector/src/main/res/values-fi/strings.xml
    @@ -1962,7 +1962,7 @@ Jotta et menetä mitään, automaattiset päivitykset kannattaa pitää käytös
         Puhelinnumero vaikuttaa epäkelvolta. Tarkista numero
     
         Rekisteröidy palvelimelle %1$s
    -    Käyttäjätunnus tai sähköpostiosoite
    +    Käyttäjätunnus tai sähköpostiosoite
         Salasana
         Seuraava
         Käyttäjätunnus on varattu
    diff --git a/vector/src/main/res/values-fr/strings.xml b/vector/src/main/res/values-fr/strings.xml
    index 86547b02fd..959266b9b7 100644
    --- a/vector/src/main/res/values-fr/strings.xml
    +++ b/vector/src/main/res/values-fr/strings.xml
    @@ -1918,7 +1918,7 @@ Si vous n’avez pas configuré de nouvelle méthode de récupération, un attaq
         Le numéro de téléphone n’a pas l’air d’être valide. Veuillez le vérifier
     
         S’inscrire sur %1$s
    -    Nom d’utilisateur ou e-mail
    +    Nom d’utilisateur ou e-mail
         Mot de passe
         Suivant
         Ce nom d’utilisateur est déjà pris
    diff --git a/vector/src/main/res/values-hu/strings.xml b/vector/src/main/res/values-hu/strings.xml
    index 4598e445fa..64eee79075 100644
    --- a/vector/src/main/res/values-hu/strings.xml
    +++ b/vector/src/main/res/values-hu/strings.xml
    @@ -1913,7 +1913,7 @@ Ha nem te állítottad be a visszaállítási metódust, akkor egy támadó pró
         A telefonszám érvénytelennek látszik. Kérlek ellenőrizd
     
         Bejelentkezés ide: %1$s
    -    Felhasználónév vagy e-mail
    +    Felhasználónév vagy e-mail
         Jelszó
         Következő
         A felhasználónév már használatban van
    diff --git a/vector/src/main/res/values-it/strings.xml b/vector/src/main/res/values-it/strings.xml
    index 6c10f7b04f..4d1ac8edaf 100644
    --- a/vector/src/main/res/values-it/strings.xml
    +++ b/vector/src/main/res/values-it/strings.xml
    @@ -1963,7 +1963,7 @@
         Il numero di telefono non sembra valido. Ricontrollalo
     
         Registrati su %1$s
    -    Nome utente o email
    +    Nome utente o email
         Password
         Avanti
         Quel nome utente esiste già
    diff --git a/vector/src/main/res/values-sq/strings.xml b/vector/src/main/res/values-sq/strings.xml
    index 397a245289..a8af77d59b 100644
    --- a/vector/src/main/res/values-sq/strings.xml
    +++ b/vector/src/main/res/values-sq/strings.xml
    @@ -1871,7 +1871,7 @@ Që të garantoni se s’ju shpëton gjë, thjesht mbajeni të aktivizuar mekani
         Numri i telefonit duket se është i vlefshëm. Ju lutemi, kontrollojeni
     
         Regjistrohuni te %1$s
    -    Emër përdoruesi ose email
    +    Emër përdoruesi ose email
         Fjalëkalim
         Pasuesi
         Ai emër përdoruesi është i zënë
    diff --git a/vector/src/main/res/values-zh-rTW/strings.xml b/vector/src/main/res/values-zh-rTW/strings.xml
    index 9e36a2ec3a..d285c364b2 100644
    --- a/vector/src/main/res/values-zh-rTW/strings.xml
    +++ b/vector/src/main/res/values-zh-rTW/strings.xml
    @@ -1866,7 +1866,7 @@ Matrix 中的消息可見度類似于電子郵件。我們忘記您的郵件意
         電話號碼似乎無效。請檢查
     
         註冊至 %1$s
    -    使用者名稱或電子郵件
    +    使用者名稱或電子郵件
         密碼
         下一個
         使用者名稱已被使用
    diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml
    index 7693bc8cee..29fff11230 100644
    --- a/vector/src/main/res/values/strings.xml
    +++ b/vector/src/main/res/values/strings.xml
    @@ -1912,7 +1912,7 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
     
         
         Sign up to %1$s
    -    Username or email
    +    Username or email
         Password
         Next
         That username is taken
    diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml
    index 017e206c00..8efe079601 100644
    --- a/vector/src/main/res/values/strings_riotX.xml
    +++ b/vector/src/main/res/values/strings_riotX.xml
    @@ -6,6 +6,8 @@
         
     
         
    +    Dev Tools
    +    Account Data
         
             %d vote
             %d votes
    @@ -26,9 +28,9 @@
             Send image with the original size
             Send images with the original size
         
    +    Username
         
     
    -
         
     
         
    @@ -38,6 +40,9 @@
         Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.
         Include a reason
         Reason for redacting
    +
    +    Event deleted by user, reason: %1$s
    +    Event moderated by room admin, reason: %1$s
         
     
     
    diff --git a/vector/src/main/res/xml/vector_settings_advanced_settings.xml b/vector/src/main/res/xml/vector_settings_advanced_settings.xml
    index ad698f0036..34cd8743be 100644
    --- a/vector/src/main/res/xml/vector_settings_advanced_settings.xml
    +++ b/vector/src/main/res/xml/vector_settings_advanced_settings.xml
    @@ -63,6 +63,14 @@
                 android:persistent="false"
                 android:title="@string/settings_push_rules"
                 app:fragment="im.vector.riotx.features.settings.push.PushRulesFragment" />
    +    
    +
    +    
    +
    +